@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
@@ -2,9 +2,18 @@
2
2
  // - machine enrollment (device-code flow): a spoke asks, the owner approves in the web UI, the spoke polls for its token
3
3
  // - device pairing: short single-use numeric codes, shown in the /hub dialog or logged at startup
4
4
  // - login links: long single-use codes opened from the terminal to sign a browser in
5
- // Pending codes live in memory only; a restart invalidates them.
5
+ // - app link codes: long single-use codes (QR / hubapp:// deep link) a native app redeems for a device token
6
+ // - CLI login (device-code flow, `hub login`): a terminal asks, a signed-in user approves it as themselves with the
7
+ // scopes they choose, the terminal polls for the decision (the caller then issues an app token for that user)
8
+ // Pending codes, enrollments and the limiters live in the Kv / Counters backends (process memory by default, so a
9
+ // restart invalidates them; Valkey in a cluster, so a code issued on one pod is redeemed on any). Codes are spent
10
+ // with GETDEL: exactly one redeem wins, on any pod.
6
11
  import { randomBytes, randomInt } from "node:crypto"
7
12
  import type { EnrollPoll, EnrollStart } from "../protocol"
13
+ import { APP_SCOPES, DEFAULT_CLI_LOGIN_SCOPES } from "./agent/app-tokens"
14
+ import { MemoryCounters } from "./backend/memory-counters"
15
+ import { MemoryKv } from "./backend/memory-kv"
16
+ import type { Counters, Kv } from "./backend/types"
8
17
  import { LOCAL_USER, newToken, type DeviceRecord, type Store } from "./store"
9
18
 
10
19
  export class RateLimitError extends Error {
@@ -21,39 +30,14 @@ export class ValidationError extends Error {
21
30
  }
22
31
  }
23
32
 
24
- /** Sliding-window counter. */
25
- export class Limiter {
26
- private hits: number[] = []
27
-
28
- constructor(
29
- private readonly max: number,
30
- private readonly windowMs: number,
31
- private readonly now: () => number = Date.now,
32
- ) {}
33
-
34
- /** True when another hit is allowed right now (does not record one). */
35
- allowed(): boolean {
36
- const cutoff = this.now() - this.windowMs
37
- this.hits = this.hits.filter((t) => t > cutoff)
38
- return this.hits.length < this.max
39
- }
40
-
41
- hit(): void {
42
- this.hits.push(this.now())
43
- }
44
-
45
- /** Records a hit if allowed. */
46
- tryHit(): boolean {
47
- if (!this.allowed()) return false
48
- this.hit()
49
- return true
50
- }
51
- }
52
-
53
33
  export const ENROLL_TTL_MS = 10 * 60_000
54
34
  export const ENROLL_INTERVAL_MS = 2000
55
35
  export const PAIR_TTL_MS = 5 * 60_000
56
36
  export const LOGIN_TTL_MS = 2 * 60_000
37
+ export const LINK_TTL_MS = 5 * 60_000
38
+ export const CLI_LOGIN_TTL_MS = 10 * 60_000
39
+ /** Longest device public key accepted at link time (characters). */
40
+ export const MAX_PUBLIC_KEY = 4096
57
41
 
58
42
  /** RFC 8628 base-20 alphabet: no vowels, no look-alike characters. */
59
43
  const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"
@@ -77,18 +61,85 @@ function name(value: unknown, what: string, fallback?: string): string {
77
61
  return text
78
62
  }
79
63
 
