@huynle/hub 0.6.1 → 0.7.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 (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,116 @@
1
+ // POST /mcp: the hub's MCP tools over MCP Streamable HTTP, for MCP clients and gateways (Brain decision txznll2f).
2
+ // Stateless: one JSON-RPC message per POST, answered as application/json (no SSE stream, no Mcp-Session-Id);
3
+ // notifications and client responses get 202. Batches are refused (-32600, as in protocol 2025-06-18).
4
+ //
5
+ // Auth: `Authorization: Bearer <token>`, any credential the agent API accepts (app, device, session and act
6
+ // tokens), checked up front with the agent API's resolver: none or invalid → 401 with `WWW-Authenticate: Bearer
7
+ // realm="hub"` (no OAuth resource metadata, so gateways detect plain bearer auth). The tools are `hub mcp`'s
8
+ // (packages/cli/src/mcp.ts) and call /api/agent with the caller's bearer, dispatched in process: scopes, grants,
9
+ // tenancy, kill switches and the audit are exactly the HTTP API's. A browser Origin other than the hub's own is
10
+ // refused (DNS rebinding); requests without one (servers, gateways) are fine. Tokens are never echoed.
11
+ import { createMcpServer, MCP_PROTOCOL_VERSIONS, NOT_FROM_USER, RPC, rpcError, type RpcResponse } from "../../packages/cli/src/mcp"
12
+ import { readCredentials } from "./agent/actor"
13
+
14
+ export const MCP_PATH = "/mcp"
15
+
16
+ /** Largest request body (bytes). */
17
+ export const MCP_MAX_BODY_BYTES = 1 << 20
18
+
19
+ export const MCP_INSTRUCTIONS =
20
+ "Hub: see and steer your AI agent sessions. Tools read sessions (status, sessions, show, log, who), message them (msg, ask, reply, decline, inbox), act within your grants (approve, deny, answer, prompt, stop) and launch sessions (launch, launches, launch_show, launch_stop), as this bearer token. " +
21
+ NOT_FROM_USER
22
+
23
+ export interface McpHttpDeps {
24
+ /** The hub's public URL (the tools' base URL; requests never leave the process). */
25
+ publicURL(): string
26
+ /** Whether a request's Origin is the hub's own. */
27
+ isOwnOrigin(origin: string): boolean
28
+ /** Whether the agent API accepts this bearer token. */
29
+ verify(token: string): Promise<boolean>
30
+ /** The hub's own request handler (the tools' /api/agent calls). */
31
+ dispatch(request: Request): Promise<Response>
32
+ readonly version: string
33
+ readonly maxBodyBytes?: number
34
+ }
35
+
36
+ const NO_STORE = { "cache-control": "no-store" }
37
+
38
+ const failure = (status: number, code: string, message: string, headers: Record<string, string> = {}) =>
39
+ Response.json({ error: { code, message } }, { status, headers: { ...NO_STORE, ...headers } })
40
+
41
+ const unauthorized = (message: string) => failure(401, "unauthorized", message, { "www-authenticate": 'Bearer realm="hub"' })
42
+
43
+ const answer = (response: RpcResponse) =>
44
+ // A message the server could not take (parse error, not a request, a batch) carries no id: HTTP 400.
45
+ Response.json(response, { status: response.error && response.id === null ? 400 : 200, headers: NO_STORE })
46
+
47
+ /** The body as text, or undefined when it is over `max` bytes (read no further). */
48
+ async function readCapped(request: Request, max: number): Promise<string | undefined> {
49
+ const length = Number(request.headers.get("content-length") ?? "")
50
+ if (Number.isFinite(length) && length > max) return undefined
51
+ if (!request.body) return ""
52
+ const reader = request.body.getReader()
53
+ const chunks: Uint8Array[] = []
54
+ let size = 0
55
+ for (;;) {
56
+ const { done, value } = await reader.read()
57
+ if (done) break
58
+ size += value.byteLength
59
+ if (size > max) {
60
+ void reader.cancel().catch(() => {})
61
+ return undefined
62
+ }
63
+ chunks.push(value)
64
+ }
65
+ return new TextDecoder().decode(Buffer.concat(chunks))
66
+ }
67
+
68
+ const acceptsJSON = (accept: string | null) => {
69
+ if (accept === null || !accept.trim()) return true
70
+ return accept.split(",").some((part) => {
71
+ const type = part.split(";")[0]!.trim().toLowerCase()
72
+ return type === "application/json" || type === "*/*" || type === "application/*"
73
+ })
74
+ }
75
+
76
+ /** Answers a request to /mcp (any method). */
77
+ export function mcpHttp(deps: McpHttpDeps): (request: Request) => Promise<Response> {
78
+ const max = deps.maxBodyBytes ?? MCP_MAX_BODY_BYTES
79
+ return async (request) => {
80
+ if (request.method !== "POST") return failure(405, "method_not_allowed", "POST JSON-RPC messages to /mcp (no SSE stream, no sessions)", { allow: "POST" })
81
+ const origin = request.headers.get("origin")
82
+ if (origin !== null && !deps.isOwnOrigin(origin)) return failure(403, "forbidden", "Cross-origin request refused")
83
+ // The bearer only: a cookie never authenticates /mcp.
84
+ const token = readCredentials(request, "").bearer
85
+ if (token === undefined) return unauthorized("Send Authorization: Bearer <hub token>")
86
+ if (!token || !(await deps.verify(token))) return unauthorized("Invalid, expired or revoked token")
87
+ const version = request.headers.get("mcp-protocol-version")
88
+ if (version !== null && !MCP_PROTOCOL_VERSIONS.includes(version)) return failure(400, "invalid", `Unsupported MCP-Protocol-Version (supported: ${MCP_PROTOCOL_VERSIONS.join(", ")})`)
89
+ const type = (request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase()
90
+ if (type !== "application/json") return failure(415, "invalid", "Content-Type must be application/json")
91
+ if (!acceptsJSON(request.headers.get("accept"))) return failure(406, "invalid", "Accept must allow application/json")
92
+ const text = await readCapped(request, max)
93
+ if (text === undefined) return failure(413, "invalid", `Request body over ${max} bytes`)
94
+ let message: unknown
95
+ try {
96
+ message = JSON.parse(text)
97
+ } catch {
98
+ return answer(rpcError(null, RPC.parse, "parse error: not JSON"))
99
+ }
100
+ const server = createMcpServer({
101
+ credentials: () => ({ url: deps.publicURL(), token, source: "http", fetch: (input, init) => deps.dispatch(new Request(input, init)) }),
102
+ version: deps.version,
103
+ instructions: MCP_INSTRUCTIONS,
104
+ })
105
+ // The client went away: its tool calls are aborted.
106
+ const close = () => server.close()
107
+ request.signal.addEventListener("abort", close, { once: true })
108
+ try {
109
+ const response = await server.handle(message)
110
+ return response ? answer(response) : new Response(null, { status: 202, headers: NO_STORE })
111
+ } finally {
112
+ request.signal.removeEventListener("abort", close)
113
+ server.close()
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,314 @@
1
+ // NodeRegistry: the hub's view of connected `hub node` daemons (design §6.3), one entry per node connection.
2
+ // Kept apart from the spoke Registry on purpose: a node is never a process, never in the catalog and never makes
3
+ // its machine "online"; it only reports runtimes, labels, capacity and launch progress. Placement (P4-T5) reads
4
+ // list(ownerID); the launch engine (P4-T8) subscribes to progress and connect/disconnect events.
5
+ // Cluster mode (design §9): each pod is the single writer of its own nodes and exports them per owner (clusterSlice);
6
+ // ./cluster/view.ts applies other pods' slices (setRemote), so list/get/forMachine see every node of the cluster (a
7
+ // node on another pod is reached through SpokeHub.requestNode). Events and count() stay local, except for
8
+ // subscribers asking for remote events (the launch supervisor on the leader pod): they also get connected,
9
+ // capacity, progress and disconnected derived from other pods' slice changes. A local node of a machine wins over a
10
+ // remote one.
11
+ import type { NodeCapacity, NodeCapacityFrame, NodeHello, NodeLaunchPhase, NodeLaunchReport, NodeProgress, NodeRuntimeInfo } from "../protocol"
12
+
13
+ export interface NodeMachine {
14
+ readonly id: string
15
+ readonly name: string
16
+ }
17
+
18
+ /** A connected node, as seen by placement and the launch engine (a copy; changing it changes nothing). */
19
+ export interface NodeView {
20
+ readonly connID: string
21
+ readonly machineID: string
22
+ readonly machineName: string
23
+ /** The machine's current owner, read live (ownership can move). */
24
+ readonly ownerID: string | undefined
25
+ readonly instanceID: string
26
+ readonly nodeVersion: string
27
+ readonly features: readonly string[]
28
+ readonly runtimes: readonly NodeRuntimeInfo[]
29
+ readonly labels: Readonly<Record<string, string>>
30
+ readonly capacity: NodeCapacity
31
+ readonly used: NodeCapacity
32
+ /** capacity − used, never below zero; memoryMB only when the node reports both. */
33
+ readonly free: NodeCapacity
34
+ readonly workspaceRoots: readonly string[]
35
+ readonly draining: boolean
36
+ /** Launches the node carries, with their latest phase (cleaned and cancelled ones are dropped). */
37
+ readonly launches: readonly NodeLaunchReport[]
38
+ readonly connectedAt: number
39
+ readonly lastSeen: number
40
+ }
41
+
42
+ export type NodeEvent =
43
+ | { readonly type: "connected"; readonly node: NodeView }
44
+ | { readonly type: "capacity"; readonly node: NodeView }
45
+ | { readonly type: "progress"; readonly node: NodeView; readonly launchID: string; readonly phase: NodeLaunchPhase; readonly message?: string }
46
+ | { readonly type: "disconnected"; readonly node: NodeView }
47
+
48
+ export type NodeListener = (event: NodeEvent) => void
49
+
50
+ /** One owner's nodes on one pod, as other pods mirror them (JSON-compatible). */
51
+ export interface NodeSlice {
52
+ readonly nodes: NodeView[]
53
+ }
54
+
55
+ /** Phases after which the node no longer carries the launch. */
56
+ const GONE: ReadonlySet<NodeLaunchPhase> = new Set(["cleaned", "cancelled"])
57
+
58
+ interface Entry {
59
+ readonly connID: string
60
+ readonly machine: NodeMachine
61
+ readonly hello: NodeHello
62
+ labels: Record<string, string>
63
+ capacity: NodeCapacity
64
+ used: NodeCapacity
65
+ draining: boolean
66
+ readonly launches: Map<string, NodeLaunchReport>
67
+ readonly connectedAt: number
68
+ lastSeen: number
69
+ }
70
+
71
+ export class NodeRegistry {
72
+ private readonly entries = new Map<string, Entry>()
73
+ private readonly listeners = new Set<NodeListener>()
74
+ /** Listeners that also get events derived from other pods' slices (the launch supervisor on the leader pod). */
75
+ private readonly remoteListeners = new Set<NodeListener>()
76
+ private readonly localChangeListeners = new Set<(owner: string | undefined) => void>()
77
+ /** Other pods' nodes: pod → owner → slice. */
78
+ private readonly remote = new Map<string, Map<string, NodeSlice>>()
79
+
80
+ constructor(
81
+ private readonly ownerOf: (machineID: string) => string | undefined,
82
+ private readonly now: () => number = Date.now,
83
+ ) {}
84
+
85
+ connect(connID: string, machine: NodeMachine, hello: NodeHello): NodeView {
86
+ const at = this.now()
87
+ const entry: Entry = {
88
+ connID,
89
+ machine: { id: machine.id, name: machine.name },
90
+ hello,
91
+ labels: { ...hello.labels },
92
+ capacity: { ...hello.capacity },
93
+ used: { ...hello.used },
94
+ draining: hello.draining,
95
+ launches: new Map(hello.launches.map((l) => [l.launchID, { ...l }])),
96
+ connectedAt: at,
97
+ lastSeen: at,
98
+ }
99
+ this.entries.set(connID, entry)
100
+ const node = this.view(entry)
101
+ this.emit({ type: "connected", node })
102
+ return node
103
+ }
104
+
105
+ setCapacity(connID: string, frame: NodeCapacityFrame): void {
106
+ const entry = this.entries.get(connID)
107
+ if (!entry) return
108
+ entry.capacity = { ...frame.capacity }
109
+ entry.used = { ...frame.used }
110
+ entry.draining = frame.draining
111
+ if (frame.labels) entry.labels = { ...frame.labels }
112
+ this.emit({ type: "capacity", node: this.view(entry) })
113
+ }
114
+
115
+ progress(connID: string, frame: NodeProgress): void {
116
+ const entry = this.entries.get(connID)
117
+ if (!entry) return
118
+ if (GONE.has(frame.phase)) entry.launches.delete(frame.launchID)
119
+ else {
120
+ const previous = entry.launches.get(frame.launchID)
121
+ entry.launches.set(frame.launchID, { ...previous, launchID: frame.launchID, phase: frame.phase })
122
+ }
123
+ this.emit({
124
+ type: "progress",
125
+ node: this.view(entry),
126
+ launchID: frame.launchID,
127
+ phase: frame.phase,
128
+ ...(frame.message !== undefined ? { message: frame.message } : {}),
129
+ })
130
+ }
131
+
132
+ touch(connID: string): void {
133
+ const entry = this.entries.get(connID)
134
+ if (entry) entry.lastSeen = this.now()
135
+ }
136
+
137
+ disconnect(connID: string): void {
138
+ const entry = this.entries.get(connID)
139
+ if (!entry) return
140
+ this.entries.delete(connID)
141
+ this.emit({ type: "disconnected", node: this.view(entry) })
142
+ }
143
+
144
+ get(connID: string): NodeView | undefined {
145
+ const entry = this.entries.get(connID)
146
+ return entry ? this.view(entry) : this.remoteNodes().find((node) => node.connID === connID)
147
+ }
148
+
149
+ /** The node connection of a machine (at most one: the hub turns away a second one); this pod's first. */
150
+ forMachine(machineID: string): NodeView | undefined {
151
+ return this.localForMachine(machineID) ?? this.remoteNodes().find((node) => node.machineID === machineID)
152
+ }
153
+
154
+ /** The node connection of a machine held by this pod. */
155
+ localForMachine(machineID: string): NodeView | undefined {
156
+ for (const entry of this.entries.values()) if (entry.machine.id === machineID) return this.view(entry)
157
+ return undefined
158
+ }
159
+
160
+ /** Connected nodes of `ownerID` (all owned nodes when omitted); a node whose machine has no owner is never listed. */
161
+ list(ownerID?: string): NodeView[] {
162
+ const out: NodeView[] = []
163
+ for (const node of [...[...this.entries.values()].map((entry) => this.view(entry)), ...this.remoteNodes()]) {
164
+ if (node.ownerID !== undefined && (ownerID === undefined || node.ownerID === ownerID)) out.push(node)
165
+ }
166
+ return out
167
+ }
168
+
169
+ // ---------- cluster (design §9) ----------
170
+
171
+ /** Listens for changes to this pod's nodes of an owner. */
172
+ onLocalChange(listener: (owner: string | undefined) => void): () => void {
173
+ this.localChangeListeners.add(listener)
174
+ return () => this.localChangeListeners.delete(listener)
175
+ }
176
+
177
+ /** Owners of this pod's nodes. */
178
+ clusterTenants(): string[] {
179
+ const owners = new Set<string>()
180
+ for (const entry of this.entries.values()) {
181
+ const owner = this.ownerOf(entry.machine.id)
182
+ if (owner !== undefined) owners.add(owner)
183
+ }
184
+ return [...owners]
185
+ }
186
+
187
+ /** This pod's nodes of an owner for other pods (undefined when none). */
188
+ clusterSlice(owner: string): NodeSlice | undefined {
189
+ const nodes = [...this.entries.values()].map((entry) => this.view(entry)).filter((node) => node.ownerID === owner)
190
+ return nodes.length ? { nodes } : undefined
191
+ }
192
+
193
+ /** Applies another pod's nodes of an owner (undefined: it has none any more). */
194
+ setRemote(pod: string, owner: string, slice: NodeSlice | undefined): void {
195
+ let slices = this.remote.get(pod)
196
+ const before = slices?.get(owner)?.nodes ?? []
197
+ if (slice) {
198
+ if (!slices) this.remote.set(pod, (slices = new Map()))
199
+ slices.set(owner, slice)
200
+ } else if (slices?.delete(owner) && !slices.size) this.remote.delete(pod)
201
+ if (this.remoteListeners.size) this.emitRemote(before, slice?.nodes ?? [])
202
+ }
203
+
204
+ /**
205
+ * Events for remote listeners from a change of another pod's slice: nodes connecting and leaving, capacity, and
206
+ * per launch its new phase, or (gone from the slice) cancelled while preparing, else cleaned.
207
+ */
208
+ private emitRemote(before: readonly NodeView[], after: readonly NodeView[]): void {
209
+ const view = (node: NodeView): NodeView => ({ ...structuredClone(node), ownerID: this.ownerOf(node.machineID) })
210
+ const old = new Map(before.map((node) => [node.connID, node]))
211
+ const events: NodeEvent[] = []
212
+ for (const node of after) {
213
+ const previous = old.get(node.connID)
214
+ old.delete(node.connID)
215
+ if (!previous) {
216
+ events.push({ type: "connected", node: view(node) })
217
+ continue
218
+ }
219
+ const capacity = (n: NodeView) => JSON.stringify([n.capacity, n.used, n.draining, n.labels])
220
+ if (capacity(previous) !== capacity(node)) events.push({ type: "capacity", node: view(node) })
221
+ const phases = new Map(previous.launches.map((l) => [l.launchID, l.phase]))
222
+ const current = new Set(node.launches.map((l) => l.launchID))
223
+ // Released launches first, then the new phases.
224
+ for (const [launchID, phase] of phases) {
225
+ if (!current.has(launchID)) events.push({ type: "progress", node: view(node), launchID, phase: phase === "preparing" ? "cancelled" : "cleaned" })
226
+ }
227
+ for (const launch of node.launches) {
228
+ if (phases.get(launch.launchID) !== launch.phase) events.push({ type: "progress", node: view(node), launchID: launch.launchID, phase: launch.phase })
229
+ }
230
+ }
231
+ for (const node of old.values()) events.push({ type: "disconnected", node: view(node) })
232
+ for (const event of events) this.notify(this.remoteListeners, event)
233
+ }
234
+
235
+ /** Other pods' nodes (owner read live), one per machine (the newest), none for a machine with a local node. */
236
+ private remoteNodes(): NodeView[] {
237
+ if (!this.remote.size) return []
238
+ const local = new Set([...this.entries.values()].map((entry) => entry.machine.id))
239
+ const byMachine = new Map<string, NodeView>()
240
+ for (const slices of this.remote.values()) {
241
+ for (const slice of slices.values()) {
242
+ for (const node of slice.nodes ?? []) {
243
+ if (local.has(node.machineID) || this.entries.has(node.connID)) continue
244
+ const known = byMachine.get(node.machineID)
245
+ if (!known || node.connectedAt > known.connectedAt) byMachine.set(node.machineID, node)
246
+ }
247
+ }
248
+ }
249
+ return [...byMachine.values()].map((node) => ({ ...structuredClone(node), ownerID: this.ownerOf(node.machineID) }))
250
+ }
251
+
252
+ /** Connected node connections (for /healthz). */
253
+ count(): number {
254
+ return this.entries.size
255
+ }
256
+
257
+ /** Events of this pod's nodes; with `remote`, also of other pods' nodes (derived from their slices). */
258
+ subscribe(listener: NodeListener, options: { readonly remote?: boolean } = {}): () => void {
259
+ this.listeners.add(listener)
260
+ if (options.remote) this.remoteListeners.add(listener)
261
+ return () => {
262
+ this.listeners.delete(listener)
263
+ this.remoteListeners.delete(listener)
264
+ }
265
+ }
266
+
267
+ private emit(event: NodeEvent): void {
268
+ for (const listener of [...this.localChangeListeners]) {
269
+ try {
270
+ listener(event.node.ownerID)
271
+ } catch (cause) {
272
+ console.error("[hub] node change listener failed:", cause instanceof Error ? cause.message : cause)
273
+ }
274
+ }
275
+ this.notify(this.listeners, event)
276
+ }
277
+
278
+ private notify(listeners: ReadonlySet<NodeListener>, event: NodeEvent): void {
279
+ for (const listener of [...listeners]) {
280
+ try {
281
+ listener(event)
282
+ } catch (cause) {
283
+ console.error("[hub] node listener failed:", cause instanceof Error ? cause.message : cause)
284
+ }
285
+ }
286
+ }
287
+
288
+ private view(entry: Entry): NodeView {
289
+ const { hello, capacity, used } = entry
290
+ const free: NodeCapacity = {
291
+ slots: Math.max(0, capacity.slots - used.slots),
292
+ ...(capacity.memoryMB !== undefined && used.memoryMB !== undefined ? { memoryMB: Math.max(0, capacity.memoryMB - used.memoryMB) } : {}),
293
+ }
294
+ return {
295
+ connID: entry.connID,
296
+ machineID: entry.machine.id,
297
+ machineName: entry.machine.name,
298
+ ownerID: this.ownerOf(entry.machine.id),
299
+ instanceID: hello.instanceID,
300
+ nodeVersion: hello.nodeVersion,
301
+ features: [...(hello.features ?? [])],
302
+ runtimes: hello.runtimes.map((r) => structuredClone(r)),
303
+ labels: { ...entry.labels },
304
+ capacity: { ...capacity },
305
+ used: { ...used },
306
+ free,
307
+ workspaceRoots: [...hello.workspaceRoots],
308
+ draining: entry.draining,
309
+ launches: [...entry.launches.values()].map((l) => ({ ...l })),
310
+ connectedAt: entry.connectedAt,
311
+ lastSeen: entry.lastSeen,
312
+ }
313
+ }
314
+ }
@@ -1,8 +1,12 @@
1
1
  // OpenID Connect sign-in (authorization code flow with PKCE S256, state and nonce) for multi-user hubs.
2
2
  // The ID token comes straight from the token endpoint over TLS (confidential client, client_secret_post),
3
- // so its signature is not checked (OIDC Core 3.1.3.7, item 6); its claims are. Pending sign-ins live in
4
- // memory only (a restart invalidates them) and are single use.
3
+ // so its signature is not checked (OIDC Core 3.1.3.7, item 6); its claims are. Pending sign-ins live in the Kv
4
+ // backend (process memory by default, so a restart invalidates them; Valkey in a cluster, so the callback may land
5
+ // on any pod) and are single use (GETDEL).
5
6
  import { createHash, randomBytes } from "node:crypto"
7
+ import { MemoryCounters } from "./backend/memory-counters"
8
+ import { MemoryKv } from "./backend/memory-kv"
9
+ import type { Counters, Kv } from "./backend/types"
6
10
  import type { OidcConfig } from "./config"
7
11
 
8
12
  export const OIDC_COOKIE = "voice_oidc"
@@ -11,8 +15,10 @@ export const OIDC_PENDING_TTL_MS = 10 * 60_000
11
15
  export const DISCOVERY_CACHE_MS = 60 * 60_000
12
16
  /** Clock skew tolerated on `exp`. */
13
17
  const SKEW_SECONDS = 60
14
- /** Most pending sign-ins kept (the oldest are dropped): /auth/login is public. */
15
- const MAX_PENDING = 1000
18
+ /** Most sign-ins started per pending lifetime (every pod together); /auth/login is public. */
19
+ const MAX_PENDING = 10_000
20
+ const PENDING_KEY = (state: string) => `oidc:pending:${state}`
21
+ const STARTED_KEY = "oidc:started"
16
22
  const FETCH_TIMEOUT_MS = 10_000
17
23
  const MAX_NEXT_CHARS = 1024
18
24
 
@@ -56,6 +62,10 @@ export interface OidcClientOptions {
56
62
  readonly publicURL: string
57
63
  readonly fetch?: typeof globalThis.fetch
58
64
  readonly now?: () => number
65
+ /** Pending sign-ins (default: in this process, on `now`). */
66
+ readonly kv?: Kv
67
+ /** The cap on sign-ins started (default: in this process, on `now`). */
68
+ readonly counters?: Counters
59
69
  }
60
70
 
61
71
  const random = () => randomBytes(32).toString("base64url")
@@ -129,7 +139,8 @@ export class OidcClient {
129
139
  private readonly config: OidcConfig
130
140
  private readonly fetch: typeof globalThis.fetch
131
141
  private readonly now: () => number
132
- private readonly pending = new Map<string, Pending>()
142
+ private readonly kv: Kv
143
+ private readonly counters: Counters
133
144
  private discovery?: { at: number; doc: Discovery }
134
145
  readonly redirectURI: string
135
146
 
@@ -137,18 +148,22 @@ export class OidcClient {
137
148
  this.config = options.config
138
149
  this.fetch = options.fetch ?? globalThis.fetch
139
150
  this.now = options.now ?? Date.now
151
+ this.kv = options.kv ?? new MemoryKv({ now: this.now })
152
+ this.counters = options.counters ?? new MemoryCounters({ now: this.now })
140
153
  this.redirectURI = `${options.publicURL}/auth/callback`
141
154
  }
142
155
 
143
156
  /** Starts a sign-in: the provider URL to redirect to and the state for the voice_oidc cookie. */
144
157
  async login(next: string | null): Promise<{ location: string; state: string }> {
145
158
  const doc = await this.discover()
146
- this.sweep()
159
+ if ((await this.counters.incr(STARTED_KEY, OIDC_PENDING_TTL_MS)) > MAX_PENDING) {
160
+ throw new OidcError(429, "Too many sign-ins are in progress. Please try again in a few minutes.", "too many pending sign-ins")
161
+ }
147
162
  const state = random()
148
163
  const nonce = random()
149
164
  const verifier = random()
150
- while (this.pending.size >= MAX_PENDING) this.pending.delete(this.pending.keys().next().value!)
151
- this.pending.set(state, { nonce, verifier, next: safeNext(next), expiresAt: this.now() + OIDC_PENDING_TTL_MS })
165
+ const pending: Pending = { nonce, verifier, next: safeNext(next), expiresAt: this.now() + OIDC_PENDING_TTL_MS }
166
+ await this.kv.set(PENDING_KEY(state), JSON.stringify(pending), { px: OIDC_PENDING_TTL_MS })
152
167
  const url = new URL(doc.authorization_endpoint)
153
168
  url.searchParams.set("response_type", "code")
154
169
  url.searchParams.set("client_id", this.config.clientId)
@@ -166,13 +181,11 @@ export class OidcClient {
166
181
  * pending entry is consumed whatever the outcome once the state matches the cookie.
167
182
  */
168
183
  async callback(params: URLSearchParams, cookieState: string | undefined): Promise<{ identity: OidcIdentity; next: string }> {
169
- this.sweep()
170
184
  const state = params.get("state") ?? ""
171
- const pending = state && cookieState === state ? this.pending.get(state) : undefined
185
+ const pending = state && cookieState === state ? await this.consume(state) : undefined
172
186
  if (!pending) {
173
187
  throw new OidcError(400, "This sign-in has expired or was already used. Please sign in again.", "state invalid")
174
188
  }
175
- this.pending.delete(state)
176
189
  const providerError = params.get("error")
177
190
  if (providerError) {
178
191
  const description = params.get("error_description")
@@ -270,9 +283,16 @@ export class OidcClient {
270
283
  }
271
284
  }
272
285
 
273
- private sweep(): void {
274
- const t = this.now()
275
- for (const [state, entry] of this.pending) if (entry.expiresAt <= t) this.pending.delete(state)
286
+ /** Takes a pending sign-in out of the Kv (GETDEL: one callback wins, on any pod); undefined when unknown or expired. */
287
+ private async consume(state: string): Promise<Pending | undefined> {
288
+ const raw = await this.kv.getdel(PENDING_KEY(state))
289
+ if (raw === undefined) return undefined
290
+ try {
291
+ const pending = JSON.parse(raw) as Pending
292
+ return pending.expiresAt > this.now() ? pending : undefined
293
+ } catch {
294
+ return undefined
295
+ }
276
296
  }
277
297
  }
278
298