@openparachute/hub 0.7.1 → 0.7.2-rc.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 (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,1547 @@
1
+ /**
2
+ * Tests for the agent-connector GRANTS subsystem (`admin-agent-grants.ts`, 4b-1).
3
+ *
4
+ * The handler has two auth classes:
5
+ * - module-auth (a `parachute:host:admin` Bearer): PUT /admin/grants,
6
+ * GET /admin/grants, GET /admin/grants/<id>/material.
7
+ * - operator-auth (a first-admin session cookie): POST /admin/grants/<id>/approve,
8
+ * POST /admin/grants/<id>/revoke.
9
+ *
10
+ * Tokens are real (minted by the actual `signAccessToken`), so an approved vault
11
+ * grant's `/material` token can be decoded + asserted (scope/aud/permissions) the
12
+ * way the vault would read it. The store is a real tmpdir JSON file, so the
13
+ * 0600 + secret-not-in-list invariants are exercised end to end.
14
+ */
15
+ import type { Database } from "bun:sqlite";
16
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
17
+ import { mkdtempSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { decodeJwt } from "jose";
21
+ import {
22
+ type AgentGrantsDeps,
23
+ handleAgentGrants,
24
+ handleOAuthGrantCallback,
25
+ } from "../admin-agent-grants.ts";
26
+ import { getGrant, readGrants } from "../grants-store.ts";
27
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
28
+ import { findTokenRowByJti } from "../jwt-sign.ts";
29
+ import { signAccessToken } from "../jwt-sign.ts";
30
+ import type { OAuthClient } from "../oauth-client.ts";
31
+ import { getFlowByState } from "../oauth-flows-store.ts";
32
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
33
+ import { rotateSigningKey } from "../signing-keys.ts";
34
+ import { createUser } from "../users.ts";
35
+
36
+ const HUB_ORIGIN = "https://hub.test";
37
+ const VAULT_ORIGIN = "http://127.0.0.1:1940";
38
+
39
+ interface Harness {
40
+ db: Database;
41
+ storePath: string;
42
+ flowsStorePath: string;
43
+ cleanup: () => void;
44
+ }
45
+
46
+ function makeHarness(): Harness {
47
+ const dir = mkdtempSync(join(tmpdir(), "phub-agent-grants-"));
48
+ const db = openHubDb(hubDbPath(dir));
49
+ rotateSigningKey(db);
50
+ return {
51
+ db,
52
+ storePath: join(dir, "agent-grants.json"),
53
+ flowsStorePath: join(dir, "agent-oauth-flows.json"),
54
+ cleanup: () => {
55
+ db.close();
56
+ rmSync(dir, { recursive: true, force: true });
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * A fake OAuth client. Deterministic verifier/challenge/state so tests can assert
63
+ * the persisted flow; the network methods return canned values or call recorded
64
+ * spies. No real network ever happens.
65
+ */
66
+ function fakeOAuth(over: Partial<OAuthClient> = {}): OAuthClient & {
67
+ calls: { refresh: number; revoke: number; register: number; exchange: number };
68
+ } {
69
+ const calls = { refresh: 0, revoke: 0, register: 0, exchange: 0 };
70
+ const base: OAuthClient = {
71
+ generateCodeVerifier: () => "fixed-verifier",
72
+ generateCodeChallenge: (v) => `chal(${v})`,
73
+ generateState: () => "fixed-state",
74
+ discover: async () => ({
75
+ issuer: "https://issuer.test",
76
+ authorizationEndpoint: "https://issuer.test/oauth/authorize",
77
+ tokenEndpoint: "https://issuer.test/oauth/token",
78
+ registrationEndpoint: "https://issuer.test/oauth/register",
79
+ revocationEndpoint: "https://issuer.test/oauth/revoke",
80
+ scopesSupported: ["vault:eng:read", "vault:eng:write"],
81
+ }),
82
+ registerClient: async () => {
83
+ calls.register++;
84
+ return { clientId: "dcr-client-1" };
85
+ },
86
+ buildAuthorizeUrl: (o) =>
87
+ `${o.authorizationEndpoint}?client_id=${o.clientId}&state=${o.state}&code_challenge=${o.codeChallenge}&code_challenge_method=S256${o.scope ? `&scope=${encodeURIComponent(o.scope)}` : ""}`,
88
+ exchangeCode: async () => {
89
+ calls.exchange++;
90
+ return {
91
+ access_token: "at-fresh",
92
+ refresh_token: "rt-fresh",
93
+ expiresAt: "2026-06-18T13:00:00.000Z",
94
+ };
95
+ },
96
+ refreshToken: async () => {
97
+ calls.refresh++;
98
+ return {
99
+ access_token: "at-refreshed",
100
+ refresh_token: "rt-refreshed",
101
+ expiresAt: "2026-06-18T14:00:00.000Z",
102
+ };
103
+ },
104
+ revokeRemote: async () => {
105
+ calls.revoke++;
106
+ },
107
+ };
108
+ return Object.assign({ calls }, base, over);
109
+ }
110
+
111
+ let currentOAuth: OAuthClient | undefined;
112
+ beforeEach(() => {
113
+ currentOAuth = undefined;
114
+ });
115
+
116
+ let harness: Harness;
117
+ beforeEach(() => {
118
+ harness = makeHarness();
119
+ });
120
+ afterEach(() => {
121
+ harness.cleanup();
122
+ });
123
+
124
+ /** Vaults that "exist" for resolveVaultOrigin. */
125
+ let installedVaults = new Set<string>(["research"]);
126
+ beforeEach(() => {
127
+ installedVaults = new Set<string>(["research"]);
128
+ });
129
+
130
+ function deps(over: Partial<AgentGrantsDeps> = {}): AgentGrantsDeps {
131
+ return {
132
+ db: harness.db,
133
+ hubOrigin: HUB_ORIGIN,
134
+ storePath: harness.storePath,
135
+ flowsStorePath: harness.flowsStorePath,
136
+ resolveVaultOrigin: (name) => (installedVaults.has(name) ? VAULT_ORIGIN : null),
137
+ ...(currentOAuth ? { oauthClient: currentOAuth } : {}),
138
+ ...over,
139
+ };
140
+ }
141
+
142
+ // --- Auth helpers -----------------------------------------------------------
143
+ //
144
+ // Robust to call order: the FIRST user created in a test is the first-admin
145
+ // (the operator). `ensureFirstAdmin` lazily creates + memoizes that user so
146
+ // `moduleBearer`/`operatorCookie`/`friendCookie` can be called in any order
147
+ // without tripping the single-user guard.
148
+
149
+ let firstAdminId: string | null = null;
150
+ beforeEach(() => {
151
+ firstAdminId = null;
152
+ });
153
+
154
+ async function ensureFirstAdmin(): Promise<string> {
155
+ if (firstAdminId) return firstAdminId;
156
+ const user = await createUser(harness.db, "operator", "pw");
157
+ firstAdminId = user.id;
158
+ return user.id;
159
+ }
160
+
161
+ async function moduleBearer(scopes = ["parachute:host:admin"]): Promise<string> {
162
+ // The module is some on-box caller — it just needs a valid host-admin Bearer.
163
+ // Reuse the first-admin as its subject (it's a token-scope gate, not a
164
+ // session gate, so the subject identity doesn't matter for these routes).
165
+ const sub = await ensureFirstAdmin();
166
+ const minted = await signAccessToken(harness.db, {
167
+ sub,
168
+ scopes,
169
+ audience: "hub",
170
+ clientId: "parachute-hub-spa",
171
+ issuer: HUB_ORIGIN,
172
+ ttlSeconds: 600,
173
+ });
174
+ return minted.token;
175
+ }
176
+
177
+ async function operatorCookie(): Promise<string> {
178
+ const userId = await ensureFirstAdmin();
179
+ const session = createSession(harness.db, { userId });
180
+ return buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
181
+ }
182
+
183
+ async function friendCookie(): Promise<string> {
184
+ await ensureFirstAdmin(); // first-admin must exist
185
+ const friend = await createUser(harness.db, "friend", "pw", { allowMulti: true });
186
+ const session = createSession(harness.db, { userId: friend.id });
187
+ return buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
188
+ }
189
+
190
+ // --- Request builders -------------------------------------------------------
191
+
192
+ function bearerReq(method: string, path: string, bearer: string, body?: unknown): Request {
193
+ return new Request(`${HUB_ORIGIN}${path}`, {
194
+ method,
195
+ headers: {
196
+ authorization: `Bearer ${bearer}`,
197
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
198
+ },
199
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
200
+ });
201
+ }
202
+
203
+ function cookieReq(method: string, path: string, cookie: string, body?: unknown): Request {
204
+ return new Request(`${HUB_ORIGIN}${path}`, {
205
+ method,
206
+ headers: {
207
+ cookie,
208
+ origin: HUB_ORIGIN,
209
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
210
+ },
211
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
212
+ });
213
+ }
214
+
215
+ async function dispatch(req: Request): Promise<Response> {
216
+ const subPath = new URL(req.url).pathname.slice("/admin/grants".length);
217
+ return handleAgentGrants(req, subPath, deps());
218
+ }
219
+
220
+ async function json(res: Response): Promise<Record<string, unknown>> {
221
+ return (await res.json()) as Record<string, unknown>;
222
+ }
223
+
224
+ // === PUT /admin/grants ======================================================
225
+
226
+ describe("PUT /admin/grants (upsert)", () => {
227
+ test("creates a pending vault grant (201) — no material in the response", async () => {
228
+ const bearer = await moduleBearer();
229
+ const res = await dispatch(
230
+ bearerReq("PUT", "/admin/grants", bearer, {
231
+ agent: "agent1",
232
+ connection: { kind: "vault", target: "research", access: "read", tags: ["#published"] },
233
+ }),
234
+ );
235
+ expect(res.status).toBe(201);
236
+ const body = await json(res);
237
+ expect(body.status).toBe("pending");
238
+ expect(body.agent).toBe("agent1");
239
+ expect(body).not.toHaveProperty("material");
240
+ expect((body.connection as Record<string, unknown>).tags).toEqual(["#published"]);
241
+ expect(typeof body.id).toBe("string");
242
+ });
243
+
244
+ test("is idempotent — re-PUT the same connection returns 200, no duplicate row", async () => {
245
+ const bearer = await moduleBearer();
246
+ const spec = { kind: "vault", target: "research", access: "read" };
247
+ const r1 = await dispatch(
248
+ bearerReq("PUT", "/admin/grants", bearer, { agent: "a", connection: spec }),
249
+ );
250
+ expect(r1.status).toBe(201);
251
+ const r2 = await dispatch(
252
+ bearerReq("PUT", "/admin/grants", bearer, { agent: "a", connection: spec }),
253
+ );
254
+ expect(r2.status).toBe(200);
255
+ expect(readGrants(harness.storePath)).toHaveLength(1);
256
+ });
257
+
258
+ test("a service grant carries inject hints", async () => {
259
+ const bearer = await moduleBearer();
260
+ const res = await dispatch(
261
+ bearerReq("PUT", "/admin/grants", bearer, {
262
+ agent: "a",
263
+ connection: { kind: "service", target: "github", inject: ["env", "mcp"] },
264
+ }),
265
+ );
266
+ expect(res.status).toBe(201);
267
+ const body = await json(res);
268
+ expect((body.connection as Record<string, unknown>).inject).toEqual(["env", "mcp"]);
269
+ });
270
+
271
+ test("an mcp grant lands pending awaiting oauth consent (4b-2)", async () => {
272
+ const bearer = await moduleBearer();
273
+ const res = await dispatch(
274
+ bearerReq("PUT", "/admin/grants", bearer, {
275
+ agent: "a",
276
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
277
+ }),
278
+ );
279
+ expect(res.status).toBe(201);
280
+ const body = await json(res);
281
+ expect(body.status).toBe("pending");
282
+ expect(body.reason).toBe("awaiting oauth consent");
283
+ });
284
+
285
+ test("re-declaring an APPROVED grant does not downgrade it to pending", async () => {
286
+ const bearer = await moduleBearer();
287
+ const cookie = await operatorCookie();
288
+ const spec = { kind: "vault", target: "research", access: "read" };
289
+ const created = await json(
290
+ await dispatch(bearerReq("PUT", "/admin/grants", bearer, { agent: "a", connection: spec })),
291
+ );
292
+ const id = created.id as string;
293
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
294
+ // re-declare
295
+ const re = await json(
296
+ await dispatch(bearerReq("PUT", "/admin/grants", bearer, { agent: "a", connection: spec })),
297
+ );
298
+ expect(re.status).toBe("approved");
299
+ });
300
+
301
+ test("401 without a Bearer", async () => {
302
+ const req = new Request(`${HUB_ORIGIN}/admin/grants`, {
303
+ method: "PUT",
304
+ headers: { "content-type": "application/json" },
305
+ body: JSON.stringify({ agent: "a", connection: { kind: "vault", target: "research" } }),
306
+ });
307
+ const res = await dispatch(req);
308
+ expect(res.status).toBe(401);
309
+ });
310
+
311
+ test("403 when the Bearer lacks parachute:host:admin", async () => {
312
+ const bearer = await moduleBearer(["vault:research:read"]);
313
+ const res = await dispatch(
314
+ bearerReq("PUT", "/admin/grants", bearer, {
315
+ agent: "a",
316
+ connection: { kind: "vault", target: "research" },
317
+ }),
318
+ );
319
+ expect(res.status).toBe(403);
320
+ });
321
+
322
+ test("400 on a bad agent name / bad spec", async () => {
323
+ const bearer = await moduleBearer();
324
+ expect(
325
+ (
326
+ await dispatch(
327
+ bearerReq("PUT", "/admin/grants", bearer, {
328
+ agent: "bad name!",
329
+ connection: { kind: "vault", target: "research" },
330
+ }),
331
+ )
332
+ ).status,
333
+ ).toBe(400);
334
+ expect(
335
+ (
336
+ await dispatch(
337
+ bearerReq("PUT", "/admin/grants", bearer, {
338
+ agent: "a",
339
+ connection: { kind: "nope", target: "x" },
340
+ }),
341
+ )
342
+ ).status,
343
+ ).toBe(400);
344
+ expect(
345
+ (
346
+ await dispatch(
347
+ bearerReq("PUT", "/admin/grants", bearer, {
348
+ agent: "a",
349
+ connection: { kind: "vault", target: "Bad Vault" },
350
+ }),
351
+ )
352
+ ).status,
353
+ ).toBe(400);
354
+ });
355
+
356
+ test("400 on a reserved vault name (no phantom pending row)", async () => {
357
+ const bearer = await moduleBearer();
358
+ const res = await dispatch(
359
+ bearerReq("PUT", "/admin/grants", bearer, {
360
+ agent: "a",
361
+ connection: { kind: "vault", target: "admin" },
362
+ }),
363
+ );
364
+ expect(res.status).toBe(400);
365
+ expect(readGrants(harness.storePath)).toHaveLength(0);
366
+ });
367
+
368
+ test("400 on an over-long agent / tags array", async () => {
369
+ const bearer = await moduleBearer();
370
+ expect(
371
+ (
372
+ await dispatch(
373
+ bearerReq("PUT", "/admin/grants", bearer, {
374
+ agent: "x".repeat(200),
375
+ connection: { kind: "vault", target: "research" },
376
+ }),
377
+ )
378
+ ).status,
379
+ ).toBe(400);
380
+ expect(
381
+ (
382
+ await dispatch(
383
+ bearerReq("PUT", "/admin/grants", bearer, {
384
+ agent: "a",
385
+ connection: {
386
+ kind: "vault",
387
+ target: "research",
388
+ tags: Array.from({ length: 100 }, (_, i) => `#t${i}`),
389
+ },
390
+ }),
391
+ )
392
+ ).status,
393
+ ).toBe(400);
394
+ });
395
+ });
396
+
397
+ // === GET /admin/grants ======================================================
398
+
399
+ describe("GET /admin/grants (list — NO secrets)", () => {
400
+ test("lists grants, never includes material even for approved grants", async () => {
401
+ const bearer = await moduleBearer();
402
+ const cookie = await operatorCookie();
403
+ // pending service grant
404
+ await dispatch(
405
+ bearerReq("PUT", "/admin/grants", bearer, {
406
+ agent: "a",
407
+ connection: { kind: "service", target: "github" },
408
+ }),
409
+ );
410
+ // approved vault grant (material stored on disk)
411
+ const created = await json(
412
+ await dispatch(
413
+ bearerReq("PUT", "/admin/grants", bearer, {
414
+ agent: "a",
415
+ connection: { kind: "vault", target: "research" },
416
+ }),
417
+ ),
418
+ );
419
+ await dispatch(cookieReq("POST", `/admin/grants/${created.id}/approve`, cookie));
420
+
421
+ const res = await dispatch(bearerReq("GET", "/admin/grants?agent=a", bearer));
422
+ expect(res.status).toBe(200);
423
+ const body = await json(res);
424
+ const grants = body.grants as Record<string, unknown>[];
425
+ expect(grants).toHaveLength(2);
426
+ for (const g of grants) expect(g).not.toHaveProperty("material");
427
+ // the raw JSON text must not leak the minted token
428
+ const raw = JSON.stringify(body);
429
+ const stored = readGrants(harness.storePath).find((r) => r.connection.kind === "vault");
430
+ expect(stored?.material).toBeDefined();
431
+ expect(raw).not.toContain((stored?.material as { token: string }).token);
432
+ });
433
+
434
+ test("?agent filter narrows to one agent", async () => {
435
+ const bearer = await moduleBearer();
436
+ await dispatch(
437
+ bearerReq("PUT", "/admin/grants", bearer, {
438
+ agent: "a",
439
+ connection: { kind: "vault", target: "research" },
440
+ }),
441
+ );
442
+ await dispatch(
443
+ bearerReq("PUT", "/admin/grants", bearer, {
444
+ agent: "b",
445
+ connection: { kind: "service", target: "github" },
446
+ }),
447
+ );
448
+ const a = await json(await dispatch(bearerReq("GET", "/admin/grants?agent=a", bearer)));
449
+ expect((a.grants as unknown[]).length).toBe(1);
450
+ const all = await json(await dispatch(bearerReq("GET", "/admin/grants", bearer)));
451
+ expect((all.grants as unknown[]).length).toBe(2);
452
+ });
453
+
454
+ test("401 without a Bearer", async () => {
455
+ const res = await dispatch(new Request(`${HUB_ORIGIN}/admin/grants`, { method: "GET" }));
456
+ expect(res.status).toBe(401);
457
+ });
458
+ });
459
+
460
+ // === POST /admin/grants/<id>/approve =======================================
461
+
462
+ describe("POST /admin/grants/<id>/approve", () => {
463
+ test("vault: MINTS a registered vault:<target>:<access> token with scoped_tags", async () => {
464
+ const bearer = await moduleBearer();
465
+ const cookie = await operatorCookie();
466
+ const created = await json(
467
+ await dispatch(
468
+ bearerReq("PUT", "/admin/grants", bearer, {
469
+ agent: "a",
470
+ connection: { kind: "vault", target: "research", access: "read", tags: ["#published"] },
471
+ }),
472
+ ),
473
+ );
474
+ const id = created.id as string;
475
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
476
+ expect(res.status).toBe(200);
477
+ const body = await json(res);
478
+ expect(body.status).toBe("approved");
479
+ expect(body).not.toHaveProperty("material");
480
+ expect(body.approvedAt).toBeDefined();
481
+
482
+ // material on disk: decode the minted token
483
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
484
+ const mat = stored?.material as { kind: string; token: string; jti: string };
485
+ expect(mat.kind).toBe("vault");
486
+ const claims = decodeJwt(mat.token) as Record<string, unknown>;
487
+ expect(claims.scope).toBe("vault:research:read");
488
+ expect(claims.aud).toBe("vault.research");
489
+ expect((claims.permissions as { scoped_tags: string[] }).scoped_tags).toEqual(["#published"]);
490
+ // registered → revocable
491
+ expect(findTokenRowByJti(harness.db, mat.jti)).not.toBeNull();
492
+ });
493
+
494
+ test("vault: re-approval revokes the prior minted token (no orphaned live token)", async () => {
495
+ const bearer = await moduleBearer();
496
+ const cookie = await operatorCookie();
497
+ const created = await json(
498
+ await dispatch(
499
+ bearerReq("PUT", "/admin/grants", bearer, {
500
+ agent: "a",
501
+ connection: { kind: "vault", target: "research" },
502
+ }),
503
+ ),
504
+ );
505
+ const id = created.id as string;
506
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
507
+ const firstJti = (
508
+ readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
509
+ ).jti;
510
+
511
+ // approve again — should revoke the first token and mint a fresh one
512
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
513
+ const secondJti = (
514
+ readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
515
+ ).jti;
516
+
517
+ expect(secondJti).not.toBe(firstJti);
518
+ expect(findTokenRowByJti(harness.db, firstJti)?.revokedAt).toBeTruthy();
519
+ expect(findTokenRowByJti(harness.db, secondJti)?.revokedAt).toBeFalsy();
520
+ });
521
+
522
+ test("vault: a revoked grant can be re-approved (re-mints fresh material)", async () => {
523
+ const bearer = await moduleBearer();
524
+ const cookie = await operatorCookie();
525
+ const created = await json(
526
+ await dispatch(
527
+ bearerReq("PUT", "/admin/grants", bearer, {
528
+ agent: "a",
529
+ connection: { kind: "vault", target: "research" },
530
+ }),
531
+ ),
532
+ );
533
+ const id = created.id as string;
534
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
535
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/revoke`, cookie));
536
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("revoked");
537
+
538
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
539
+ expect(res.status).toBe(200);
540
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
541
+ expect(stored?.status).toBe("approved");
542
+ expect((stored?.material as { kind: string }).kind).toBe("vault");
543
+ });
544
+
545
+ test("service: stores the operator-pasted token", async () => {
546
+ const bearer = await moduleBearer();
547
+ const cookie = await operatorCookie();
548
+ const created = await json(
549
+ await dispatch(
550
+ bearerReq("PUT", "/admin/grants", bearer, {
551
+ agent: "a",
552
+ connection: { kind: "service", target: "github", inject: ["env"] },
553
+ }),
554
+ ),
555
+ );
556
+ const id = created.id as string;
557
+ const res = await dispatch(
558
+ cookieReq("POST", `/admin/grants/${id}/approve`, cookie, { token: "ghp_secret123" }),
559
+ );
560
+ expect(res.status).toBe(200);
561
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
562
+ expect((stored?.material as { kind: string; token: string }).token).toBe("ghp_secret123");
563
+ });
564
+
565
+ test("service approve rejects an over-long pasted token (400)", async () => {
566
+ const bearer = await moduleBearer();
567
+ const cookie = await operatorCookie();
568
+ const created = await json(
569
+ await dispatch(
570
+ bearerReq("PUT", "/admin/grants", bearer, {
571
+ agent: "a",
572
+ connection: { kind: "service", target: "github", inject: ["env"] },
573
+ }),
574
+ ),
575
+ );
576
+ const id = created.id as string;
577
+ const res = await dispatch(
578
+ cookieReq("POST", `/admin/grants/${id}/approve`, cookie, { token: "x".repeat(9000) }),
579
+ );
580
+ expect(res.status).toBe(400);
581
+ // not stored — the grant stays pending, no oversized material on disk.
582
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
583
+ expect(stored?.status).toBe("pending");
584
+ expect(stored?.material).toBeUndefined();
585
+ });
586
+
587
+ test("service: 400 when no token is pasted", async () => {
588
+ const bearer = await moduleBearer();
589
+ const cookie = await operatorCookie();
590
+ const created = await json(
591
+ await dispatch(
592
+ bearerReq("PUT", "/admin/grants", bearer, {
593
+ agent: "a",
594
+ connection: { kind: "service", target: "github" },
595
+ }),
596
+ ),
597
+ );
598
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${created.id}/approve`, cookie));
599
+ expect(res.status).toBe(400);
600
+ });
601
+
602
+ test("vault: 400 when the vault no longer exists", async () => {
603
+ const bearer = await moduleBearer();
604
+ const cookie = await operatorCookie();
605
+ const created = await json(
606
+ await dispatch(
607
+ bearerReq("PUT", "/admin/grants", bearer, {
608
+ agent: "a",
609
+ connection: { kind: "vault", target: "research" },
610
+ }),
611
+ ),
612
+ );
613
+ installedVaults.delete("research");
614
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${created.id}/approve`, cookie));
615
+ expect(res.status).toBe(400);
616
+ });
617
+
618
+ test("401 without a session cookie", async () => {
619
+ const bearer = await moduleBearer();
620
+ const created = await json(
621
+ await dispatch(
622
+ bearerReq("PUT", "/admin/grants", bearer, {
623
+ agent: "a",
624
+ connection: { kind: "vault", target: "research" },
625
+ }),
626
+ ),
627
+ );
628
+ const res = await dispatch(
629
+ new Request(`${HUB_ORIGIN}/admin/grants/${created.id}/approve`, {
630
+ method: "POST",
631
+ headers: { origin: HUB_ORIGIN },
632
+ }),
633
+ );
634
+ expect(res.status).toBe(401);
635
+ });
636
+
637
+ test("403 for a non-first-admin (friend) session", async () => {
638
+ const bearer = await moduleBearer();
639
+ const created = await json(
640
+ await dispatch(
641
+ bearerReq("PUT", "/admin/grants", bearer, {
642
+ agent: "a",
643
+ connection: { kind: "vault", target: "research" },
644
+ }),
645
+ ),
646
+ );
647
+ const cookie = await friendCookie();
648
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${created.id}/approve`, cookie));
649
+ expect(res.status).toBe(403);
650
+ });
651
+
652
+ test("404 for an unknown grant id", async () => {
653
+ const cookie = await operatorCookie();
654
+ const res = await dispatch(cookieReq("POST", "/admin/grants/does-not-exist/approve", cookie));
655
+ expect(res.status).toBe(404);
656
+ });
657
+ });
658
+
659
+ // === GET /admin/grants/<id>/material =======================================
660
+
661
+ describe("GET /admin/grants/<id>/material", () => {
662
+ test("vault: returns token + mcpUrl for an approved grant", async () => {
663
+ const bearer = await moduleBearer();
664
+ const cookie = await operatorCookie();
665
+ const created = await json(
666
+ await dispatch(
667
+ bearerReq("PUT", "/admin/grants", bearer, {
668
+ agent: "a",
669
+ connection: { kind: "vault", target: "research" },
670
+ }),
671
+ ),
672
+ );
673
+ const id = created.id as string;
674
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
675
+
676
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
677
+ expect(res.status).toBe(200);
678
+ const body = await json(res);
679
+ expect(body.kind).toBe("vault");
680
+ expect(typeof body.token).toBe("string");
681
+ expect(body.mcpUrl).toBe("https://hub.test/vault/research/mcp");
682
+ });
683
+
684
+ test("service: returns token + inject for an approved grant", async () => {
685
+ const bearer = await moduleBearer();
686
+ const cookie = await operatorCookie();
687
+ const created = await json(
688
+ await dispatch(
689
+ bearerReq("PUT", "/admin/grants", bearer, {
690
+ agent: "a",
691
+ connection: { kind: "service", target: "github", inject: ["env", "mcp"] },
692
+ }),
693
+ ),
694
+ );
695
+ const id = created.id as string;
696
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie, { token: "ghp_x" }));
697
+
698
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
699
+ expect(res.status).toBe(200);
700
+ const body = await json(res);
701
+ expect(body.kind).toBe("service");
702
+ expect(body.token).toBe("ghp_x");
703
+ expect(body.inject).toEqual(["env", "mcp"]);
704
+ });
705
+
706
+ test("409 when the grant is still pending (not approved)", async () => {
707
+ const bearer = await moduleBearer();
708
+ const created = await json(
709
+ await dispatch(
710
+ bearerReq("PUT", "/admin/grants", bearer, {
711
+ agent: "a",
712
+ connection: { kind: "vault", target: "research" },
713
+ }),
714
+ ),
715
+ );
716
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${created.id}/material`, bearer));
717
+ expect(res.status).toBe(409);
718
+ });
719
+
720
+ test("404 for an unknown grant id", async () => {
721
+ const bearer = await moduleBearer();
722
+ const res = await dispatch(bearerReq("GET", "/admin/grants/nope/material", bearer));
723
+ expect(res.status).toBe(404);
724
+ });
725
+
726
+ test("401 without a Bearer (material is module-auth gated)", async () => {
727
+ const bearer = await moduleBearer();
728
+ const cookie = await operatorCookie();
729
+ const created = await json(
730
+ await dispatch(
731
+ bearerReq("PUT", "/admin/grants", bearer, {
732
+ agent: "a",
733
+ connection: { kind: "vault", target: "research" },
734
+ }),
735
+ ),
736
+ );
737
+ await dispatch(cookieReq("POST", `/admin/grants/${created.id}/approve`, cookie));
738
+ const res = await dispatch(
739
+ new Request(`${HUB_ORIGIN}/admin/grants/${created.id}/material`, { method: "GET" }),
740
+ );
741
+ expect(res.status).toBe(401);
742
+ });
743
+ });
744
+
745
+ // === POST /admin/grants/<id>/revoke ========================================
746
+
747
+ describe("POST /admin/grants/<id>/revoke", () => {
748
+ test("drops the stored material, revokes the minted token, sets status=revoked", async () => {
749
+ const bearer = await moduleBearer();
750
+ const cookie = await operatorCookie();
751
+ const created = await json(
752
+ await dispatch(
753
+ bearerReq("PUT", "/admin/grants", bearer, {
754
+ agent: "a",
755
+ connection: { kind: "vault", target: "research" },
756
+ }),
757
+ ),
758
+ );
759
+ const id = created.id as string;
760
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
761
+ const jti = (
762
+ readGrants(harness.storePath).find((r) => r.id === id)?.material as { jti: string }
763
+ ).jti;
764
+
765
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/revoke`, cookie));
766
+ expect(res.status).toBe(200);
767
+ const body = await json(res);
768
+ expect(body.status).toBe("revoked");
769
+
770
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
771
+ expect(stored?.material).toBeUndefined();
772
+ // the minted token is now revoked in the registry
773
+ const row = findTokenRowByJti(harness.db, jti);
774
+ expect(row?.revokedAt).toBeTruthy();
775
+
776
+ // /material now 409s
777
+ const mat = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
778
+ expect(mat.status).toBe(409);
779
+ });
780
+
781
+ test("403 for a non-first-admin session", async () => {
782
+ const bearer = await moduleBearer();
783
+ const created = await json(
784
+ await dispatch(
785
+ bearerReq("PUT", "/admin/grants", bearer, {
786
+ agent: "a",
787
+ connection: { kind: "vault", target: "research" },
788
+ }),
789
+ ),
790
+ );
791
+ const cookie = await friendCookie();
792
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${created.id}/revoke`, cookie));
793
+ expect(res.status).toBe(403);
794
+ });
795
+
796
+ test("404 for an unknown grant id", async () => {
797
+ const cookie = await operatorCookie();
798
+ const res = await dispatch(cookieReq("POST", "/admin/grants/nope/revoke", cookie));
799
+ expect(res.status).toBe(404);
800
+ });
801
+ });
802
+
803
+ // === POST /admin/grants/reconcile (grant-GC, #96) ===========================
804
+
805
+ describe("POST /admin/grants/reconcile", () => {
806
+ /** Create a grant via PUT; return its id + the connection spec (sent back as a liveConnection). */
807
+ async function makeGrant(
808
+ bearer: string,
809
+ agent: string,
810
+ connection: Record<string, unknown>,
811
+ ): Promise<{ id: string; connection: Record<string, unknown> }> {
812
+ const created = await json(
813
+ await dispatch(bearerReq("PUT", "/admin/grants", bearer, { agent, connection })),
814
+ );
815
+ return { id: created.id as string, connection };
816
+ }
817
+
818
+ test("prunes grants whose spec is NOT in liveConnections; keeps those that are", async () => {
819
+ const bearer = await moduleBearer();
820
+ const keep = await makeGrant(bearer, "a", {
821
+ kind: "vault",
822
+ target: "research",
823
+ access: "read",
824
+ });
825
+ const drop = await makeGrant(bearer, "a", { kind: "service", target: "github" });
826
+
827
+ const res = await dispatch(
828
+ bearerReq("POST", "/admin/grants/reconcile", bearer, {
829
+ agent: "a",
830
+ liveConnections: [keep.connection], // drop's spec is absent → it is pruned
831
+ }),
832
+ );
833
+ expect(res.status).toBe(200);
834
+ const body = await json(res);
835
+ expect(body.pruned).toBe(1);
836
+ expect(body.prunedIds).toEqual([drop.id]);
837
+
838
+ // the kept grant survives; the dropped one is gone (row removed, not revoked)
839
+ expect(getGrant(harness.storePath, keep.id)?.status).toBe("pending");
840
+ expect(getGrant(harness.storePath, drop.id)).toBeNull();
841
+ });
842
+
843
+ test("REGRESSION: a still-wanted service / tagged-vault grant is NOT pruned (spec-based, no cross-repo key drift)", async () => {
844
+ // The hub derives keys from the live SPECS with its own connectionKey — so a
845
+ // service grant (hub key `service:github`) sent back as its spec matches and
846
+ // survives. (Under the old "agent sends pre-computed keys" contract, the agent
847
+ // would send `env:github`, the hub key `service:github` wouldn't match, and this
848
+ // still-wanted grant would be WRONGLY pruned. Caught by live verification.)
849
+ const bearer = await moduleBearer();
850
+ const svc = await makeGrant(bearer, "a", {
851
+ kind: "service",
852
+ target: "github",
853
+ inject: ["env"],
854
+ });
855
+ const tagged = await makeGrant(bearer, "a", {
856
+ kind: "vault",
857
+ target: "research",
858
+ access: "read",
859
+ tags: ["#published", "#wip"],
860
+ });
861
+
862
+ const res = await dispatch(
863
+ bearerReq("POST", "/admin/grants/reconcile", bearer, {
864
+ agent: "a",
865
+ liveConnections: [svc.connection, tagged.connection],
866
+ }),
867
+ );
868
+ expect(res.status).toBe(200);
869
+ expect((await json(res)).pruned).toBe(0);
870
+ expect(getGrant(harness.storePath, svc.id)).not.toBeNull();
871
+ expect(getGrant(harness.storePath, tagged.id)).not.toBeNull();
872
+ });
873
+
874
+ test("a pruned APPROVED vault grant has its registry token revoked", async () => {
875
+ const bearer = await moduleBearer();
876
+ const cookie = await operatorCookie();
877
+ const g = await makeGrant(bearer, "a", { kind: "vault", target: "research", access: "read" });
878
+ await dispatch(cookieReq("POST", `/admin/grants/${g.id}/approve`, cookie));
879
+ const jti = (getGrant(harness.storePath, g.id)?.material as { jti: string }).jti;
880
+ expect(findTokenRowByJti(harness.db, jti)?.revokedAt).toBeFalsy();
881
+
882
+ // reconcile with NO live connections for this agent → prune everything
883
+ const res = await dispatch(
884
+ bearerReq("POST", "/admin/grants/reconcile", bearer, { agent: "a", liveConnections: [] }),
885
+ );
886
+ expect(res.status).toBe(200);
887
+ expect((await json(res)).pruned).toBe(1);
888
+
889
+ // the row is removed AND the minted token is revoked in the registry
890
+ expect(getGrant(harness.storePath, g.id)).toBeNull();
891
+ expect(findTokenRowByJti(harness.db, jti)?.revokedAt).toBeTruthy();
892
+ });
893
+
894
+ test("liveConnections=[] prunes ALL of the agent's grants", async () => {
895
+ const bearer = await moduleBearer();
896
+ await makeGrant(bearer, "a", { kind: "vault", target: "research", access: "read" });
897
+ await makeGrant(bearer, "a", { kind: "service", target: "github" });
898
+
899
+ const res = await dispatch(
900
+ bearerReq("POST", "/admin/grants/reconcile", bearer, { agent: "a", liveConnections: [] }),
901
+ );
902
+ expect(res.status).toBe(200);
903
+ expect((await json(res)).pruned).toBe(2);
904
+ expect(readGrants(harness.storePath).filter((r) => r.agent === "a")).toHaveLength(0);
905
+ });
906
+
907
+ test("leaves OTHER agents' grants untouched", async () => {
908
+ const bearer = await moduleBearer();
909
+ const mine = await makeGrant(bearer, "a", { kind: "service", target: "github" });
910
+ const theirs = await makeGrant(bearer, "b", { kind: "service", target: "github" });
911
+
912
+ const res = await dispatch(
913
+ bearerReq("POST", "/admin/grants/reconcile", bearer, { agent: "a", liveConnections: [] }),
914
+ );
915
+ expect(res.status).toBe(200);
916
+ expect((await json(res)).pruned).toBe(1);
917
+ expect(getGrant(harness.storePath, mine.id)).toBeNull();
918
+ // agent b's grant is untouched even though it has the SAME connectionKey
919
+ expect(getGrant(harness.storePath, theirs.id)?.agent).toBe("b");
920
+ });
921
+
922
+ test("returns the right count/ids (empty when nothing is pruned)", async () => {
923
+ const bearer = await moduleBearer();
924
+ const g = await makeGrant(bearer, "a", { kind: "service", target: "github" });
925
+
926
+ const res = await dispatch(
927
+ bearerReq("POST", "/admin/grants/reconcile", bearer, {
928
+ agent: "a",
929
+ liveConnections: [g.connection], // the only grant is still live → nothing pruned
930
+ }),
931
+ );
932
+ expect(res.status).toBe(200);
933
+ const body = await json(res);
934
+ expect(body.pruned).toBe(0);
935
+ expect(body.prunedIds).toEqual([]);
936
+ expect(getGrant(harness.storePath, g.id)).not.toBeNull();
937
+ });
938
+
939
+ test("401 without the module Bearer (auth-gated)", async () => {
940
+ const bearer = await moduleBearer();
941
+ await makeGrant(bearer, "a", { kind: "service", target: "github" });
942
+
943
+ // No Authorization header at all.
944
+ const req = new Request(`${HUB_ORIGIN}/admin/grants/reconcile`, {
945
+ method: "POST",
946
+ headers: { "content-type": "application/json" },
947
+ body: JSON.stringify({ agent: "a", liveConnections: [] }),
948
+ });
949
+ const res = await handleAgentGrants(req, "/reconcile", deps());
950
+ expect(res.status).toBe(401);
951
+ // nothing was pruned
952
+ expect(readGrants(harness.storePath).filter((r) => r.agent === "a")).toHaveLength(1);
953
+ });
954
+
955
+ test("a cookie-only request (no Bearer) is rejected — module-auth only", async () => {
956
+ const bearer = await moduleBearer();
957
+ await makeGrant(bearer, "a", { kind: "service", target: "github" });
958
+ const cookie = await operatorCookie();
959
+ // An operator cookie is NOT module-auth — reconcile is host-admin-Bearer only.
960
+ const res = await dispatch(
961
+ cookieReq("POST", "/admin/grants/reconcile", cookie, { agent: "a", liveConnections: [] }),
962
+ );
963
+ expect(res.status).toBe(401);
964
+ expect(readGrants(harness.storePath).filter((r) => r.agent === "a")).toHaveLength(1);
965
+ });
966
+
967
+ test("405 on a non-POST method", async () => {
968
+ const bearer = await moduleBearer();
969
+ const res = await dispatch(bearerReq("GET", "/admin/grants/reconcile", bearer));
970
+ expect(res.status).toBe(405);
971
+ });
972
+
973
+ test("400 on a missing/invalid agent or liveConnections", async () => {
974
+ const bearer = await moduleBearer();
975
+ // missing agent
976
+ expect(
977
+ (
978
+ await dispatch(
979
+ bearerReq("POST", "/admin/grants/reconcile", bearer, { liveConnections: [] }),
980
+ )
981
+ ).status,
982
+ ).toBe(400);
983
+ // liveConnections not an array
984
+ expect(
985
+ (
986
+ await dispatch(
987
+ bearerReq("POST", "/admin/grants/reconcile", bearer, {
988
+ agent: "a",
989
+ liveConnections: "x",
990
+ }),
991
+ )
992
+ ).status,
993
+ ).toBe(400);
994
+ // an invalid liveConnections entry (not a valid connection spec)
995
+ expect(
996
+ (
997
+ await dispatch(
998
+ bearerReq("POST", "/admin/grants/reconcile", bearer, {
999
+ agent: "a",
1000
+ liveConnections: [{ kind: "bogus" }],
1001
+ }),
1002
+ )
1003
+ ).status,
1004
+ ).toBe(400);
1005
+ // a bad agent charset
1006
+ expect(
1007
+ (
1008
+ await dispatch(
1009
+ bearerReq("POST", "/admin/grants/reconcile", bearer, {
1010
+ agent: "bad name",
1011
+ liveConnections: [],
1012
+ }),
1013
+ )
1014
+ ).status,
1015
+ ).toBe(400);
1016
+ });
1017
+ });
1018
+
1019
+ // === method/routing guards ==================================================
1020
+
1021
+ describe("routing", () => {
1022
+ test("405 on unsupported collection method", async () => {
1023
+ const bearer = await moduleBearer();
1024
+ const res = await dispatch(bearerReq("DELETE", "/admin/grants", bearer));
1025
+ expect(res.status).toBe(405);
1026
+ });
1027
+
1028
+ test("405 on GET /approve (operator routes are POST-only)", async () => {
1029
+ const bearer = await moduleBearer();
1030
+ const cookie = await operatorCookie();
1031
+ const created = await json(
1032
+ await dispatch(
1033
+ bearerReq("PUT", "/admin/grants", bearer, {
1034
+ agent: "a",
1035
+ connection: { kind: "vault", target: "research" },
1036
+ }),
1037
+ ),
1038
+ );
1039
+ const res = await dispatch(cookieReq("GET", `/admin/grants/${created.id}/approve`, cookie));
1040
+ expect(res.status).toBe(405);
1041
+ });
1042
+
1043
+ test("404 on an unknown item verb", async () => {
1044
+ const bearer = await moduleBearer();
1045
+ const res = await dispatch(bearerReq("GET", "/admin/grants/x/bogus", bearer));
1046
+ expect(res.status).toBe(404);
1047
+ });
1048
+ });
1049
+
1050
+ // === 4b-2: mcp grants (OAuth client + static bearer) ========================
1051
+
1052
+ describe("approve(mcp) — static bearer", () => {
1053
+ test("a pasted token → approved + mcp material (no discovery, no flow)", async () => {
1054
+ currentOAuth = fakeOAuth();
1055
+ const bearer = await moduleBearer();
1056
+ const cookie = await operatorCookie();
1057
+ const created = await json(
1058
+ await dispatch(
1059
+ bearerReq("PUT", "/admin/grants", bearer, {
1060
+ agent: "a",
1061
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1062
+ }),
1063
+ ),
1064
+ );
1065
+ const id = created.id as string;
1066
+ const res = await dispatch(
1067
+ cookieReq("POST", `/admin/grants/${id}/approve`, cookie, { token: "static-paste-123" }),
1068
+ );
1069
+ expect(res.status).toBe(200);
1070
+ const body = await json(res);
1071
+ expect(body.status).toBe("approved");
1072
+ expect(body).not.toHaveProperty("material");
1073
+ expect(body).not.toHaveProperty("authorizeUrl");
1074
+
1075
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
1076
+ const mat = stored?.material as { kind: string; access_token: string; mcpUrl: string };
1077
+ expect(mat.kind).toBe("mcp");
1078
+ expect(mat.access_token).toBe("static-paste-123");
1079
+ expect(mat.mcpUrl).toBe("https://remote.test/mcp");
1080
+ // no OAuth machinery ran, no pending flow persisted
1081
+ expect(getFlowByState(harness.flowsStorePath, "fixed-state")).toBeNull();
1082
+ });
1083
+
1084
+ test("a static-bearer /material returns { kind:mcp, token, mcpUrl }", async () => {
1085
+ currentOAuth = fakeOAuth();
1086
+ const bearer = await moduleBearer();
1087
+ const cookie = await operatorCookie();
1088
+ const created = await json(
1089
+ await dispatch(
1090
+ bearerReq("PUT", "/admin/grants", bearer, {
1091
+ agent: "a",
1092
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1093
+ }),
1094
+ ),
1095
+ );
1096
+ const id = created.id as string;
1097
+ await dispatch(
1098
+ cookieReq("POST", `/admin/grants/${id}/approve`, cookie, { token: "static-paste-123" }),
1099
+ );
1100
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1101
+ expect(res.status).toBe(200);
1102
+ const body = await json(res);
1103
+ expect(body.kind).toBe("mcp");
1104
+ expect(body.token).toBe("static-paste-123");
1105
+ expect(body.mcpUrl).toBe("https://remote.test/mcp");
1106
+ // no refresh occurred for a static bearer
1107
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.refresh).toBe(0);
1108
+ });
1109
+
1110
+ test("rejects an over-long pasted token (400)", async () => {
1111
+ currentOAuth = fakeOAuth();
1112
+ const bearer = await moduleBearer();
1113
+ const cookie = await operatorCookie();
1114
+ const created = await json(
1115
+ await dispatch(
1116
+ bearerReq("PUT", "/admin/grants", bearer, {
1117
+ agent: "a",
1118
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1119
+ }),
1120
+ ),
1121
+ );
1122
+ const res = await dispatch(
1123
+ cookieReq("POST", `/admin/grants/${created.id}/approve`, cookie, { token: "x".repeat(9000) }),
1124
+ );
1125
+ expect(res.status).toBe(400);
1126
+ expect(readGrants(harness.storePath).find((r) => r.id === created.id)?.status).toBe("pending");
1127
+ });
1128
+ });
1129
+
1130
+ describe("approve(mcp) — start OAuth flow", () => {
1131
+ test("no token → discover + DCR + persist flow + return authorizeUrl (stays pending)", async () => {
1132
+ currentOAuth = fakeOAuth();
1133
+ const bearer = await moduleBearer();
1134
+ const cookie = await operatorCookie();
1135
+ const created = await json(
1136
+ await dispatch(
1137
+ bearerReq("PUT", "/admin/grants", bearer, {
1138
+ agent: "a",
1139
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1140
+ }),
1141
+ ),
1142
+ );
1143
+ const id = created.id as string;
1144
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1145
+ expect(res.status).toBe(200);
1146
+ const body = await json(res);
1147
+ expect(body.status).toBe("pending");
1148
+ expect(body.reason).toBe("awaiting oauth consent");
1149
+ expect(typeof body.authorizeUrl).toBe("string");
1150
+ expect(body.authorizeUrl as string).toContain("code_challenge_method=S256");
1151
+ expect(body.authorizeUrl as string).toContain("state=fixed-state");
1152
+ // grant is still pending (no material yet)
1153
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("pending");
1154
+
1155
+ // a pending flow was persisted, bound to (state, grantId)
1156
+ const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
1157
+ expect(flow?.grantId).toBe(id);
1158
+ expect(flow?.clientId).toBe("dcr-client-1");
1159
+ expect(flow?.verifier).toBe("fixed-verifier");
1160
+ expect(flow?.scope).toBe("vault:eng:read vault:eng:write");
1161
+ expect(flow?.redirectUri).toBe("https://hub.test/oauth/agent-grant/callback");
1162
+ // DCR happened once
1163
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.register).toBe(1);
1164
+ });
1165
+
1166
+ test("502 when discovery fails (grant stays pending, no flow)", async () => {
1167
+ currentOAuth = fakeOAuth({
1168
+ discover: async () => {
1169
+ throw new Error("no metadata");
1170
+ },
1171
+ });
1172
+ const bearer = await moduleBearer();
1173
+ const cookie = await operatorCookie();
1174
+ const created = await json(
1175
+ await dispatch(
1176
+ bearerReq("PUT", "/admin/grants", bearer, {
1177
+ agent: "a",
1178
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1179
+ }),
1180
+ ),
1181
+ );
1182
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${created.id}/approve`, cookie));
1183
+ expect(res.status).toBe(502);
1184
+ expect(getFlowByState(harness.flowsStorePath, "fixed-state")).toBeNull();
1185
+ });
1186
+
1187
+ // #671: least-privilege scope for a Parachute-vault MCP target. The fake
1188
+ // discovery advertises the broad set (vault:eng:read + vault:eng:write); for a
1189
+ // vault-shaped URL the flow must request ONLY vault:<name>:read instead.
1190
+ test("#671 vault-shaped MCP target → requests least-privilege vault:<name>:read", async () => {
1191
+ currentOAuth = fakeOAuth();
1192
+ const bearer = await moduleBearer();
1193
+ const cookie = await operatorCookie();
1194
+ const created = await json(
1195
+ await dispatch(
1196
+ bearerReq("PUT", "/admin/grants", bearer, {
1197
+ agent: "a",
1198
+ // A Parachute vault MCP URL at this hub's origin.
1199
+ connection: { kind: "mcp", target: "https://hub.test/vault/research/mcp" },
1200
+ }),
1201
+ ),
1202
+ );
1203
+ const id = created.id as string;
1204
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1205
+ expect(res.status).toBe(200);
1206
+ // The persisted flow's scope is the single least-privilege read scope, NOT
1207
+ // the resource's full advertised set.
1208
+ const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
1209
+ expect(flow?.scope).toBe("vault:research:read");
1210
+ // And the authorize URL carries that scope, not the broad one.
1211
+ const body = await json(res);
1212
+ expect(body.authorizeUrl as string).toContain(encodeURIComponent("vault:research:read"));
1213
+ expect(body.authorizeUrl as string).not.toContain("vault%3Aeng%3Awrite");
1214
+ });
1215
+
1216
+ // #671: the complement — a non-vault MCP target keeps the old behavior
1217
+ // (request the resource's advertised scopes_supported).
1218
+ test("#671 non-vault MCP target → falls back to the advertised scopes_supported", async () => {
1219
+ currentOAuth = fakeOAuth();
1220
+ const bearer = await moduleBearer();
1221
+ const cookie = await operatorCookie();
1222
+ const created = await json(
1223
+ await dispatch(
1224
+ bearerReq("PUT", "/admin/grants", bearer, {
1225
+ agent: "a",
1226
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1227
+ }),
1228
+ ),
1229
+ );
1230
+ const id = created.id as string;
1231
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1232
+ const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
1233
+ expect(flow?.scope).toBe("vault:eng:read vault:eng:write");
1234
+ });
1235
+ });
1236
+
1237
+ // callback dispatch helper (the route is a browser GET, no auth)
1238
+ async function callback(query: string): Promise<Response> {
1239
+ const req = new Request(`${HUB_ORIGIN}/oauth/agent-grant/callback${query}`, { method: "GET" });
1240
+ return handleOAuthGrantCallback(req, deps());
1241
+ }
1242
+
1243
+ describe("GET /oauth/agent-grant/callback", () => {
1244
+ async function startFlow(): Promise<string> {
1245
+ const bearer = await moduleBearer();
1246
+ const cookie = await operatorCookie();
1247
+ const created = await json(
1248
+ await dispatch(
1249
+ bearerReq("PUT", "/admin/grants", bearer, {
1250
+ agent: "a",
1251
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1252
+ }),
1253
+ ),
1254
+ );
1255
+ const id = created.id as string;
1256
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1257
+ return id;
1258
+ }
1259
+
1260
+ test("happy path → exchange + store material + status approved", async () => {
1261
+ currentOAuth = fakeOAuth();
1262
+ const id = await startFlow();
1263
+
1264
+ const res = await callback("?code=auth-code-1&state=fixed-state");
1265
+ expect(res.status).toBe(200);
1266
+ expect(res.headers.get("content-type")).toContain("text/html");
1267
+ const html = await res.text();
1268
+ expect(html).toContain("Connected");
1269
+ // NEVER a token in the HTML
1270
+ expect(html).not.toContain("at-fresh");
1271
+ expect(html).not.toContain("rt-fresh");
1272
+
1273
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
1274
+ expect(stored?.status).toBe("approved");
1275
+ const mat = stored?.material as { kind: string; access_token: string; refresh_token: string };
1276
+ expect(mat.kind).toBe("mcp");
1277
+ expect(mat.access_token).toBe("at-fresh");
1278
+ expect(mat.refresh_token).toBe("rt-fresh");
1279
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.exchange).toBe(1);
1280
+
1281
+ // the flow was consumed (single-use)
1282
+ expect(getFlowByState(harness.flowsStorePath, "fixed-state")).toBeNull();
1283
+ });
1284
+
1285
+ test("unknown / replayed state → error HTML, no material", async () => {
1286
+ currentOAuth = fakeOAuth();
1287
+ const id = await startFlow();
1288
+ // first use consumes the flow
1289
+ await callback("?code=auth-code-1&state=fixed-state");
1290
+ // replay
1291
+ const res = await callback("?code=auth-code-1&state=fixed-state");
1292
+ expect(res.status).toBe(400);
1293
+ const html = await res.text();
1294
+ expect(html).toContain("Connection failed");
1295
+ // exchange ran only once (the replay never reached it)
1296
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.exchange).toBe(1);
1297
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
1298
+ });
1299
+
1300
+ test("a totally unknown state → 400, no exchange", async () => {
1301
+ currentOAuth = fakeOAuth();
1302
+ const res = await callback("?code=x&state=never-minted");
1303
+ expect(res.status).toBe(400);
1304
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.exchange).toBe(0);
1305
+ });
1306
+
1307
+ test("operator Deny (?error=access_denied) → grant stays pending with reason 'operator declined'", async () => {
1308
+ currentOAuth = fakeOAuth();
1309
+ const id = await startFlow();
1310
+ const res = await callback("?error=access_denied&state=fixed-state");
1311
+ expect(res.status).toBe(400);
1312
+ const html = await res.text();
1313
+ expect(html).toContain("not authorized");
1314
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
1315
+ expect(stored?.status).toBe("pending");
1316
+ // admin can now distinguish "not yet tried" from "tried + denied"
1317
+ expect(stored?.reason).toBe("operator declined");
1318
+ // flow consumed even on deny
1319
+ expect(getFlowByState(harness.flowsStorePath, "fixed-state")).toBeNull();
1320
+ });
1321
+
1322
+ test("token exchange failure → error HTML, grant stays pending", async () => {
1323
+ currentOAuth = fakeOAuth({
1324
+ exchangeCode: async () => {
1325
+ throw new Error("invalid_grant");
1326
+ },
1327
+ });
1328
+ const id = await startFlow();
1329
+ const res = await callback("?code=bad&state=fixed-state");
1330
+ expect(res.status).toBe(502);
1331
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("pending");
1332
+ });
1333
+
1334
+ test("re-consent replaces material + best-effort revokes the old refresh", async () => {
1335
+ currentOAuth = fakeOAuth();
1336
+ const id = await startFlow();
1337
+ // complete the first consent → approved with rt-fresh + revocationEndpoint
1338
+ await callback("?code=c1&state=fixed-state");
1339
+ const cookie = await operatorCookie();
1340
+ // re-approve (already approved) → starts a fresh flow (reuses clientId, same issuer)
1341
+ const reApprove = await json(
1342
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie)),
1343
+ );
1344
+ expect(reApprove.status).toBe("pending");
1345
+ expect(typeof reApprove.authorizeUrl).toBe("string");
1346
+ // DCR was NOT called again (clientId reused for the same issuer)
1347
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.register).toBe(1);
1348
+
1349
+ // complete the second consent → revokes the prior refresh
1350
+ const res = await callback("?code=c2&state=fixed-state");
1351
+ expect(res.status).toBe(200);
1352
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.revoke).toBe(1);
1353
+ });
1354
+ });
1355
+
1356
+ describe("material(mcp) — auto-refresh", () => {
1357
+ // Drive a grant to approved-via-OAuth, with expiry in the (near) past/future.
1358
+ async function approvedViaOAuth(expiresAt: string): Promise<string> {
1359
+ currentOAuth = fakeOAuth({
1360
+ exchangeCode: async () => ({
1361
+ access_token: "at-initial",
1362
+ refresh_token: "rt-initial",
1363
+ expiresAt,
1364
+ }),
1365
+ });
1366
+ const bearer = await moduleBearer();
1367
+ const cookie = await operatorCookie();
1368
+ const created = await json(
1369
+ await dispatch(
1370
+ bearerReq("PUT", "/admin/grants", bearer, {
1371
+ agent: "a",
1372
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1373
+ }),
1374
+ ),
1375
+ );
1376
+ const id = created.id as string;
1377
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1378
+ const req = new Request(`${HUB_ORIGIN}/oauth/agent-grant/callback?code=c&state=fixed-state`, {
1379
+ method: "GET",
1380
+ });
1381
+ await handleOAuthGrantCallback(req, deps());
1382
+ return id;
1383
+ }
1384
+
1385
+ test("fresh token (far from expiry) → returned without a refresh", async () => {
1386
+ const far = new Date(Date.now() + 60 * 60 * 1000).toISOString();
1387
+ const id = await approvedViaOAuth(far);
1388
+ (currentOAuth as ReturnType<typeof fakeOAuth>).calls.refresh = 0;
1389
+ const bearer = await moduleBearer();
1390
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1391
+ expect(res.status).toBe(200);
1392
+ expect((await json(res)).token).toBe("at-initial");
1393
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.refresh).toBe(0);
1394
+ });
1395
+
1396
+ test("near-expiry token → refresh first, return the new token, persist it", async () => {
1397
+ // expiry within the 120s skew window
1398
+ const soon = new Date(Date.now() + 60 * 1000).toISOString();
1399
+ const id = await approvedViaOAuth(soon);
1400
+ const before = (currentOAuth as ReturnType<typeof fakeOAuth>).calls.refresh;
1401
+ const bearer = await moduleBearer();
1402
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1403
+ expect(res.status).toBe(200);
1404
+ expect((await json(res)).token).toBe("at-refreshed");
1405
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.refresh).toBe(before + 1);
1406
+ // new access + rotated refresh persisted
1407
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
1408
+ const mat = stored?.material as { access_token: string; refresh_token: string };
1409
+ expect(mat.access_token).toBe("at-refreshed");
1410
+ expect(mat.refresh_token).toBe("rt-refreshed");
1411
+ });
1412
+
1413
+ test("refresh failure → status needs_consent + 409, material dropped", async () => {
1414
+ const soon = new Date(Date.now() + 60 * 1000).toISOString();
1415
+ // approve via OAuth, THEN swap in a client whose refresh fails
1416
+ const id = await approvedViaOAuth(soon);
1417
+ currentOAuth = fakeOAuth({
1418
+ refreshToken: async () => {
1419
+ throw new Error("invalid_grant");
1420
+ },
1421
+ });
1422
+ const bearer = await moduleBearer();
1423
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1424
+ expect(res.status).toBe(409);
1425
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
1426
+ expect(stored?.status).toBe("needs_consent");
1427
+ expect(stored?.material).toBeUndefined();
1428
+ expect(stored?.reason).toContain("refresh failed");
1429
+ });
1430
+
1431
+ test("a needs_consent grant's /material 409 reason carries useful text", async () => {
1432
+ const soon = new Date(Date.now() + 60 * 1000).toISOString();
1433
+ const id = await approvedViaOAuth(soon);
1434
+ currentOAuth = fakeOAuth({
1435
+ refreshToken: async () => {
1436
+ throw new Error("invalid_grant");
1437
+ },
1438
+ });
1439
+ const bearer = await moduleBearer();
1440
+ // first call drives it to needs_consent
1441
+ await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1442
+ // second call: 409 whose reason text tells the operator to re-consent
1443
+ const res = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1444
+ expect(res.status).toBe(409);
1445
+ const body = await json(res);
1446
+ expect(body.error).toBe("not_approved");
1447
+ const desc = body.error_description as string;
1448
+ expect(desc).toContain("needs_consent");
1449
+ expect(desc).toMatch(/re-consent|reconnect|approve/i);
1450
+ });
1451
+
1452
+ test("needs_consent → re-approve → callback revives to approved with fresh material", async () => {
1453
+ // Drive a grant to needs_consent (approved-via-OAuth, then a failed refresh).
1454
+ const soon = new Date(Date.now() + 60 * 1000).toISOString();
1455
+ const id = await approvedViaOAuth(soon);
1456
+ currentOAuth = fakeOAuth({
1457
+ refreshToken: async () => {
1458
+ throw new Error("invalid_grant");
1459
+ },
1460
+ });
1461
+ const bearer = await moduleBearer();
1462
+ await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1463
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("needs_consent");
1464
+
1465
+ // Re-approve (no token) on a needs_consent grant → starts a FRESH OAuth flow.
1466
+ currentOAuth = fakeOAuth();
1467
+ const cookie = await operatorCookie();
1468
+ const reApprove = await json(
1469
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie)),
1470
+ );
1471
+ expect(reApprove.status).toBe("pending");
1472
+ expect(typeof reApprove.authorizeUrl).toBe("string");
1473
+ // a fresh flow was persisted, bound to this grant
1474
+ const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
1475
+ expect(flow?.grantId).toBe(id);
1476
+
1477
+ // Complete the consent → revived to approved with fresh material.
1478
+ const res = await callback("?code=revive&state=fixed-state");
1479
+ expect(res.status).toBe(200);
1480
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
1481
+ expect(stored?.status).toBe("approved");
1482
+ const mat = stored?.material as { kind: string; access_token: string };
1483
+ expect(mat.kind).toBe("mcp");
1484
+ expect(mat.access_token).toBe("at-fresh");
1485
+ // /material now serves a live token (the fake's hardcoded expiry is past, so
1486
+ // the lazy refresh kicks in and returns the refreshed token — proving the
1487
+ // revived grant is fully functional, no longer 409ing).
1488
+ const matRes = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1489
+ expect(matRes.status).toBe(200);
1490
+ expect((await json(matRes)).token).toBe("at-refreshed");
1491
+ });
1492
+ });
1493
+
1494
+ describe("revoke(mcp)", () => {
1495
+ test("best-effort revokes the remote refresh + drops material", async () => {
1496
+ currentOAuth = fakeOAuth();
1497
+ const bearer = await moduleBearer();
1498
+ const cookie = await operatorCookie();
1499
+ const created = await json(
1500
+ await dispatch(
1501
+ bearerReq("PUT", "/admin/grants", bearer, {
1502
+ agent: "a",
1503
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1504
+ }),
1505
+ ),
1506
+ );
1507
+ const id = created.id as string;
1508
+ await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie));
1509
+ await callback("?code=c&state=fixed-state"); // → approved with rt-fresh + revocationEndpoint
1510
+
1511
+ const before = (currentOAuth as ReturnType<typeof fakeOAuth>).calls.revoke;
1512
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/revoke`, cookie));
1513
+ expect(res.status).toBe(200);
1514
+ expect((await json(res)).status).toBe("revoked");
1515
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.revoke).toBe(before + 1);
1516
+
1517
+ const stored = readGrants(harness.storePath).find((r) => r.id === id);
1518
+ expect(stored?.material).toBeUndefined();
1519
+ // /material now 409s
1520
+ const mat = await dispatch(bearerReq("GET", `/admin/grants/${id}/material`, bearer));
1521
+ expect(mat.status).toBe(409);
1522
+ });
1523
+
1524
+ test("a static-bearer revoke drops material without a remote revoke call", async () => {
1525
+ currentOAuth = fakeOAuth();
1526
+ const bearer = await moduleBearer();
1527
+ const cookie = await operatorCookie();
1528
+ const created = await json(
1529
+ await dispatch(
1530
+ bearerReq("PUT", "/admin/grants", bearer, {
1531
+ agent: "a",
1532
+ connection: { kind: "mcp", target: "https://remote.test/mcp" },
1533
+ }),
1534
+ ),
1535
+ );
1536
+ const id = created.id as string;
1537
+ await dispatch(
1538
+ cookieReq("POST", `/admin/grants/${id}/approve`, cookie, { token: "static-paste" }),
1539
+ );
1540
+ const before = (currentOAuth as ReturnType<typeof fakeOAuth>).calls.revoke;
1541
+ const res = await dispatch(cookieReq("POST", `/admin/grants/${id}/revoke`, cookie));
1542
+ expect(res.status).toBe(200);
1543
+ // no remote revoke for a static bearer (no refresh/revocation endpoint)
1544
+ expect((currentOAuth as ReturnType<typeof fakeOAuth>).calls.revoke).toBe(before);
1545
+ expect(readGrants(harness.storePath).find((r) => r.id === id)?.material).toBeUndefined();
1546
+ });
1547
+ });