@huynle/hub 0.6.1 → 0.7.1

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 (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -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 +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -2,9 +2,15 @@
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
+ // Pending codes, enrollments and the limiters live in the Kv / Counters backends (process memory by default, so a
7
+ // restart invalidates them; Valkey in a cluster, so a code issued on one pod is redeemed on any). Codes are spent
8
+ // with GETDEL: exactly one redeem wins, on any pod.
6
9
  import { randomBytes, randomInt } from "node:crypto"
7
10
  import type { EnrollPoll, EnrollStart } from "../protocol"
11
+ import { MemoryCounters } from "./backend/memory-counters"
12
+ import { MemoryKv } from "./backend/memory-kv"
13
+ import type { Counters, Kv } from "./backend/types"
8
14
  import { LOCAL_USER, newToken, type DeviceRecord, type Store } from "./store"
9
15
 
10
16
  export class RateLimitError extends Error {
@@ -21,39 +27,13 @@ export class ValidationError extends Error {
21
27
  }
22
28
  }
23
29
 
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
30
  export const ENROLL_TTL_MS = 10 * 60_000
54
31
  export const ENROLL_INTERVAL_MS = 2000
55
32
  export const PAIR_TTL_MS = 5 * 60_000
56
33
  export const LOGIN_TTL_MS = 2 * 60_000
34
+ export const LINK_TTL_MS = 5 * 60_000
35
+ /** Longest device public key accepted at link time (characters). */
36
+ export const MAX_PUBLIC_KEY = 4096
57
37
 
58
38
  /** RFC 8628 base-20 alphabet: no vowels, no look-alike characters. */
59
39
  const USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ"
@@ -77,18 +57,37 @@ function name(value: unknown, what: string, fallback?: string): string {
77
57
  return text
78
58
  }
79
59
 
60
+ /** A device name from a client: control and format characters dropped, whitespace collapsed, ≤ 64 characters. */
61
+ function deviceLabel(value: unknown, fallback: string): string {
62
+ const text = typeof value === "string" ? value.replace(/[\p{Cc}\p{Cf}]/gu, " ").trim().replace(/\s+/g, " ") : ""
63
+ return text ? [...text].slice(0, 64).join("").trim() : fallback
64
+ }
65
+
66
+ const PEM = /^-----BEGIN PUBLIC KEY-----([\s\S]*)-----END PUBLIC KEY-----$/
67
+ const KEY_BODY = /^[A-Za-z0-9+/_-]{16,}={0,2}$/
68
+
69
+ /** An optional device public key: PEM ("PUBLIC KEY") or bare base64/base64url; undefined when absent. */
70
+ function publicKey(value: unknown): string | undefined {
71
+ if (value === undefined || value === null || value === "") return undefined
72
+ if (typeof value !== "string" || value.length > MAX_PUBLIC_KEY) throw new ValidationError("Invalid public key")
73
+ const key = value.trim()
74
+ const body = (PEM.exec(key)?.[1] ?? key).replace(/\s+/g, "")
75
+ if (!KEY_BODY.test(body)) throw new ValidationError("Invalid public key")
76
+ return key
77
+ }
78
+
80
79
  interface Enrollment {
81
- userCode: string
82
- deviceCode: string
83
- machineName: string
84
- created: number
85
- expiresAt: number
86
- status: "pending" | "approved" | "denied"
80
+ readonly userCode: string
81
+ readonly deviceCode: string
82
+ readonly machineName: string
83
+ readonly created: number
84
+ readonly expiresAt: number
85
+ readonly status: "pending" | "approved" | "denied"
87
86
  /** The approver: owner of the machine created on the next poll. */
88
- ownerID?: string
87
+ readonly ownerID?: string
89
88
  }
90
89
 
91
- /** A pending pairing code or login link and the user its device will belong to. */
90
+ /** A pending pairing code, login link or app link code and the user its device will belong to. */
92
91
  interface Grant {
93
92
  readonly expiresAt: number
94
93
  readonly ownerID: string
@@ -107,108 +106,167 @@ export interface AuthOptions {
107
106
  readonly now?: () => number
108
107
  /** oidc: no bootstrap pairing code (devices are created by sign-in or by a machine owner). */
109
108
  readonly mode?: "pairing" | "oidc"
109
+ /** Pending codes and enrollments (default: in this process, on `now`). */
110
+ readonly kv?: Kv
111
+ /** The enrollment and pairing-failure limits (default: in this process, on `now`). */
112
+ readonly counters?: Counters
113
+ }
114
+
115
+ /** Enrollments started per window (every pod together). */
116
+ const ENROLL_MAX = 5
117
+ const ENROLL_WINDOW_MS = 60_000
118
+ /** Failed pairing attempts per window before /api/pair refuses (every pod together). */
119
+ const PAIR_FAILURES_MAX = 10
120
+ const PAIR_FAILURES_WINDOW_MS = 60_000
121
+ /**
122
+ * Enrollment slots the pending list reads back: more than the rate limit lets live at once (a fixed window admits
123
+ * up to twice its limit across a window boundary).
124
+ */
125
+ const ENROLL_SLOTS = 2 * ENROLL_MAX * (ENROLL_TTL_MS / ENROLL_WINDOW_MS + 1)
126
+ /** The enrollment sequence's window: it never resets in practice. */
127
+ const ENROLL_SEQ_TTL_MS = 100 * 365 * 24 * 3_600_000
128
+
129
+ type CodeKind = "pair" | "login" | "link"
130
+
131
+ const KEYS = {
132
+ enroll: (deviceCode: string) => `auth:enroll:${deviceCode}`,
133
+ enrollCode: (userCode: string) => `auth:enroll-code:${userCode}`,
134
+ enrollDecided: (deviceCode: string) => `auth:enroll-decided:${deviceCode}`,
135
+ enrollSlot: (slot: number) => `auth:enroll-slot:${slot}`,
136
+ enrollSeq: "auth:enroll-seq",
137
+ enrollRate: "auth:enroll-rate",
138
+ pairFailures: "auth:pair-failures",
139
+ code: (kind: CodeKind, code: string) => `auth:${kind}:${code}`,
140
+ }
141
+
142
+ function parse<T>(raw: string | undefined): T | undefined {
143
+ if (raw === undefined) return undefined
144
+ try {
145
+ return JSON.parse(raw) as T
146
+ } catch {
147
+ return undefined
148
+ }
110
149
  }
111
150
 
112
151
  export class Auth {
113
152
  private readonly store: Store
114
153
  private readonly publicURL: string
115
154
  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
155
  private readonly mode: "pairing" | "oidc"
120
- private readonly enrollLimiter: Limiter
121
- private readonly pairFailures: Limiter
156
+ private readonly kv: Kv
157
+ private readonly counters: Counters
158
+ /** Pairing codes this process issued and their expiry: a new code never repeats one of them. */
159
+ private readonly issuedPairCodes = new Map<string, number>()
160
+ /** Code writes in flight (codes are issued synchronously); a redeem here waits for them. */
161
+ private readonly writes = new Set<Promise<void>>()
122
162
 
123
163
  constructor(options: AuthOptions) {
124
164
  this.store = options.store
125
165
  this.publicURL = options.publicURL
126
166
  this.now = options.now ?? Date.now
127
167
  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)
168
+ this.kv = options.kv ?? new MemoryKv({ now: this.now })
169
+ this.counters = options.counters ?? new MemoryCounters({ now: this.now })
130
170
  }
131
171
 
132
172
  // ---------- machine enrollment ----------
133
173
 
134
- startEnrollment(machineName: unknown): EnrollStart {
174
+ async startEnrollment(machineName: unknown): Promise<EnrollStart> {
135
175
  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",
176
+ if ((await this.counters.incr(KEYS.enrollRate, ENROLL_WINDOW_MS)) > ENROLL_MAX) {
177
+ throw new RateLimitError("Too many enrollment requests; try again in a minute")
149
178
  }
150
- this.enrollments.set(enrollment.deviceCode, enrollment)
179
+ const created = this.now()
180
+ const expiresAt = created + ENROLL_TTL_MS
181
+ const px = ENROLL_TTL_MS + 1
182
+ const deviceCode = newToken()
183
+ let code = userCode()
184
+ while (!(await this.kv.set(KEYS.enrollCode(code), deviceCode, { px, nx: true }))) code = userCode()
185
+ const enrollment: Enrollment = { userCode: code, deviceCode, machineName: machine, created, expiresAt, status: "pending" }
186
+ await this.kv.set(KEYS.enroll(deviceCode), JSON.stringify(enrollment), { px })
187
+ const slot = await this.counters.incr(KEYS.enrollSeq, ENROLL_SEQ_TTL_MS)
188
+ await this.kv.set(KEYS.enrollSlot(slot), deviceCode, { px })
151
189
  return {
152
190
  userCode: code,
153
- deviceCode: enrollment.deviceCode,
191
+ deviceCode,
154
192
  approveURL: `${this.publicURL}/approve?code=${code}`,
155
193
  intervalMs: ENROLL_INTERVAL_MS,
156
- expiresAt: enrollment.expiresAt,
194
+ expiresAt,
157
195
  }
158
196
  }
159
197
 
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
198
+ /** Approved and denied results are delivered once (GETDEL); afterwards the code reads as expired. */
199
+ async pollEnrollment(deviceCode: unknown): Promise<EnrollPoll> {
200
+ const enrollment = typeof deviceCode === "string" ? await this.enrollment(deviceCode) : undefined
164
201
  if (!enrollment) return { status: "expired" }
165
202
  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)
203
+ const taken = parse<Enrollment>(await this.kv.getdel(KEYS.enroll(enrollment.deviceCode)))
204
+ if (!taken || taken.status === "pending") return { status: "expired" }
205
+ await this.kv.del(KEYS.enrollCode(taken.userCode))
206
+ if (taken.status === "denied") return { status: "denied" }
207
+ const { machine, token } = await this.store.addMachine(taken.machineName, taken.ownerID ?? LOCAL_USER)
169
208
  return { status: "approved", machineID: machine.id, machineToken: token }
170
209
  }
171
210
 
172
- pendingEnrollments(): PendingEnrollment[] {
173
- this.sweep()
174
- return [...this.enrollments.values()]
175
- .filter((e) => e.status === "pending")
211
+ /** Pending enrollments, oldest first. */
212
+ async pendingEnrollments(): Promise<PendingEnrollment[]> {
213
+ const last = await this.counters.get(KEYS.enrollSeq)
214
+ const slots: number[] = []
215
+ for (let slot = Math.max(1, last - ENROLL_SLOTS + 1); slot <= last; slot++) slots.push(slot)
216
+ const deviceCodes = await Promise.all(slots.map((slot) => this.kv.get(KEYS.enrollSlot(slot))))
217
+ const unique = [...new Set(deviceCodes.filter((code): code is string => code !== undefined))]
218
+ const enrollments = await Promise.all(unique.map((code) => this.enrollment(code)))
219
+ return enrollments
220
+ .filter((e): e is Enrollment => e?.status === "pending")
176
221
  .map(({ userCode, machineName, created, expiresAt }) => ({ userCode, machineName, created, expiresAt }))
177
222
  }
178
223
 
179
224
  /** 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
225
+ async approveEnrollment(code: string, ownerID: string = LOCAL_USER): Promise<boolean> {
226
+ return (await this.decide(code, "approved", ownerID)) !== undefined
182
227
  }
183
228
 
184
- denyEnrollment(code: string): boolean {
185
- return this.decide(code, "denied") !== undefined
229
+ async denyEnrollment(code: string): Promise<boolean> {
230
+ return (await this.decide(code, "denied")) !== undefined
186
231
  }
187
232
 
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
233
+ /**
234
+ * Approves or denies a pending enrollment; the machine name, or undefined when no such code is pending. The
235
+ * first decision wins (SET NX), on any pod.
236
+ */
237
+ async decide(code: string, status: "approved" | "denied", ownerID: string = LOCAL_USER): Promise<string | undefined> {
238
+ const deviceCode = await this.kv.get(KEYS.enrollCode(normalizeUserCode(code)))
239
+ const enrollment = deviceCode === undefined ? undefined : await this.enrollment(deviceCode)
240
+ if (!enrollment || enrollment.status !== "pending") return undefined
241
+ const px = this.remaining(enrollment.expiresAt)
242
+ if (!(await this.kv.set(KEYS.enrollDecided(enrollment.deviceCode), status, { px, nx: true }))) return undefined
243
+ const decided: Enrollment = { ...enrollment, status, ...(status === "approved" ? { ownerID } : {}) }
244
+ await this.kv.set(KEYS.enroll(enrollment.deviceCode), JSON.stringify(decided), { px })
245
+ return enrollment.machineName
246
+ }
247
+
248
+ /** The enrollment of a device code, unless it expired. */
249
+ private async enrollment(deviceCode: string): Promise<Enrollment | undefined> {
250
+ const enrollment = parse<Enrollment>(await this.kv.get(KEYS.enroll(deviceCode)))
251
+ return enrollment && this.remaining(enrollment.expiresAt) > 0 ? enrollment : undefined
252
+ }
253
+
254
+ /** Milliseconds a record expiring at `expiresAt` is still valid for (valid through expiresAt itself). */
255
+ private remaining(expiresAt: number): number {
256
+ return expiresAt - this.now() + 1
200
257
  }
201
258
 
202
259
  // ---------- device pairing ----------
203
260
 
204
261
  /** A pairing code; the device it pairs belongs to `ownerID`. */
205
262
  issuePairCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
206
- this.sweep()
263
+ const t = this.now()
264
+ for (const [code, expiresAt] of this.issuedPairCodes) if (expiresAt < t) this.issuedPairCodes.delete(code)
207
265
  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 }
266
+ while (this.issuedPairCodes.has(code)) code = String(randomInt(1_000_000)).padStart(6, "0")
267
+ const issued = this.issue("pair", code, ownerID, PAIR_TTL_MS)
268
+ this.issuedPairCodes.set(code, issued.expiresAt)
269
+ return issued
212
270
  }
213
271
 
214
272
  /** A pairing code for the first device (pairing mode only), or undefined once any device exists. */
@@ -220,52 +278,97 @@ export class Auth {
220
278
  * Redeems a pairing code. Throws RateLimitError after too many recent failures. A code whose owner
221
279
  * is gone or disabled counts as invalid.
222
280
  */
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)
281
+ async pair(code: unknown, deviceName: unknown): Promise<{ device: DeviceRecord; token: string } | undefined> {
282
+ if ((await this.counters.get(KEYS.pairFailures)) >= PAIR_FAILURES_MAX) throw new RateLimitError()
283
+ const grant = await this.redeem("pair", typeof code === "string" ? code.trim() : "")
228
284
  if (!grant) {
229
- this.pairFailures.hit()
285
+ await this.counters.incr(KEYS.pairFailures, PAIR_FAILURES_WINDOW_MS)
230
286
  return undefined
231
287
  }
232
- this.pairCodes.delete(key)
233
288
  if (!this.active(grant.ownerID)) return undefined
234
- return this.store.addDevice(name(deviceName, "device name", "Device"), grant.ownerID)
289
+ return await this.store.addDevice(name(deviceName, "device name", "Device"), grant.ownerID)
235
290
  }
236
291
 
237
292
  // ---------- login links ----------
238
293
 
239
294
  /** A single-use login link code; it signs a browser in as `ownerID`. */
240
295
  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 }
