@huynle/hub 0.9.2 → 0.10.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 (141) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +154 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +534 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +81 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +547 -58
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +227 -237
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-CuRIffka.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-bX8B6NLo.js +33 -0
  123. package/web/dist/assets/index-rW6LKQj1.css +1 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -0,0 +1,868 @@
1
+ // Connect an app (AF1, docs/plans/2026-11-08-app-front-contracts.md §3; decisions w6lfbfaq, zn7oxf4g, nixct956): an
2
+ // OAuth 2.1-style authorization code + PKCE flow for registered, confidential app clients.
3
+ //
4
+ // - Clients live on the registering user's agent data (`appClients`, the secret's sha256 only): audience "hub"
5
+ // (an admin's, every user may connect) or "user" (personal: only its owner sees and uses it). IDs are hub-unique.
6
+ // - GET /connect/authorize opens a pending flow (hcf_, 10 min) in the shared Kv, then the consent page approves or
7
+ // denies it as the signed-in user (cookie only); approve mints a one-time code (hco_, 60 s, stored by hash).
8
+ // - POST /connect/token (form-encoded, client_secret_basic|post + PKCE S256) turns a code into a connection on the
9
+ // user's agent data (`connections`, hashes only) with an access token (hca_<connection>_, 1 h) and a rotating
10
+ // refresh token (hcr_<connection>_, 30 days sliding). The just-rotated refresh token answers the same new pair for
11
+ // 30 s (sealed with a key derived from it, never stored in clear); any other reuse revokes the connection, as does
12
+ // a second use of a code.
13
+ // - POST /connect/revoke (RFC 7009) ends the connection; so does the user (Settings) or a client change. Ending one
14
+ // calls `ended` (the hub deletes the app's grants and webhooks).
15
+ // - The access-token verifier resolves to the app actor whose tokenID is the connection ID (stable across refresh).
16
+ // Codes, flows, secrets and tokens are never logged or audited; only IDs and scope names are.
17
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from "node:crypto"
18
+ import {
19
+ ACCESS_TOKEN_TTL_MS,
20
+ AUTHORIZATION_CODE_TTL_MS,
21
+ CLIENT_AUDIENCES,
22
+ type ClientAudience,
23
+ CONNECT_FLOW_TTL_MS,
24
+ CONNECT_LIMITS,
25
+ CONNECT_PATHS,
26
+ CONNECT_TOKEN_PATTERNS,
27
+ parseConnectToken,
28
+ parseScopeParam,
29
+ pkceChallengeProblem,
30
+ pkceVerifierProblem,
31
+ REFRESH_GRACE_MS,
32
+ REFRESH_TOKEN_IDLE_MS,
33
+ redirectUriMatches,
34
+ redirectUriProblem,
35
+ TOKEN_ERROR_STATUS,
36
+ type TokenError,
37
+ } from "../../protocol"
38
+ import type { AppClientRecord, ConnectionRecord } from "../agent-data"
39
+ import type { Counters, Kv } from "../backend/types"
40
+ import { hashToken, type Store, type UserRecord } from "../store"
41
+ import type { AppActor, BearerVerifier, VerifyResult } from "./actor"
42
+ import { boundedText } from "./body"
43
+ import { AgentError } from "./errors"
44
+
45
+ /** Longest form body the token and revoke endpoints read. */
46
+ const FORM_MAX = 16 * 1024
47
+ /** How long a spent code remembers the connection it issued (a second use revokes it). */
48
+ const CODE_USED_TTL_MS = 10 * 60_000
49
+ const RATE_WINDOW_MS = 60_000
50
+ const NO_STORE = { "cache-control": "no-store" }
51
+
52
+ const KEYS = {
53
+ flow: (id: string) => `connect:flow:${id}`,
54
+ code: (hash: string) => `connect:code:${hash}`,
55
+ codeUsed: (hash: string) => `connect:code-used:${hash}`,
56
+ authorizeRate: (clientID: string, address: string) => `connect:authorize-rate:${clientID}:${address}`,
57
+ tokenRate: (clientID: string) => `connect:token-rate:${clientID}`,
58
+ authFailures: (clientID: string, address: string) => `connect:auth-failures:${clientID}:${address}`,
59
+ }
60
+
61
+ interface Flow {
62
+ readonly id: string
63
+ readonly clientID: string
64
+ readonly redirectURI: string
65
+ readonly scopes: readonly string[]
66
+ readonly state: string
67
+ readonly challenge: string
68
+ readonly created: number
69
+ readonly expiresAt: number
70
+ }
71
+
72
+ interface Code {
73
+ readonly clientID: string
74
+ readonly redirectURI: string
75
+ readonly challenge: string
76
+ readonly userID: string
77
+ readonly scopes: readonly string[]
78
+ readonly expiresAt: number
79
+ }
80
+
81
+ /** A client as Settings shows it (never the secret or its hash). */
82
+ export interface AppClientView {
83
+ readonly id: string
84
+ readonly name: string
85
+ readonly redirectURIs: readonly string[]
86
+ readonly scopes: readonly string[]
87
+ readonly created: number
88
+ readonly createdBy: string
89
+ readonly audience: ClientAudience
90
+ readonly disabled?: true
91
+ }
92
+
93
+ export interface ConnectionView {
94
+ readonly id: string
95
+ readonly clientID: string
96
+ readonly clientName: string
97
+ readonly scopes: readonly string[]
98
+ readonly created: number
99
+ readonly lastUsed?: number
100
+ readonly refreshExpiresAt: number
101
+ }
102
+
103
+ export interface ConnectFlowView {
104
+ readonly id: string
105
+ readonly client: { readonly id: string; readonly name: string; readonly redirectOrigin: string }
106
+ readonly scopes: readonly string[]
107
+ readonly expiresAt: number
108
+ readonly connection?: { readonly id: string; readonly scopes: readonly string[] }
109
+ }
110
+
111
+ export interface AppConnectDeps {
112
+ readonly store: Store
113
+ readonly kv: Kv
114
+ readonly counters: Counters
115
+ /** The hub's public URL: the issuer. */
116
+ publicURL(): string
117
+ /** What a client may ask for (APP_SCOPES). */
118
+ readonly scopes: readonly string[]
119
+ readonly now?: () => number
120
+ /** A connection ended (revoked, reused, lapsed, its client changed): the hub deletes its grants and webhooks. */
121
+ ended?(userID: string, connectionID: string): Promise<void>
122
+ }
123
+
124
+ interface Found {
125
+ readonly owner: UserRecord
126
+ readonly record: AppClientRecord
127
+ }
128
+
129
+ /** The answer of the token endpoint (RFC 6749 §5.1). */
130
+ interface TokenBody {
131
+ readonly access_token: string
132
+ readonly token_type: "Bearer"
133
+ readonly expires_in: number
134
+ readonly refresh_token: string
135
+ readonly scope: string
136
+ readonly connection_id: string
137
+ }
138
+
139
+ class OAuthError extends Error {
140
+ constructor(
141
+ readonly error: TokenError,
142
+ message: string,
143
+ readonly headers: Record<string, string> = {},
144
+ ) {
145
+ super(message)
146
+ }
147
+ }
148
+
149
+ const sha256 = (value: string) => createHash("sha256").update(value).digest()
150
+ /** Constant-time comparison of two hex digests. */
151
+ function sameHash(a: string | undefined, b: string): boolean {
152
+ if (!a) return false
153
+ const x = Buffer.from(a, "hex")
154
+ const y = Buffer.from(b, "hex")
155
+ return x.length === y.length && x.length > 0 && timingSafeEqual(x, y)
156
+ }
157
+ const secret = () => randomBytes(32).toString("base64url")
158
+ const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((s): s is string => typeof s === "string") : [])
159
+ const audienceOf = (record: AppClientRecord): ClientAudience => (record.audience === "user" ? "user" : "hub")
160
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
161
+
162
+ /** The new pair, sealed for the refresh grace with a key only the presenter of the old refresh token can derive. */
163
+ function seal(body: TokenBody, oldRefresh: string): string {
164
+ const iv = randomBytes(12)
165
+ const cipher = createCipheriv("aes-256-gcm", sha256(`hub-connect-grace\n${oldRefresh}`), iv)
166
+ const data = Buffer.concat([cipher.update(JSON.stringify(body), "utf8"), cipher.final()])
167
+ return Buffer.concat([iv, cipher.getAuthTag(), data]).toString("base64url")
168
+ }
169
+
170
+ function unseal(sealed: string, oldRefresh: string): TokenBody | undefined {
171
+ try {
172
+ const raw = Buffer.from(sealed, "base64url")
173
+ const decipher = createDecipheriv("aes-256-gcm", sha256(`hub-connect-grace\n${oldRefresh}`), raw.subarray(0, 12))
174
+ decipher.setAuthTag(raw.subarray(12, 28))
175
+ return JSON.parse(Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8")) as TokenBody
176
+ } catch {
177
+ return undefined
178
+ }
179
+ }
180
+
181
+ const escapeHTML = (text: string) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
182
+
183
+ /** The authorize endpoint's error page (never a redirect: no open redirector); it cannot be framed. */
184
+ function errorPage(message: string): Response {
185
+ const html = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Cannot connect</title><h1>Cannot connect this app</h1><p>${escapeHTML(message)}</p><p>Start again from the app, or ask whoever runs it to check its Hub registration.</p></html>`
186
+ return new Response(html, {
187
+ status: 400,
188
+ headers: { "content-type": "text/html; charset=utf-8", "content-security-policy": "default-src 'none'; frame-ancestors 'none'", "x-frame-options": "DENY", ...NO_STORE },
189
+ })
190
+ }
191
+
192
+ function oauthResponse(cause: OAuthError): Response {
193
+ return Response.json({ error: cause.error, error_description: cause.message.slice(0, 200) }, { status: TOKEN_ERROR_STATUS[cause.error], headers: { ...NO_STORE, pragma: "no-cache", ...cause.headers } })
194
+ }
195
+
196
+ export class AppConnect {
197
+ private readonly now: () => number
198
+
199
+ constructor(private readonly deps: AppConnectDeps) {
200
+ this.now = deps.now ?? Date.now
201
+ }
202
+
203
+ // ---------- metadata ----------
204
+
205
+ /** RFC 8414 authorization server metadata. */
206
+ metadata(): Response {
207
+ const issuer = this.deps.publicURL()
208
+ return Response.json(
209
+ {
210
+ issuer,
211
+ authorization_endpoint: `${issuer}${CONNECT_PATHS.authorize}`,
212
+ token_endpoint: `${issuer}${CONNECT_PATHS.token}`,
213
+ revocation_endpoint: `${issuer}${CONNECT_PATHS.revoke}`,
214
+ response_types_supported: ["code"],
215
+ grant_types_supported: ["authorization_code", "refresh_token"],
216
+ code_challenge_methods_supported: ["S256"],
217
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
218
+ scopes_supported: [...this.deps.scopes],
219
+ authorization_response_iss_parameter_supported: true,
220
+ },
221
+ { headers: { "cache-control": "public, max-age=60" } },
222
+ )
223
+ }
224
+
225
+ // ---------- clients ----------
226
+
227
+ /** The client of an ID, wherever it is stored. */
228
+ private find(id: string): Found | undefined {
229
+ if (!CONNECT_TOKEN_PATTERNS.clientID.test(id)) return undefined
230
+ for (const owner of this.deps.store.users()) {
231
+ const record = owner.agent?.appClients?.find((c) => c.id === id)
232
+ if (record) return { owner, record }
233
+ }
234
+ return undefined
235
+ }
236
+
237
+ /** A client that may connect now: enabled, and a personal one's owner active. */
238
+ private usable(id: string): Found | undefined {
239
+ const found = this.find(id)
240
+ if (!found || found.record.disabled) return undefined
241
+ if (audienceOf(found.record) === "user" && found.owner.disabled) return undefined
242
+ return found
243
+ }
244
+
245
+ /** Whether `user` may see (and connect to) the client: hub-wide ones, and their own personal ones. */
246
+ private visible(found: Found, userID: string): boolean {
247
+ return audienceOf(found.record) === "hub" || found.owner.id === userID
248
+ }
249
+
250
+ private allClients(): Found[] {
251
+ return this.deps.store.users().flatMap((owner) => (owner.agent?.appClients ?? []).map((record) => ({ owner, record })))
252
+ }
253
+
254
+ private clientView({ owner, record }: Found): AppClientView {
255
+ return {
256
+ id: record.id,
257
+ name: typeof record.name === "string" ? record.name : record.id,
258
+ redirectURIs: strings(record.redirectURIs),
259
+ scopes: strings(record.scopes),
260
+ created: typeof record.created === "number" ? record.created : 0,
261
+ createdBy: owner.id,
262
+ audience: audienceOf(record),
263
+ ...(record.disabled ? { disabled: true as const } : {}),
264
+ }
265
+ }
266
+
267
+ /** The clients `user` sees: hub-wide ones and their own personal ones, newest first. */
268
+ clients(user: UserRecord): AppClientView[] {
269
+ return this.allClients()
270
+ .filter((found) => this.visible(found, user.id))
271
+ .map((found) => this.clientView(found))
272
+ .sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1))
273
+ }
274
+
275
+ /** A client `user` may change: hub-wide ones for admins (forbidden for others), personal ones for their owner only. */
276
+ private manageable(user: UserRecord, id: string): Found {
277
+ const found = this.find(id)
278
+ if (!found || !this.visible(found, user.id)) throw new AgentError("not_found", "No such app client")
279
+ if (audienceOf(found.record) === "hub" && user.role !== "admin") throw new AgentError("forbidden", "Only an admin changes a hub-wide app client")
280
+ return found
281
+ }
282
+
283
+ private newID(prefix: string, taken: (id: string) => boolean): string {
284
+ for (;;) {
285
+ const id = `${prefix}${randomBytes(8).toString("hex")}`
286
+ if (!taken(id)) return id
287
+ }
288
+ }
289
+
290
+ async register(user: UserRecord, body: unknown): Promise<{ client: AppClientView; secret: string }> {
291
+ const input = this.clientInput(body, false)
292
+ const audience = input.audience ?? (user.role === "admin" ? "hub" : "user")
293
+ if (audience === "hub" && user.role !== "admin") throw new AgentError("forbidden", "Only an admin registers a hub-wide app client", { hint: 'Register it for your own account (audience "user")' })
294
+ if (audience === "hub" && this.allClients().filter((c) => audienceOf(c.record) === "hub").length >= CONNECT_LIMITS.clients) {
295
+ throw new AgentError("rate_limited", `At most ${CONNECT_LIMITS.clients} hub-wide app clients`, { hint: "Delete unused clients" })
296
+ }
297
+ if (audience === "user" && (user.agent?.appClients ?? this.deps.store.agentData(user.id).appClients ?? []).filter((c) => audienceOf(c) === "user").length >= CONNECT_LIMITS.personalClientsPerUser) {
298
+ throw new AgentError("rate_limited", `At most ${CONNECT_LIMITS.personalClientsPerUser} personal app clients per user`, { hint: "Delete unused clients" })
299
+ }
300
+ const id = this.newID("hcl_", (candidate) => this.find(candidate) !== undefined)
301
+ const plain = `hcs_${secret()}`
302
+ const record: AppClientRecord = { id, secretHash: hashToken(plain), name: input.name!, redirectURIs: [...input.redirectURIs!], scopes: [...input.scopes!], audience, created: this.now() }
303
+ await this.deps.store.updateAgentData(user.id, (data) => {
304
+ data.appClients = [...(data.appClients ?? []).filter((c) => c.id !== id), record]
305
+ })
306
+ await this.deps.store.audit({ actor: user.id, action: "app-client.create", target: id, detail: audience })
307
+ return { client: this.clientView({ owner: this.deps.store.user(user.id) ?? user, record }), secret: plain }
308
+ }
309
+
310
+ async update(user: UserRecord, id: string, body: unknown): Promise<AppClientView> {
311
+ const found = this.manageable(user, id)
312
+ const input = this.clientInput(body, true)
313
+ let next: AppClientRecord | undefined
314
+ await this.deps.store.updateAgentData(found.owner.id, (data) => {
315
+ next = undefined
316
+ data.appClients = (data.appClients ?? []).map((c) => {
317
+ if (c.id !== id) return c
318
+ const { disabled: _drop, ...rest } = c
319
+ next = {
320
+ ...rest,
321
+ ...(input.name !== undefined ? { name: input.name } : {}),
322
+ ...(input.redirectURIs ? { redirectURIs: [...input.redirectURIs] } : {}),
323
+ ...(input.scopes ? { scopes: [...input.scopes] } : {}),
324
+ ...((input.disabled ?? c.disabled === true) ? { disabled: true as const } : {}),
325
+ updated: this.now(),
326
+ }
327
+ return next
328
+ })
329
+ })
330
+ if (!next) throw new AgentError("not_found", "No such app client")
331
+ await this.deps.store.audit({ actor: user.id, action: "app-client.update", target: id, ...(input.disabled !== undefined ? { detail: input.disabled ? "disabled" : "enabled" } : {}) })
332
+ if (next.disabled) await this.endClient(id, user.id, "client disabled")
333
+ else if (input.scopes) await this.narrowClient(id, input.scopes, user.id)
334
+ return this.clientView({ owner: found.owner, record: next })
335
+ }
336
+
337
+ async rotate(user: UserRecord, id: string): Promise<{ client: AppClientView; secret: string }> {
338
+ const found = this.manageable(user, id)
339
+ const plain = `hcs_${secret()}`
340
+ let next: AppClientRecord | undefined
341
+ await this.deps.store.updateAgentData(found.owner.id, (data) => {
342
+ next = undefined
343
+ data.appClients = (data.appClients ?? []).map((c) => (c.id === id ? (next = { ...c, secretHash: hashToken(plain), updated: this.now() }) : c))
344
+ })
345
+ if (!next) throw new AgentError("not_found", "No such app client")
346
+ await this.deps.store.audit({ actor: user.id, action: "app-client.secret", target: id })
347
+ return { client: this.clientView({ owner: found.owner, record: next }), secret: plain }
348
+ }
349
+
350
+ async remove(user: UserRecord, id: string): Promise<void> {
351
+ const found = this.manageable(user, id)
352
+ let removed = false
353
+ await this.deps.store.updateAgentData(found.owner.id, (data) => {
354
+ const before = data.appClients ?? []
355
+ removed = before.some((c) => c.id === id)
356
+ data.appClients = before.filter((c) => c.id !== id)
357
+ })
358
+ if (!removed) throw new AgentError("not_found", "No such app client")
359
+ await this.deps.store.audit({ actor: user.id, action: "app-client.delete", target: id })
360
+ await this.endClient(id, user.id, "client deleted")
361
+ }
362
+
363
+ /** Register/PATCH bodies: name 1–100, redirect URIs (redirectUriProblem), known scopes, audience; PATCH also disabled. */
364
+ private clientInput(body: unknown, partial: boolean): { name?: string; redirectURIs?: string[]; scopes?: string[]; audience?: ClientAudience; disabled?: boolean } {
365
+ if (!isObject(body)) throw new AgentError("invalid", "Body must be a JSON object")
366
+ const allowed = partial ? ["name", "redirectURIs", "scopes", "disabled"] : ["name", "redirectURIs", "scopes", "audience"]
367
+ const extra = Object.keys(body).find((key) => !allowed.includes(key))
368
+ if (extra !== undefined) throw new AgentError("invalid", `Unknown field: ${extra.slice(0, 40)}`)
369
+ if (partial && Object.keys(body).length === 0) throw new AgentError("invalid", "Nothing to change")
370
+ const out: { name?: string; redirectURIs?: string[]; scopes?: string[]; audience?: ClientAudience; disabled?: boolean } = {}
371
+ if (body.name !== undefined || !partial) {
372
+ const name = typeof body.name === "string" ? body.name.trim().replace(/\s+/g, " ") : ""
373
+ if (!name || name.length > CONNECT_LIMITS.clientNameMax) throw new AgentError("invalid", `name is 1–${CONNECT_LIMITS.clientNameMax} characters`)
374
+ out.name = name
375
+ }
376
+ if (body.redirectURIs !== undefined || !partial) {
377
+ const uris = body.redirectURIs
378
+ if (!Array.isArray(uris) || uris.length === 0 || uris.length > CONNECT_LIMITS.redirectURIsPerClient) throw new AgentError("invalid", `redirectURIs lists 1–${CONNECT_LIMITS.redirectURIsPerClient} URIs`)
379
+ for (const uri of uris) {
380
+ const problem = redirectUriProblem(uri)
381
+ if (problem) throw new AgentError("invalid", `redirectURIs: ${problem}`)
382
+ }
383
+ out.redirectURIs = [...new Set(uris as string[])]
384
+ }
385
+ if (body.scopes !== undefined || !partial) {
386
+ const scopes = body.scopes
387
+ if (!Array.isArray(scopes) || scopes.length === 0 || !scopes.every((s): s is string => typeof s === "string")) throw new AgentError("invalid", "scopes lists at least one scope")
388
+ const unknown = scopes.find((s) => !this.deps.scopes.includes(s))
389
+ if (unknown !== undefined) throw new AgentError("invalid", `Unknown scope: ${unknown.slice(0, 64)}`, { extra: { scopes: this.deps.scopes } })
390
+ out.scopes = [...new Set(scopes)]
391
+ }
392
+ if (body.audience !== undefined) {
393
+ if (!CLIENT_AUDIENCES.includes(body.audience as ClientAudience)) throw new AgentError("invalid", 'audience is "hub" or "user"')
394
+ out.audience = body.audience as ClientAudience
395
+ }
396
+ if (body.disabled !== undefined) {
397
+ if (typeof body.disabled !== "boolean") throw new AgentError("invalid", "disabled is true or false")
398
+ out.disabled = body.disabled
399
+ }
400
+ return out
401
+ }
402
+
403
+ // ---------- connections ----------
404
+
405
+ /** The connection of an ID and its user, wherever it is stored. */
406
+ private connectionOf(id: string): { user: UserRecord; connection: ConnectionRecord } | undefined {
407
+ for (const user of this.deps.store.users()) {
408
+ const connection = user.agent?.connections?.find((c) => c.id === id)
409
+ if (connection) return { user, connection }
410
+ }
411
+ return undefined
412
+ }
413
+
414
+ /** Whether the user's connection exists now (grants and webhooks of app:<id>). Synchronous: the Store's cache. */
415
+ alive(userID: string, id: string): boolean {
416
+ return (this.deps.store.agentData(userID).connections ?? []).some((c) => c.id === id)
417
+ }
418
+
419
+ /** The scopes a connection has now (its own, within its client's ceiling). */
420
+ scopesOf(userID: string, id: string): string[] | undefined {
421
+ const connection = (this.deps.store.agentData(userID).connections ?? []).find((c) => c.id === id)
422
+ if (!connection) return undefined
423
+ const ceiling = strings(this.find(connection.clientID)?.record.scopes)
424
+ return strings(connection.scopes).filter((s) => ceiling.includes(s))
425
+ }
426
+
427
+ /** The user's connections (lapsed ones left out), newest first. */
428
+ connections(userID: string): ConnectionView[] {
429
+ const at = this.now()
430
+ return (this.deps.store.agentData(userID).connections ?? [])
431
+ .filter((c) => (c.refreshExpiresAt ?? 0) >= at)
432
+ .map((c) => {
433
+ const client = this.find(c.clientID)
434
+ return {
435
+ id: c.id,
436
+ clientID: c.clientID,
437
+ clientName: typeof client?.record.name === "string" ? client.record.name : c.clientID,
438
+ scopes: this.scopesOf(userID, c.id) ?? [],
439
+ created: c.created ?? 0,
440
+ ...(typeof c.lastUsed === "number" ? { lastUsed: c.lastUsed } : {}),
441
+ refreshExpiresAt: c.refreshExpiresAt ?? 0,
442
+ }
443
+ })
444
+ .sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1))
445
+ }
446
+
447
+ /**
448
+ * Ends a connection (both tokens at once) and tells the hub (grants, webhooks); false when it did not exist.
449
+ * `by` is the audit actor; `action` connect.revoke or connect.reuse.
450
+ */
451
+ async end(userID: string, id: string, options: { readonly by: string; readonly why: string; readonly action?: "connect.revoke" | "connect.reuse" }): Promise<boolean> {
452
+ let removed = false
453
+ await this.deps.store.updateAgentData(userID, (data) => {
454
+ const before = data.connections ?? []
455
+ removed = before.some((c) => c.id === id)
456
+ if (removed) data.connections = before.filter((c) => c.id !== id)
457
+ })
458
+ if (!removed) return false
459
+ await this.deps.store.audit({ actor: options.by, action: options.action ?? "connect.revoke", target: id, detail: options.why })
460
+ try {
461
+ await this.deps.ended?.(userID, id)
462
+ } catch (cause) {
463
+ console.error(`[hub] cleaning up connection ${id} failed: ${(cause as Error).message}`)
464
+ }
465
+ return true
466
+ }
467
+
468
+ private async endClient(clientID: string, by: string, why: string): Promise<void> {
469
+ for (const user of this.deps.store.users()) {
470
+ for (const c of user.agent?.connections ?? []) if (c.clientID === clientID) await this.end(user.id, c.id, { by, why })
471
+ }
472
+ }
473
+
474
+ /** A client's ceiling narrowed: every connection keeps only what is still allowed (none left: it ends). */
475
+ private async narrowClient(clientID: string, ceiling: readonly string[], by: string): Promise<void> {
476
+ for (const user of this.deps.store.users()) {
477
+ for (const c of user.agent?.connections ?? []) {
478
+ if (c.clientID !== clientID) continue
479
+ const kept = strings(c.scopes).filter((s) => ceiling.includes(s))
480
+ if (!kept.length) await this.end(user.id, c.id, { by, why: "client scopes" })
481
+ else await this.deps.store.updateAgentData(user.id, (data) => void (data.connections = (data.connections ?? []).map((x) => (x.id === c.id ? { ...x, scopes: kept } : x))))
482
+ }
483
+ }
484
+ }
485
+
486
+ // ---------- authorize and consent ----------
487
+
488
+ /** GET /connect/authorize: a flow and a redirect to the consent page, an error redirect, or an error page. */
489
+ async authorize(url: URL, address = "unknown"): Promise<Response> {
490
+ const q = url.searchParams
491
+ const one = (key: string): string | undefined => (q.getAll(key).length === 1 ? q.get(key)! : undefined)
492
+ const clientID = one("client_id") ?? ""
493
+ const redirectURI = one("redirect_uri")
494
+ const found = this.usable(clientID)
495
+ if (!found) return errorPage("This app is not registered with this hub, or it is disabled.")
496
+ if (!redirectUriMatches(strings(found.record.redirectURIs), redirectURI)) return errorPage("The app asked to come back to an address it did not register.")
497
+ const rawState = one("state")
498
+ const state = rawState && rawState.length <= CONNECT_LIMITS.stateMax ? rawState : undefined
499
+ const back = (error: string, description: string) => {
500
+ const target = new URL(redirectURI!)
501
+ target.searchParams.set("error", error)
502
+ target.searchParams.set("error_description", description.slice(0, 200))
503
+ if (state !== undefined) target.searchParams.set("state", state)
504
+ target.searchParams.set("iss", this.deps.publicURL())
505
+ return new Response(null, { status: 302, headers: { location: target.toString(), ...NO_STORE } })
506
+ }
507
+ if (one("response_type") !== "code") return back("unsupported_response_type", "response_type must be code")
508
+ if (state === undefined) return back("invalid_request", `state is required (1–${CONNECT_LIMITS.stateMax} characters)`)
509
+ const challenge = one("code_challenge")
510
+ const pkce = pkceChallengeProblem(challenge, one("code_challenge_method"))
511
+ if (pkce) return back("invalid_request", pkce)
512
+ const parsed = parseScopeParam(one("scope"), this.deps.scopes)
513
+ if ("problem" in parsed) return back("invalid_scope", parsed.problem)
514
+ const ceiling = strings(found.record.scopes)
515
+ const beyond = parsed.scopes.find((s) => !ceiling.includes(s))
516
+ if (beyond !== undefined) return back("invalid_scope", `This app may not ask for ${beyond}`)
517
+ if ((await this.deps.counters.incr(KEYS.authorizeRate(clientID, address), RATE_WINDOW_MS)) > CONNECT_LIMITS.authorizePerMinute) return back("temporarily_unavailable", "Too many requests; try again in a minute")
518
+ const created = this.now()
519
+ const flow: Flow = { id: `hcf_${randomBytes(12).toString("hex")}`, clientID, redirectURI: redirectURI!, scopes: parsed.scopes, state, challenge: challenge!, created, expiresAt: created + CONNECT_FLOW_TTL_MS }
520
+ await this.deps.kv.set(KEYS.flow(flow.id), JSON.stringify(flow), { px: CONNECT_FLOW_TTL_MS + 1 })
521
+ // Not audited here (anyone may call it): the user's consent or denial is.
522
+ return new Response(null, { status: 302, headers: { location: `/connect/consent/${flow.id}`, ...NO_STORE } })
523
+ }
524
+
525
+ /** The pending flow `user` may decide (personal clients: their owner only); not_found otherwise. */
526
+ private async flow(user: UserRecord, id: string): Promise<{ flow: Flow; found: Found }> {
527
+ const missing = () => new AgentError("not_found", "This request expired: start again from the app")
528
+ if (!CONNECT_TOKEN_PATTERNS.flow.test(id)) throw missing()
529
+ const raw = await this.deps.kv.get(KEYS.flow(id))
530
+ const flow = raw ? (JSON.parse(raw) as Flow) : undefined
531
+ if (!flow || flow.expiresAt < this.now()) throw missing()
532
+ const found = this.usable(flow.clientID)
533
+ if (!found || !this.visible(found, user.id)) throw missing()
534
+ return { flow, found }
535
+ }
536
+
537
+ /** GET /api/connect/flows/:flow: what the consent page shows. */
538
+ async flowView(user: UserRecord, id: string): Promise<ConnectFlowView> {
539
+ const { flow, found } = await this.flow(user, id)
540
+ const connection = (this.deps.store.agentData(user.id).connections ?? []).find((c) => c.clientID === flow.clientID && (c.refreshExpiresAt ?? 0) >= this.now())
541
+ return {
542
+ id: flow.id,
543
+ client: { id: found.record.id, name: this.clientView(found).name, redirectOrigin: new URL(flow.redirectURI).origin },
544
+ scopes: [...flow.scopes],
545
+ expiresAt: flow.expiresAt,
546
+ ...(connection ? { connection: { id: connection.id, scopes: this.scopesOf(user.id, connection.id) ?? [] } } : {}),
547
+ }
548
+ }
549
+
550
+ /** Approve (with the scopes the user kept: a non-empty subset of the request) or deny: where the page goes next. */
551
+ async decide(user: UserRecord, id: string, decision: "approve" | "deny", scopes?: unknown): Promise<{ redirect: string }> {
552
+ const { flow } = await this.flow(user, id)
553
+ let approved: string[] = []
554
+ if (decision === "approve") {
555
+ if (!Array.isArray(scopes) || !scopes.every((s): s is string => typeof s === "string") || scopes.length === 0) throw new AgentError("invalid", "Choose at least one scope")
556
+ const extra = scopes.find((s) => !flow.scopes.includes(s))
557
+ if (extra !== undefined) throw new AgentError("invalid", `The app did not ask for ${extra.slice(0, 64)}`)
558
+ approved = flow.scopes.filter((s) => scopes.includes(s))
559
+ const mine = this.deps.store.agentData(user.id).connections ?? []
560
+ if (!mine.some((c) => c.clientID === flow.clientID) && mine.length >= CONNECT_LIMITS.connectionsPerUser) {
561
+ throw new AgentError("rate_limited", `At most ${CONNECT_LIMITS.connectionsPerUser} connected apps per user`, { hint: "Revoke unused connections in Settings → Agents" })
562
+ }
563
+ }
564
+ // The first decision wins (GETDEL), on any pod.
565
+ if (!(await this.deps.kv.getdel(KEYS.flow(flow.id)))) throw new AgentError("not_found", "This request expired: start again from the app")
566
+ const target = new URL(flow.redirectURI)
567
+ if (decision === "approve") {
568
+ const code = `hco_${secret()}`
569
+ const record: Code = { clientID: flow.clientID, redirectURI: flow.redirectURI, challenge: flow.challenge, userID: user.id, scopes: approved, expiresAt: this.now() + AUTHORIZATION_CODE_TTL_MS }
570
+ await this.deps.kv.set(KEYS.code(hashToken(code)), JSON.stringify(record), { px: AUTHORIZATION_CODE_TTL_MS + 1 })
571
+ target.searchParams.set("code", code)
572
+ await this.deps.store.audit({ actor: user.id, action: "connect.consent", target: flow.clientID, detail: approved.join(" ") })
573
+ } else {
574
+ target.searchParams.set("error", "access_denied")
575
+ await this.deps.store.audit({ actor: user.id, action: "connect.deny", target: flow.clientID })
576
+ }
577
+ target.searchParams.set("state", flow.state)
578
+ target.searchParams.set("iss", this.deps.publicURL())
579
+ return { redirect: target.toString() }
580
+ }
581
+
582
+ // ---------- token and revoke endpoints ----------
583
+
584
+ /** The form of a token/revoke request: form-encoded only, each parameter at most once. */
585
+ private async form(request: Request): Promise<URLSearchParams> {
586
+ const type = (request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase()
587
+ if (type !== "application/x-www-form-urlencoded") throw new OAuthError("invalid_request", "Send application/x-www-form-urlencoded")
588
+ let text: string
589
+ try {
590
+ text = await boundedText(request, FORM_MAX)
591
+ } catch {
592
+ throw new OAuthError("invalid_request", "The request body is too large")
593
+ }
594
+ const form = new URLSearchParams(text)
595
+ for (const key of new Set(form.keys())) if (form.getAll(key).length > 1) throw new OAuthError("invalid_request", `${key.slice(0, 40)} is given more than once`)
596
+ return form
597
+ }
598
+
599
+ /**
600
+ * client_secret_basic or client_secret_post (not both), then the secret. A failure counts against (client,
601
+ * address): past CONNECT_LIMITS.failedClientAuthPerMinute that address's failures answer 429, while a request with
602
+ * the right secret is never refused for them. Only authenticated requests count against the per-client limit.
603
+ */
604
+ private async authenticate(request: Request, form: URLSearchParams, address: string): Promise<Found> {
605
+ const header = request.headers.get("authorization") ?? ""
606
+ const basic = /^\s*Basic\s+([A-Za-z0-9+/=]+)\s*$/i.exec(header)
607
+ let id: string | undefined
608
+ let presented: string | undefined
609
+ if (basic) {
610
+ if (form.has("client_secret")) throw new OAuthError("invalid_request", "Use one client authentication method")
611
+ const decoded = Buffer.from(basic[1]!, "base64").toString("utf8")
612
+ const colon = decoded.indexOf(":")
613
+ try {
614
+ id = decodeURIComponent(decoded.slice(0, Math.max(0, colon)).replace(/\+/g, " "))
615
+ presented = decodeURIComponent(decoded.slice(colon + 1).replace(/\+/g, " "))
616
+ } catch {
617
+ id = undefined
618
+ }
619
+ if (form.has("client_id") && form.get("client_id") !== id) throw new OAuthError("invalid_request", "client_id does not match the authentication")
620
+ } else {
621
+ id = form.get("client_id") ?? undefined
622
+ presented = form.get("client_secret") ?? undefined
623
+ }
624
+ const refused = (message: string) => new OAuthError("invalid_client", message, basic ? { "www-authenticate": 'Basic realm="hub"' } : {})
625
+ const busy = () => new OAuthError("temporarily_unavailable", "Too many requests; try again in a minute", { "retry-after": String(RATE_WINDOW_MS / 1000) })
626
+ const found = id ? this.find(id) : undefined
627
+ if (!found || presented === undefined || !sameHash(found.record.secretHash, hashToken(presented)) || (audienceOf(found.record) === "user" && found.owner.disabled)) {
628
+ const key = id && CONNECT_TOKEN_PATTERNS.clientID.test(id) ? id : "other"
629
+ if ((await this.deps.counters.incr(KEYS.authFailures(key, address), RATE_WINDOW_MS)) > CONNECT_LIMITS.failedClientAuthPerMinute) throw busy()
630
+ throw refused("Client authentication failed")
631
+ }
632
+ if ((await this.deps.counters.incr(KEYS.tokenRate(found.record.id), RATE_WINDOW_MS)) > CONNECT_LIMITS.tokenPerMinute) throw busy()
633
+ if (found.record.disabled) throw new OAuthError("unauthorized_client", "This app client is disabled")
634
+ return found
635
+ }
636
+
637
+ /** POST /connect/token. */
638
+ async token(request: Request, address = "unknown"): Promise<Response> {
639
+ try {
640
+ const form = await this.form(request)
641
+ const grantType = form.get("grant_type")
642
+ if (grantType !== "authorization_code" && grantType !== "refresh_token") {
643
+ // Authenticate first: an unknown client learns nothing more.
644
+ await this.authenticate(request, form, address)
645
+ throw new OAuthError(grantType ? "unsupported_grant_type" : "invalid_request", grantType ? "grant_type is authorization_code or refresh_token" : "grant_type is required")
646
+ }
647
+ const client = await this.authenticate(request, form, address)
648
+ const body = grantType === "authorization_code" ? await this.exchange(client, form) : await this.refresh(client, form)
649
+ return Response.json(body, { headers: { ...NO_STORE, pragma: "no-cache" } })
650
+ } catch (cause) {
651
+ if (cause instanceof OAuthError) return oauthResponse(cause)
652
+ throw cause
653
+ }
654
+ }
655
+
656
+ private fresh(connectionID: string): { access: string; refresh: string } {
657
+ return { access: `hca_${connectionID}_${secret()}`, refresh: `hcr_${connectionID}_${secret()}` }
658
+ }
659
+
660
+ private body(connectionID: string, pair: { access: string; refresh: string }, scopes: readonly string[]): TokenBody {
661
+ return { access_token: pair.access, token_type: "Bearer", expires_in: ACCESS_TOKEN_TTL_MS / 1000, refresh_token: pair.refresh, scope: scopes.join(" "), connection_id: connectionID }
662
+ }
663
+
664
+ /** grant_type=authorization_code: the code (once), the redirect URI and the PKCE verifier → the user's connection. */
665
+ private async exchange(client: Found, form: URLSearchParams): Promise<TokenBody> {
666
+ const code = form.get("code")
667
+ const redirectURI = form.get("redirect_uri")
668
+ const verifier = form.get("code_verifier")
669
+ if (!code || !redirectURI) throw new OAuthError("invalid_request", "code and redirect_uri are required")
670
+ const problem = pkceVerifierProblem(verifier)
671
+ if (problem) throw new OAuthError("invalid_request", problem)
672
+ const codeHash = hashToken(code)
673
+ // The binding (client, redirect URI) is checked before the code is spent: another client presenting an
674
+ // intercepted code neither consumes nor revokes anything.
675
+ const peeked = await this.deps.kv.get(KEYS.code(codeHash))
676
+ if (peeked) {
677
+ const bound = JSON.parse(peeked) as Code
678
+ if (bound.clientID !== client.record.id) throw new OAuthError("invalid_grant", "The code was issued to another client")
679
+ if (bound.redirectURI !== redirectURI) throw new OAuthError("invalid_grant", "redirect_uri does not match the authorization request")
680
+ }
681
+ const raw = peeked ? await this.deps.kv.getdel(KEYS.code(codeHash)) : undefined
682
+ if (!raw) {
683
+ const used = await this.deps.kv.get(KEYS.codeUsed(codeHash))
684
+ const issued = used ? (JSON.parse(used) as { userID: string; connectionID: string; clientID: string }) : undefined
685
+ if (issued && issued.clientID === client.record.id) {
686
+ await this.end(issued.userID, issued.connectionID, { by: "system", why: "authorization code reused", action: "connect.reuse" })
687
+ }
688
+ throw new OAuthError("invalid_grant", "The code is unknown, expired or already used")
689
+ }
690
+ const grant = JSON.parse(raw) as Code
691
+ if (grant.expiresAt < this.now()) throw new OAuthError("invalid_grant", "The code expired")
692
+ if (grant.clientID !== client.record.id) throw new OAuthError("invalid_grant", "The code was issued to another client")
693
+ if (grant.redirectURI !== redirectURI) throw new OAuthError("invalid_grant", "redirect_uri does not match the authorization request")
694
+ const expected = Buffer.from(grant.challenge)
695
+ const actual = Buffer.from(createHash("sha256").update(verifier!).digest("base64url"))
696
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) throw new OAuthError("invalid_grant", "code_verifier does not match the code challenge")
697
+ const user = this.deps.store.user(grant.userID)
698
+ if (!user || user.disabled) throw new OAuthError("invalid_grant", "The user cannot connect apps now")
699
+ if (audienceOf(client.record) === "user" && client.owner.id !== user.id) throw new OAuthError("invalid_grant", "This client is not this user's")
700
+
701
+ const existing = (user.agent?.connections ?? []).find((c) => c.clientID === client.record.id)
702
+ const connectionID = existing?.id ?? this.newID("", (id) => this.taken(id))
703
+ await this.deps.kv.set(KEYS.codeUsed(codeHash), JSON.stringify({ userID: user.id, connectionID, clientID: client.record.id }), { px: CODE_USED_TTL_MS })
704
+ const pair = this.fresh(connectionID)
705
+ const at = this.now()
706
+ let full = false
707
+ await this.deps.store.updateAgentData(user.id, (data) => {
708
+ const list = data.connections ?? []
709
+ const current = list.find((c) => c.id === connectionID)
710
+ full = !current && list.length >= CONNECT_LIMITS.connectionsPerUser
711
+ if (full) return
712
+ const record: ConnectionRecord = {
713
+ id: connectionID,
714
+ clientID: client.record.id,
715
+ scopes: [...grant.scopes],
716
+ created: current?.created ?? at,
717
+ lastUsed: at,
718
+ accessHash: hashToken(pair.access),
719
+ accessExpiresAt: at + ACCESS_TOKEN_TTL_MS,
720
+ refreshHash: hashToken(pair.refresh),
721
+ refreshExpiresAt: at + REFRESH_TOKEN_IDLE_MS,
722
+ }
723
+ data.connections = [...list.filter((c) => c.id !== connectionID), record]
724
+ })
725
+ if (full) throw new OAuthError("invalid_grant", `At most ${CONNECT_LIMITS.connectionsPerUser} connected apps per user`)
726
+ await this.deps.store.audit({ actor: user.id, action: "connect.token", target: connectionID, detail: "authorization_code" })
727
+ return this.body(connectionID, pair, grant.scopes)
728
+ }
729
+
730
+ /** A connection ID the hub never used: not a connection's, not a hand-made app token's. */
731
+ private taken(id: string): boolean {
732
+ return this.deps.store.users().some((u) => (u.agent?.connections ?? []).some((c) => c.id === id) || (u.agent?.appTokens ?? []).some((t) => t.id === id))
733
+ }
734
+
735
+ /** grant_type=refresh_token: rotate; the just-rotated token within the grace answers the same pair; else reuse. */
736
+ private async refresh(client: Found, form: URLSearchParams): Promise<TokenBody> {
737
+ const presented = form.get("refresh_token")
738
+ if (!presented) throw new OAuthError("invalid_request", "refresh_token is required")
739
+ const parsed = parseConnectToken(presented)
740
+ const invalid = () => new OAuthError("invalid_grant", "The refresh token is unknown, expired or revoked")
741
+ if (!parsed || parsed.kind !== "refresh") throw invalid()
742
+ const located = this.connectionOf(parsed.connectionID)
743
+ if (!located) throw invalid()
744
+ await this.deps.store.recheck({ users: [located.user.id] })
745
+ const user = this.deps.store.user(located.user.id)
746
+ const connection = user?.agent?.connections?.find((c) => c.id === parsed.connectionID)
747
+ if (!user || !connection || connection.clientID !== client.record.id) throw invalid()
748
+ if (user.disabled) throw invalid()
749
+ let narrowed: string[] | undefined
750
+ const scope = form.get("scope")
751
+ if (scope !== null) {
752
+ const result = parseScopeParam(scope, strings(connection.scopes))
753
+ if ("problem" in result) throw new OAuthError("invalid_scope", result.problem)
754
+ narrowed = result.scopes
755
+ }
756
+
757
+ const hash = hashToken(presented)
758
+ const pair = this.fresh(connection.id)
759
+ const at = this.now()
760
+ type Outcome = { kind: "rotated"; body: TokenBody } | { kind: "grace"; sealed: string } | { kind: "lapsed" } | { kind: "reuse" } | { kind: "gone" }
761
+ let outcome = { kind: "gone" } as Outcome
762
+ await this.deps.store.updateAgentData(user.id, (data) => {
763
+ const current = (data.connections ?? []).find((c) => c.id === connection.id)
764
+ outcome = { kind: "gone" }
765
+ if (!current) return
766
+ if (sameHash(current.refreshHash, hash)) {
767
+ if ((current.refreshExpiresAt ?? 0) < at) {
768
+ outcome = { kind: "lapsed" }
769
+ return
770
+ }
771
+ const scopes = narrowed ?? strings(current.scopes)
772
+ const body = this.body(current.id, pair, scopes)
773
+ outcome = { kind: "rotated", body }
774
+ const next: ConnectionRecord = {
775
+ ...current,
776
+ scopes: [...scopes],
777
+ lastUsed: at,
778
+ accessHash: hashToken(pair.access),
779
+ accessExpiresAt: at + ACCESS_TOKEN_TTL_MS,
780
+ refreshHash: hashToken(pair.refresh),
781
+ refreshExpiresAt: at + REFRESH_TOKEN_IDLE_MS,
782
+ previous: { refreshHash: hash, accessHash: current.accessHash ?? "", until: at + REFRESH_GRACE_MS, accessUntil: Math.min(current.accessExpiresAt ?? 0, at + REFRESH_GRACE_MS), sealed: seal(body, presented) },
783
+ }
784
+ data.connections = (data.connections ?? []).map((c) => (c.id === current.id ? next : c))
785
+ return
786
+ }
787
+ const previous = current.previous
788
+ if (previous && sameHash(previous.refreshHash, hash) && at <= previous.until) outcome = { kind: "grace", sealed: previous.sealed }
789
+ else outcome = { kind: "reuse" }
790
+ })
791
+ const result: Outcome = outcome
792
+ switch (result.kind) {
793
+ case "rotated":
794
+ await this.deps.store.audit({ actor: user.id, action: "connect.token", target: connection.id, detail: "refresh_token" })
795
+ return result.body
796
+ case "grace": {
797
+ const body = unseal(result.sealed, presented)
798
+ if (body) return body
799
+ throw invalid()
800
+ }
801
+ case "lapsed":
802
+ await this.end(user.id, connection.id, { by: "system", why: "refresh token idle 30 days" })
803
+ throw invalid()
804
+ case "reuse":
805
+ await this.end(user.id, connection.id, { by: "system", why: "refresh token reused", action: "connect.reuse" })
806
+ throw new OAuthError("invalid_grant", "The refresh token was already used: the connection is revoked")
807
+ case "gone":
808
+ throw invalid()
809
+ }
810
+ }
811
+
812
+ /** POST /connect/revoke (RFC 7009): either token of the client's connection ends it; 200 for anything else. */
813
+ async revoke(request: Request, address = "unknown"): Promise<Response> {
814
+ try {
815
+ const form = await this.form(request)
816
+ const client = await this.authenticate(request, form, address)
817
+ const value = form.get("token")
818
+ if (!value) throw new OAuthError("invalid_request", "token is required")
819
+ const parsed = parseConnectToken(value)
820
+ const located = parsed ? this.connectionOf(parsed.connectionID) : undefined
821
+ if (located && located.connection.clientID === client.record.id) {
822
+ const hash = hashToken(value)
823
+ const c = located.connection
824
+ if ([c.accessHash, c.refreshHash, c.previous?.accessHash, c.previous?.refreshHash].some((h) => sameHash(h, hash))) {
825
+ await this.end(located.user.id, c.id, { by: `app:${c.id}`, why: "revoked by the app" })
826
+ }
827
+ }
828
+ return new Response(null, { status: 200, headers: NO_STORE })
829
+ } catch (cause) {
830
+ if (cause instanceof OAuthError) return oauthResponse(cause)
831
+ throw cause
832
+ }
833
+ }
834
+
835
+ // ---------- the access token verifier ----------
836
+
837
+ /** hca_<connection>_<secret> → the connected app's actor; past its hour "expired"; anything else undefined. */
838
+ readonly verifier: BearerVerifier = {
839
+ kind: "app",
840
+ verify: async (value: string): Promise<VerifyResult> => {
841
+ const parsed = parseConnectToken(value)
842
+ if (!parsed || parsed.kind !== "access") return undefined
843
+ const located = this.connectionOf(parsed.connectionID)
844
+ if (!located) return undefined
845
+ // A revocation on another pod applies at once: read the user again (a no-op for the file store).
846
+ await this.deps.store.recheck({ users: [located.user.id] })
847
+ const connection = this.deps.store.agentData(located.user.id).connections?.find((c) => c.id === parsed.connectionID)
848
+ if (!connection) return undefined
849
+ const client = this.usable(connection.clientID)
850
+ if (!client) return undefined
851
+ const hash = hashToken(value)
852
+ const at = this.now()
853
+ if (sameHash(connection.accessHash, hash)) {
854
+ if ((connection.accessExpiresAt ?? 0) < at) return "expired"
855
+ } else if (!(connection.previous && sameHash(connection.previous.accessHash, hash) && at <= connection.previous.accessUntil)) return undefined
856
+ const actor: AppActor = {
857
+ kind: "app",
858
+ userID: located.user.id,
859
+ tokenID: connection.id,
860
+ scopes: this.scopesOf(located.user.id, connection.id) ?? [],
861
+ label: this.clientView(client).name,
862
+ clientID: client.record.id,
863
+ connection: true,
864
+ }
865
+ return actor
866
+ },
867
+ }
868
+ }