@huynle/hub 0.7.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 (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -3,11 +3,14 @@
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
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)
6
8
  // Pending codes, enrollments and the limiters live in the Kv / Counters backends (process memory by default, so a
7
9
  // restart invalidates them; Valkey in a cluster, so a code issued on one pod is redeemed on any). Codes are spent
8
10
  // with GETDEL: exactly one redeem wins, on any pod.
9
11
  import { randomBytes, randomInt } from "node:crypto"
10
12
  import type { EnrollPoll, EnrollStart } from "../protocol"
13
+ import { APP_SCOPES, DEFAULT_CLI_LOGIN_SCOPES } from "./agent/app-tokens"
11
14
  import { MemoryCounters } from "./backend/memory-counters"
12
15
  import { MemoryKv } from "./backend/memory-kv"
13
16
  import type { Counters, Kv } from "./backend/types"
@@ -32,6 +35,7 @@ export const ENROLL_INTERVAL_MS = 2000
32
35
  export const PAIR_TTL_MS = 5 * 60_000
33
36
  export const LOGIN_TTL_MS = 2 * 60_000
34
37
  export const LINK_TTL_MS = 5 * 60_000
38
+ export const CLI_LOGIN_TTL_MS = 10 * 60_000
35
39
  /** Longest device public key accepted at link time (characters). */
36
40
  export const MAX_PUBLIC_KEY = 4096
37
41
 
@@ -63,6 +67,15 @@ function deviceLabel(value: unknown, fallback: string): string {
63
67
  return text ? [...text].slice(0, 64).join("").trim() : fallback
64
68
  }
65
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
+
66
79
  const PEM = /^-----BEGIN PUBLIC KEY-----([\s\S]*)-----END PUBLIC KEY-----$/
67
80
  const KEY_BODY = /^[A-Za-z0-9+/_-]{16,}={0,2}$/
68
81
 
@@ -87,6 +100,45 @@ interface Enrollment {
87
100
  readonly ownerID?: string
88
101
  }
89
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[]
116
+ }
117
+
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
+
90
142
  /** A pending pairing code, login link or app link code and the user its device will belong to. */
91
143
  interface Grant {
92
144
  readonly expiresAt: number
@@ -135,6 +187,10 @@ const KEYS = {
135
187
  enrollSlot: (slot: number) => `auth:enroll-slot:${slot}`,
136
188
  enrollSeq: "auth:enroll-seq",
137
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",
138
194
  pairFailures: "auth:pair-failures",
139
195
  code: (kind: CodeKind, code: string) => `auth:${kind}:${code}`,
140
196
  }
@@ -181,7 +237,8 @@ export class Auth {
181
237
  const px = ENROLL_TTL_MS + 1
182
238
  const deviceCode = newToken()
183
239
  let code = userCode()
184
- while (!(await this.kv.set(KEYS.enrollCode(code), deviceCode, { px, nx: true }))) 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()
185
242
  const enrollment: Enrollment = { userCode: code, deviceCode, machineName: machine, created, expiresAt, status: "pending" }
186
243
  await this.kv.set(KEYS.enroll(deviceCode), JSON.stringify(enrollment), { px })
187
244
  const slot = await this.counters.incr(KEYS.enrollSeq, ENROLL_SEQ_TTL_MS)
@@ -256,6 +313,75 @@ export class Auth {
256
313
  return expiresAt - this.now() + 1
257
314
  }
258
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
383
+ }
384
+
259
385
  // ---------- device pairing ----------
260
386
 
261
387
  /** A pairing code; the device it pairs belongs to `ownerID`. */
@@ -6,6 +6,7 @@ import { FileStateStore, type FileStateStoreOptions } from "./file-state"
6
6
  import { MemoryBus } from "./memory-bus"
7
7
  import { MemoryCatalog } from "./memory-catalog"
8
8
  import { MemoryCounters, MemoryLocks } from "./memory-counters"
9
+ import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX } from "../agent/events"
9
10
  import { MemoryEventLog } from "./memory-events"
10
11
  import { MemoryKv } from "./memory-kv"
11
12
  import { MemoryStateStore } from "./memory-state"
