my-durable 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +328 -2
  4. package/ai_docs/durable_pi/announcement.md +860 -0
  5. package/ai_docs/durable_pi/changelog.md +51 -0
  6. package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
  7. package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
  8. package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
  9. package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
  10. package/ai_docs/durable_pi/readme.md +617 -0
  11. package/ai_docs/durable_pi/sources.md +22 -0
  12. package/ai_docs/durable_pi/spec.md +4696 -0
  13. package/ai_docs/durable_pi/vacation-planner.md +57 -0
  14. package/ai_docs/pi/chord.md +268 -0
  15. package/ai_docs/pi/cli-integration.md +110 -0
  16. package/ai_docs/pi/cli.md +325 -0
  17. package/ai_docs/pi/codemode.md +211 -0
  18. package/ai_docs/pi/compaction.md +467 -0
  19. package/ai_docs/pi/configuration.md +51 -0
  20. package/ai_docs/pi/containerization.md +187 -0
  21. package/ai_docs/pi/custom-provider.md +177 -0
  22. package/ai_docs/pi/environment-variables.md +102 -0
  23. package/ai_docs/pi/extensions.md +278 -0
  24. package/ai_docs/pi/how-pi-works.md +53 -0
  25. package/ai_docs/pi/index.md +43 -0
  26. package/ai_docs/pi/json.md +230 -0
  27. package/ai_docs/pi/keybindings.md +198 -0
  28. package/ai_docs/pi/llama-cpp.md +118 -0
  29. package/ai_docs/pi/mcp.md +274 -0
  30. package/ai_docs/pi/message-types.md +265 -0
  31. package/ai_docs/pi/models.md +209 -0
  32. package/ai_docs/pi/overview.md +166 -0
  33. package/ai_docs/pi/packages.md +133 -0
  34. package/ai_docs/pi/pi-agent-core.md +568 -0
  35. package/ai_docs/pi/pi-ai.md +1942 -0
  36. package/ai_docs/pi/prompt-templates.md +63 -0
  37. package/ai_docs/pi/providers.md +204 -0
  38. package/ai_docs/pi/quickstart.md +140 -0
  39. package/ai_docs/pi/readme.md +99 -0
  40. package/ai_docs/pi/rpc-commands.md +864 -0
  41. package/ai_docs/pi/rpc-extension-ui.md +204 -0
  42. package/ai_docs/pi/rpc.md +197 -0
  43. package/ai_docs/pi/sdk.md +151 -0
  44. package/ai_docs/pi/security.md +103 -0
  45. package/ai_docs/pi/session-format.md +297 -0
  46. package/ai_docs/pi/sessions.md +70 -0
  47. package/ai_docs/pi/settings.md +173 -0
  48. package/ai_docs/pi/shell-aliases.md +97 -0
  49. package/ai_docs/pi/skills.md +97 -0
  50. package/ai_docs/pi/slash-commands.md +64 -0
  51. package/ai_docs/pi/sources.md +57 -0
  52. package/ai_docs/pi/terminal-setup.md +223 -0
  53. package/ai_docs/pi/termux.md +121 -0
  54. package/ai_docs/pi/themes.md +140 -0
  55. package/ai_docs/pi/tmux.md +59 -0
  56. package/ai_docs/pi/toolkit.md +168 -0
  57. package/ai_docs/pi/tui.md +129 -0
  58. package/ai_docs/pi/usage.md +98 -0
  59. package/ai_docs/pi/virtual-models.md +118 -0
  60. package/ai_docs/pi/windows.md +71 -0
  61. package/apps/dpi-backend/.nvmrc +1 -0
  62. package/apps/dpi-backend/API.md +99 -0
  63. package/apps/dpi-backend/README.md +119 -0
  64. package/apps/dpi-backend/deploy/backend.env.example +9 -0
  65. package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
  66. package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
  67. package/apps/dpi-backend/package.json +33 -0
  68. package/apps/dpi-backend/prompts/one_sentence.md +3 -0
  69. package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
  70. package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
  71. package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
  72. package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
  73. package/apps/dpi-backend/scripts/token.ts +57 -0
  74. package/apps/dpi-backend/src/addons.ts +139 -0
  75. package/apps/dpi-backend/src/auth.ts +20 -0
  76. package/apps/dpi-backend/src/config.ts +54 -0
  77. package/apps/dpi-backend/src/contract-check.ts +118 -0
  78. package/apps/dpi-backend/src/errors.ts +50 -0
  79. package/apps/dpi-backend/src/extensions.ts +510 -0
  80. package/apps/dpi-backend/src/faux.ts +80 -0
  81. package/apps/dpi-backend/src/history.ts +165 -0
  82. package/apps/dpi-backend/src/idempotency.ts +20 -0
  83. package/apps/dpi-backend/src/jev.ts +43 -0
  84. package/apps/dpi-backend/src/jobs.ts +790 -0
  85. package/apps/dpi-backend/src/main.ts +76 -0
  86. package/apps/dpi-backend/src/model-catalog.ts +180 -0
  87. package/apps/dpi-backend/src/pairing-files.ts +142 -0
  88. package/apps/dpi-backend/src/pairing.ts +459 -0
  89. package/apps/dpi-backend/src/routes.ts +217 -0
  90. package/apps/dpi-backend/src/shared-state.ts +85 -0
  91. package/apps/dpi-backend/src/store.ts +1240 -0
  92. package/apps/dpi-backend/src/streams.ts +256 -0
  93. package/apps/dpi-backend/src/tunnel.ts +955 -0
  94. package/apps/dpi-backend/tsconfig.json +13 -0
  95. package/apps/dpi-playground/README.md +83 -0
  96. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
  97. package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
  98. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
  99. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
  100. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
  101. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
  102. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
  103. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
  104. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
  105. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
  106. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
  107. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
  108. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
  109. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
  110. package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
  111. package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
  112. package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
  113. package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
  114. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
  115. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
  116. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
  117. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
  118. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
  119. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
  120. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
  121. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
  122. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
  123. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
  124. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
  125. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
  126. package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
  127. package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
  128. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
  129. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
  130. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
  131. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
  132. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
  133. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
  134. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
  135. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
  136. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
  137. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
  138. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
  139. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
  140. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
  141. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
  142. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
  143. package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
  144. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
  145. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
  146. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
  147. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
  148. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
  149. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
  150. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
  151. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
  152. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
  153. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
  154. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
  155. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
  156. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
  157. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
  158. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
  159. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
  160. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
  161. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
  162. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
  163. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
  164. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
  165. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
  166. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
  167. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
  168. package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
  169. package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
  170. package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
  171. package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
  172. package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
  173. package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
  174. package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
  175. package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
  176. package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
  177. package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
  178. package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
  179. package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
  180. package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
  181. package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
  182. package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
  183. package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
  184. package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
  185. package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
  186. package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
  187. package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
  188. package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
  189. package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
  190. package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
  191. package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
  192. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
  193. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
  194. package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
  195. package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
  196. package/apps/dpi-playground/package.json +37 -0
  197. package/apps/dpi-sdk/README.md +74 -0
  198. package/apps/dpi-sdk/package.json +20 -0
  199. package/apps/dpi-sdk/scripts/net.ts +73 -0
  200. package/apps/dpi-sdk/src/api.ts +631 -0
  201. package/apps/dpi-sdk/src/app-state.ts +111 -0
  202. package/apps/dpi-sdk/src/client.ts +330 -0
  203. package/apps/dpi-sdk/src/errors.ts +16 -0
  204. package/apps/dpi-sdk/src/jobs.ts +99 -0
  205. package/apps/dpi-sdk/src/models.ts +472 -0
  206. package/apps/dpi-sdk/src/node/chunk.ts +488 -0
  207. package/apps/dpi-sdk/src/node/config.ts +175 -0
  208. package/apps/dpi-sdk/src/node/drivers.ts +402 -0
  209. package/apps/dpi-sdk/src/node/ops.ts +114 -0
  210. package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
  211. package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
  212. package/apps/dpi-sdk/src/node/registry.ts +390 -0
  213. package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
  214. package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
  215. package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
  216. package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
  217. package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
  218. package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
  219. package/apps/dpi-sdk/src/sse.ts +117 -0
  220. package/apps/dpi-sdk/src/transcript.ts +291 -0
  221. package/apps/dpi-sdk/tsconfig.json +13 -0
  222. package/apps/dpi-sdk/w +20 -0
  223. package/apps/dpi-tui/README.md +54 -0
  224. package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
  225. package/apps/dpi-tui/package.json +20 -0
  226. package/apps/dpi-tui/src/app.ts +433 -0
  227. package/apps/dpi-tui/src/commands.ts +151 -0
  228. package/apps/dpi-tui/src/main.ts +122 -0
  229. package/apps/dpi-tui/src/pairing.ts +287 -0
  230. package/apps/dpi-tui/src/pickers.ts +238 -0
  231. package/apps/dpi-tui/src/render.ts +164 -0
  232. package/apps/dpi-tui/src/theme.ts +57 -0
  233. package/apps/dpi-tui/tsconfig.json +14 -0
  234. package/bin/device.mjs +5 -0
  235. package/bin/hub.mjs +5 -0
  236. package/bin/tui.mjs +5 -0
  237. package/deploy/Caddyfile +120 -0
  238. package/docs/verification.md +102 -0
  239. package/dpi-network.example.yaml +62 -0
  240. package/images/01_durable_agent_network.svg +185 -0
  241. package/images/02_pi_made_durable.svg +204 -0
  242. package/images/03_restart_mid_tool.svg +168 -0
  243. package/images/07_one_api_every_client.svg +123 -0
  244. package/images/08_device_session_agent.svg +123 -0
  245. package/images/09_compact_keep_history.svg +143 -0
  246. package/images/10_shared_app_state.svg +177 -0
  247. package/images/11_durable_jobs_wait.svg +141 -0
  248. package/images/12_tools_change_mid_run.svg +231 -0
  249. package/images/13_hook_blocks_tool.svg +122 -0
  250. package/images/14_fork_at_any_entry.svg +119 -0
  251. package/images/15_web_client.png +0 -0
  252. package/images/16_terminal_client.svg +43 -0
  253. package/images/17_devices_are_data.svg +182 -0
  254. package/images/18_should_every_agent_be_durable.svg +118 -0
  255. package/images/19_sqlite_on_every_device.svg +155 -0
  256. package/images/20_admission_and_effects.svg +134 -0
  257. package/justfile +33 -0
  258. package/package.json +41 -4
  259. package/scripts/verify.mjs +210 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * Hub WSS tunnel endpoint — Durable Agent Network P2-12 (spec 04 §4).
