@openparachute/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 (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
@@ -0,0 +1,1365 @@
1
+ /**
2
+ * Agent-connector GRANTS — the approval-gated resource-grant subsystem for
3
+ * vault-native agents (Phase 4b-1, agent-connectors design 2026-06-17).
4
+ *
5
+ * An agent (a `#agent/definition` note in the agent module) declares connections
6
+ * it WANTS beyond its own def-vault — other LOCAL vaults (tag-scoped) and
7
+ * external SERVICE credentials (GitHub, Cloudflare). The agent module registers
8
+ * each as a PENDING grant here; the operator approves per-connection in hub
9
+ * admin; the hub mints (vault) / stores (service) the secret; the agent module
10
+ * fetches the material at spawn and injects it. The `mcp` (remote/OAuth) kind is
11
+ * MODELED but not grantable in 4b-1 — it stays `pending` with a clear reason
12
+ * (slice 2 is hub-as-OAuth-client).
13
+ *
14
+ * The one invariant: **a vault note can only REQUEST; it can never GRANT.** A
15
+ * grant created by the module sits `pending` and grants nothing until the
16
+ * operator approves. Worst case, a note written by anyone sits pending forever.
17
+ *
18
+ * Generalizes the hub's Connections engine from "event→action triggers" to
19
+ * "approval-gated resource grants": same vault-token mint path (`mintVaultGrant`
20
+ * mirrors `admin-connections.ts:mintCredential`), same registered-mint /
21
+ * revoke-on-teardown discipline, same auth gates.
22
+ *
23
+ * ── Endpoints (all under `/admin/grants`) ────────────────────────────────────
24
+ *
25
+ * MODULE-AUTH (a `parachute:host:admin` Bearer — the agent module presents one,
26
+ * minted the same way the SPA mints via `/admin/host-admin-token`):
27
+ *
28
+ * PUT /admin/grants { agent, connection } → upsert (idempotent
29
+ * by (agent, connection-key)); returns the
30
+ * grant (NO material). New mcp grants land
31
+ * pending with reason "oauth not yet
32
+ * supported".
33
+ * GET /admin/grants?agent=<name> → { grants: [...] } — NO material on any row.
34
+ * GET /admin/grants/<id>/material → APPROVED grants only: the injectable
35
+ * secret. vault → { kind, token, mcpUrl };
36
+ * service → { kind, token, inject }.
37
+ * 404 unknown id, 409 not approved.
38
+ *
39
+ * OPERATOR-AUTH (a first-admin session cookie; CSRF-belted by the dispatch in
40
+ * hub-server.ts, exactly like /admin/connections POST/DELETE):
41
+ *
42
+ * POST /admin/grants/<id>/approve { token? } → vault: MINT now + store;
43
+ * service: store the pasted `token`. Returns
44
+ * the updated grant (no material).
45
+ * POST /admin/grants/<id>/revoke → drop the stored material + status=revoked
46
+ * (the agent loses it next spawn).
47
+ *
48
+ * ── Secret discipline ────────────────────────────────────────────────────────
49
+ * The minted/pasted secret lives in the grant store on disk (0600), is NEVER
50
+ * logged, NEVER returned by PUT/GET-list/approve/revoke — ONLY by the
51
+ * approved-only, module-auth-gated `/material` endpoint.
52
+ */
53
+ import type { Database } from "bun:sqlite";
54
+ import {
55
+ type AdminAuthContext,
56
+ type AdminAuthError,
57
+ adminAuthErrorResponse,
58
+ requireScope,
59
+ } from "./admin-auth.ts";
60
+ import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
61
+ import {
62
+ type ConnectionSpec,
63
+ type GrantAccess,
64
+ type GrantInject,
65
+ type GrantMaterial,
66
+ type GrantRecord,
67
+ connectionKey,
68
+ getGrant,
69
+ grantId,
70
+ listGrantsForAgent,
71
+ putGrant,
72
+ readGrants,
73
+ removeGrant,
74
+ } from "./grants-store.ts";
75
+ import {
76
+ type signAccessToken as SignAccessTokenFn,
77
+ recordTokenMint,
78
+ revokeTokenByJti,
79
+ signAccessToken,
80
+ } from "./jwt-sign.ts";
81
+ import { type OAuthClient, deriveVaultScopeFromMcpUrl, realOAuthClient } from "./oauth-client.ts";
82
+ import { type PendingFlow, deleteFlow, getFlowByState, putFlow } from "./oauth-flows-store.ts";
83
+ import { findSession, parseSessionCookie } from "./sessions.ts";
84
+ import { isFirstAdmin } from "./users.ts";
85
+ import { validateVaultName } from "./vault-name.ts";
86
+
87
+ /**
88
+ * TTL of a minted vault grant token. 90 days — matches the Connections
89
+ * engine's standing-credential posture (a headless agent re-fetches at spawn;
90
+ * a long-lived token spares a re-mint every turn). Registered in the tokens
91
+ * table so revoke can drop it.
92
+ */
93
+ const VAULT_GRANT_TTL_SECONDS = 90 * 24 * 60 * 60;
94
+ const GRANT_CLIENT_ID = "parachute-hub-spa";
95
+
96
+ /** Agent-name charset — lands in the grant id + a `?agent=` query. Conservative slug. */
97
+ const AGENT_NAME_RE = /^[a-z0-9][a-z0-9_.-]*$/i;
98
+ /** Grant-id charset — lands in a URL path segment. */
99
+ const GRANT_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
100
+ /** Service key charset — `github`, `cloudflare`, … lands in env-var / MCP names. */
101
+ const SERVICE_KEY_RE = /^[a-z0-9][a-z0-9_-]*$/i;
102
+ /** A tag the agent declares for vault tag-scope. Conservative — no whitespace. */
103
+ const TAG_RE = /^\S+$/;
104
+
105
+ /**
106
+ * Input length caps (reviewer N3). A host-admin Bearer is already
107
+ * high-privilege, but these bound disk bloat from a runaway/misconfigured
108
+ * module registering grants on the operator's own machine.
109
+ */
110
+ const MAX_AGENT_LEN = 128;
111
+ const MAX_TARGET_LEN = 512;
112
+ const MAX_TAG_LEN = 128;
113
+ /** Cap the operator-pasted service token. Operator-gated (nil trust exposure), but
114
+ * bounds a fat-finger paste from bloating the on-disk store — matches the other caps. */
115
+ const MAX_TOKEN_LEN = 8192;
116
+ const MAX_TAGS = 64;
117
+ /**
118
+ * Reconcile cap (#96). A host-admin Bearer is high-privilege, but this bounds a
119
+ * runaway/misconfigured module from POSTing a giant liveConnections array. The cap
120
+ * is generous vs. realistic agent connection counts ("a handful per agent"); each
121
+ * entry is further validated + bounded by parseConnectionSpec.
122
+ */
123
+ const MAX_LIVE_KEYS = 256;
124
+
125
+ export interface AgentGrantsDeps {
126
+ db: Database;
127
+ /**
128
+ * Hub origin — the minted-token `iss`, the base of the vault MCP URL, AND the
129
+ * base of the OAuth-client `redirect_uri`
130
+ * (`<hubOrigin>/oauth/agent-grant/callback`).
131
+ */
132
+ hubOrigin: string;
133
+ /** Absolute path to `agent-grants.json` in the hub state dir. */
134
+ storePath: string;
135
+ /** Absolute path to `agent-oauth-flows.json` (the in-flight OAuth consents, 4b-2). */
136
+ flowsStorePath: string;
137
+ /**
138
+ * Resolve a vault's loopback origin from services.json, or `null` when no
139
+ * vault by that name is installed. Mirrors `ConnectionsDeps.resolveVaultOrigin`
140
+ * — used here purely as the "does this vault exist?" check at approve time.
141
+ */
142
+ resolveVaultOrigin: (vaultName: string) => string | null;
143
+ /** Test seam — defaults to the real `signAccessToken`. */
144
+ signToken?: typeof SignAccessTokenFn;
145
+ /** Test seam — the OAuth-client engine (defaults to the real, network-bound one). */
146
+ oauthClient?: OAuthClient;
147
+ /** Test seam for the clock. */
148
+ now?: () => Date;
149
+ }
150
+
151
+ /** The OAuth-client engine — injected for tests, the real (network) one by default. */
152
+ function oauth(deps: AgentGrantsDeps): OAuthClient {
153
+ return deps.oauthClient ?? realOAuthClient;
154
+ }
155
+
156
+ /** `<hubOrigin>/oauth/agent-grant/callback` — the DCR-registered redirect_uri. */
157
+ function callbackUrl(hubOrigin: string): string {
158
+ return `${hubOrigin.replace(/\/+$/, "")}/oauth/agent-grant/callback`;
159
+ }
160
+
161
+ /** Refresh skew — refresh an mcp access token this many ms before its expiry. */
162
+ const REFRESH_SKEW_MS = 120 * 1000;
163
+
164
+ // ===========================================================================
165
+ // Router
166
+ // ===========================================================================
167
+
168
+ /**
169
+ * Dispatch `/admin/grants...`. `subPath` is the path AFTER `/admin/grants`
170
+ * (`""` for the collection, `/<id>/material|approve|revoke` for items).
171
+ *
172
+ * The two auth classes split by route, mirroring the Connections engine:
173
+ * - module-auth (host-admin Bearer): PUT collection, GET collection, GET /material.
174
+ * - operator-auth (first-admin cookie): POST /approve, POST /revoke.
175
+ */
176
+ export async function handleAgentGrants(
177
+ req: Request,
178
+ subPath: string,
179
+ deps: AgentGrantsDeps,
180
+ ): Promise<Response> {
181
+ const method = req.method.toUpperCase();
182
+ const segments = subPath.startsWith("/")
183
+ ? subPath
184
+ .slice(1)
185
+ .split("/")
186
+ .map((s) => decodeURIComponent(s))
187
+ .filter((s) => s.length > 0)
188
+ : [];
189
+
190
+ // --- Collection: PUT (upsert) / GET (list) — module-auth. ---
191
+ if (segments.length === 0) {
192
+ if (method === "PUT") return upsertGrant(req, deps);
193
+ if (method === "GET") return listGrants(req, deps);
194
+ return jsonError(405, "method_not_allowed", "use PUT or GET on /admin/grants");
195
+ }
196
+
197
+ // --- Collection sub-action: POST /reconcile (grant-GC, #96) — module-auth. ---
198
+ // `reconcile` is a reserved single-segment action, NOT a grant id (the
199
+ // GRANT_ID_RE slug never collides — but anchoring it here makes the routing
200
+ // explicit). The agent module POSTs the live connection keys for one holder;
201
+ // the hub prunes every grant for that holder whose key is no longer live.
202
+ if (segments.length === 1 && segments[0] === "reconcile") {
203
+ if (method !== "POST") {
204
+ return jsonError(405, "method_not_allowed", "use POST on /admin/grants/reconcile");
205
+ }
206
+ return reconcileGrants(req, deps);
207
+ }
208
+
209
+ const id = segments[0] ?? "";
210
+ const verb = segments[1];
211
+
212
+ // --- Item: GET /<id>/material — module-auth. ---
213
+ if (verb === "material") {
214
+ if (method !== "GET") {
215
+ return jsonError(405, "method_not_allowed", "use GET on /admin/grants/<id>/material");
216
+ }
217
+ return grantMaterial(req, id, deps);
218
+ }
219
+
220
+ // --- Item: POST /<id>/approve | /revoke — operator-auth. ---
221
+ if (verb === "approve") {
222
+ if (method !== "POST") {
223
+ return jsonError(405, "method_not_allowed", "use POST on /admin/grants/<id>/approve");
224
+ }
225
+ return approveGrant(req, id, deps);
226
+ }
227
+ if (verb === "revoke") {
228
+ if (method !== "POST") {
229
+ return jsonError(405, "method_not_allowed", "use POST on /admin/grants/<id>/revoke");
230
+ }
231
+ return revokeGrant(req, id, deps);
232
+ }
233
+
234
+ return jsonError(
235
+ 404,
236
+ "not_found",
237
+ "use PUT/GET /admin/grants, GET /admin/grants/<id>/material, POST /admin/grants/<id>/approve|revoke",
238
+ );
239
+ }
240
+
241
+ // ===========================================================================
242
+ // Auth
243
+ // ===========================================================================
244
+
245
+ /** Module-auth: a `parachute:host:admin` Bearer (the agent module's host-admin token). */
246
+ async function requireModuleAuth(
247
+ req: Request,
248
+ deps: AgentGrantsDeps,
249
+ ): Promise<AdminAuthContext | Response> {
250
+ try {
251
+ return await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.hubOrigin);
252
+ } catch (err) {
253
+ return adminAuthErrorResponse(err as AdminAuthError);
254
+ }
255
+ }
256
+
257
+ /** Operator-auth: a first-admin session cookie (CSRF belt is applied upstream). */
258
+ function requireOperator(req: Request, deps: AgentGrantsDeps): { userId: string } | Response {
259
+ const sid = parseSessionCookie(req.headers.get("cookie"));
260
+ const session = sid ? findSession(deps.db, sid) : null;
261
+ if (!session) {
262
+ return jsonError(401, "unauthenticated", "no admin session — sign in at /login first");
263
+ }
264
+ if (!isFirstAdmin(deps.db, session.userId)) {
265
+ return jsonError(
266
+ 403,
267
+ "not_admin",
268
+ "grant approval is restricted to the hub admin — your account home is at /account/",
269
+ );
270
+ }
271
+ return { userId: session.userId };
272
+ }
273
+
274
+ // ===========================================================================
275
+ // PUT /admin/grants — upsert a pending grant (module-auth)
276
+ // ===========================================================================
277
+
278
+ async function upsertGrant(req: Request, deps: AgentGrantsDeps): Promise<Response> {
279
+ const auth = await requireModuleAuth(req, deps);
280
+ if (auth instanceof Response) return auth;
281
+
282
+ let body: unknown;
283
+ try {
284
+ body = await req.json();
285
+ } catch {
286
+ return jsonError(400, "invalid_request", "body must be JSON");
287
+ }
288
+ if (!body || typeof body !== "object") {
289
+ return jsonError(400, "invalid_request", "body must be a JSON object");
290
+ }
291
+ const b = body as Record<string, unknown>;
292
+
293
+ const agent = typeof b.agent === "string" ? b.agent.trim() : "";
294
+ if (!agent || agent.length > MAX_AGENT_LEN || !AGENT_NAME_RE.test(agent)) {
295
+ return jsonError(
296
+ 400,
297
+ "invalid_request",
298
+ `agent is required and must match [a-zA-Z0-9][a-zA-Z0-9_.-]* (max ${MAX_AGENT_LEN} chars)`,
299
+ );
300
+ }
301
+
302
+ const parsed = parseConnectionSpec(b.connection);
303
+ if ("error" in parsed) return jsonError(400, "invalid_request", parsed.error);
304
+ const spec = parsed.spec;
305
+
306
+ const id = grantId(agent, spec);
307
+ const now = (deps.now?.() ?? new Date()).toISOString();
308
+ const existing = getGrant(deps.storePath, id);
309
+
310
+ // Idempotent upsert. An already-approved/revoked grant keeps its status +
311
+ // material on re-declare (the module re-registering the same want must NOT
312
+ // downgrade an active grant to pending, nor re-open a revoked one). A new
313
+ // grant lands pending — mcp pending with its slice-2 reason.
314
+ //
315
+ // Recovery from `revoked` (reviewer N2): re-declaring does NOT re-open it —
316
+ // the operator re-grants by approving the revoked row directly (the
317
+ // operator-gated approve path accepts any non-mcp grant regardless of prior
318
+ // status, re-minting fresh material). So a revoked grant is dormant, not
319
+ // dead; an explicit operator approve revives it.
320
+ if (existing) {
321
+ // Re-declare may refresh the agent-side `inject` hints on a service spec
322
+ // without changing the grant's identity (inject is not part of the key).
323
+ const merged: GrantRecord = { ...existing, connection: spec };
324
+ putGrant(deps.storePath, merged);
325
+ return grantResponse(200, merged);
326
+ }
327
+
328
+ // 4b-2: an mcp want is now grantable — it sits pending awaiting the operator's
329
+ // OAuth consent (or a pasted static bearer), not "not yet supported".
330
+ const pendingReason = spec.kind === "mcp" ? "awaiting oauth consent" : undefined;
331
+ const record: GrantRecord = {
332
+ id,
333
+ agent,
334
+ connection: spec,
335
+ status: "pending",
336
+ ...(pendingReason ? { reason: pendingReason } : {}),
337
+ createdAt: now,
338
+ };
339
+ putGrant(deps.storePath, record);
340
+ return grantResponse(201, record);
341
+ }
342
+
343
+ /**
344
+ * Parse + validate a connection spec from the request body. Returns either the
345
+ * normalized spec or a human-readable error. Normalizes `target`/`access`/`tags`
346
+ * to keep the stored shape + derived key stable.
347
+ */
348
+ function parseConnectionSpec(raw: unknown): { spec: ConnectionSpec } | { error: string } {
349
+ if (!raw || typeof raw !== "object") {
350
+ return { error: "connection is required and must be an object" };
351
+ }
352
+ const c = raw as Record<string, unknown>;
353
+ const kind = c.kind;
354
+ if (kind !== "vault" && kind !== "service" && kind !== "mcp") {
355
+ return { error: `connection.kind must be "vault", "service", or "mcp"` };
356
+ }
357
+ const target = typeof c.target === "string" ? c.target.trim() : "";
358
+ if (!target) return { error: "connection.target is required" };
359
+ if (target.length > MAX_TARGET_LEN) {
360
+ return { error: `connection.target exceeds the ${MAX_TARGET_LEN}-char limit` };
361
+ }
362
+
363
+ if (kind === "vault") {
364
+ // Full vault-name validation (reviewer N4) — length + charset + reserved
365
+ // names (`admin`, `list`, `new`, `assets`). Rejecting reserved names here
366
+ // stops a phantom pending row that could never be approved.
367
+ const v = validateVaultName(target);
368
+ if (!v.ok) {
369
+ return { error: `connection.target ${v.error}` };
370
+ }
371
+ let access: GrantAccess = "read";
372
+ if (c.access !== undefined) {
373
+ if (c.access !== "read" && c.access !== "write") {
374
+ return { error: `connection.access must be "read" or "write"` };
375
+ }
376
+ access = c.access;
377
+ }
378
+ const tags: string[] = [];
379
+ if (c.tags !== undefined) {
380
+ if (!Array.isArray(c.tags)) {
381
+ return { error: "connection.tags must be an array of tag strings" };
382
+ }
383
+ if (c.tags.length > MAX_TAGS) {
384
+ return { error: `connection.tags exceeds the ${MAX_TAGS}-entry limit` };
385
+ }
386
+ for (const t of c.tags) {
387
+ const trimmed = typeof t === "string" ? t.trim() : "";
388
+ if (trimmed.length === 0 || trimmed.length > MAX_TAG_LEN || !TAG_RE.test(trimmed)) {
389
+ return {
390
+ error: `connection.tags entries must be non-empty whitespace-free strings (max ${MAX_TAG_LEN} chars)`,
391
+ };
392
+ }
393
+ tags.push(trimmed);
394
+ }
395
+ }
396
+ return {
397
+ spec: { kind: "vault", target: v.name, access, ...(tags.length > 0 ? { tags } : {}) },
398
+ };
399
+ }
400
+
401
+ if (kind === "service") {
402
+ if (!SERVICE_KEY_RE.test(target)) {
403
+ return { error: `connection.target "${target}" is not a valid service key` };
404
+ }
405
+ const inject: GrantInject[] = [];
406
+ if (c.inject !== undefined) {
407
+ if (!Array.isArray(c.inject)) {
408
+ return { error: `connection.inject must be an array of "env"/"mcp"` };
409
+ }
410
+ for (const i of c.inject) {
411
+ if (i !== "env" && i !== "mcp") {
412
+ return { error: `connection.inject entries must be "env" or "mcp"` };
413
+ }
414
+ if (!inject.includes(i)) inject.push(i);
415
+ }
416
+ }
417
+ return {
418
+ spec: { kind: "service", target, ...(inject.length > 0 ? { inject } : {}) },
419
+ };
420
+ }
421
+
422
+ // mcp — modeled, not grantable in 4b-1. Accept a URL target; the grant lands
423
+ // pending with the slice-2 reason. Validate it parses as an http(s) URL so a
424
+ // typo doesn't sit pending forever masquerading as an OAuth-blocked grant.
425
+ let url: URL;
426
+ try {
427
+ url = new URL(target);
428
+ } catch {
429
+ return { error: `connection.target "${target}" must be an absolute URL for kind "mcp"` };
430
+ }
431
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
432
+ return { error: `connection.target must be an http(s) URL for kind "mcp"` };
433
+ }
434
+ return { spec: { kind: "mcp", target } };
435
+ }
436
+
437
+ // ===========================================================================
438
+ // GET /admin/grants?agent=<name> — list (module-auth, NO material)
439
+ // ===========================================================================
440
+
441
+ async function listGrants(req: Request, deps: AgentGrantsDeps): Promise<Response> {
442
+ const auth = await requireModuleAuth(req, deps);
443
+ if (auth instanceof Response) return auth;
444
+
445
+ const agent = new URL(req.url).searchParams.get("agent");
446
+ if (agent !== null && !AGENT_NAME_RE.test(agent)) {
447
+ return jsonError(400, "invalid_request", "?agent must match [a-zA-Z0-9][a-zA-Z0-9_.-]*");
448
+ }
449
+
450
+ // Default to all grants when no agent filter is given (the approval UI lists
451
+ // every agent's grants); narrow to one agent for the module's status check.
452
+ const records = agent ? listGrantsForAgent(deps.storePath, agent) : readGrants(deps.storePath);
453
+
454
+ const grants = records.map(toListing);
455
+ return new Response(JSON.stringify({ grants }), {
456
+ status: 200,
457
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
458
+ });
459
+ }
460
+
461
+ // ===========================================================================
462
+ // GET /admin/grants/<id>/material — approved-only secret (module-auth)
463
+ // ===========================================================================
464
+
465
+ async function grantMaterial(req: Request, id: string, deps: AgentGrantsDeps): Promise<Response> {
466
+ const auth = await requireModuleAuth(req, deps);
467
+ if (auth instanceof Response) return auth;
468
+
469
+ if (!GRANT_ID_RE.test(id)) {
470
+ return jsonError(400, "invalid_request", "grant id is not a valid identifier");
471
+ }
472
+ const grant = getGrant(deps.storePath, id);
473
+ if (!grant) {
474
+ return jsonError(404, "not_found", `no grant ${id}`);
475
+ }
476
+ if (grant.status !== "approved" || !grant.material) {
477
+ // For a needs_consent grant, tell the operator how to revive it (re-approve
478
+ // re-runs the OAuth consent) so admin/agent surfaces can render an actionable
479
+ // message rather than a bare "not approved".
480
+ const hint =
481
+ grant.status === "needs_consent"
482
+ ? " — re-consent (approve again) to revive this connection"
483
+ : " — material is available only for approved grants";
484
+ return jsonError(
485
+ 409,
486
+ "not_approved",
487
+ `grant ${id} is ${grant.status}${grant.reason ? ` (${grant.reason})` : ""}${hint}`,
488
+ );
489
+ }
490
+
491
+ // The injectable secret. vault → token + the vault's MCP URL (so the agent
492
+ // can add it as an MCP server); service → token + the inject hints; mcp →
493
+ // refresh-if-needed then token + the remote MCP URL.
494
+ let payload: Record<string, unknown>;
495
+ if (grant.material.kind === "vault") {
496
+ payload = {
497
+ kind: "vault",
498
+ token: grant.material.token,
499
+ mcpUrl: vaultMcpUrl(deps.hubOrigin, grant.connection.target),
500
+ };
501
+ } else if (grant.material.kind === "service") {
502
+ payload = {
503
+ kind: "service",
504
+ token: grant.material.token,
505
+ inject: grant.connection.kind === "service" ? (grant.connection.inject ?? []) : [],
506
+ };
507
+ } else {
508
+ // mcp — refresh first if it's an OAuth grant near/past expiry. A refresh
509
+ // FAILURE flips the grant to needs_consent (material dropped) and 409s.
510
+ const resolved = await resolveMcpMaterial(grant, deps);
511
+ if (resolved instanceof Response) return resolved;
512
+ payload = {
513
+ kind: "mcp",
514
+ // Field-name seam: store field is `access_token`; wire field is `token`.
515
+ token: resolved.access_token,
516
+ mcpUrl: resolved.mcpUrl,
517
+ };
518
+ }
519
+
520
+ return new Response(JSON.stringify(payload), {
521
+ status: 200,
522
+ headers: {
523
+ "content-type": "application/json",
524
+ // The body carries a live secret — never cache it.
525
+ "cache-control": "no-store",
526
+ },
527
+ });
528
+ }
529
+
530
+ /**
531
+ * Resolve an approved mcp grant's live access token, refreshing first if it's an
532
+ * OAuth grant that's expired or within the skew window. A static-bearer grant (no
533
+ * refresh_token / no expiresAt) returns its stored token unchanged. A refresh
534
+ * FAILURE flips the grant to `needs_consent` (material dropped) and returns a 409
535
+ * Response — the connection is simply absent next spawn until the operator
536
+ * re-consents.
537
+ *
538
+ * Returns the live mcp material on success, or a `Response` (the 409) on failure.
539
+ */
540
+ async function resolveMcpMaterial(
541
+ grant: GrantRecord,
542
+ deps: AgentGrantsDeps,
543
+ ): Promise<Extract<GrantMaterial, { kind: "mcp" }> | Response> {
544
+ const mat = grant.material;
545
+ if (!mat || mat.kind !== "mcp") {
546
+ return jsonError(409, "not_approved", `grant ${grant.id} has no mcp material`);
547
+ }
548
+
549
+ // Static bearer — no refresh token / no expiry → return as-is.
550
+ if (!mat.refresh_token || !mat.expiresAt || !mat.tokenEndpoint || !mat.clientId) {
551
+ return mat;
552
+ }
553
+
554
+ const now = deps.now?.() ?? new Date();
555
+ const expiresMs = new Date(mat.expiresAt).getTime();
556
+ const needsRefresh = !Number.isFinite(expiresMs) || expiresMs - now.getTime() <= REFRESH_SKEW_MS;
557
+ if (!needsRefresh) return mat;
558
+
559
+ // Refresh.
560
+ try {
561
+ const refreshed = await oauth(deps).refreshToken(
562
+ {
563
+ tokenEndpoint: mat.tokenEndpoint,
564
+ refreshToken: mat.refresh_token,
565
+ clientId: mat.clientId,
566
+ now: deps.now ?? (() => new Date()),
567
+ },
568
+ undefined,
569
+ );
570
+ const updatedMaterial: GrantMaterial = {
571
+ kind: "mcp",
572
+ access_token: refreshed.access_token,
573
+ // Rotated refresh, if returned; else keep the existing one.
574
+ refresh_token: refreshed.refresh_token ?? mat.refresh_token,
575
+ ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}),
576
+ ...(mat.issuer ? { issuer: mat.issuer } : {}),
577
+ clientId: mat.clientId,
578
+ tokenEndpoint: mat.tokenEndpoint,
579
+ ...(mat.revocationEndpoint ? { revocationEndpoint: mat.revocationEndpoint } : {}),
580
+ mcpUrl: mat.mcpUrl,
581
+ };
582
+ const updated: GrantRecord = {
583
+ id: grant.id,
584
+ agent: grant.agent,
585
+ connection: grant.connection,
586
+ status: "approved",
587
+ createdAt: grant.createdAt,
588
+ ...(grant.approvedAt ? { approvedAt: grant.approvedAt } : {}),
589
+ material: updatedMaterial,
590
+ };
591
+ putGrant(deps.storePath, updated);
592
+ return updatedMaterial;
593
+ } catch (err) {
594
+ // Refresh died (refresh token revoked/expired). Flip to needs_consent + drop
595
+ // material — the operator re-consents to revive. NEVER log the token/error
596
+ // detail that could carry one; the reason is the error message only.
597
+ const reason = `refresh failed: ${err instanceof Error ? err.message : "unknown error"}`;
598
+ const downgraded: GrantRecord = {
599
+ id: grant.id,
600
+ agent: grant.agent,
601
+ connection: grant.connection,
602
+ status: "needs_consent",
603
+ reason,
604
+ createdAt: grant.createdAt,
605
+ ...(grant.approvedAt ? { approvedAt: grant.approvedAt } : {}),
606
+ // material dropped.
607
+ };
608
+ putGrant(deps.storePath, downgraded);
609
+ console.log(`agent grant needs re-consent: id=${grant.id} agent=${grant.agent} kind=mcp`);
610
+ return jsonError(409, "not_approved", `grant ${grant.id} ${reason} — re-consent required`);
611
+ }
612
+ }
613
+
614
+ /** `<hub-origin>/vault/<name>/mcp` — the MCP endpoint a client connects to. */
615
+ function vaultMcpUrl(hubOrigin: string, vaultName: string): string {
616
+ return `${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}/mcp`;
617
+ }
618
+
619
+ // ===========================================================================
620
+ // POST /admin/grants/<id>/approve — operator approves (operator-auth)
621
+ // ===========================================================================
622
+
623
+ async function approveGrant(req: Request, id: string, deps: AgentGrantsDeps): Promise<Response> {
624
+ const op = requireOperator(req, deps);
625
+ if (op instanceof Response) return op;
626
+
627
+ if (!GRANT_ID_RE.test(id)) {
628
+ return jsonError(400, "invalid_request", "grant id is not a valid identifier");
629
+ }
630
+ const grant = getGrant(deps.storePath, id);
631
+ if (!grant) return jsonError(404, "not_found", `no grant ${id}`);
632
+
633
+ let body: { token?: unknown } = {};
634
+ try {
635
+ const raw = await req.text();
636
+ if (raw.trim().length > 0) body = JSON.parse(raw) as { token?: unknown };
637
+ } catch {
638
+ return jsonError(400, "invalid_request", "body must be JSON when present");
639
+ }
640
+
641
+ const conn = grant.connection;
642
+ const now = deps.now?.() ?? new Date();
643
+ const approvedAt = now.toISOString();
644
+
645
+ if (conn.kind === "mcp") {
646
+ return approveMcpGrant(grant, body, deps, approvedAt);
647
+ }
648
+
649
+ if (conn.kind === "vault") {
650
+ // Re-approval of an already-approved vault grant: revoke the prior minted
651
+ // token first so exactly one live token exists per grant.
652
+ if (grant.material?.kind === "vault") {
653
+ try {
654
+ revokeTokenByJti(deps.db, grant.material.jti, now);
655
+ } catch {
656
+ // Best-effort — a missing registry row leaves nothing to revoke.
657
+ }
658
+ }
659
+ // Approve-time existence check: the vault must still be installed.
660
+ if (deps.resolveVaultOrigin(conn.target) === null) {
661
+ return jsonError(400, "unknown_vault", `no vault named "${conn.target}" in this hub`);
662
+ }
663
+ const access = conn.access ?? "read";
664
+ const scope = `vault:${conn.target}:${access}`;
665
+ let minted: { token: string; jti: string; expiresAt: string };
666
+ try {
667
+ minted = await mintVaultGrant(deps, op.userId, conn.target, scope, conn.tags ?? []);
668
+ } catch (err) {
669
+ return jsonError(
670
+ 500,
671
+ "mint_failed",
672
+ `failed to mint vault grant: ${err instanceof Error ? err.message : String(err)}`,
673
+ );
674
+ }
675
+ // Rebuild from the identity fields rather than spreading `grant` — that
676
+ // drops any pending `reason` cleanly (no mutate-after-spread).
677
+ const updated: GrantRecord = {
678
+ id: grant.id,
679
+ agent: grant.agent,
680
+ connection: grant.connection,
681
+ status: "approved",
682
+ createdAt: grant.createdAt,
683
+ approvedAt,
684
+ material: {
685
+ kind: "vault",
686
+ token: minted.token,
687
+ jti: minted.jti,
688
+ expiresAt: minted.expiresAt,
689
+ },
690
+ };
691
+ putGrant(deps.storePath, updated);
692
+ console.log(`agent grant approved: id=${id} agent=${grant.agent} kind=vault scope=${scope}`);
693
+ return grantResponse(200, updated);
694
+ }
695
+
696
+ // service — store the operator-pasted API token.
697
+ // Trim — a pasted " tok " must not inject whitespace into the eventual
698
+ // `Authorization: Bearer` header (drive-by correctness fix; the mcp
699
+ // static-bearer path has the same trim).
700
+ const token = typeof body.token === "string" ? body.token.trim() : "";
701
+ if (token.length === 0) {
702
+ return jsonError(
703
+ 400,
704
+ "token_required",
705
+ "approving a service grant requires a non-empty `token` (the API credential to store)",
706
+ );
707
+ }
708
+ if (token.length > MAX_TOKEN_LEN) {
709
+ return jsonError(400, "invalid_request", `token exceeds the ${MAX_TOKEN_LEN}-char limit`);
710
+ }
711
+ // Rebuild from identity fields (drops any pending `reason`).
712
+ const updated: GrantRecord = {
713
+ id: grant.id,
714
+ agent: grant.agent,
715
+ connection: grant.connection,
716
+ status: "approved",
717
+ createdAt: grant.createdAt,
718
+ approvedAt,
719
+ material: { kind: "service", token },
720
+ };
721
+ putGrant(deps.storePath, updated);
722
+ // NEVER log the token. Identity fields only.
723
+ console.log(
724
+ `agent grant approved: id=${id} agent=${grant.agent} kind=service target=${conn.target}`,
725
+ );
726
+ return grantResponse(200, updated);
727
+ }
728
+
729
+ // ===========================================================================
730
+ // approve(mcp) — static bearer OR start the OAuth consent flow (4b-2)
731
+ // ===========================================================================
732
+
733
+ /**
734
+ * Approve a `kind:mcp` grant. Two paths:
735
+ *
736
+ * - body `{ token }` (a pasted static bearer) → store
737
+ * `material:{kind:"mcp", access_token, mcpUrl}` (no refresh) + status
738
+ * `approved` immediately. No discovery. Weaker lifecycle (no expiry, no
739
+ * refresh, no issuer-side revoke).
740
+ * - body `{}` (no token) → START OAuth: discover → DCR (reuse a stored clientId
741
+ * for the SAME issuer, else register) → mint PKCE + state → persist a pending
742
+ * flow → return a `GrantListing` with `authorizeUrl` (status stays pending,
743
+ * reason "awaiting oauth consent"). The operator's browser follows the URL;
744
+ * the callback completes the flow.
745
+ *
746
+ * A re-approve of an already-approved mcp grant starts a FRESH flow; the callback
747
+ * replaces the material + best-effort revokes the old refresh token.
748
+ */
749
+ async function approveMcpGrant(
750
+ grant: GrantRecord,
751
+ body: { token?: unknown },
752
+ deps: AgentGrantsDeps,
753
+ approvedAt: string,
754
+ ): Promise<Response> {
755
+ const mcpUrl = grant.connection.target;
756
+
757
+ // --- Static-bearer path: a pasted token short-circuits discovery. ---
758
+ if (typeof body.token === "string" && body.token.trim().length > 0) {
759
+ // Trim — a pasted " tok " must not inject whitespace into the eventual
760
+ // `Authorization: Bearer` header.
761
+ const token = body.token.trim();
762
+ if (token.length > MAX_TOKEN_LEN) {
763
+ return jsonError(400, "invalid_request", `token exceeds the ${MAX_TOKEN_LEN}-char limit`);
764
+ }
765
+ const updated: GrantRecord = {
766
+ id: grant.id,
767
+ agent: grant.agent,
768
+ connection: grant.connection,
769
+ status: "approved",
770
+ createdAt: grant.createdAt,
771
+ approvedAt,
772
+ material: { kind: "mcp", access_token: token, mcpUrl },
773
+ };
774
+ putGrant(deps.storePath, updated);
775
+ // NEVER log the token.
776
+ console.log(
777
+ `agent grant approved: id=${grant.id} agent=${grant.agent} kind=mcp mode=static-bearer`,
778
+ );
779
+ return grantResponse(200, updated);
780
+ }
781
+ // A non-empty-but-non-string token, or an over-long one, was handled above; a
782
+ // present-but-empty token falls through to the OAuth path (treated as "no token").
783
+ if (body.token !== undefined && typeof body.token !== "string") {
784
+ return jsonError(400, "invalid_request", "token must be a string when present");
785
+ }
786
+
787
+ // --- OAuth path: discover → DCR → PKCE/state → persist flow → authorizeUrl. ---
788
+ const client = oauth(deps);
789
+ const redirectUri = callbackUrl(deps.hubOrigin);
790
+
791
+ let discovery: Awaited<ReturnType<OAuthClient["discover"]>>;
792
+ try {
793
+ discovery = await client.discover(mcpUrl);
794
+ } catch (err) {
795
+ return jsonError(
796
+ 502,
797
+ "discovery_failed",
798
+ `could not discover OAuth metadata for ${mcpUrl}: ${err instanceof Error ? err.message : "unknown error"}`,
799
+ );
800
+ }
801
+
802
+ // DCR client reuse: if the grant already has mcp material with a clientId for
803
+ // the SAME issuer (a re-consent), reuse it; else register a fresh client.
804
+ // ACCEPTED (per design): if the issuer rotates its registration (so the stored
805
+ // clientId is no longer valid), a fresh register orphans the old DCR client at
806
+ // the issuer. There is no cross-issuer client GC — orphans accrue only on issuer
807
+ // rotation, which is rare; noted, not built.
808
+ let clientId: string | undefined;
809
+ if (
810
+ grant.material?.kind === "mcp" &&
811
+ grant.material.clientId &&
812
+ grant.material.issuer === discovery.issuer
813
+ ) {
814
+ clientId = grant.material.clientId;
815
+ }
816
+ if (!clientId) {
817
+ if (!discovery.registrationEndpoint) {
818
+ return jsonError(
819
+ 502,
820
+ "registration_unsupported",
821
+ `the issuer ${discovery.issuer} does not advertise a registration_endpoint (RFC 7591 DCR) — paste a static bearer token instead`,
822
+ );
823
+ }
824
+ try {
825
+ const reg = await client.registerClient(discovery.registrationEndpoint, redirectUri);
826
+ clientId = reg.clientId;
827
+ } catch (err) {
828
+ return jsonError(
829
+ 502,
830
+ "registration_failed",
831
+ `dynamic client registration failed: ${err instanceof Error ? err.message : "unknown error"}`,
832
+ );
833
+ }
834
+ }
835
+
836
+ const verifier = client.generateCodeVerifier();
837
+ const challenge = client.generateCodeChallenge(verifier);
838
+ const state = client.generateState();
839
+ // Scope (#671). When the target is a Parachute vault MCP (`…/vault/<name>/mcp`),
840
+ // request a single least-privilege `vault:<name>:read` scope rather than the
841
+ // resource's full advertised set (which for a vault includes hub:admin,
842
+ // vault:<name>:write, … — wildly over-privileged for a read-only agent).
843
+ // Write is a deliberate future knob, not the default. For any non-vault MCP
844
+ // URL, fall back to the resource's advertised scopes (9728→8414), space-joined;
845
+ // omit if none.
846
+ const vaultScope = deriveVaultScopeFromMcpUrl(mcpUrl);
847
+ const scope = vaultScope
848
+ ? vaultScope
849
+ : discovery.scopesSupported?.length
850
+ ? discovery.scopesSupported.join(" ")
851
+ : undefined;
852
+
853
+ const flow: PendingFlow = {
854
+ state,
855
+ grantId: grant.id,
856
+ issuer: discovery.issuer,
857
+ clientId,
858
+ tokenEndpoint: discovery.tokenEndpoint,
859
+ ...(discovery.revocationEndpoint ? { revocationEndpoint: discovery.revocationEndpoint } : {}),
860
+ verifier,
861
+ mcpUrl,
862
+ ...(scope ? { scope } : {}),
863
+ redirectUri,
864
+ createdAt: (deps.now?.() ?? new Date()).toISOString(),
865
+ };
866
+ putFlow(deps.flowsStorePath, flow, (deps.now?.() ?? new Date()).getTime());
867
+
868
+ const authorizeUrl = client.buildAuthorizeUrl({
869
+ authorizationEndpoint: discovery.authorizationEndpoint,
870
+ clientId,
871
+ redirectUri,
872
+ ...(scope ? { scope } : {}),
873
+ state,
874
+ codeChallenge: challenge,
875
+ });
876
+
877
+ // Keep the grant pending with the slice-2 reason; surface authorizeUrl so the
878
+ // admin UI can redirect the browser. NEVER log the verifier/state.
879
+ console.log(
880
+ `agent grant oauth flow started: id=${grant.id} agent=${grant.agent} issuer=${discovery.issuer}`,
881
+ );
882
+ const listing: GrantListing & { authorizeUrl: string } = {
883
+ id: grant.id,
884
+ agent: grant.agent,
885
+ connection: grant.connection,
886
+ status: "pending",
887
+ reason: "awaiting oauth consent",
888
+ authorizeUrl,
889
+ };
890
+ return new Response(JSON.stringify(listing), {
891
+ status: 200,
892
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
893
+ });
894
+ }
895
+
896
+ // ===========================================================================
897
+ // GET /oauth/agent-grant/callback — the operator's browser redirect target (4b-2)
898
+ // ===========================================================================
899
+
900
+ /**
901
+ * The OAuth-client callback. The remote issuer redirects the operator's browser
902
+ * here with `?code&state` (success) or `?error&state` (RFC 6749 §4.1.2.1 — e.g.
903
+ * the operator clicked Deny). GET, no Bearer; the single-use `state` is the CSRF
904
+ * defense (this is a cross-site redirect IN — same-origin is NOT required).
905
+ *
906
+ * On success: look up the flow by `state` (delete-on-use), exchange the code,
907
+ * store the mcp material, flip the grant `approved`. If the grant previously had
908
+ * mcp material with a refresh_token + revocationEndpoint, best-effort revoke the
909
+ * OLD one first (one live credential per grant).
910
+ *
911
+ * Renders minimal HTML — NEVER a token. On any error, an HTML error page; the
912
+ * grant stays pending/needs_consent.
913
+ */
914
+ export async function handleOAuthGrantCallback(
915
+ req: Request,
916
+ deps: AgentGrantsDeps,
917
+ ): Promise<Response> {
918
+ const url = new URL(req.url);
919
+ const state = url.searchParams.get("state") ?? "";
920
+ const code = url.searchParams.get("code");
921
+ const errorParam = url.searchParams.get("error");
922
+
923
+ if (!state) {
924
+ return htmlPage(400, "Connection failed", "The authorization response was missing its state.");
925
+ }
926
+
927
+ // Single-use: look up, then delete the flow (delete-on-use). The get→delete
928
+ // pair is NOT atomic, but the race is benign: the auth `code` is single-use at
929
+ // the ISSUER (RFC 6749 §10.5), so a concurrent second callback with the same
930
+ // `state` exchanges the same code and the issuer rejects the second exchange.
931
+ // The design's concurrency posture is first-wins; the loser sees a token-error
932
+ // page, the grant ends approved exactly once.
933
+ const flow = getFlowByState(deps.flowsStorePath, state, (deps.now?.() ?? new Date()).getTime());
934
+ if (!flow) {
935
+ // Unknown / replayed / expired state — never mint anything.
936
+ return htmlPage(
937
+ 400,
938
+ "Connection failed",
939
+ "This authorization link is unknown, already used, or expired. Start the connection again from the admin page.",
940
+ );
941
+ }
942
+ deleteFlow(deps.flowsStorePath, state);
943
+
944
+ // The operator clicked Deny (or the issuer returned an error). Leave the grant
945
+ // pending, but record WHY so admin can distinguish "not yet tried" from
946
+ // "tried + denied". (htmlPage single-escapes its args — pass the RAW string.)
947
+ if (errorParam) {
948
+ const denied = getGrant(deps.storePath, flow.grantId);
949
+ if (denied && denied.status === "pending") {
950
+ putGrant(deps.storePath, {
951
+ id: denied.id,
952
+ agent: denied.agent,
953
+ connection: denied.connection,
954
+ status: "pending",
955
+ reason: "operator declined",
956
+ createdAt: denied.createdAt,
957
+ ...(denied.approvedAt ? { approvedAt: denied.approvedAt } : {}),
958
+ });
959
+ }
960
+ return htmlPage(
961
+ 400,
962
+ "Connection not authorized",
963
+ `The remote service did not grant access (${errorParam}). The connection stays pending — you can try again from the admin page.`,
964
+ );
965
+ }
966
+
967
+ if (!code) {
968
+ return htmlPage(
969
+ 400,
970
+ "Connection failed",
971
+ "The authorization response was missing its code. Start the connection again from the admin page.",
972
+ );
973
+ }
974
+
975
+ const grant = getGrant(deps.storePath, flow.grantId);
976
+ if (!grant) {
977
+ // The grant was removed mid-consent — nothing to populate.
978
+ return htmlPage(
979
+ 404,
980
+ "Connection failed",
981
+ "The grant this authorization belonged to no longer exists.",
982
+ );
983
+ }
984
+
985
+ const client = oauth(deps);
986
+ let tokens: Awaited<ReturnType<OAuthClient["exchangeCode"]>>;
987
+ try {
988
+ tokens = await client.exchangeCode({
989
+ tokenEndpoint: flow.tokenEndpoint,
990
+ code,
991
+ redirectUri: flow.redirectUri,
992
+ codeVerifier: flow.verifier,
993
+ clientId: flow.clientId,
994
+ now: deps.now ?? (() => new Date()),
995
+ });
996
+ } catch (err) {
997
+ // Token exchange failed — grant stays pending (the operator can retry).
998
+ // htmlPage single-escapes its args — pass the RAW message.
999
+ return htmlPage(
1000
+ 502,
1001
+ "Connection failed",
1002
+ `The token exchange with the remote service failed: ${err instanceof Error ? err.message : "unknown error"}. The connection stays pending — try again from the admin page.`,
1003
+ );
1004
+ }
1005
+
1006
+ // Best-effort revoke a prior refresh token (re-consent replaces material).
1007
+ if (
1008
+ grant.material?.kind === "mcp" &&
1009
+ grant.material.refresh_token &&
1010
+ grant.material.revocationEndpoint
1011
+ ) {
1012
+ await client.revokeRemote({
1013
+ revocationEndpoint: grant.material.revocationEndpoint,
1014
+ refreshToken: grant.material.refresh_token,
1015
+ clientId: flow.clientId,
1016
+ });
1017
+ }
1018
+
1019
+ const material: GrantMaterial = {
1020
+ kind: "mcp",
1021
+ access_token: tokens.access_token,
1022
+ ...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}),
1023
+ ...(tokens.expiresAt ? { expiresAt: tokens.expiresAt } : {}),
1024
+ issuer: flow.issuer,
1025
+ clientId: flow.clientId,
1026
+ tokenEndpoint: flow.tokenEndpoint,
1027
+ ...(flow.revocationEndpoint ? { revocationEndpoint: flow.revocationEndpoint } : {}),
1028
+ mcpUrl: flow.mcpUrl,
1029
+ };
1030
+ const updated: GrantRecord = {
1031
+ id: grant.id,
1032
+ agent: grant.agent,
1033
+ connection: grant.connection,
1034
+ status: "approved",
1035
+ createdAt: grant.createdAt,
1036
+ approvedAt: (deps.now?.() ?? new Date()).toISOString(),
1037
+ material,
1038
+ };
1039
+ putGrant(deps.storePath, updated);
1040
+ // NEVER log a token.
1041
+ console.log(`agent grant approved: id=${grant.id} agent=${grant.agent} kind=mcp mode=oauth`);
1042
+
1043
+ return htmlPage(
1044
+ 200,
1045
+ "Connected",
1046
+ "The connection is authorized. You can close this tab — the agent will use it on its next run.",
1047
+ );
1048
+ }
1049
+
1050
+ /** Minimal server-rendered HTML — NEVER carries a token. */
1051
+ function htmlPage(status: number, heading: string, message: string): Response {
1052
+ const html = `<!doctype html>
1053
+ <html lang="en">
1054
+ <head>
1055
+ <meta charset="utf-8">
1056
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1057
+ <title>${escapeHtml(heading)} — Parachute</title>
1058
+ <style>
1059
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 32rem; margin: 4rem auto; padding: 0 1.5rem; color: #1a1a1a; }
1060
+ h1 { font-size: 1.5rem; }
1061
+ p { line-height: 1.6; color: #444; }
1062
+ </style>
1063
+ </head>
1064
+ <body>
1065
+ <h1>${escapeHtml(heading)}</h1>
1066
+ <p>${escapeHtml(message)}</p>
1067
+ </body>
1068
+ </html>
1069
+ `;
1070
+ return new Response(html, {
1071
+ status,
1072
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
1073
+ });
1074
+ }
1075
+
1076
+ function escapeHtml(s: string): string {
1077
+ return s
1078
+ .replace(/&/g, "&amp;")
1079
+ .replace(/</g, "&lt;")
1080
+ .replace(/>/g, "&gt;")
1081
+ .replace(/"/g, "&quot;")
1082
+ .replace(/'/g, "&#39;");
1083
+ }
1084
+
1085
+ // ===========================================================================
1086
+ // POST /admin/grants/<id>/revoke — operator revokes (operator-auth)
1087
+ // ===========================================================================
1088
+
1089
+ async function revokeGrant(req: Request, id: string, deps: AgentGrantsDeps): Promise<Response> {
1090
+ const op = requireOperator(req, deps);
1091
+ if (op instanceof Response) return op;
1092
+
1093
+ if (!GRANT_ID_RE.test(id)) {
1094
+ return jsonError(400, "invalid_request", "grant id is not a valid identifier");
1095
+ }
1096
+ const grant = getGrant(deps.storePath, id);
1097
+ if (!grant) return jsonError(404, "not_found", `no grant ${id}`);
1098
+
1099
+ const now = deps.now?.() ?? new Date();
1100
+ await tearDownGrantMaterial(grant, deps, now);
1101
+
1102
+ const updated: GrantRecord = {
1103
+ id: grant.id,
1104
+ agent: grant.agent,
1105
+ connection: grant.connection,
1106
+ status: "revoked",
1107
+ createdAt: grant.createdAt,
1108
+ ...(grant.approvedAt ? { approvedAt: grant.approvedAt } : {}),
1109
+ // material + reason intentionally dropped — the secret leaves the store.
1110
+ };
1111
+ putGrant(deps.storePath, updated);
1112
+ console.log(`agent grant revoked: id=${id} agent=${grant.agent} kind=${grant.connection.kind}`);
1113
+ return grantResponse(200, updated);
1114
+ }
1115
+
1116
+ /**
1117
+ * Tear down a grant's credential material — shared by `revoke` (operator intent,
1118
+ * keeps the row at status:revoked) and `reconcile` (the holder is gone, the row is
1119
+ * then removed entirely). Idempotent + best-effort: a missing registry row or a
1120
+ * failed remote revoke must not throw.
1121
+ *
1122
+ * - vault → revoke the minted token in the registry so it's dead immediately,
1123
+ * not just absent from the next fetch.
1124
+ * - mcp → best-effort revoke the refresh token at the issuer so the remote
1125
+ * credential dies, not just our local copy. A static bearer (no
1126
+ * refresh/revocation endpoint) is a no-op (operator rotates upstream).
1127
+ * - service → operator-owned external cred; nothing to revoke remotely — the
1128
+ * caller drops our copy (the operator rotates upstream if needed).
1129
+ */
1130
+ async function tearDownGrantMaterial(
1131
+ grant: GrantRecord,
1132
+ deps: AgentGrantsDeps,
1133
+ now: Date,
1134
+ ): Promise<void> {
1135
+ if (grant.material?.kind === "vault") {
1136
+ try {
1137
+ revokeTokenByJti(deps.db, grant.material.jti, now);
1138
+ } catch {
1139
+ // Best-effort — a missing registry row leaves nothing to revoke.
1140
+ }
1141
+ } else if (
1142
+ grant.material?.kind === "mcp" &&
1143
+ grant.material.refresh_token &&
1144
+ grant.material.revocationEndpoint &&
1145
+ grant.material.clientId
1146
+ ) {
1147
+ // Best-effort + locally guarded (reviewer NIT2): `revokeRemote` swallows its
1148
+ // own errors today, but don't rely on that — a throwing impl/test-double must
1149
+ // not abort a reconcile mid-loop and leave the remaining grants un-pruned.
1150
+ try {
1151
+ await oauth(deps).revokeRemote({
1152
+ revocationEndpoint: grant.material.revocationEndpoint,
1153
+ refreshToken: grant.material.refresh_token,
1154
+ clientId: grant.material.clientId,
1155
+ });
1156
+ } catch {
1157
+ // best-effort — the local material is dropped by the caller regardless.
1158
+ }
1159
+ }
1160
+ }
1161
+
1162
+ // ===========================================================================
1163
+ // POST /admin/grants/reconcile — grant-GC for a holder (#96; module-auth)
1164
+ // ===========================================================================
1165
+
1166
+ /**
1167
+ * Reconcile (garbage-collect) one holder's grants against its CURRENTLY-declared
1168
+ * connections. The agent module POSTs the live connection SPECS a holder still
1169
+ * wants; the hub re-derives each key with its OWN `connectionKey()` and prunes
1170
+ * every grant for that holder whose key is NOT in the live set — tearing down its
1171
+ * material exactly like `revoke`, then REMOVING the row (the holder is gone, so a
1172
+ * lingering status:revoked row is just cruft).
1173
+ *
1174
+ * Module-auth (host-admin Bearer), mirroring PUT/GET — NOT operator-cookie-gated.
1175
+ * Pruning only ever REMOVES access (never escalates), so the "a note can only
1176
+ * REQUEST, never GRANT" invariant is untouched: this can't mint or approve.
1177
+ *
1178
+ * Body: `{ agent: "<name>", liveConnections: [<ConnectionSpec>, ...] }`.
1179
+ * `liveConnections: []` (the agent/def is gone) prunes ALL of that holder's grants.
1180
+ * Sending SPECS (not pre-computed keys) is deliberate: the hub re-derives keys with
1181
+ * the same normalization it stored them under, so there is NO dependency on the
1182
+ * agent module's separate connectionKey() impl (which diverges for service /
1183
+ * tagged-vault / mixed-case-mcp grants — a still-wanted grant would otherwise be
1184
+ * wrongly pruned; caught by live verification 2026-06-18).
1185
+ *
1186
+ * Returns `{ pruned: <number>, prunedIds: ["<id>", ...] }`.
1187
+ */
1188
+ async function reconcileGrants(req: Request, deps: AgentGrantsDeps): Promise<Response> {
1189
+ const auth = await requireModuleAuth(req, deps);
1190
+ if (auth instanceof Response) return auth;
1191
+
1192
+ let body: unknown;
1193
+ try {
1194
+ body = await req.json();
1195
+ } catch {
1196
+ return jsonError(400, "invalid_request", "body must be JSON");
1197
+ }
1198
+ if (!body || typeof body !== "object") {
1199
+ return jsonError(400, "invalid_request", "body must be a JSON object");
1200
+ }
1201
+ const b = body as Record<string, unknown>;
1202
+
1203
+ const agent = typeof b.agent === "string" ? b.agent.trim() : "";
1204
+ if (!agent || agent.length > MAX_AGENT_LEN || !AGENT_NAME_RE.test(agent)) {
1205
+ return jsonError(
1206
+ 400,
1207
+ "invalid_request",
1208
+ `agent is required and must match [a-zA-Z0-9][a-zA-Z0-9_.-]* (max ${MAX_AGENT_LEN} chars)`,
1209
+ );
1210
+ }
1211
+
1212
+ // The caller sends the live CONNECTION SPECS (not pre-computed keys): the hub
1213
+ // re-derives each key with its OWN connectionKey() — the same normalization +
1214
+ // function it used to store/grantId the grants — so the keep-set is guaranteed
1215
+ // to match the stored keys. (Sending agent-computed keys would couple to the
1216
+ // agent module's separate connectionKey() impl, which diverges for service /
1217
+ // tagged-vault / mixed-case-mcp grants — caught live 2026-06-18.)
1218
+ if (!Array.isArray(b.liveConnections)) {
1219
+ return jsonError(
1220
+ 400,
1221
+ "invalid_request",
1222
+ "liveConnections is required and must be an array of connection specs",
1223
+ );
1224
+ }
1225
+ if (b.liveConnections.length > MAX_LIVE_KEYS) {
1226
+ return jsonError(
1227
+ 400,
1228
+ "invalid_request",
1229
+ `liveConnections exceeds the ${MAX_LIVE_KEYS}-entry limit`,
1230
+ );
1231
+ }
1232
+ const liveKeys = new Set<string>();
1233
+ for (const raw of b.liveConnections) {
1234
+ const parsed = parseConnectionSpec(raw);
1235
+ if ("error" in parsed) {
1236
+ return jsonError(400, "invalid_request", `liveConnections entry invalid: ${parsed.error}`);
1237
+ }
1238
+ liveKeys.add(connectionKey(parsed.spec));
1239
+ }
1240
+
1241
+ const now = deps.now?.() ?? new Date();
1242
+ const held = listGrantsForAgent(deps.storePath, agent);
1243
+ const prunedIds: string[] = [];
1244
+ for (const grant of held) {
1245
+ // Both sides of this comparison use the HUB's connectionKey (stored grant +
1246
+ // re-derived live spec) — no cross-repo key-format dependency.
1247
+ if (liveKeys.has(connectionKey(grant.connection))) continue;
1248
+ await tearDownGrantMaterial(grant, deps, now);
1249
+ removeGrant(deps.storePath, grant.id);
1250
+ prunedIds.push(grant.id);
1251
+ }
1252
+
1253
+ if (prunedIds.length > 0) {
1254
+ // Identity fields + count only — NEVER a token.
1255
+ console.log(`agent grants reconciled: agent=${agent} pruned=${prunedIds.length}`);
1256
+ }
1257
+ return new Response(JSON.stringify({ pruned: prunedIds.length, prunedIds }), {
1258
+ status: 200,
1259
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
1260
+ });
1261
+ }
1262
+
1263
+ // ===========================================================================
1264
+ // Mint
1265
+ // ===========================================================================
1266
+
1267
+ /**
1268
+ * Mint the vault token for an approved vault grant: a REGISTERED
1269
+ * (created_via "agent_grant") `vault:<target>:<access>` JWT, audience-bound +
1270
+ * vault_scope-pinned to the target, carrying `permissions.scoped_tags` when
1271
+ * tags were declared (the vault's tag-scope enforcement reads them). Mirrors
1272
+ * `admin-connections.ts:mintCredential`.
1273
+ */
1274
+ async function mintVaultGrant(
1275
+ deps: AgentGrantsDeps,
1276
+ userId: string,
1277
+ vault: string,
1278
+ scope: string,
1279
+ scopedTags: readonly string[],
1280
+ ): Promise<{ token: string; jti: string; expiresAt: string }> {
1281
+ const sign = deps.signToken ?? signAccessToken;
1282
+ const signed = await sign(deps.db, {
1283
+ sub: userId || "agent-grant",
1284
+ scopes: [scope],
1285
+ audience: `vault.${vault}`,
1286
+ clientId: GRANT_CLIENT_ID,
1287
+ issuer: deps.hubOrigin,
1288
+ ttlSeconds: VAULT_GRANT_TTL_SECONDS,
1289
+ vaultScope: [vault],
1290
+ ...(scopedTags.length > 0
1291
+ ? { extraClaims: { permissions: { scoped_tags: [...scopedTags] } } }
1292
+ : {}),
1293
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
1294
+ });
1295
+ // Register the long-lived mint so revoke can drop it (hub-module-boundary
1296
+ // registered-mint rule — an unregistered long-lived token is unrevocable).
1297
+ recordTokenMint(deps.db, {
1298
+ jti: signed.jti,
1299
+ createdVia: "agent_grant",
1300
+ subject: "agent-grant",
1301
+ ...(userId ? { userId } : {}),
1302
+ clientId: GRANT_CLIENT_ID,
1303
+ scopes: [scope],
1304
+ expiresAt: signed.expiresAt,
1305
+ ...(scopedTags.length > 0
1306
+ ? { permissions: JSON.stringify({ scoped_tags: [...scopedTags] }) }
1307
+ : {}),
1308
+ ...(deps.now !== undefined ? { now: deps.now } : {}),
1309
+ });
1310
+ return { token: signed.token, jti: signed.jti, expiresAt: signed.expiresAt };
1311
+ }
1312
+
1313
+ // ===========================================================================
1314
+ // Wire shapes
1315
+ // ===========================================================================
1316
+
1317
+ /**
1318
+ * The list/echo wire shape for a grant — id, agent, connection, status,
1319
+ * optional reason + approvedAt. **Carries NO `material`** (the secret never
1320
+ * leaves via this shape). This is what PUT, GET-list, approve, and revoke all
1321
+ * return.
1322
+ */
1323
+ export interface GrantListing {
1324
+ id: string;
1325
+ agent: string;
1326
+ connection: ConnectionSpec;
1327
+ status: GrantRecord["status"];
1328
+ reason?: string;
1329
+ approvedAt?: string;
1330
+ /**
1331
+ * Present ONLY on a fresh `approve(mcp)` that started an OAuth flow — the URL
1332
+ * the admin UI redirects the operator's browser to. A superset of the 4b-1
1333
+ * listing shape; the UI ignores it for vault/service approves (absent there).
1334
+ * Never persisted; `toListing` never emits it (it's a response-only field).
1335
+ */
1336
+ authorizeUrl?: string;
1337
+ }
1338
+
1339
+ function toListing(g: GrantRecord): GrantListing {
1340
+ return {
1341
+ id: g.id,
1342
+ agent: g.agent,
1343
+ connection: g.connection,
1344
+ status: g.status,
1345
+ ...(g.reason ? { reason: g.reason } : {}),
1346
+ ...(g.approvedAt ? { approvedAt: g.approvedAt } : {}),
1347
+ };
1348
+ }
1349
+
1350
+ function grantResponse(status: number, g: GrantRecord): Response {
1351
+ return new Response(JSON.stringify(toListing(g)), {
1352
+ status,
1353
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
1354
+ });
1355
+ }
1356
+
1357
+ function jsonError(status: number, error: string, description: string): Response {
1358
+ return new Response(JSON.stringify({ error, error_description: description }), {
1359
+ status,
1360
+ headers: { "content-type": "application/json" },
1361
+ });
1362
+ }
1363
+
1364
+ /** Re-exported so callers/tests can reuse the stable key derivation. */
1365
+ export { connectionKey, grantId };