64
+ /** A device name from a client: control and format characters dropped, whitespace collapsed, ≤ 64 characters. */
65
+ function deviceLabel(value: unknown, fallback: string): string {
66
+ const text = typeof value === "string" ? value.replace(/[\p{Cc}\p{Cf}]/gu, " ").trim().replace(/\s+/g, " ") : ""
67
+ return text ? [...text].slice(0, 64).join("").trim() : fallback
68
+ }
69
+
70
+ /** App token scopes from a client: a list of known scopes (deduplicated, in order); absent is none when `optional`. */
71
+ function scopeList(value: unknown, optional: boolean): string[] {
72
+ if (value === undefined && optional) return []
73
+ if (!Array.isArray(value) || !value.every((s): s is string => typeof s === "string")) throw new ValidationError("scopes must be a list of scope names")
74
+ const unknown = value.find((s) => !APP_SCOPES.includes(s))
75
+ if (unknown !== undefined) throw new ValidationError(`Unknown scope: ${unknown.slice(0, 64)}`)
76
+ return [...new Set(value)]
77
+ }
78
+
79
+ const PEM = /^-----BEGIN PUBLIC KEY-----([\s\S]*)-----END PUBLIC KEY-----$/
80
+ const KEY_BODY = /^[A-Za-z0-9+/_-]{16,}={0,2}$/
81
+
82
+ /** An optional device public key: PEM ("PUBLIC KEY") or bare base64/base64url; undefined when absent. */
83
+ function publicKey(value: unknown): string | undefined {
84
+ if (value === undefined || value === null || value === "") return undefined
85
+ if (typeof value !== "string" || value.length > MAX_PUBLIC_KEY) throw new ValidationError("Invalid public key")
86
+ const key = value.trim()
87
+ const body = (PEM.exec(key)?.[1] ?? key).replace(/\s+/g, "")
88
+ if (!KEY_BODY.test(body)) throw new ValidationError("Invalid public key")
89
+ return key
90
+ }
91
+
80
92
  interface Enrollment {
81
- userCode: string
82
- deviceCode: string
83
- machineName: string
84
- created: number
85
- expiresAt: number
86
- status: "pending" | "approved" | "denied"
93
+ readonly userCode: string
94
+ readonly deviceCode: string
95
+ readonly machineName: string
96
+ readonly created: number
97
+ readonly expiresAt: number
98
+ readonly status: "pending" | "approved" | "denied"
87
99
  /** The approver: owner of the machine created on the next poll. */
88
- ownerID?: string
100
+ readonly ownerID?: string
101
+ }
102
+
103
+ interface CliLogin {
104
+ readonly userCode: string
105
+ readonly deviceCode: string
106
+ readonly clientName: string
107
+ /** The scopes the terminal asked for (shown on the approval page, ticked by default). */
108
+ readonly scopes: readonly string[]
109
+ readonly created: number
110
+ readonly expiresAt: number
111
+ readonly status: "pending" | "approved" | "denied"
112
+ /** The approver: the user the app token is issued for. */
113
+ readonly ownerID?: string
114
+ /** The scopes the approver chose. */
115
+ readonly approved?: readonly string[]
89
116
  }
90
117
 