3
+ *
4
+ * Upgrade path `/api/tunnel` on the hub's HTTP server, behind the TLS reverse
5
+ * proxy. Auth is hash-first: the bearer token is hashed exactly once and
6
+ * looked up through the unique `tunnel_credentials.token_hash` index; every
7
+ * decision after that (revoked tombstone, retired, pairing epoch) reads the
8
+ * row that the hash resolved to. Auth errors are raw HTTP JSON responses —
9
+ * there is no WebSocket, no WS close and no control frame before 101.
10
+ *
11
+ * After 101 the connection is in `awaiting-handshake` for HANDSHAKE_DEADLINE_MS
12
+ * and only a tuple-correct `pair-handshake` (checked against the AUTHED row,
13
+ * including a previous-generation credential) flips it to `open`, swaps out
14
+ * any prior authenticated connection for that device, and answers `hello`.
15
+ * Hello makes the transport available; it does NOT mark the device online —
16
+ * monitor online is a REST-probe decision that lands with P2-16.
17
+ *
18
+ * All numbers come from protocol limits (CANON); the rate/connection policy
19
+ * below is hub-side constructor-injected deployment policy, never overridable
20
+ * per request.
21
+ */
22
+ import type { IncomingMessage } from "node:http";
23
+ import type { Duplex } from "node:stream";
24
+ import { createHash, randomUUID } from "node:crypto";
25
+ import { WebSocketServer, type WebSocket } from "ws";
26
+ import type { NetworkRegistry } from "../../../../dpi-sdk/src/node/registry.js";
27
+ import type { TunnelDiagnostics } from "../../../../dpi-sdk/src/api.js";
28
+ import {
29
+ checkTunnelMessage,
30
+ allowedInPhase,
31
+ assertDirection,
32
+ type TunnelFrame,
33
+ type TunnelWireMessage,
34
+ } from "../../../../dpi-sdk/src/protocol/tunnel.js";
35
+ import {
36
+ HANDSHAKE_DEADLINE_MS,
37
+ IDLE_TIMEOUT_MS,
38
+ PING_INTERVAL_MS,
39
+ PING_MISS_LIMIT,
40
+ IP_BUCKET_IDLE_TTL_MS,
41
+ IP_BUCKET_MAX_KEYS,
42
+ WS_BACKPRESSURE_BYTES,
43
+ WS_MAX_MESSAGE_BYTES,
44
+ WS_UPGRADE_LIMITS,
45
+ } from "../../../../dpi-sdk/src/protocol/limits.js";
46
+ import { Audit, scrubSecrets, type AuditEvent } from "./audit.js";
47
+ import { clientIp, ipKey, viaTls } from "./client-ip.js";
48
+ import { TokenBuckets } from "./limiter.js";
49
+
50
+ export interface TunnelPolicy {
51
+ /** Upgrade attempts per client IP per minute (token bucket refill). */
52
+ ipPerMinute: number;
53
+ /** Burst allowance per client IP; defaults to the S-10 burst, never above `ipPerMinute`. */
54
+ ipCapacity: number;
55
+ /** Hub-wide upgrade attempts per minute (deployment safeguard on top of the per-IP policy). */
56
+ globalPerMinute: number;
57
+ /** Concurrent authenticated connections hub-wide. */
58
+ maxAuthenticated: number;
59
+ /** Concurrent sockets between 101 and a valid handshake, hub-wide. */
60
+ maxPendingHandshakes: number;
61
+ /** Pending + live connections per client IP; raise for a NAT that hides many devices. */
62
+ maxConnectionsPerIp: number;
63
+ /** Upper bound for retryAfterMs in 429 bodies (bounded per 04 §6). */
64
+ retryAfterMaxMs: number;
65
+ /** Refuse upgrades that a loopback TLS proxy did not vouch for (spec 05 §4.2). Off for plain local development. */
66
+ requireTls: boolean;
67
+ /** How long a closed connection's peer may take to answer the close handshake before the socket is destroyed. */
68
+ teardownGraceMs: number;
69
+ }
70
+
71
+ export const DEFAULT_TUNNEL_POLICY: TunnelPolicy = {
72
+ ipPerMinute: WS_UPGRADE_LIMITS.perMinutePerIp,
73
+ ipCapacity: WS_UPGRADE_LIMITS.burstPerIp,
74
+ globalPerMinute: 120,
75
+ maxAuthenticated: WS_UPGRADE_LIMITS.livePerHub,
76
+ maxPendingHandshakes: WS_UPGRADE_LIMITS.pendingPerHub,
77
+ maxConnectionsPerIp: WS_UPGRADE_LIMITS.connectionsPerIp,
78
+ retryAfterMaxMs: 60_000,
79
+ requireTls: false,
80
+ teardownGraceMs: 3_000,
81
+ };
82
+
83
+ export interface TunnelHandlers {
84
+ /** A connection completed handshake and received hello (transport available, NOT monitor online). */
85
+ onOpen?(conn: TunnelConnection): void;
86
+ /** A schema/direction/phase-valid device→hub frame on an open connection. */
87
+ onMessage?(conn: TunnelConnection, frame: TunnelWireMessage): void;
88
+ /** An authenticated connection went away (swap, close, error). */
89
+ onClose?(deviceId: string, connectionId: string): void;
90
+ }
91
+
92
+ export interface TunnelConnection {
93
+ readonly deviceId: string;
94
+ readonly deviceUid: string;
95
+ readonly connectionId: string;
96
+ readonly pairingEpoch: string;
97
+ readonly credentialId: string;
98
+ readonly generation: number;
99
+ /** Direction/phase-checked outbound send; false when the socket is gone. */
100
+ send(frame: TunnelFrame): boolean;
101
+ close(code: number, reason: string): void;
102
+ }
103
+
104
+ export interface TunnelTestHooks {
105
+ now?(): number;
106
+ }
107
+
108
+ interface AuthedRow {
109
+ credential_id: string;
110
+ device_id: string;
111
+ pairing_epoch: string;
112
+ generation: number;
113
+ status: string;
114
+ }
115
+
116
+ /** An upgrade that passed path, rate, capacity and credential checks. */
117
+ interface Admission {
118
+ row: AuthedRow;
119
+ deviceUid: string;
120
+ keyFingerprint: string;
121
+ ip: string;
122
+ }
123
+
124
+ const FAULT_LOG_INTERVAL_MS = 10_000;
125
+
126
+ interface Conn {
127
+ ws: WebSocket;
128
+ auth: AuthedRow;
129
+ deviceUid: string;
130
+ connectionId: string;
131
+ ip: string;
132
+ /** True while this connection counts against its address's pending+live cap. */
133
+ ipCounted: boolean;
134
+ phase: "awaiting-handshake" | "open" | "closed";
135
+ handshakeTimer?: NodeJS.Timeout;
136
+ pingTimer?: NodeJS.Timeout;
137
+ idleTimer?: NodeJS.Timeout;
138
+ pongMisses: number;
139
+ lastByte: number;
140
+ /** F13: frees the pending-handshake reservation exactly once. */
141
+ releasePending?: () => void;
142
+ }
143
+
144
+ export class TunnelServer {
145
+ private readonly wss: WebSocketServer;
146
+ private readonly openListeners: Array<(conn: TunnelConnection) => void> = [];
147
+ private readonly frameListeners: Array<
148
+ (conn: TunnelConnection, frame: TunnelWireMessage) => void
149
+ > = [];
150
+ private readonly connections = new Map<string, Conn>(); // deviceId → authenticated
151
+ private readonly ipBuckets: TokenBuckets;
152
+ private readonly globalBuckets: TokenBuckets;
153
+ /** Pending + live connections per client address (entries are removed at zero). */
154
+ private readonly ipConnections = new Map<string, number>();
155
+ private pendingHandshakes = 0;
156
+ private faultLoggedAt = 0;
157
+
158
+ constructor(
159
+ private readonly registry: NetworkRegistry,
160
+ private readonly audit: Audit,
161
+ private readonly hubId: string,
162
+ handlers: TunnelHandlers = {},
163
+ policy: Partial<TunnelPolicy> = {},
164
+ hooks: TunnelTestHooks = {},
165
+ ) {
166
+ this.policy = { ...DEFAULT_TUNNEL_POLICY, ...policy };
167
+ if (policy.ipCapacity === undefined)
168
+ this.policy.ipCapacity = Math.min(DEFAULT_TUNNEL_POLICY.ipCapacity, this.policy.ipPerMinute);
169
+ this.handlers = handlers;
170
+ this.now = hooks.now ?? Date.now;
171
+ this.ipBuckets = new TokenBuckets(
172
+ {
173
+ capacity: this.policy.ipCapacity,
174
+ perMinute: this.policy.ipPerMinute,
175
+ maxKeys: IP_BUCKET_MAX_KEYS,
176
+ idleTtlMs: IP_BUCKET_IDLE_TTL_MS,
177
+ },
178
+ this.now,
179
+ );
180
+ this.globalBuckets = new TokenBuckets(
181
+ {
182
+ capacity: this.policy.globalPerMinute,
183
+ perMinute: this.policy.globalPerMinute,
184
+ maxKeys: 1,
185
+ idleTtlMs: IP_BUCKET_IDLE_TTL_MS,
186
+ },
187
+ this.now,
188
+ );
189
+ this.wss = new WebSocketServer({
190
+ noServer: true,
191
+ maxPayload: WS_MAX_MESSAGE_BYTES,
192
+ perMessageDeflate: false,
193
+ });
194
+ }
195
+
196
+ private readonly policy: TunnelPolicy;
197
+ private handlers: TunnelHandlers;
198
+ private readonly now: () => number;
199
+
200
+ /** Authenticated connection count (diagnostics/tests). */
201
+ activeCount(): number {
202
+ return this.connections.size;
203
+ }
204
+
205
+ /** Sockets between 101 and a valid handshake (diagnostics/tests: release on failure). */
206
+ pendingHandshakeCount(): number {
207
+ return this.pendingHandshakes;
208
+ }
209
+
210
+ /** True when the device has an open, handshake-complete tunnel connection. */
211
+ hasConnection(deviceId: string): boolean {
212
+ return this.connections.get(deviceId)?.phase === "open";
213
+ }
214
+
215
+ diagnostics(deviceId: string): Omit<TunnelDiagnostics, "readerCount"> {
216
+ const conn = this.connections.get(deviceId);
217
+ return conn?.phase === "open"
218
+ ? { state: "connected", lastSeenAt: conn.lastByte }
219
+ : { state: "disconnected" };
220
+ }
221
+
222
+ /** Current authenticated connectionId, or undefined when offline. */
223
+ connectionIdOf(deviceId: string): string | undefined {
224
+ const c = this.connections.get(deviceId);
225
+ return c && c.phase === "open" ? c.connectionId : undefined;
226
+ }
227
+
228
+ /** Protocol-violation close of the device's current connection. */
229
+ closeConnection(deviceId: string, code: number, reason: string): void {
230
+ const c = this.connections.get(deviceId);
231
+ if (c) this.teardown(c, code, reason);
232
+ }
233
+
234
+ /**
235
+ * Replace the handlers (the dispatcher installs itself after construction).
236
+ * Per-connection frame validation (schema/direction/phase) has already run
237
+ * by the time onMessage fires.
238
+ */
239
+ setHandlers(handlers: TunnelHandlers): void {
240
+ this.handlers = handlers;
241
+ }
242
+
243
+ /** Additional post-handshake listeners (rotation recovery, implicit ack). */
244
+ addOpenListener(fn: (conn: TunnelConnection) => void): void {
245
+ this.openListeners.push(fn);
246
+ }
247
+
248
+ /** Additional validated open-phase device→hub frame listeners. */
249
+ addDeviceFrameListener(fn: (conn: TunnelConnection, frame: TunnelWireMessage) => void): void {
250
+ this.frameListeners.push(fn);
251
+ }
252
+
253
+ /** Send a frame to the device's current connection (no-op when offline). */
254
+ send(deviceId: string, frame: TunnelFrame): boolean {
255
+ const c = this.connections.get(deviceId);
256
+ if (!c || c.phase !== "open" || c.ws.readyState !== c.ws.OPEN) return false;
257
+ // Slow-consumer isolation (04 §10): a device that stops reading fills the
258
+ // kernel+ws buffer; past the CANON cap we cut THIS connection only.
259
+ if (c.ws.bufferedAmount > WS_BACKPRESSURE_BYTES) {
260
+ this.teardown(c, 1011, "slow consumer: outbound buffer over cap");
261
+ return false;
262
+ }
263
+ try {
264
+ c.ws.send(JSON.stringify(frame));
265
+ return true;
266
+ } catch {
267
+ return false;
268
+ }
269
+ }
270
+
271
+ /** Server 'upgrade' listener: exact path/method, then rate caps, then auth. */
272
+ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
273
+ let admitted: Admission | undefined;
274
+ try {
275
+ admitted = this.admit(req, socket);
276
+ } catch (e) {
277
+ // Any fault before the 101 (registry read, audit storage, anything unexpected) fails THIS
278
+ // request closed with a plain 503; it must never escape into the server's 'upgrade' emitter.
279
+ this.reportFault("upgrade", e);
280
+ this.httpJson(socket, 503, {
281
+ error: { code: "unavailable", message: "tunnel: temporarily unavailable" },
282
+ });
283
+ return;
284
+ }
285
+ if (!admitted) return;
286
+ const { row, deviceUid, keyFingerprint, ip } = admitted;
287
+
288
+ this.pendingHandshakes++;
289
+ let released = false;
290
+ const releasePending = () => {
291
+ if (released) return;
292
+ released = true;
293
+ this.pendingHandshakes--;
294
+ socket.off("close", releasePending);
295
+ socket.off("error", releasePending);
296
+ };
297
+ socket.once("close", releasePending);
298
+ socket.once("error", releasePending);
299
+ let upgraded = false;
300
+ try {
301
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
302
+ upgraded = true;
303
+ // Transfer the reservation to the APPLICATION handshake, not the 101.
304
+ this.onConnection(ws, row, deviceUid, keyFingerprint, ip, releasePending);
305
+ });
306
+ } catch {
307
+ releasePending();
308
+ socket.destroy();
309
+ } finally {
310
+ // No async verifyClient hook is configured: rejection never calls back.
311
+ if (!upgraded) releasePending();
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Everything that happens before the 101: path/method, rate caps, capacity,
317
+ * credential lookup. Writes the refusal itself and returns undefined when
318
+ * the request is not admitted. May throw; the caller fails closed.
319
+ */
320
+ private admit(req: IncomingMessage, socket: Duplex): Admission | undefined {
321
+ let path = "/";
322
+ try {
323
+ path = new URL(req.url ?? "/", "http://hub.local").pathname;
324
+ } catch {
325
+ /* fall through to reject */
326
+ }
327
+ if (req.method !== "GET" || path !== "/api/tunnel") {
328
+ this.httpJson(socket, 404, {
329
+ type: "tunnel.auth-error",
330
+ v: 1,
331
+ code: "token_unknown",
332
+ message: "tunnel: wrong endpoint",
333
+ });
334
+ return undefined;
335
+ }
336
+ const ip = ipKey(clientIp(req));
337
+ // Before any bucket: a request that cannot show TLS must not be able to spend (or probe) a client's budget.
338
+ if (this.policy.requireTls && !viaTls(req)) {
339
+ this.audit.denied(
340
+ "auth.denied",
341
+ ip,
342
+ { endpoint: "tunnel.upgrade", code: "tls_required" },
343
+ "tunnel.upgrade:tls_required",
344
+ );
345
+ this.httpJson(socket, 403, {
346
+ error: { code: "tls_required", message: "tunnel: this hub is only served over HTTPS" },
347
+ });
348
+ return undefined;
349
+ }
350
+ // The only legitimate client is the device connector, which sends no Origin; browsers always do.
351
+ // Refused before any bucket is touched so such requests can neither drain nor probe the limits.
352
+ if (req.headers.origin !== undefined) {
353
+ this.audit.denied(
354
+ "auth.denied",
355
+ ip,
356
+ { endpoint: "tunnel.upgrade", code: "origin_forbidden" },
357
+ "tunnel.upgrade:origin_forbidden",
358
+ );
359
+ this.httpJson(socket, 403, {
360
+ error: { code: "origin_forbidden", message: "tunnel: browser origins are not allowed" },
361
+ });
362
+ return undefined;
363
+ }
364
+ // What an address can spend before it shows a credential is its OWN budget. The hub-wide bucket
365
+ // and the capacity caps are only charged below, once the credential resolves to a live pairing:
366
+ // otherwise a handful of addresses sending garbage would starve every real device (GA-03).
367
+ const ipCheck = this.ipBuckets.take(ip);
368
+ if (!ipCheck.ok) {
369
+ this.refuseRate(socket, ip, "ip", ipCheck.retryAfterMs);
370
+ return undefined;
371
+ }
372
+ if ((this.ipConnections.get(ip) ?? 0) >= this.policy.maxConnectionsPerIp) {
373
+ this.audit.denied(
374
+ "rate_limited",
375
+ ip,
376
+ { endpoint: "tunnel.upgrade", scope: "ip_connections" },
377
+ "tunnel.upgrade:ip_connections",
378
+ );
379
+ this.httpJson(socket, 429, {
380
+ type: "tunnel.auth-error",
381
+ v: 1,
382
+ code: "rate_limited",
383
+ message: "tunnel: too many connections from this address",
384
+ retryAfterMs: Math.min(this.policy.retryAfterMaxMs, 5_000),
385
+ });
386
+ return undefined;
387
+ }
388
+
389
+ const m = /^Bearer (dpt_[A-Za-z0-9_-]{43})$/.exec(
390
+ String(req.headers.authorization ?? "").trim(),
391
+ );
392
+ if (!m) {
393
+ this.deny(socket, ip, "token_unknown", "tunnel: missing or malformed bearer credential");
394
+ return undefined;
395
+ }
396
+ // Hash exactly once; the unique token_hash index does the lookup.
397
+ const hash = createHash("sha256").update(m[1], "utf8").digest("hex");
398
+ const row = this.registry.db
399
+ .prepare(
400
+ "SELECT credential_id, device_id, pairing_epoch, generation, status FROM tunnel_credentials WHERE token_hash=?",
401
+ )
402
+ .get(hash) as AuthedRow | undefined;
403
+ if (!row || row.status === "retired") {
404
+ this.deny(socket, ip, "token_unknown", "tunnel: credential not recognized");
405
+ return undefined;
406
+ }
407
+ if (row.status === "revoked") {
408
+ this.deny(socket, ip, "revoked", "tunnel: credential revoked; re-pair required");
409
+ return undefined;
410
+ }
411
+ // Pairing must still be live and be THE epoch this credential belongs to.
412
+ const pairing = this.registry.db
413
+ .prepare("SELECT pairing_epoch, status, key_fingerprint FROM pairings WHERE device_id=?")
414
+ .get(row.device_id) as
415
+ | { pairing_epoch: string; status: string; key_fingerprint: string }
416
+ | undefined;
417
+ if (!pairing || pairing.status !== "paired" || pairing.pairing_epoch !== row.pairing_epoch) {
418
+ this.deny(socket, ip, "revoked", "tunnel: pairing no longer active for this credential");
419
+ return undefined;
420
+ }
421
+ const device = this.registry.db
422
+ .prepare("SELECT device_uid FROM devices WHERE id=?")
423
+ .get(row.device_id) as { device_uid: string | null } | undefined;
424
+ if (!device?.device_uid) {
425
+ this.deny(socket, ip, "token_unknown", "tunnel: device record incomplete");
426
+ return undefined;
427
+ }
428
+
429
+ // A live credential: from here the request is a real device's, and the shared budgets apply.
430
+ const globalCheck = this.globalBuckets.take("*");
431
+ if (!globalCheck.ok) {
432
+ this.refuseRate(socket, ip, "global", globalCheck.retryAfterMs);
433
+ return undefined;
434
+ }
435
+ // A device that already holds the live slot is replaced, not added.
436
+ const replacing = this.connections.has(row.device_id);
437
+ if (
438
+ this.pendingHandshakes >= this.policy.maxPendingHandshakes ||
439
+ (!replacing && this.connections.size >= this.policy.maxAuthenticated)
440
+ ) {
441
+ this.audit.denied(
442
+ "rate_limited",
443
+ ip,
444
+ { endpoint: "tunnel.upgrade", scope: "capacity" },
445
+ "tunnel.upgrade:capacity",
446
+ );
447
+ this.httpJson(socket, 429, {
448
+ type: "tunnel.auth-error",
449
+ v: 1,
450
+ code: "rate_limited",
451
+ message: "tunnel: hub at connection capacity",
452
+ retryAfterMs: Math.min(this.policy.retryAfterMaxMs, 5_000),
453
+ });
454
+ return undefined;
455
+ }
456
+ return { row, deviceUid: device.device_uid, keyFingerprint: pairing.key_fingerprint, ip };
457
+ }
458
+
459
+ private refuseRate(
460
+ socket: Duplex,
461
+ ip: string,
462
+ scope: "ip" | "global",
463
+ retryAfterMs: number,
464
+ ): void {
465
+ this.audit.denied(
466
+ "rate_limited",
467
+ ip,
468
+ { endpoint: "tunnel.upgrade", scope },
469
+ `tunnel.upgrade:${scope}`,
470
+ );
471
+ this.httpJson(socket, 429, {
472
+ type: "tunnel.auth-error",
473
+ v: 1,
474
+ code: "rate_limited",
475
+ message: "tunnel: too many upgrade attempts",
476
+ retryAfterMs: Math.max(1, Math.min(this.policy.retryAfterMaxMs, retryAfterMs)),
477
+ });
478
+ }
479
+
480
+ /** Stop everything (hub shutdown). */
481
+ close(): void {
482
+ for (const c of this.connections.values()) this.teardown(c, 1001, "hub shutdown");
483
+ this.wss.close();
484
+ }
485
+
486
+ // -- internals ----------------------------------------------------------
487
+
488
+ /** Pre-auth refusals are coalesced per (ip, reason, minute): a flood never costs a database write per request. */
489
+ private deny(
490
+ socket: Duplex,
491
+ ip: string,
492
+ code: "revoked" | "token_unknown",
493
+ message: string,
494
+ ): void {
495
+ this.audit.denied(
496
+ "auth.denied",
497
+ ip,
498
+ { endpoint: "tunnel.upgrade", code },
499
+ `tunnel.upgrade:${code}`,
500
+ );
501
+ this.httpJson(socket, 401, { type: "tunnel.auth-error", v: 1, code, message });
502
+ }
503
+
504
+ /** Raw HTTP JSON response on the upgrade socket (≤4 KiB by construction). */
505
+ private httpJson(socket: Duplex, status: number, body: Record<string, unknown>): void {
506
+ const reason =
507
+ status === 401
508
+ ? "Unauthorized"
509
+ : status === 403
510
+ ? "Forbidden"
511
+ : status === 404
512
+ ? "Not Found"
513
+ : status === 503
514
+ ? "Service Unavailable"
515
+ : "Too Many Requests";
516
+ const payload = Buffer.from(JSON.stringify(body), "utf8");
517
+ try {
518
+ socket.write(
519
+ `HTTP/1.1 ${status} ${reason}\r\nContent-Type: application/json\r\nContent-Length: ${payload.length}\r\nConnection: close\r\n\r\n`,
520
+ );
521
+ socket.write(payload);
522
+ } catch {
523
+ /* the peer is already gone */
524
+ } finally {
525
+ socket.destroy();
526
+ }
527
+ }
528
+
529
+ /** Log a contained fault (rate-limited, secret-scrubbed). The caller has already failed closed. */
530
+ private reportFault(where: string, e: unknown): void {
531
+ const now = Date.now();
532
+ if (now - this.faultLoggedAt < FAULT_LOG_INTERVAL_MS) return;
533
+ this.faultLoggedAt = now;
534
+ console.error(
535
+ `tunnel: ${where} failed closed: ${scrubSecrets(String((e as Error)?.message ?? e)).slice(0, 200)}`,
536
+ );
537
+ }
538
+
539
+ /** For events that describe something that already happened: a storage fault is logged, never thrown into a socket/timer callback. */
540
+ private auditBestEffort(event: AuditEvent): void {
541
+ try {
542
+ this.audit.append(event);
543
+ } catch (e) {
544
+ this.reportFault(`audit ${event.action}`, e);
545
+ }
546
+ }
547
+
548
+ /** Run connection-scoped work so an unexpected fault drops only this connection, never the process. */
549
+ private contain(conn: Conn, where: string, fn: () => void): void {
550
+ try {
551
+ fn();
552
+ } catch (e) {
553
+ this.reportFault(where, e);
554
+ this.teardown(conn, 1011, "tunnel: internal error");
555
+ }
556
+ }
557
+
558
+ private onConnection(
559
+ ws: WebSocket,
560
+ auth: AuthedRow,
561
+ deviceUid: string,
562
+ keyFingerprint: string,
563
+ ip: string,
564
+ releasePending: () => void,
565
+ ): void {
566
+ const conn: Conn = {
567
+ ws,
568
+ auth,
569
+ deviceUid,
570
+ connectionId: randomUUID(),
571
+ ip,
572
+ ipCounted: true,
573
+ phase: "awaiting-handshake",
574
+ pongMisses: 0,
575
+ lastByte: this.now(),
576
+ releasePending,
577
+ };
578
+ this.ipConnections.set(ip, (this.ipConnections.get(ip) ?? 0) + 1);
579
+ // 5 s to produce a valid handshake (04 §4).
580
+ conn.handshakeTimer = setTimeout(() => {
581
+ this.contain(conn, "handshake deadline", () =>
582
+ this.failHandshake(
583
+ conn,
584
+ "handshake_timeout",
585
+ "tunnel: no valid handshake within deadline",
586
+ ip,
587
+ ),
588
+ );
589
+ }, HANDSHAKE_DEADLINE_MS);
590
+
591
+ ws.on("message", (data: Buffer, isBinary: boolean) => {
592
+ conn.lastByte = this.now();
593
+ void isBinary;
594
+ this.contain(conn, "frame", () => this.onFrame(conn, data, ip));
595
+ });
596
+ ws.on("pong", () => {
597
+ conn.lastByte = this.now();
598
+ conn.pongMisses = 0;
599
+ });
600
+ ws.on("error", () => this.teardown(conn, 1011, "socket error"));
601
+ ws.on("close", () => this.teardown(conn, 1006, "closed"));
602
+ }
603
+
604
+ private onFrame(conn: Conn, data: Buffer, ip: string): void {
605
+ if (conn.phase === "closed") return;
606
+ let msg: unknown;
607
+ try {
608
+ msg = JSON.parse(data.toString("utf8"));
609
+ } catch {
610
+ this.failProtocol(conn, "malformed_json", "tunnel: frame is not valid JSON");
611
+ return;
612
+ }
613
+ if (!checkTunnelMessage(msg)) {
614
+ this.failProtocol(conn, "schema_violation", "tunnel: frame failed schema check");
615
+ return;
616
+ }
617
+ const frame = msg as TunnelWireMessage;
618
+ try {
619
+ assertDirection(frame, "device->hub");
620
+ } catch (e) {
621
+ this.failProtocol(conn, "direction_violation", (e as Error).message);
622
+ return;
623
+ }
624
+ if (!allowedInPhase(frame, conn.phase)) {
625
+ this.failProtocol(
626
+ conn,
627
+ "phase_violation",
628
+ `tunnel: message not allowed in phase ${conn.phase}`,
629
+ );
630
+ return;
631
+ }
632
+ if (conn.phase === "awaiting-handshake") {
633
+ this.completeHandshake(conn, frame as Extract<TunnelFrame, { type: "pair-handshake" }>, ip);
634
+ return;
635
+ }
636
+ this.handlers.onMessage?.(this.asConnection(conn), frame);
637
+ const asConn2 = this.asConnection(conn);
638
+ for (const fn of this.frameListeners) {
639
+ try {
640
+ fn(asConn2, frame);
641
+ } catch (e) {
642
+ this.auditBestEffort({
643
+ actor: "hub",
644
+ action: "tunnel.listener_error",
645
+ outcome: "denied",
646
+ connectionId: conn.connectionId,
647
+ detail: { stage: "frame", error: String((e as Error).message).slice(0, 200) },
648
+ });
649
+ }
650
+ }
651
+ }
652
+
653
+ private completeHandshake(
654
+ conn: Conn,
655
+ hs: Extract<TunnelFrame, { type: "pair-handshake" }>,
656
+ ip: string,
657
+ ): void {
658
+ // Tuple must match the row the TOKEN authenticated — never caller-declared
659
+ // identity. A previous-generation credential is fine: it is the row that
660
+ // was looked up, and the pairing epoch must still be the live one.
661
+ // The auth snapshot was taken at the 101: a rotation completing or a revoke
662
+ // racing the upgrade may have ended this credential since, so its status is
663
+ // re-read here. 'current'→'previous' (a rotation in flight) stays admittable.
664
+ const credential = this.registry.db
665
+ .prepare("SELECT status FROM tunnel_credentials WHERE credential_id=?")
666
+ .get(conn.auth.credential_id) as { status: string } | undefined;
667
+ const pairing = this.registry.db
668
+ .prepare("SELECT pairing_epoch, status, key_fingerprint FROM pairings WHERE device_id=?")
669
+ .get(conn.auth.device_id) as
670
+ | { pairing_epoch: string; status: string; key_fingerprint: string }
671
+ | undefined;
672
+ const problems: string[] = [];
673
+ if (!credential || credential.status === "retired" || credential.status === "revoked")
674
+ problems.push("credential no longer active");
675
+ if (!pairing || pairing.status !== "paired") problems.push("pairing not active");
676
+ else if (hs.pairingEpoch !== pairing.pairing_epoch) problems.push("pairing epoch mismatch");
677
+ if (hs.deviceId !== conn.auth.device_id) problems.push("deviceId mismatch");
678
+ if (hs.pairingEpoch !== conn.auth.pairing_epoch) problems.push("credential epoch mismatch");
679
+ if (hs.credentialId !== conn.auth.credential_id) problems.push("credentialId mismatch");
680
+ if (hs.generation !== conn.auth.generation) problems.push("generation mismatch");
681
+ if (hs.deviceUid !== conn.deviceUid) problems.push("deviceUid mismatch");
682
+ if (pairing && hs.keyFingerprint !== pairing.key_fingerprint)
683
+ problems.push("key fingerprint mismatch");
684
+ if (problems.length > 0) {
685
+ this.failHandshake(conn, "handshake_mismatch", `tunnel: ${problems.join("; ")}`, ip);
686
+ return;
687
+ }
688
+
689
+ // The connect is recorded BEFORE the device is admitted: when audit storage cannot take the
690
+ // row the new connection is refused (fail closed) and the device's current one is left alone.
691
+ try {
692
+ this.audit.append({
693
+ actor: `device:${conn.auth.device_id}`,
694
+ action: "tunnel.connect",
695
+ outcome: "ok",
696
+ connectionId: conn.connectionId,
697
+ detail: { credentialId: conn.auth.credential_id, generation: conn.auth.generation, ip },
698
+ });
699
+ } catch (e) {
700
+ this.reportFault("tunnel.connect audit", e);
701
+ this.failProtocol(
702
+ conn,
703
+ "unavailable",
704
+ "tunnel: hub cannot record this connection; retry later",
705
+ 1011,
706
+ );
707
+ return;
708
+ }
709
+
710
+ // Swap: the new connection replaces the old one ONLY now. The old socket's
711
+ // teardown must not delete this new registration (identity check below).
712
+ const previous = this.connections.get(conn.auth.device_id);
713
+ if (previous && previous !== conn)
714
+ this.teardown(previous, 1000, "superseded by new connection");
715
+
716
+ clearTimeout(conn.handshakeTimer);
717
+ conn.handshakeTimer = undefined;
718
+ conn.phase = "open";
719
+ conn.releasePending?.(); // F13: handshake done — the authed connection now counts under maxAuthenticated
720
+ conn.releasePending = undefined;
721
+ this.connections.set(conn.auth.device_id, conn);
722
+ this.registry.recordTunnelState(conn.auth.device_id, {
723
+ connectionId: conn.connectionId,
724
+ connectedAt: this.now(),
725
+ });
726
+
727
+ // Heartbeat: ping every 15 s; 2 unanswered pings close; 45 s byte-idle closes.
728
+ conn.pingTimer = setInterval(() => {
729
+ if (conn.ws.readyState !== conn.ws.OPEN) return;
730
+ if (conn.pongMisses >= PING_MISS_LIMIT) {
731
+ this.teardown(conn, 1001, "ping timeout");
732
+ return;
733
+ }
734
+ conn.pongMisses++;
735
+ try {
736
+ conn.ws.ping();
737
+ } catch {
738
+ this.teardown(conn, 1011, "ping failed");
739
+ }
740
+ }, PING_INTERVAL_MS);
741
+ conn.idleTimer = setInterval(
742
+ () => {
743
+ if (this.now() - conn.lastByte > IDLE_TIMEOUT_MS) this.teardown(conn, 1001, "idle timeout");
744
+ },
745
+ Math.min(IDLE_TIMEOUT_MS, 5_000),
746
+ );
747
+
748
+ conn.ws.send(
749
+ JSON.stringify({
750
+ type: "hello",
751
+ apiVersion: 1,
752
+ tunnelProto: 1,
753
+ hubId: this.hubId,
754
+ connectionId: conn.connectionId,
755
+ serverTime: this.now(),
756
+ } satisfies TunnelFrame),
757
+ );
758
+ this.handlers.onOpen?.(this.asConnection(conn));
759
+ const asConn = this.asConnection(conn);
760
+ // F08: a listener throwing must never take the hub down; the exception
761
+ // is audited and the connection stays up.
762
+ for (const fn of this.openListeners) {
763
+ try {
764
+ fn(asConn);
765
+ } catch (e) {
766
+ this.auditBestEffort({
767
+ actor: "hub",
768
+ action: "tunnel.listener_error",
769
+ outcome: "denied",
770
+ connectionId: conn.connectionId,
771
+ detail: { stage: "open", error: String((e as Error).message).slice(0, 200) },
772
+ });
773
+ }
774
+ }
775
+ }
776
+
777
+ /** Handshake-phase failure: fault + close(1008), nothing registered. */
778
+ private failHandshake(conn: Conn, code: string, message: string, ip: string): void {
779
+ this.auditBestEffort({
780
+ actor: `device:${conn.auth.device_id}`,
781
+ action: "tunnel.disconnect",
782
+ outcome: "denied",
783
+ connectionId: conn.connectionId,
784
+ detail: { stage: "handshake", code, ip },
785
+ });
786
+ this.failProtocol(conn, code, message);
787
+ }
788
+
789
+ /** Protocol violation on any phase: fault frame (if writable) + close (1008 policy violation unless the hub itself is at fault). */
790
+ private failProtocol(conn: Conn, code: string, message: string, closeCode = 1008): void {
791
+ if (conn.ws.readyState === conn.ws.OPEN) {
792
+ try {
793
+ conn.ws.send(
794
+ JSON.stringify({ type: "fault", error: { code, message } } satisfies TunnelFrame),
795
+ );
796
+ conn.ws.close(closeCode, message.slice(0, 120));
797
+ } catch {
798
+ /* socket already gone */
799
+ }
800
+ }
801
+ this.teardown(conn, closeCode, message);
802
+ }
803
+
804
+ private teardown(conn: Conn, code: number, reason: string): void {
805
+ conn.releasePending?.(); // F13: free the handshake reservation on ANY exit
806
+ if (conn.ipCounted) {
807
+ conn.ipCounted = false;
808
+ const left = (this.ipConnections.get(conn.ip) ?? 1) - 1;
809
+ if (left > 0) this.ipConnections.set(conn.ip, left);
810
+ else this.ipConnections.delete(conn.ip);
811
+ }
812
+ if (conn.phase === "closed") return;
813
+ conn.phase = "closed";
814
+ if (conn.handshakeTimer) clearTimeout(conn.handshakeTimer);
815
+ if (conn.pingTimer) clearInterval(conn.pingTimer);
816
+ if (conn.idleTimer) clearInterval(conn.idleTimer);
817
+ conn.handshakeTimer = conn.pingTimer = conn.idleTimer = undefined;
818
+ // Identity check: an OLD socket must never remove a NEW connection.
819
+ // Cleanup is fault-tolerant: a storage or handler fault is logged but can neither
820
+ // skip the remaining steps nor escape into a socket/timer callback.
821
+ if (this.connections.get(conn.auth.device_id) === conn) {
822
+ this.connections.delete(conn.auth.device_id);
823
+ try {
824
+ this.registry.clearTunnelStateFor(conn.auth.device_id, conn.connectionId);
825
+ } catch (e) {
826
+ this.reportFault("clear tunnel state", e);
827
+ }
828
+ this.auditBestEffort({
829
+ actor: `device:${conn.auth.device_id}`,
830
+ action: "tunnel.disconnect",
831
+ outcome: "ok",
832
+ connectionId: conn.connectionId,
833
+ detail: { code, reason: reason.slice(0, 128) },
834
+ });
835
+ try {
836
+ this.handlers.onClose?.(conn.auth.device_id, conn.connectionId);
837
+ } catch (e) {
838
+ this.reportFault("close handler", e);
839
+ }
840
+ }
841
+ if (conn.ws.readyState === conn.ws.OPEN || conn.ws.readyState === conn.ws.CLOSING) {
842
+ try {
843
+ conn.ws.close(code, reason.slice(0, 120));
844
+ } catch {
845
+ /* already closing */
846
+ }
847
+ // The ping/idle/handshake timers are cleared above: a peer that never answers the close
848
+ // handshake would otherwise keep this dead socket (and its fd) open forever.
849
+ const force = setTimeout(() => {
850
+ try {
851
+ conn.ws.terminate();
852
+ } catch {
853
+ /* already gone */
854
+ }
855
+ }, this.policy.teardownGraceMs);
856
+ force.unref();
857
+ conn.ws.once("close", () => clearTimeout(force));
858
+ }
859
+ }
860
+
861
+ private asConnection(conn: Conn): TunnelConnection {
862
+ return {
863
+ deviceId: conn.auth.device_id,
864
+ deviceUid: conn.deviceUid,
865
+ connectionId: conn.connectionId,
866
+ pairingEpoch: conn.auth.pairing_epoch,
867
+ credentialId: conn.auth.credential_id,
868
+ generation: conn.auth.generation,
869
+ send: (frame) => this.send(conn.auth.device_id, frame),
870
+ close: (code, reason) => this.teardown(conn, code, reason),
871
+ };
872
+ }
873
+ }