296
+ return this.issue("login", randomBytes(32).toString("base64url"), ownerID, LOGIN_TTL_MS)
245
297
  }
246
298
 
247
299
  /** 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
300
+ async redeemLogin(code: unknown): Promise<string | undefined> {
301
+ const grant = await this.redeem("login", code)
302
+ return grant && this.active(grant.ownerID) ? grant.ownerID : undefined
303
+ }
304
+
305
+ async redeemLoginCode(code: unknown): Promise<boolean> {
306
+ return (await this.redeemLogin(code)) !== undefined
307
+ }
308
+
309
+ // ---------- app link codes ----------
310
+
311
+ /** A single-use app link code; the device it links belongs to `ownerID`. Never valid at /login or /api/pair. */
312
+ issueLinkCode(ownerID: string = LOCAL_USER): { code: string; expiresAt: number } {
313
+ return this.issue("link", randomBytes(32).toString("base64url"), ownerID, LINK_TTL_MS)
314
+ }
315
+
316
+ /**
317
+ * Redeems an app link code for a new device and its token, or undefined (unknown, used, expired, or owner
318
+ * gone or disabled). Throws ValidationError for a malformed public key (the code stays valid). The code is
319
+ * deleted before the device is created. No rate limiter: 256-bit codes cannot be guessed, and a shared
320
+ * limiter would let bad redeems lock out pairing.
321
+ */
322
+ async redeemLink(code: unknown, deviceName: unknown, key?: unknown): Promise<{ device: DeviceRecord; token: string } | undefined> {
323
+ const label = deviceLabel(deviceName, "App")
324
+ const pk = publicKey(key)
325
+ const grant = await this.redeem("link", code)
326
+ if (!grant || !this.active(grant.ownerID)) return undefined
327
+ return await this.store.addDevice(label, grant.ownerID, pk ? { publicKey: pk } : {})
328
+ }
329
+
330
+ // ---------- pending codes ----------
331
+
332
+ /**
333
+ * Issues a code at once and stores it in the background (callers are synchronous); a redeem in this process
334
+ * waits for the write, and another pod's redeem comes after a person typed or opened the code. Should the
335
+ * code already exist (another pod issued the same pairing code), both are dropped: a code never signs anyone
336
+ * in as someone else.
337
+ */
338
+ private issue(kind: CodeKind, code: string, ownerID: string, ttlMs: number): { code: string; expiresAt: number } {
339
+ const expiresAt = this.now() + ttlMs
340
+ const key = KEYS.code(kind, code)
341
+ const grant: Grant = { expiresAt, ownerID }
342
+ this.track(
343
+ this.kv.set(key, JSON.stringify(grant), { px: ttlMs + 1, nx: true }).then(async (stored) => {
344
+ if (stored) return
345
+ await this.kv.del(key)
346
+ console.warn(`[hub] a new ${kind} code collided with a pending one; both were dropped`)
347
+ }),
348
+ )
349
+ return { code, expiresAt }
350
+ }
351
+
352
+ /** Spends a code (GETDEL): its grant, or undefined (unknown, used, expired). */
353
+ private async redeem(kind: CodeKind, code: unknown): Promise<Grant | undefined> {
354
+ if (typeof code !== "string" || !code) return undefined
355
+ if (this.writes.size) await Promise.all([...this.writes])
356
+ const grant = parse<Grant>(await this.kv.getdel(KEYS.code(kind, code)))
357
+ return grant && this.remaining(grant.expiresAt) > 0 ? grant : undefined
254
358
  }
255
359
 
256
- redeemLoginCode(code: unknown): boolean {
257
- return this.redeemLogin(code) !== undefined
360
+ private track(write: Promise<unknown>): void {
361
+ const tracked: Promise<void> = write
362
+ .then(
363
+ () => {},
364
+ (cause: unknown) => console.error(`[hub] pending code not stored: ${cause instanceof Error ? cause.message : cause}`),
365
+ )
366
+ .finally(() => this.writes.delete(tracked))
367
+ this.writes.add(tracked)
258
368
  }
259
369
 
260
370
  private active(userID: string): boolean {
261
371
  const user = this.store.user(userID)
262
372
  return user !== undefined && !user.disabled
263
373
  }
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
374
  }