91
- /** A pending pairing code or login link and the user its device will belong to. */
118
+ /** POST /cli/login → a code to approve in the console (never a credential). */
119
+ export interface CliLoginStart {
120
+ readonly userCode: string
121
+ readonly deviceCode: string
122
+ readonly approveURL: string
123
+ readonly intervalMs: number
124
+ readonly expiresAt: number
125
+ readonly scopes: readonly string[]
126
+ }
127
+
128
+ /** A pending CLI login as the approval page shows it. */
129
+ export interface PendingCliLogin {
130
+ readonly userCode: string
131
+ readonly clientName: string
132
+ readonly scopes: readonly string[]
133
+ readonly created: number
134
+ readonly expiresAt: number
135
+ }
136
+
137
+ /** The decision on a CLI login, delivered once. `approved` carries who approved it and the scopes they chose. */
138
+ export type CliLoginPoll =
139
+ | { readonly status: "pending" | "denied" | "expired" }
140
+ | { readonly status: "approved"; readonly ownerID: string; readonly clientName: string; readonly scopes: readonly string[] }
141
+
142
+ /** A pending pairing code, login link or app link code and the user its device will belong to. */
92
143
  interface Grant {
93
144
  readonly expiresAt: number
94
145
  readonly ownerID: string
@@ -107,108 +158,241 @@ export interface AuthOptions {
107
158
  readonly now?: () => number
108
159
  /** oidc: no bootstrap pairing code (devices are created by sign-in or by a machine owner). */
109
160
  readonly mode?: "pairing" | "oidc"
161
+ /** Pending codes and enrollments (default: in this process, on `now`). */
162
+ readonly kv?: Kv
163
+ /** The enrollment and pairing-failure limits (default: in this process, on `now`). */
164
+ readonly counters?: Counters
165
+ }
166
+
167
+ /** Enrollments started per window (every pod together). */
168
+ const ENROLL_MAX = 5
169
+ const ENROLL_WINDOW_MS = 60_000
170
+ /** Failed pairing attempts per window before /api/pair refuses (every pod together). */
171
+ const PAIR_FAILURES_MAX = 10
172
+ const PAIR_FAILURES_WINDOW_MS = 60_000
173
+ /**
174
+ * Enrollment slots the pending list reads back: more than the rate limit lets live at once (a fixed window admits
175
+ * up to twice its limit across a window boundary).
176
+ */
177
+ const ENROLL_SLOTS = 2 * ENROLL_MAX * (ENROLL_TTL_MS / ENROLL_WINDOW_MS + 1)
178
+ /** The enrollment sequence's window: it never resets in practice. */
179
+ const ENROLL_SEQ_TTL_MS = 100 * 365 * 24 * 3_600_000
180
+
181
+ type CodeKind = "pair" | "login" | "link"
182
+
183
+ const KEYS = {
184
+ enroll: (deviceCode: string) => `auth:enroll:${deviceCode}`,
185
+ enrollCode: (userCode: string) => `auth:enroll-code:${userCode}`,
186
+ enrollDecided: (deviceCode: string) => `auth:enroll-decided:${deviceCode}`,
187
+ enrollSlot: (slot: number) => `auth:enroll-slot:${slot}`,
188
+ enrollSeq: "auth:enroll-seq",
189
+ enrollRate: "auth:enroll-rate",
190
+ cliLogin: (deviceCode: string) => `auth:cli-login:${deviceCode}`,
191
+ cliLoginCode: (userCode: string) => `auth:cli-login-code:${userCode}`,
192
+ cliLoginDecided: (deviceCode: string) => `auth:cli-login-decided:${deviceCode}`,
193
+ cliLoginRate: "auth:cli-login-rate",
194
+ pairFailures: "auth:pair-failures",
195
+ code: (kind: CodeKind, code: string) => `auth:${kind}:${code}`,
196
+ }
197
+
198
+ function parse<T>(raw: string | undefined): T | undefined {
199
+ if (raw === undefined) return undefined
200
+ try {
201
+ return JSON.parse(raw) as T
202
+ } catch {
203
+ return undefined
204
+ }
110
205
  }
111
206
 
112
207
  export class Auth {
113
208
  private readonly store: Store
114
209
  private readonly publicURL: string
115
210
  private readonly now: () => number
116
- private readonly enrollments = new Map<string, Enrollment>() // by deviceCode
117
- private readonly pairCodes = new Map<string, Grant>()
118
- private readonly loginCodes = new Map<string, Grant>()
119
211
  private readonly mode: "pairing" | "oidc"
120
- private readonly enrollLimiter: Limiter
121
- private readonly pairFailures: Limiter
212
+ private readonly kv: Kv
213
+ private readonly counters: Counters
214
+ /** Pairing codes this process issued and their expiry: a new code never repeats one of them. */
215
+ private readonly issuedPairCodes = new Map<string, number>()
216
+ /** Code writes in flight (codes are issued synchronously); a redeem here waits for them. */
217
+ private readonly writes = new Set<Promise<void>>()
122
218
 
123
219
  constructor(options: AuthOptions) {
124
220
  this.store = options.store
125
221
  this.publicURL = options.publicURL
126
222
  this.now = options.now ?? Date.now
127
223
  this.mode = options.mode ?? "pairing"
128
- this.enrollLimiter = new Limiter(5, 60_000, this.now)
129
- this.pairFailures = new Limiter(10, 60_000, this.now)
224
+ this.kv = options.kv ?? new MemoryKv({ now: this.now })
225
+ this.counters = options.counters ?? new MemoryCounters({ now: this.now })
130
226
  }
131
227
 
132
228
  // ---------- machine enrollment ----------
133
229
 
134
- startEnrollment(machineName: unknown): EnrollStart {
230
+ async startEnrollment(machineName: unknown): Promise<EnrollStart> {
135
231
  const machine = name(machineName, "machine name")
136
- if (!this.enrollLimiter.tryHit()) throw new RateLimitError("Too many enrollment requests; try again in a minute")
137
- this.sweep()
138
- const taken = new Set([...this.enrollments.values()].map((e) => e.userCode))
139
- let code = userCode()
140
- while (taken.has(code)) code = userCode()
141
- const created = this.now()
142
- const enrollment: Enrollment = {
143
- userCode: code,
144
- deviceCode: newToken(),
145
- machineName: machine,
146
- created,
147
- expiresAt: created + ENROLL_TTL_MS,
148
- status: "pending",
232
+ if ((await this.counters.incr(KEYS.enrollRate, ENROLL_WINDOW_MS)) > ENROLL_MAX) {
233
+ throw new RateLimitError("Too many enrollment requests; try again in a minute")
149
234
  }
150
- this.enrollments.set(enrollment.deviceCode, enrollment)
235
+ const created = this.now()
236
+ const expiresAt = created + ENROLL_TTL_MS
237
+ const px = ENROLL_TTL_MS + 1
238
+ const deviceCode = newToken()
239
+ let code = userCode()
240
+ // A user code names one request: never one a CLI login holds.
241
+ while ((await this.kv.get(KEYS.cliLoginCode(code))) !== undefined || !(await this.kv.set(KEYS.enrollCode(code), deviceCode, { px, nx: true }))) code = userCode()
242
+ const enrollment: Enrollment = { userCode: code, deviceCode, machineName: machine, created, expiresAt, status: "pending" }
243
+ await this.kv.set(KEYS.enroll(deviceCode), JSON.stringify(enrollment), { px })
244
+ const slot = await this.counters.incr(KEYS.enrollSeq, ENROLL_SEQ_TTL_MS)
245
+ await this.kv.set(KEYS.enrollSlot(slot), deviceCode, { px })
151
246
  return {
152
247
  userCode: code,
153
- deviceCode: enrollment.deviceCode,
248
+ deviceCode,
154
249
  approveURL: `${this.publicURL}/approve?code=${code}`,
155
250
  intervalMs: ENROLL_INTERVAL_MS,
156
- expiresAt: enrollment.expiresAt,
251
+ expiresAt,
157
252
  }
158
253
  }
159
254
 
160
- /** Approved and denied results are delivered once; afterwards the code reads as expired. */
161
- pollEnrollment(deviceCode: unknown): EnrollPoll {
162
- this.sweep()
163
- const enrollment = typeof deviceCode === "string" ? this.enrollments.get(deviceCode) : undefined
255
+ /** Approved and denied results are delivered once (GETDEL); afterwards the code reads as expired. */
256
+ async pollEnrollment(deviceCode: unknown): Promise<EnrollPoll> {
257
+ const enrollment = typeof deviceCode === "string" ? await this.enrollment(deviceCode) : undefined
164
258
  if (!enrollment) return { status: "expired" }
165
259
  if (enrollment.status === "pending") return { status: "pending" }
166
- this.enrollments.delete(enrollment.deviceCode)
167
- if (enrollment.status === "denied") return { status: "denied" }
168
- const { machine, token } = this.store.addMachine(enrollment.machineName, enrollment.ownerID ?? LOCAL_USER)
260
+ const taken = parse<Enrollment>(await this.kv.getdel(KEYS.enroll(enrollment.deviceCode)))
261
+ if (!taken || taken.status === "pending") return { status: "expired" }
262
+ await this.kv.del(KEYS.enrollCode(taken.userCode))
263
+ if (taken.status === "denied") return { status: "denied" }
264
+ const { machine, token } = await this.store.addMachine(taken.machineName, taken.ownerID ?? LOCAL_USER)
169
265
  return { status: "approved", machineID: machine.id, machineToken: token }
170
266
  }
171
267
 
172
- pendingEnrollments(): PendingEnrollment[] {
173
- this.sweep()
174
- return [...this.enrollments.values()]
175
- .filter((e) => e.status === "pending")
268
+ /** Pending enrollments, oldest first. */
269
+ async pendingEnrollments(): Promise<PendingEnrollment[]> {
270
+ const last = await this.counters.get(KEYS.enrollSeq)
271
+ const slots: number[] = []
272
+ for (let slot = Math.max(1, last - ENROLL_SLOTS + 1); slot <= last; slot++) slots.push(slot)
273
+ const deviceCodes = await Promise.all(slots.map((slot) => this.kv.get(KEYS.enrollSlot(slot))))
274
+ const unique = [...new Set(deviceCodes.filter((code): code is string => code !== undefined))]
275
+ const enrollments = await Promise.all(unique.map((code) => this.enrollment(code)))
276
+ return enrollments
277
+ .filter((e): e is Enrollment => e?.status === "pending")
176
278
  .map(({ userCode, machineName, created, expiresAt }) => ({ userCode, machineName, created, expiresAt }))
177
279
  }
178
280
 
179
281
  /** Approves a pending enrollment; `ownerID` (the approver) will own the machine. */
180
- approveEnrollment(code: string, ownerID: string = LOCAL_USER): boolean {
181
- return this.decide(code, "approved", ownerID) !== undefined
282
+ async approveEnrollment(code: string, ownerID: string = LOCAL_USER): Promise<boolean> {
283
+ return (await this.decide(code, "approved", ownerID)) !== undefined
182
284
  }
183
285
 
184
- denyEnrollment(code: string): boolean {
185
- return this.decide(code, "denied") !== undefined
286
+ async denyEnrollment(code: string): Promise<boolean> {
287
+ return (await this.decide(code, "denied")) !== undefined
186
288
  }
187
289
 
188
- /** Approves or denies a pending enrollment; the machine name, or undefined when no such code is pending. */
189
- decide(code: string, status: "approved" | "denied", ownerID: string = LOCAL_USER): string | undefined {
190
- this.sweep()
191
- const wanted = normalizeUserCode(code)
192
- for (const enrollment of this.enrollments.values()) {
193
- if (enrollment.userCode === wanted && enrollment.status === "pending") {
194
- enrollment.status = status
195
- if (status === "approved") enrollment.ownerID = ownerID
196
- return enrollment.machineName
197
- }
198
- }
199
- return undefined
290
+ /**
291
+ * Approves or denies a pending enrollment; the machine name, or undefined when no such code is pending. The
292
+ * first decision wins (SET NX), on any pod.
293
+ */
294
+ async decide(code: string, status: "approved" | "denied", ownerID: string = LOCAL_USER): Promise<string | undefined> {
295
+ const deviceCode = await this.kv.get(KEYS.enrollCode(normalizeUserCode(code)))
296
+ const enrollment = deviceCode === undefined ? undefined : await this.enrollment(deviceCode)
297
+ if (!enrollment || enrollment.status !== "pending") return undefined
298
+ const px = this.remaining(enrollment.expiresAt)
299
+ if (!(await this.kv.set(KEYS.enrollDecided(enrollment.deviceCode), status, { px, nx: true }))) return undefined
300
+ const decided: Enrollment = { ...enrollment, status, ...(status === "approved" ? { ownerID } : {}) }
301
+ await this.kv.set(KEYS.enroll(enrollment.deviceCode), JSON.stringify(decided), { px })
302
+ return enrollment.machineName
303
+ }
304
+
305
+ /** The enrollment of a device code, unless it expired. */
306
+ private async enrollment(deviceCode: string): Promise<Enrollment | undefined> {
307
+ const enrollment = parse<Enrollment>(await this.kv.get(KEYS.enroll(deviceCode)))
308
+ return enrollment && this.remaining(enrollment.expiresAt) > 0 ? enrollment : undefined
309
+ }
310
+
311
+ /** Milliseconds a record expiring at `expiresAt` is still valid for (valid through expiresAt itself). */
312
+ private remaining(expiresAt: number): number {
313
+ return expiresAt - this.now() + 1
314
+ }
315
+
316
+ // ---------- CLI login ----------
317
+
318
+ /**
319
+ * Starts a CLI login for `clientName` (the terminal's machine name), asking for the default scopes plus
320
+ * `scopes`. Rate limited like enrollment. The device code is the poller's secret; the user code is what the
321
+ * approver confirms.
322
+ */
323
+ async startCliLogin(clientName: unknown, scopes: unknown): Promise<CliLoginStart> {
324
+ const client = name(clientName, "client name")
325
+ const requested = [...new Set([...DEFAULT_CLI_LOGIN_SCOPES, ...scopeList(scopes, true)])]
326
+ if ((await this.counters.incr(KEYS.cliLoginRate, ENROLL_WINDOW_MS)) > ENROLL_MAX) throw new RateLimitError("Too many login requests; try again in a minute")
327
+ const created = this.now()
328
+ const expiresAt = created + CLI_LOGIN_TTL_MS
329
+ const px = CLI_LOGIN_TTL_MS + 1
330
+ const deviceCode = newToken()
331
+ let code = userCode()
332
+ while ((await this.kv.get(KEYS.enrollCode(code))) !== undefined || !(await this.kv.set(KEYS.cliLoginCode(code), deviceCode, { px, nx: true }))) code = userCode()
333
+ const login: CliLogin = { userCode: code, deviceCode, clientName: client, scopes: requested, created, expiresAt, status: "pending" }
334
+ await this.kv.set(KEYS.cliLogin(deviceCode), JSON.stringify(login), { px })
335
+ return { userCode: code, deviceCode, approveURL: `${this.publicURL}/approve?code=${code}`, intervalMs: ENROLL_INTERVAL_MS, expiresAt, scopes: requested }
336
+ }
337
+
338
+ /** The pending CLI login of a user code, for the approval page; undefined once decided or expired. */
339
+ async cliLogin(code: string): Promise<PendingCliLogin | undefined> {
340
+ const login = await this.cliLoginOf(code)
341
+ if (!login || login.status !== "pending") return undefined
342
+ const { userCode, clientName, scopes, created, expiresAt } = login
343
+ return { userCode, clientName, scopes: [...scopes], created, expiresAt }
344
+ }
345
+
346
+ /**
347
+ * Approves (as `ownerID`, with the `scopes` they chose: at least one known scope) or denies a pending CLI login:
348
+ * its client name, or undefined when no such code is pending. The first decision wins (SET NX), on any pod.
349
+ */
350
+ async decideCliLogin(code: string, status: "approved" | "denied", ownerID: string, scopes?: unknown): Promise<string | undefined> {
351
+ const approved = status === "approved" ? scopeList(scopes, false) : undefined
352
+ if (approved && approved.length === 0) throw new ValidationError("Choose at least one scope")
353
+ const login = await this.cliLoginOf(code)
354
+ if (!login || login.status !== "pending") return undefined
355
+ const px = this.remaining(login.expiresAt)
356
+ if (!(await this.kv.set(KEYS.cliLoginDecided(login.deviceCode), status, { px, nx: true }))) return undefined
357
+ const decided: CliLogin = { ...login, status, ownerID, ...(approved ? { approved } : {}) }
358
+ await this.kv.set(KEYS.cliLogin(login.deviceCode), JSON.stringify(decided), { px })
359
+ return login.clientName
360
+ }
361
+
362
+ /** The decision on a CLI login, delivered once (GETDEL); afterwards the code reads as expired. */
363
+ async pollCliLogin(deviceCode: unknown): Promise<CliLoginPoll> {
364
+ const login = typeof deviceCode === "string" && deviceCode ? await this.pendingLogin(deviceCode) : undefined
365
+ if (!login) return { status: "expired" }
366
+ if (login.status === "pending") return { status: "pending" }
367
+ const taken = parse<CliLogin>(await this.kv.getdel(KEYS.cliLogin(login.deviceCode)))
368
+ if (!taken || taken.status === "pending") return { status: "expired" }
369
+ await this.kv.del(KEYS.cliLoginCode(taken.userCode))
370
+ if (taken.status === "denied" || !taken.ownerID || !taken.approved) return { status: "denied" }
371
+ return { status: "approved", ownerID: taken.ownerID, clientName: taken.clientName, scopes: [...taken.approved] }
372
+ }
373
+
374
+ private async cliLoginOf(code: string): Promise<CliLogin | undefined> {
375
+ const deviceCode = await this.kv.get(KEYS.cliLoginCode(normalizeUserCode(code)))
376
+ return deviceCode === undefined ? undefined : await this.pendingLogin(deviceCode)
377
+ }
378
+
379
+ /** The CLI login of a device code, unless it expired. */
380
+ private async pendingLogin(deviceCode: string): Promise<CliLogin | undefined> {
381
+ const login = parse<CliLogin>(await this.kv.get(KEYS.cliLogin(deviceCode)))
382
+ return login && this.remaining(login.expiresAt) > 0 ? login : undefined
200
383
  }
201
384
 
202
385
  // ---------- device pairing ----------
203
386
 
204
387
  /** A pairing code; the device it pairs belongs to `ownerID`. */
205
388
  issuePairCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
206
- this.sweep()
389
+ const t = this.now()
390
+ for (const [code, expiresAt] of this.issuedPairCodes) if (expiresAt < t) this.issuedPairCodes.delete(code)
207
391
  let code = String(randomInt(1_000_000)).padStart(6, "0")
208
- while (this.pairCodes.has(code)) code = String(randomInt(1_000_000)).padStart(6, "0")
209
- const expiresAt = this.now() + PAIR_TTL_MS
210
- this.pairCodes.set(code, { expiresAt, ownerID })
211
- return { code, expiresAt }
392
+ while (this.issuedPairCodes.has(code)) code = String(randomInt(1_000_000)).padStart(6, "0")
393
+ const issued = this.issue("pair", code, ownerID, PAIR_TTL_MS)
394
+ this.issuedPairCodes.set(code, issued.expiresAt)
395
+ return issued
212
396
  }
213
397
 
214
398
  /** A pairing code for the first device (pairing mode only), or undefined once any device exists. */
@@ -220,52 +404,97 @@ export class Auth {
220
404
  * Redeems a pairing code. Throws RateLimitError after too many recent failures. A code whose owner
221
405
  * is gone or disabled counts as invalid.
222
406
  */
223
- pair(code: unknown, deviceName: unknown): { device: DeviceRecord; token: string } | undefined {
224
- if (!this.pairFailures.allowed()) throw new RateLimitError()
225
- this.sweep()
226
- const key = typeof code === "string" ? code.trim() : ""
227
- const grant = this.pairCodes.get(key)
407
+ async pair(code: unknown, deviceName: unknown): Promise<{ device: DeviceRecord; token: string } | undefined> {
408
+ if ((await this.counters.get(KEYS.pairFailures)) >= PAIR_FAILURES_MAX) throw new RateLimitError()
409
+ const grant = await this.redeem("pair", typeof code === "string" ? code.trim() : "")
228
410
  if (!grant) {
229
- this.pairFailures.hit()
411
+ await this.counters.incr(KEYS.pairFailures, PAIR_FAILURES_WINDOW_MS)
230
412
  return undefined
231
413
  }
232
- this.pairCodes.delete(key)
233
414
  if (!this.active(grant.ownerID)) return undefined
234
- return this.store.addDevice(name(deviceName, "device name", "Device"), grant.ownerID)
415
+ return await this.store.addDevice(name(deviceName, "device name", "Device"), grant.ownerID)
235
416
  }
236
417
 
237
418
  // ---------- login links ----------
238
419
 
239
420
  /** A single-use login link code; it signs a browser in as `ownerID`. */
240
421
  issueLoginCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
241
- const code = randomBytes(32).toString("base64url")
242
- const expiresAt = this.now() + LOGIN_TTL_MS
243
- this.loginCodes.set(code, { expiresAt, ownerID })
244
- return { code, expiresAt }
422
+ return this.issue("login", randomBytes(32).toString("base64url"), ownerID, LOGIN_TTL_MS)
245
423
  }
246
424
 
247
425
  /** Redeems a login code: the user to sign in as, or undefined (unknown, used, expired, or owner disabled). */
248
- redeemLogin(code: unknown): string | undefined {
249
- this.sweep()
250
- const grant = typeof code === "string" ? this.loginCodes.get(code) : undefined
251
- if (!grant) return undefined
252
- this.loginCodes.delete(code as string)
253
- return this.active(grant.ownerID) ? grant.ownerID : undefined
426
+ async redeemLogin(code: unknown): Promise<string | undefined> {
427
+ const grant = await this.redeem("login", code)
428
+ return grant && this.active(grant.ownerID) ? grant.ownerID : undefined
429
+ }
430
+
431
+ async redeemLoginCode(code: unknown): Promise<boolean> {
432
+ return (await this.redeemLogin(code)) !== undefined
433
+ }
434
+
435
+ // ---------- app link codes ----------
436
+
437
+ /** A single-use app link code; the device it links belongs to `ownerID`. Never valid at /login or /api/pair. */
438
+ issueLinkCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
439
+ return this.issue("link", randomBytes(32).toString("base64url"), ownerID, LINK_TTL_MS)
254
440
  }
255
441
 
256
- redeemLoginCode(code: unknown): boolean {
257
- return this.redeemLogin(code) !== undefined
442
+ /**
443
+ * Redeems an app link code for a new device and its token, or undefined (unknown, used, expired, or owner
444
+ * gone or disabled). Throws ValidationError for a malformed public key (the code stays valid). The code is
445
+ * deleted before the device is created. No rate limiter: 256-bit codes cannot be guessed, and a shared
446
+ * limiter would let bad redeems lock out pairing.
447
+ */
448
+ async redeemLink(code: unknown, deviceName: unknown, key?: unknown): Promise<{ device: DeviceRecord; token: string } | undefined> {
449
+ const label = deviceLabel(deviceName, "App")
450
+ const pk = publicKey(key)
451
+ const grant = await this.redeem("link", code)
452
+ if (!grant || !this.active(grant.ownerID)) return undefined
453
+ return await this.store.addDevice(label, grant.ownerID, pk ? { publicKey: pk } : {})
454
+ }
455
+
456
+ // ---------- pending codes ----------
457
+
458
+ /**
459
+ * Issues a code at once and stores it in the background (callers are synchronous); a redeem in this process
460
+ * waits for the write, and another pod's redeem comes after a person typed or opened the code. Should the
461
+ * code already exist (another pod issued the same pairing code), both are dropped: a code never signs anyone
462
+ * in as someone else.
463
+ */
464
+ private issue(kind: CodeKind, code: string, ownerID: string, ttlMs: number): { code: string; expiresAt: number } {
465
+ const expiresAt = this.now() + ttlMs
466
+ const key = KEYS.code(kind, code)
467
+ const grant: Grant = { expiresAt, ownerID }
468
+ this.track(
469
+ this.kv.set(key, JSON.stringify(grant), { px: ttlMs + 1, nx: true }).then(async (stored) => {
470
+ if (stored) return
471
+ await this.kv.del(key)
472
+ console.warn(`[hub] a new ${kind} code collided with a pending one; both were dropped`)
473
+ }),
474
+ )
475
+ return { code, expiresAt }
476
+ }
477
+
478
+ /** Spends a code (GETDEL): its grant, or undefined (unknown, used, expired). */
479
+ private async redeem(kind: CodeKind, code: unknown): Promise<Grant | undefined> {
480
+ if (typeof code !== "string" || !code) return undefined
481
+ if (this.writes.size) await Promise.all([...this.writes])
482
+ const grant = parse<Grant>(await this.kv.getdel(KEYS.code(kind, code)))
483
+ return grant && this.remaining(grant.expiresAt) > 0 ? grant : undefined
484
+ }
485
+
486
+ private track(write: Promise<unknown>): void {
487
+ const tracked: Promise<void> = write
488
+ .then(
489
+ () => {},
490
+ (cause: unknown) => console.error(`[hub] pending code not stored: ${cause instanceof Error ? cause.message : cause}`),
491
+ )
492
+ .finally(() => this.writes.delete(tracked))
493
+ this.writes.add(tracked)
258
494
  }
259
495
 
260
496
  private active(userID: string): boolean {
261
497
  const user = this.store.user(userID)
262
498
  return user !== undefined && !user.disabled
263
499
  }
264
-
265
- private sweep(): void {
266
- const t = this.now()
267
- for (const [key, e] of this.enrollments) if (e.expiresAt < t) this.enrollments.delete(key)
268
- for (const [key, grant] of this.pairCodes) if (grant.expiresAt < t) this.pairCodes.delete(key)
269
- for (const [key, grant] of this.loginCodes) if (grant.expiresAt < t) this.loginCodes.delete(key)
270
- }
271
500
  }