@@ -37,6 +38,7 @@ export function memoryBackends(options: MemoryBackendsOptions = {}): Backends {
37
38
  state: new MemoryStateStore({ now }),
38
39
  bus: new MemoryBus(options.timers ? { timers: options.timers } : {}),
39
40
  events: new MemoryEventLog({ now }),
41
+ agentEvents: new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
40
42
  counters: new MemoryCounters({ now }),
41
43
  locks: new MemoryLocks({ now }),
42
44
  catalog: new MemoryCatalog({ now }),
@@ -95,6 +97,7 @@ export function clusterBackends(options: ClusterBackendsOptions): Backends {
95
97
  state,
96
98
  bus,
97
99
  events: new ValkeyEventLog(shared, now ? { now } : {}),
100
+ agentEvents: new ValkeyEventLog(shared, { ...(now ? { now } : {}), max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS, prefix: "hub:agent:" }),
98
101
  counters: new ValkeyCounters(shared),
99
102
  locks: new ValkeyLocks(shared, now ? { now } : {}),
100
103
  catalog: new ValkeyCatalog(shared, now ? { now } : {}),
@@ -235,6 +235,8 @@ export interface Backends {
235
235
  readonly state: StateStore
236
236
  readonly bus: Bus
237
237
  readonly events: EventLog
238
+ /** The canonical agent event journal's log (24 h / 10,000 per user); the hub makes a memory one when absent. */
239
+ readonly agentEvents?: EventLog
238
240
  readonly counters: Counters
239
241
  readonly locks: Locks
240
242
  readonly catalog: Catalog
@@ -16,6 +16,10 @@ import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError }
16
16
  import { isNotificationKind, Push, type PushSender } from "./push"
17
17
  import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
18
18
  import { Webhooks } from "./agent/webhooks"
19
+ import { issueAppToken } from "./agent/app-tokens"
20
+ import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent } from "./agent/events"
21
+ import { EventWriters } from "./agent/event-writers"
22
+ import { MemoryEventLog } from "./backend/memory-events"
19
23
  import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
20
24
  import { hubBackends, type Backends } from "./backend"
21
25
  import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
@@ -33,11 +37,12 @@ import type { Launch } from "./agent/launch-store"
33
37
  import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
34
38
  import { hubDirectory } from "./agent/directory-routes"
35
39
  import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
40
+ import { MCP_PATH, mcpHttp } from "./mcp-http"
36
41
  import { SessionTokens } from "./agent/session-tokens"
37
42
  import { SessionTokenPush } from "./session-token-push"
38
43
  import { TokenKeys } from "./token-keys"
39
44
  import { EventLog } from "./events-log"
40
- import { BusyError, Registry, type RegistryMessage } from "./registry"
45
+ import { BusyError, Registry } from "./registry"
41
46
  import { createActions, type Unsupported, unsupportedMessage } from "./actions"
42
47
  import { audioFormat, SpeechError } from "./speech/provider"
43
48
  import { SpeechService } from "./speech/service"
@@ -52,9 +57,8 @@ import { randomBytes, randomUUID } from "node:crypto"
52
57
  import { LaunchEngine } from "./agent/launch-engine"
53
58
  import { LaunchTokens } from "./agent/launch-tokens"
54
59
  import { LaunchFeed, launchInfo } from "./agent/launch-routes"
55
- import type { WatchRegistry } from "./agent/watch"
56
60
  import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
57
- import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
61
+ import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
58
62
 
59
63
  export interface Speech {
60
64
  transcribe(audio: Uint8Array, format: string): Promise<string>
@@ -137,6 +141,10 @@ export interface Central {
137
141
  readonly launchTokens: LaunchTokens
138
142
  /** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
139
143
  readonly webhooks: Webhooks
144
+ /** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
145
+ readonly agentEvents: AgentEvents
146
+ /** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
147
+ readonly agentEventWriters: EventWriters
140
148
  /** The JSON API's route table (method, path, access), for audits and tests. */
141
149
  readonly routes: readonly RouteInfo[]
142
150
  /**
@@ -232,8 +240,6 @@ const MAX_TITLE_CHARS = 200
232
240
  const MAX_HERDR_WORKSPACE_CHARS = 100
233
241
  /** View preferences: entries per list and characters per entry. */
234
242
  const VIEW_PREF_MAX_ENTRIES = 500
235
- /** hiddenSessions holds archived sessions (Archive in the console), which add up over time. */
236
- const VIEW_PREF_MAX_ARCHIVED = 5000
237
243
  const VIEW_PREF_MAX_CHARS = 4096
238
244
  const DECISIONS = new Set<PermissionDecision>(["once", "always", "reject"])
239
245
  /** Longest utterance accepted for conversation mode (characters, after trimming). */
@@ -822,20 +828,33 @@ export function startCentral(options: CentralOptions): Central {
822
828
  resolve: options.resolveHost ?? systemResolveHost,
823
829
  ...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
824
830
  appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes(WEBHOOK_SCOPE)) === true,
831
+ appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
825
832
  userAgent: `hub/${options.version ?? "0.0.0"}`,
826
833
  })
827
- launchFeed.subscribe((userID, launch) => {
828
- void webhooks
829
- .onLaunch(userID, launch, launchInfo(launch))
830
- // Queued here, sent by the webhooks leader (kicked when that is another pod).
831
- .then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))
832
- .catch((cause) => console.error(`[hub] webhook queue failed: ${(cause as Error).message}`))
833
- }, { origin: true })
834
834
  const webhookLeader = leader("webhooks", {
835
835
  onElected: () => webhooks.start(),
836
836
  onDeposed: () => webhooks.stop(),
837
837
  onKick: () => void webhooks.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`)),
838
838
  })
839
+ // The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
840
+ // presence by the agent-events leader, everything else where it happens (event-writers.ts).
841
+ const agentEvents = new AgentEvents({
842
+ log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
843
+ kv: backends.kv,
844
+ now,
845
+ onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
846
+ })
847
+ const agentEventWriters = new EventWriters({
848
+ events: agentEvents,
849
+ kv: backends.kv,
850
+ registry,
851
+ machineOwner: (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID,
852
+ users: allUsers,
853
+ messages: feed,
854
+ launches: launchFeed,
855
+ })
856
+ agentEventWriters.start()
857
+ leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
839
858
  for (const loop of leaders) void loop.start()
840
859
 
841
860
  // One credential resolver for every /api request: device cookie, device token, session and app tokens.
@@ -889,26 +908,7 @@ export function startCentral(options: CentralOptions): Central {
889
908
  void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
890
909
  })
891
910
  const escalate: Escalate = (escalation) => escalations.escalate(escalation)
892
- /** Cluster mode: what GET /api/agent/watch reads; status of sessions on any pod comes from the user's live events. */
893
- const watchRegistry = live
894
- ? (userID: string): WatchRegistry => ({
895
- catalog: () => registry.catalog(),
896
- sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
897
- subscribe: (listener) => {
898
- const offCatalog = registry.subscribe((message) => {
899
- if (message.type === "catalog") listener(message)
900
- })
901
- const subscription = live.subscribe(userID, (event) => {
902
- if (event.message.type === "status") listener(event.message as unknown as RegistryMessage, userID)
903
- })
904
- return () => {
905
- offCatalog()
906
- subscription.close()
907
- }
908
- },
909
- })
910
- : undefined
911
- const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, launchFeed, webhooks, maxPromptChars: MAX_PROMPT_CHARS, ...(watchRegistry ? { watchRegistry } : {}) }
911
+ const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS }
912
912
  const resolveActor = createResolver({
913
913
  cookieName: COOKIE,
914
914
  findDevice: (token) => deviceOfToken(token),
@@ -927,6 +927,16 @@ export function startCentral(options: CentralOptions): Central {
927
927
  routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
928
928
  })
929
929
 
930
+ // POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
931
+ // to this handler in process.
932
+ const mcp = mcpHttp({
933
+ publicURL: () => publicURL,
934
+ isOwnOrigin: (origin) => origins.has(origin),
935
+ verify: async (token) => (await resolveActor(new Request(`${url}${MCP_PATH}`, { headers: { authorization: `Bearer ${token}` } }))).ok,
936
+ dispatch: async (request) => (await handle(request, server)) ?? error(500, "No response"),
937
+ version: options.version ?? "0.0.0",
938
+ })
939
+
930
940
  async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
931
941
  const url = new URL(request.url)
932
942
  const { pathname } = url
@@ -963,12 +973,20 @@ export function startCentral(options: CentralOptions): Central {
963
973
  if (poll.status === "approved") broadcast({ type: "catalog" })
964
974
  return Response.json(poll)
965
975
  }
976
+ // `hub login` without a token (CLI login device code): no credential; the device code is the poller's secret.
977
+ if (method === "POST" && pathname === "/cli/login") {
978
+ const input = await body(request)
979
+ return Response.json(await auth.startCliLogin(input.clientName, input.scopes), { headers: NO_STORE })
980
+ }
981
+ if (method === "POST" && pathname === "/cli/login/poll") return await cliLoginPoll((await body(request)).deviceCode)
982
+ if (method === "GET" && pathname === "/spoke/presence") return await spokePresence(request)
966
983
  if (method === "GET" && pathname === "/.well-known/hub") {
967
984
  return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
968
985
  }
969
986
  if (method === "GET" && pathname === "/login") return login(request, url)
970
987
  if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
971
988
  if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
989
+ if (pathname === MCP_PATH) return await mcp(request)
972
990
  if (isAgentPath(pathname)) return await agent.handle(request, url)
973
991
  if (pathname.startsWith("/api/")) return await api(request, server, url)
974
992
  if (method === "GET") return await staticFile(pathname, request.headers.get("accept-encoding"))
@@ -978,6 +996,31 @@ export function startCentral(options: CentralOptions): Central {
978
996
  }
979
997
  }
980
998
 
999
+ /**
1000
+ * GET /spoke/presence: which harness spokes of the caller's own machine are connected (machine token only). The
1001
+ * one read-only query a machine credential can make about itself, for `hubctl setup verify` (spoke-connected
1002
+ * readiness): harness, evidence level and features only, never sessions, directories, pids or other machines.
1003
+ */
1004
+ async function spokePresence(request: Request): Promise<Response> {
1005
+ const refused = () => Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
1006
+ const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")?.[1]
1007
+ if (!token || token.startsWith("hlt1.")) return refused()
1008
+ const machine = await store.findMachine(token)
1009
+ if (machine) await store.recheck({ users: [machine.ownerID] })
1010
+ const owner = machine && store.user(machine.ownerID)
1011
+ if (!machine || machine.revoked || !owner || owner.disabled) return refused()
1012
+ const processes = registry.catalog().find((m) => m.id === machine.id)?.processes ?? []
1013
+ const seen = new Set<string>()
1014
+ const spokes = processes.flatMap((p) => {
1015
+ const entry = { harness: p.harness, evidence: p.evidence, features: [...p.features] }
1016
+ const key = JSON.stringify(entry)
1017
+ if (seen.has(key)) return []
1018
+ seen.add(key)
1019
+ return [entry]
1020
+ })
1021
+ return Response.json({ machineID: machine.id, spokes })
1022
+ }
1023
+
981
1024
  async function login(request: Request, url: URL): Promise<Response> {
982
1025
  const next = url.searchParams.get("next") ?? "/"
983
1026
  if (!LOGIN_NEXT.test(next)) return error(400, "Invalid next path")
@@ -1060,12 +1103,24 @@ export function startCentral(options: CentralOptions): Central {
1060
1103
  // The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
1061
1104
  route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
1062
1105
  route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
1106
+ const before = new Set(store.viewPrefs(user.id).hiddenSessions)
1063
1107
  const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
1064
1108
  // The user's other devices only: never another user, and not the device that made the change.
1065
1109
  toUser(user.id, { type: "prefs", prefs }, device.id)
1110
+ // Archive changes made in the console go to the agent event journal like the agent API's.
1111
+ const after = new Set(prefs.hiddenSessions)
1112
+ const changes = [...[...after].filter((id) => !before.has(id)).map((id) => archiveEvent(id, true)), ...[...before].filter((id) => !after.has(id)).map((id) => archiveEvent(id, false))]
1113
+ for (const change of changes) await agentEvents.append(user.id, change).catch((cause) => console.error(`[hub] archive event failed: ${(cause as Error).message}`))
1066
1114
  return Response.json({ prefs }, { headers: NO_STORE })
1067
1115
  }),
1068
1116
  route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
1117
+ // A CLI login (`hub login`): the user approves it as themselves, with the scopes they tick, or denies it.
1118
+ route("GET", "/api/cli-logins/:code", "user", async ({ params }) => {
1119
+ const login = await auth.cliLogin(params.code!)
1120
+ return login ? Response.json({ login }, { headers: NO_STORE }) : error(404, "No pending CLI login with that code")
1121
+ }),
1122
+ route("POST", "/api/cli-logins/:code/approve", "user", async ({ request, params, user }) => decideCliLogin(params.code!, "approved", user, (await body(request)).scopes)),
1123
+ route("POST", "/api/cli-logins/:code/deny", "user", ({ params, user }) => decideCliLogin(params.code!, "denied", user)),
1069
1124
  route("POST", "/api/enrollments/:code/approve", "user", ({ params, user }) => decideEnrollment(params.code!, "approved", user)),
1070
1125
  route("POST", "/api/enrollments/:code/deny", "user", ({ params, user }) => decideEnrollment(params.code!, "denied", user)),
1071
1126
  route("GET", "/api/devices", "user", ({ device, user }) =>
@@ -1431,6 +1486,31 @@ export function startCentral(options: CentralOptions): Central {
1431
1486
  return new Response(null, { status: 204 })
1432
1487
  }
1433
1488
 
1489
+ async function decideCliLogin(code: string, status: "approved" | "denied", user: UserRecord, scopes?: unknown): Promise<Response> {
1490
+ const clientName = await auth.decideCliLogin(code, status, user.id, scopes)
1491
+ if (clientName === undefined) return error(404, "No pending CLI login with that code")
1492
+ await store.audit({ actor: user.id, action: status === "approved" ? "cli-login.approve" : "cli-login.deny", detail: clientName })
1493
+ return new Response(null, { status: 204 })
1494
+ }
1495
+
1496
+ /**
1497
+ * The decision on a CLI login, once. Approved: a new app token of the approver (still active) with the scopes
1498
+ * they chose, in this response only (no-store, never logged).
1499
+ */
1500
+ async function cliLoginPoll(deviceCode: unknown): Promise<Response> {
1501
+ const poll = await auth.pollCliLogin(deviceCode)
1502
+ if (poll.status !== "approved") return Response.json({ status: poll.status }, { headers: NO_STORE })
1503
+ const owner = store.user(poll.ownerID)
1504
+ if (!owner || owner.disabled) return Response.json({ status: "denied" }, { headers: NO_STORE })
1505
+ try {
1506
+ const { record, token } = await issueAppToken(store, owner.id, { label: `CLI login: ${poll.clientName}`, scopes: poll.scopes })
1507
+ return Response.json({ status: "approved", token, tokenID: record.id, scopes: record.scopes }, { headers: NO_STORE })
1508
+ } catch (cause) {
1509
+ if (cause instanceof AgentError && cause.code === "rate_limited") return error(429, cause.message, "rate_limited")
1510
+ throw cause
1511
+ }
1512
+ }
1513
+
1434
1514
  async function revokeMachine(id: string, user: UserRecord): Promise<Response> {
1435
1515
  if (!await store.revokeMachine(id)) return error(404, "No such machine")
1436
1516
  await store.bumpMachineEpoch(id)
@@ -1930,6 +2010,8 @@ export function startCentral(options: CentralOptions): Central {
1930
2010
  launchFeed,
1931
2011
  launchTokens,
1932
2012
  webhooks,
2013
+ agentEvents,
2014
+ agentEventWriters,
1933
2015
  routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
1934
2016
  drain(drainOptions: DrainOptions = {}) {
1935
2017
  draining ??= runDrain(drainOptions)
@@ -1940,6 +2022,7 @@ export function startCentral(options: CentralOptions): Central {
1940
2022
  await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
1941
2023
  launches.shutdown()
1942
2024
  webhooks.stop()
2025
+ agentEventWriters.stop()
1943
2026
  tokenPush.stop()
1944
2027
  delivery.stop()
1945
2028
  decisions.stop()
@@ -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
+ }