@openparachute/hub 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1320 @@
1
+ /**
2
+ * Tests for `kind: "credential"` connections (H4, surface-runtime design):
3
+ * provision round-trip, renewal (proof-of-possession; expired requires the
4
+ * operator), teardown (revoke + best-effort removal notification),
5
+ * privilege-escalation rejections, and the catalog round-trip.
6
+ *
7
+ * Shape mirrors admin-connections.test.ts: real DB + real mints (so the JWT
8
+ * claims — scope / aud / vault_scope / permissions.scoped_tags — can be
9
+ * decoded the way vault would read them), mocked module HTTP via fetchImpl.
10
+ */
11
+ import type { Database } from "bun:sqlite";
12
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
13
+ import { mkdtempSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { decodeJwt } from "jose";
17
+ import {
18
+ type ConnectionsDeps,
19
+ type InstalledModuleInfo,
20
+ buildCatalog,
21
+ handleConnections,
22
+ } from "../admin-connections.ts";
23
+ import { putConnection, readConnections } from "../connections-store.ts";
24
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
25
+ import {
26
+ findTokenRowByJti,
27
+ listActiveRevocations,
28
+ recordTokenMint,
29
+ revokeTokenByJti,
30
+ signAccessToken,
31
+ } from "../jwt-sign.ts";
32
+ import {
33
+ type ModuleManifest,
34
+ ModuleManifestError,
35
+ validateModuleManifest,
36
+ } from "../module-manifest.ts";
37
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
38
+ import { rotateSigningKey } from "../signing-keys.ts";
39
+ import { createUser } from "../users.ts";
40
+
41
+ const HUB_ORIGIN = "https://hub.test";
42
+ const VAULT_ORIGIN = "http://127.0.0.1:1940";
43
+ const SURFACE_ORIGIN = "http://127.0.0.1:1946";
44
+
45
+ interface Harness {
46
+ db: Database;
47
+ storePath: string;
48
+ cleanup: () => void;
49
+ }
50
+
51
+ function makeHarness(): Harness {
52
+ const dir = mkdtempSync(join(tmpdir(), "phub-cred-conn-"));
53
+ const db = openHubDb(hubDbPath(dir));
54
+ rotateSigningKey(db);
55
+ return {
56
+ db,
57
+ storePath: join(dir, "connections.json"),
58
+ cleanup: () => {
59
+ db.close();
60
+ rmSync(dir, { recursive: true, force: true });
61
+ },
62
+ };
63
+ }
64
+
65
+ let harness: Harness;
66
+ beforeEach(() => {
67
+ harness = makeHarness();
68
+ });
69
+ afterEach(() => {
70
+ harness.cleanup();
71
+ });
72
+
73
+ async function adminCookie(): Promise<{ cookie: string; userId: string }> {
74
+ const user = await createUser(harness.db, "operator", "hunter2");
75
+ const session = createSession(harness.db, { userId: user.id });
76
+ return {
77
+ cookie: buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)),
78
+ userId: user.id,
79
+ };
80
+ }
81
+
82
+ // --- Mock fetch (same shape as admin-connections.test.ts) -------------------
83
+
84
+ interface RecordedReq {
85
+ method: string;
86
+ url: string;
87
+ bearer: string | null;
88
+ body: unknown;
89
+ }
90
+ type Responder = (req: RecordedReq) => Response;
91
+
92
+ function mockFetch(routes: Record<string, Responder>): {
93
+ fetchImpl: typeof fetch;
94
+ calls: RecordedReq[];
95
+ } {
96
+ const calls: RecordedReq[] = [];
97
+ const fetchImpl = (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
98
+ const url = typeof input === "string" ? input : input.toString();
99
+ const method = (init?.method ?? "GET").toUpperCase();
100
+ const auth =
101
+ (init?.headers as Record<string, string> | undefined)?.authorization ??
102
+ (init?.headers as Record<string, string> | undefined)?.Authorization ??
103
+ null;
104
+ const bearer = auth ? auth.replace(/^Bearer\s+/i, "") : null;
105
+ let body: unknown;
106
+ if (typeof init?.body === "string") {
107
+ try {
108
+ body = JSON.parse(init.body);
109
+ } catch {
110
+ body = init.body;
111
+ }
112
+ }
113
+ const path = new URL(url).pathname;
114
+ const rec: RecordedReq = { method, url, bearer, body };
115
+ calls.push(rec);
116
+ const responder = routes[`${method} ${path}`];
117
+ if (!responder) return new Response("no route", { status: 599 });
118
+ return responder(rec);
119
+ }) as typeof fetch;
120
+ return { fetchImpl, calls };
121
+ }
122
+
123
+ function ok(payload: unknown): Response {
124
+ return new Response(JSON.stringify(payload), {
125
+ status: 200,
126
+ headers: { "content-type": "application/json" },
127
+ });
128
+ }
129
+
130
+ // --- Module fixtures --------------------------------------------------------
131
+
132
+ /** A surface-like module declaring a read credential (the woven-boulder shape). */
133
+ const SURFACE_MANIFEST: ModuleManifest = {
134
+ name: "surface",
135
+ manifestName: "parachute-surface",
136
+ port: 1946,
137
+ paths: ["/surface"],
138
+ health: "/surface/healthz",
139
+ credentials: [
140
+ {
141
+ key: "vault",
142
+ title: "Standing vault credential",
143
+ description: "Tag-scoped read credential for a backed surface.",
144
+ scope: "vault:{vault}:read",
145
+ endpoint: "/api/credential",
146
+ },
147
+ {
148
+ key: "vault-write",
149
+ title: "Standing vault write credential",
150
+ scope: "vault:{vault}:write",
151
+ endpoint: "/api/credential",
152
+ },
153
+ ],
154
+ };
155
+
156
+ function modulesOf(...manifests: ModuleManifest[]): InstalledModuleInfo[] {
157
+ return manifests.map((manifest) => ({
158
+ short: manifest.name,
159
+ manifest,
160
+ mount: manifest.paths[0] ?? null,
161
+ }));
162
+ }
163
+
164
+ function credDeps(fetchImpl: typeof fetch, modules: InstalledModuleInfo[]): ConnectionsDeps {
165
+ return {
166
+ db: harness.db,
167
+ hubOrigin: HUB_ORIGIN,
168
+ modules,
169
+ resolveVaultOrigin: (v) => (v === "default" ? VAULT_ORIGIN : null),
170
+ resolveModuleOrigin: (short) => (short === "surface" ? SURFACE_ORIGIN : null),
171
+ channelOrigin: null,
172
+ storePath: harness.storePath,
173
+ fetchImpl,
174
+ };
175
+ }
176
+
177
+ function postCredential(
178
+ cookie: string,
179
+ body: Record<string, unknown>,
180
+ deps: ConnectionsDeps,
181
+ ): Promise<Response> {
182
+ return handleConnections(
183
+ new Request("http://127.0.0.1/admin/connections", {
184
+ method: "POST",
185
+ headers: { cookie, "content-type": "application/json" },
186
+ body: JSON.stringify({ kind: "credential", ...body }),
187
+ }),
188
+ "",
189
+ deps,
190
+ );
191
+ }
192
+
193
+ function renewReq(id: string, bearer?: string): Request {
194
+ return new Request(`http://127.0.0.1/admin/connections/${id}/renew`, {
195
+ method: "POST",
196
+ headers: bearer ? { authorization: `Bearer ${bearer}` } : {},
197
+ });
198
+ }
199
+
200
+ interface DeliveredCredential {
201
+ kind: string;
202
+ op: string;
203
+ connection_id: string;
204
+ key: string;
205
+ vault: string;
206
+ scope: string;
207
+ scoped_tags: string[];
208
+ token?: string;
209
+ jti?: string;
210
+ expires_at?: string;
211
+ renew_path?: string;
212
+ }
213
+
214
+ // ===========================================================================
215
+ // Provision round-trip
216
+ // ===========================================================================
217
+
218
+ describe("credential connection — provision (H4)", () => {
219
+ test("full round-trip: registered mint, scoped_tags claim, delivered payload shape, persisted record", async () => {
220
+ const { cookie } = await adminCookie();
221
+ const { fetchImpl, calls } = mockFetch({
222
+ "POST /api/credential": () => ok({ ok: true }),
223
+ });
224
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
225
+
226
+ const res = await postCredential(
227
+ cookie,
228
+ { credential: { module: "surface", key: "vault", vault: "default", tags: ["boulder"] } },
229
+ deps,
230
+ );
231
+ expect(res.status).toBe(200);
232
+ const out = (await res.json()) as {
233
+ ok: boolean;
234
+ connection: { id: string; kind: string };
235
+ expires_at: string;
236
+ };
237
+ expect(out.ok).toBe(true);
238
+ expect(out.connection.kind).toBe("credential");
239
+ expect(out.connection.id).toBe("cred-surface-vault-default");
240
+
241
+ // Delivery: POSTed to the module's declared endpoint on its loopback
242
+ // origin, authenticated with a short-lived surface:admin bearer.
243
+ const delivery = calls.find((c) => c.url === `${SURFACE_ORIGIN}/api/credential`);
244
+ expect(delivery).toBeDefined();
245
+ expect(delivery!.method).toBe("POST");
246
+ expect(delivery!.bearer).toBeTruthy();
247
+ const adminClaims = decodeJwt(delivery!.bearer!) as { scope?: string; aud?: string };
248
+ expect(adminClaims.scope).toBe("surface:admin");
249
+ expect(adminClaims.aud).toBe("surface");
250
+
251
+ const payload = delivery!.body as DeliveredCredential;
252
+ expect(payload.kind).toBe("credential");
253
+ expect(payload.op).toBe("provisioned");
254
+ expect(payload.connection_id).toBe("cred-surface-vault-default");
255
+ expect(payload.key).toBe("vault");
256
+ expect(payload.vault).toBe("default");
257
+ expect(payload.scope).toBe("vault:default:read");
258
+ expect(payload.scoped_tags).toEqual(["boulder"]);
259
+ expect(payload.renew_path).toBe("/admin/connections/cred-surface-vault-default/renew");
260
+ expect(payload.token).toBeTruthy();
261
+ expect(payload.jti).toBeTruthy();
262
+
263
+ // The delivered token carries the claims vault enforces: scope, aud,
264
+ // vault_scope pin, and permissions.scoped_tags (the tag-scope claim path).
265
+ const claims = decodeJwt(payload.token!) as {
266
+ scope?: string;
267
+ aud?: string;
268
+ vault_scope?: string[];
269
+ permissions?: { scoped_tags?: string[] };
270
+ jti?: string;
271
+ };
272
+ expect(claims.scope).toBe("vault:default:read");
273
+ expect(claims.aud).toBe("vault.default");
274
+ expect(claims.vault_scope).toEqual(["default"]);
275
+ expect(claims.permissions?.scoped_tags).toEqual(["boulder"]);
276
+
277
+ // REGISTERED mint (the registered-mint rule): a tokens row exists with
278
+ // the credential provenance + the permissions JSON.
279
+ const row = findTokenRowByJti(harness.db, payload.jti!);
280
+ expect(row).not.toBeNull();
281
+ expect(row!.createdVia).toBe("connection_credential");
282
+ expect(row!.revokedAt).toBeNull();
283
+ expect(JSON.parse(row!.permissions ?? "{}")).toEqual({ scoped_tags: ["boulder"] });
284
+
285
+ // Persisted record: jti + scope + tags, cascade-matchable vault fields.
286
+ const records = readConnections(harness.storePath);
287
+ expect(records.length).toBe(1);
288
+ const rec = records[0]!;
289
+ expect(rec.kind).toBe("credential");
290
+ expect(rec.source).toEqual({ module: "vault", vault: "default", event: "credential" });
291
+ expect(rec.sink.module).toBe("surface");
292
+ expect(rec.provisioned.type).toBe("credential");
293
+ expect(rec.provisioned.vault).toBe("default");
294
+ expect(rec.provisioned.mintedJtis).toEqual([payload.jti!]);
295
+ expect(rec.provisioned.scope).toBe("vault:default:read");
296
+ expect(rec.provisioned.scopedTags).toEqual(["boulder"]);
297
+ expect(rec.provisioned.endpoint).toBe("/api/credential");
298
+ });
299
+
300
+ test("read credential MAY be vault-wide (no tags) — scoped_tags claim absent", async () => {
301
+ const { cookie } = await adminCookie();
302
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
303
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
304
+
305
+ const res = await postCredential(
306
+ cookie,
307
+ { credential: { module: "surface", key: "vault", vault: "default" } },
308
+ deps,
309
+ );
310
+ expect(res.status).toBe(200);
311
+ const payload = calls[calls.length - 1]!.body as DeliveredCredential;
312
+ expect(payload.scoped_tags).toEqual([]);
313
+ const claims = decodeJwt(payload.token!) as { permissions?: unknown };
314
+ expect(claims.permissions).toBeUndefined();
315
+ });
316
+
317
+ test("write credential REQUIRES non-empty tags (tags are the sharing scope)", async () => {
318
+ const { cookie } = await adminCookie();
319
+ const { fetchImpl, calls } = mockFetch({});
320
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
321
+
322
+ const res = await postCredential(
323
+ cookie,
324
+ { credential: { module: "surface", key: "vault-write", vault: "default" } },
325
+ deps,
326
+ );
327
+ expect(res.status).toBe(400);
328
+ const body = (await res.json()) as { error: string };
329
+ expect(body.error).toBe("invalid_request");
330
+ expect(calls.length).toBe(0); // nothing minted, nothing delivered
331
+ });
332
+
333
+ test("failed delivery revokes the fresh mint (no undelivered live credential)", async () => {
334
+ const { cookie } = await adminCookie();
335
+ const { fetchImpl, calls } = mockFetch({
336
+ "POST /api/credential": () => new Response("boom", { status: 500 }),
337
+ });
338
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
339
+
340
+ const res = await postCredential(
341
+ cookie,
342
+ { credential: { module: "surface", key: "vault", vault: "default", tags: ["t"] } },
343
+ deps,
344
+ );
345
+ expect(res.status).toBe(502);
346
+ const attempted = calls.find((c) => c.url === `${SURFACE_ORIGIN}/api/credential`);
347
+ const jti = (decodeJwt((attempted!.body as DeliveredCredential).token!) as { jti?: string })
348
+ .jti!;
349
+ const row = findTokenRowByJti(harness.db, jti);
350
+ expect(row!.revokedAt).not.toBeNull();
351
+ expect(readConnections(harness.storePath).length).toBe(0); // not persisted
352
+ });
353
+
354
+ test("re-approval (same module/key/vault) revokes the prior credential and upserts", async () => {
355
+ const { cookie } = await adminCookie();
356
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
357
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
358
+
359
+ await postCredential(
360
+ cookie,
361
+ { credential: { module: "surface", key: "vault", vault: "default", tags: ["a"] } },
362
+ deps,
363
+ );
364
+ const firstJti = (readConnections(harness.storePath)[0]!.provisioned.mintedJtis ?? [])[0]!;
365
+
366
+ const res2 = await postCredential(
367
+ cookie,
368
+ { credential: { module: "surface", key: "vault", vault: "default", tags: ["b"] } },
369
+ deps,
370
+ );
371
+ expect(res2.status).toBe(200);
372
+ const records = readConnections(harness.storePath);
373
+ expect(records.length).toBe(1); // upserted, not duplicated
374
+ expect(records[0]!.provisioned.scopedTags).toEqual(["b"]);
375
+ // The first credential is dead.
376
+ expect(findTokenRowByJti(harness.db, firstJti)!.revokedAt).not.toBeNull();
377
+ expect(calls.filter((c) => c.method === "POST").length).toBe(2);
378
+ });
379
+ });
380
+
381
+ // ===========================================================================
382
+ // Privilege-escalation rejections
383
+ // ===========================================================================
384
+
385
+ describe("credential connection — escalation guard", () => {
386
+ test("manifest validator rejects vault:{vault}:admin and other-namespace scope templates", () => {
387
+ const base = {
388
+ name: "evil",
389
+ manifestName: "evil",
390
+ port: 1999,
391
+ paths: ["/evil"],
392
+ health: "/health",
393
+ };
394
+ expect(() =>
395
+ validateModuleManifest(
396
+ {
397
+ ...base,
398
+ credentials: [{ key: "v", title: "V", scope: "vault:{vault}:admin", endpoint: "/api/c" }],
399
+ },
400
+ "test",
401
+ ),
402
+ ).toThrow(ModuleManifestError);
403
+ expect(() =>
404
+ validateModuleManifest(
405
+ {
406
+ ...base,
407
+ credentials: [{ key: "v", title: "V", scope: "scribe:{vault}:read", endpoint: "/api/c" }],
408
+ },
409
+ "test",
410
+ ),
411
+ ).toThrow(ModuleManifestError);
412
+ // Literal vault names are not declarable either — the operator picks.
413
+ expect(() =>
414
+ validateModuleManifest(
415
+ {
416
+ ...base,
417
+ credentials: [{ key: "v", title: "V", scope: "vault:default:read", endpoint: "/api/c" }],
418
+ },
419
+ "test",
420
+ ),
421
+ ).toThrow(ModuleManifestError);
422
+ });
423
+
424
+ test("POST-time re-check rejects an admin template smuggled past validation (defense in depth)", async () => {
425
+ const { cookie } = await adminCookie();
426
+ const { fetchImpl, calls } = mockFetch({});
427
+ // Hand-built (NOT validator-produced) manifest with an escalating scope.
428
+ const evil: ModuleManifest = {
429
+ name: "surface",
430
+ manifestName: "parachute-surface",
431
+ port: 1946,
432
+ paths: ["/surface"],
433
+ health: "/healthz",
434
+ credentials: [
435
+ { key: "vault", title: "V", scope: "vault:{vault}:admin", endpoint: "/api/credential" },
436
+ ],
437
+ };
438
+ const deps = credDeps(fetchImpl, modulesOf(evil));
439
+ const res = await postCredential(
440
+ cookie,
441
+ { credential: { module: "surface", key: "vault", vault: "default", tags: ["t"] } },
442
+ deps,
443
+ );
444
+ expect(res.status).toBe(400);
445
+ const body = (await res.json()) as { error: string };
446
+ expect(body.error).toBe("invalid_scope");
447
+ expect(calls.length).toBe(0);
448
+ });
449
+
450
+ test("undeclared credential key / unknown module / unknown vault all 400", async () => {
451
+ const { cookie } = await adminCookie();
452
+ const { fetchImpl } = mockFetch({});
453
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
454
+
455
+ const badKey = await postCredential(
456
+ cookie,
457
+ { credential: { module: "surface", key: "nope", vault: "default" } },
458
+ deps,
459
+ );
460
+ expect(badKey.status).toBe(400);
461
+ expect(((await badKey.json()) as { error: string }).error).toBe("unknown_credential");
462
+
463
+ const badModule = await postCredential(
464
+ cookie,
465
+ { credential: { module: "ghost", key: "vault", vault: "default" } },
466
+ deps,
467
+ );
468
+ expect(badModule.status).toBe(400);
469
+ expect(((await badModule.json()) as { error: string }).error).toBe("unknown_module");
470
+
471
+ const badVault = await postCredential(
472
+ cookie,
473
+ { credential: { module: "surface", key: "vault", vault: "ghost" } },
474
+ deps,
475
+ );
476
+ expect(badVault.status).toBe(400);
477
+ expect(((await badVault.json()) as { error: string }).error).toBe("unknown_vault");
478
+ });
479
+
480
+ test("operator gate still applies to credential creation (no session → 401)", async () => {
481
+ const { fetchImpl } = mockFetch({});
482
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
483
+ const res = await handleConnections(
484
+ new Request("http://127.0.0.1/admin/connections", {
485
+ method: "POST",
486
+ headers: { "content-type": "application/json" },
487
+ body: JSON.stringify({
488
+ kind: "credential",
489
+ credential: { module: "surface", key: "vault", vault: "default" },
490
+ }),
491
+ }),
492
+ "",
493
+ deps,
494
+ );
495
+ expect(res.status).toBe(401);
496
+ });
497
+ });
498
+
499
+ // ===========================================================================
500
+ // Renewal
501
+ // ===========================================================================
502
+
503
+ describe("credential connection — renewal (proof of possession)", () => {
504
+ async function provision(
505
+ deps: ConnectionsDeps,
506
+ calls: RecordedReq[],
507
+ tags: string[] = ["boulder"],
508
+ ): Promise<DeliveredCredential & { cookie: string }> {
509
+ const { cookie } = await adminCookie();
510
+ const res = await postCredential(
511
+ cookie,
512
+ { credential: { module: "surface", key: "vault", vault: "default", tags } },
513
+ deps,
514
+ );
515
+ expect(res.status).toBe(200);
516
+ const payload = calls.find((c) => c.url === `${SURFACE_ORIGIN}/api/credential`)!
517
+ .body as DeliveredCredential;
518
+ return { ...payload, cookie };
519
+ }
520
+
521
+ test("happy path: current credential as Bearer → new token (same scope/tags), old jti revoked, record updated", async () => {
522
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
523
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
524
+ const cred = await provision(deps, calls);
525
+
526
+ const res = await handleConnections(
527
+ renewReq(cred.connection_id, cred.token),
528
+ `/${cred.connection_id}/renew`,
529
+ deps,
530
+ );
531
+ expect(res.status).toBe(200);
532
+ const out = (await res.json()) as { ok: boolean; credential: DeliveredCredential };
533
+ expect(out.ok).toBe(true);
534
+ expect(out.credential.op).toBe("renewed");
535
+ expect(out.credential.scope).toBe("vault:default:read");
536
+ expect(out.credential.scoped_tags).toEqual(["boulder"]);
537
+ expect(out.credential.jti).not.toBe(cred.jti);
538
+
539
+ // Same claims shape on the re-mint.
540
+ const claims = decodeJwt(out.credential.token!) as {
541
+ scope?: string;
542
+ permissions?: { scoped_tags?: string[] };
543
+ };
544
+ expect(claims.scope).toBe("vault:default:read");
545
+ expect(claims.permissions?.scoped_tags).toEqual(["boulder"]);
546
+
547
+ // Old jti revoked + on the revocation list; new jti registered + live.
548
+ expect(findTokenRowByJti(harness.db, cred.jti!)!.revokedAt).not.toBeNull();
549
+ expect(listActiveRevocations(harness.db, new Date())).toContain(cred.jti!);
550
+ const newRow = findTokenRowByJti(harness.db, out.credential.jti!);
551
+ expect(newRow!.revokedAt).toBeNull();
552
+ expect(newRow!.createdVia).toBe("connection_credential");
553
+
554
+ // Record now names ONLY the new jti.
555
+ const rec = readConnections(harness.storePath)[0]!;
556
+ expect(rec.provisioned.mintedJtis).toEqual([out.credential.jti!]);
557
+ });
558
+
559
+ test("renewed credential can renew again (the chain extends)", async () => {
560
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
561
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
562
+ const cred = await provision(deps, calls);
563
+
564
+ const first = await handleConnections(
565
+ renewReq(cred.connection_id, cred.token),
566
+ `/${cred.connection_id}/renew`,
567
+ deps,
568
+ );
569
+ const out1 = (await first.json()) as { credential: DeliveredCredential };
570
+ const second = await handleConnections(
571
+ renewReq(cred.connection_id, out1.credential.token),
572
+ `/${cred.connection_id}/renew`,
573
+ deps,
574
+ );
575
+ expect(second.status).toBe(200);
576
+ });
577
+
578
+ test("EXPIRED credential cannot renew itself — 401 names the operator path", async () => {
579
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
580
+ // Mint in the past so the 90d credential is already expired "now".
581
+ const past = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000);
582
+ const deps = { ...credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST)), now: () => past };
583
+ const cred = await provision(deps, calls);
584
+
585
+ const liveDeps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
586
+ const res = await handleConnections(
587
+ renewReq(cred.connection_id, cred.token),
588
+ `/${cred.connection_id}/renew`,
589
+ liveDeps,
590
+ );
591
+ expect(res.status).toBe(401);
592
+ const body = (await res.json()) as { error: string; error_description: string };
593
+ expect(body.error).toBe("invalid_credential");
594
+ expect(body.error_description).toContain("operator");
595
+ });
596
+
597
+ test("a DIFFERENT valid hub token (not this connection's jti) is refused — 403", async () => {
598
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
599
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
600
+ const cred = await provision(deps, calls);
601
+
602
+ // Provision a SECOND credential connection (different vault key) and try
603
+ // to renew the first with the second's token.
604
+ const res2 = await postCredential(
605
+ cred.cookie,
606
+ {
607
+ credential: { module: "surface", key: "vault-write", vault: "default", tags: ["w"] },
608
+ },
609
+ deps,
610
+ );
611
+ expect(res2.status).toBe(200);
612
+ const otherCred = calls
613
+ .filter((c) => c.url === `${SURFACE_ORIGIN}/api/credential`)
614
+ .map((c) => c.body as DeliveredCredential)
615
+ .find((p) => p.connection_id !== cred.connection_id)!;
616
+
617
+ const res = await handleConnections(
618
+ renewReq(cred.connection_id, otherCred.token),
619
+ `/${cred.connection_id}/renew`,
620
+ deps,
621
+ );
622
+ expect(res.status).toBe(403);
623
+ expect(((await res.json()) as { error: string }).error).toBe("not_credential_holder");
624
+ });
625
+
626
+ test("REVOKED credential cannot renew (revocation enforced by validateAccessToken)", async () => {
627
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
628
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
629
+ const cred = await provision(deps, calls);
630
+
631
+ // Renew once (revokes the original), then replay the ORIGINAL token.
632
+ await handleConnections(
633
+ renewReq(cred.connection_id, cred.token),
634
+ `/${cred.connection_id}/renew`,
635
+ deps,
636
+ );
637
+ const replay = await handleConnections(
638
+ renewReq(cred.connection_id, cred.token),
639
+ `/${cred.connection_id}/renew`,
640
+ deps,
641
+ );
642
+ expect(replay.status).toBe(401);
643
+ });
644
+
645
+ test("renew without a Bearer → 401; unknown connection id → 404", async () => {
646
+ const { fetchImpl } = mockFetch({});
647
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
648
+ const noBearer = await handleConnections(renewReq("cred-x"), "/cred-x/renew", deps);
649
+ expect(noBearer.status).toBe(404); // no such connection (checked first)
650
+
651
+ const unknown = await handleConnections(renewReq("ghost"), "/ghost/renew", deps);
652
+ expect(unknown.status).toBe(404);
653
+ });
654
+ });
655
+
656
+ // ===========================================================================
657
+ // Teardown
658
+ // ===========================================================================
659
+
660
+ describe("credential connection — teardown", () => {
661
+ test("DELETE revokes the credential jti and best-effort notifies the module endpoint", async () => {
662
+ const { cookie } = await adminCookie();
663
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
664
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
665
+
666
+ await postCredential(
667
+ cookie,
668
+ { credential: { module: "surface", key: "vault", vault: "default", tags: ["t"] } },
669
+ deps,
670
+ );
671
+ const cred = calls.find((c) => c.url === `${SURFACE_ORIGIN}/api/credential`)!
672
+ .body as DeliveredCredential;
673
+
674
+ const res = await handleConnections(
675
+ new Request(`http://127.0.0.1/admin/connections/${cred.connection_id}`, {
676
+ method: "DELETE",
677
+ headers: { cookie },
678
+ }),
679
+ `/${cred.connection_id}`,
680
+ deps,
681
+ );
682
+ expect(res.status).toBe(200);
683
+
684
+ // jti revoked (the authoritative kill).
685
+ expect(findTokenRowByJti(harness.db, cred.jti!)!.revokedAt).not.toBeNull();
686
+ // Removal payload POSTed to the declared endpoint.
687
+ const removal = calls
688
+ .filter((c) => c.url === `${SURFACE_ORIGIN}/api/credential`)
689
+ .map((c) => c.body as DeliveredCredential)
690
+ .find((p) => p.op === "removed");
691
+ expect(removal).toBeDefined();
692
+ expect(removal!.connection_id).toBe(cred.connection_id);
693
+ expect(removal!.token).toBeUndefined(); // removal carries no secret
694
+ // Record gone.
695
+ expect(readConnections(harness.storePath).length).toBe(0);
696
+ });
697
+
698
+ test("notification failure is best-effort: revocation + record removal still land (207)", async () => {
699
+ const { cookie } = await adminCookie();
700
+ let deliveries = 0;
701
+ const { fetchImpl, calls } = mockFetch({
702
+ "POST /api/credential": () => {
703
+ deliveries++;
704
+ // First call (provision delivery) succeeds; the removal notify fails.
705
+ return deliveries === 1 ? ok({ ok: true }) : new Response("down", { status: 500 });
706
+ },
707
+ });
708
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
709
+
710
+ await postCredential(
711
+ cookie,
712
+ { credential: { module: "surface", key: "vault", vault: "default", tags: ["t"] } },
713
+ deps,
714
+ );
715
+ const cred = calls.find((c) => c.url === `${SURFACE_ORIGIN}/api/credential`)!
716
+ .body as DeliveredCredential;
717
+
718
+ const res = await handleConnections(
719
+ new Request(`http://127.0.0.1/admin/connections/${cred.connection_id}`, {
720
+ method: "DELETE",
721
+ headers: { cookie },
722
+ }),
723
+ `/${cred.connection_id}`,
724
+ deps,
725
+ );
726
+ expect(res.status).toBe(207);
727
+ const body = (await res.json()) as { errors: { step: string }[] };
728
+ expect(body.errors.some((e) => e.step === "credential_notify")).toBe(true);
729
+ expect(findTokenRowByJti(harness.db, cred.jti!)!.revokedAt).not.toBeNull();
730
+ expect(readConnections(harness.storePath).length).toBe(0);
731
+ });
732
+ });
733
+
734
+ // ===========================================================================
735
+ // Catalog
736
+ // ===========================================================================
737
+
738
+ describe("credential connection — catalog", () => {
739
+ test("buildCatalog carries credential declarations (metadata only, no secrets)", () => {
740
+ const catalog = buildCatalog(modulesOf(SURFACE_MANIFEST));
741
+ expect(catalog.credentials).toEqual([
742
+ {
743
+ module: "surface",
744
+ key: "vault",
745
+ title: "Standing vault credential",
746
+ description: "Tag-scoped read credential for a backed surface.",
747
+ scope: "vault:{vault}:read",
748
+ endpoint: "/api/credential",
749
+ },
750
+ {
751
+ module: "surface",
752
+ key: "vault-write",
753
+ title: "Standing vault write credential",
754
+ description: null,
755
+ scope: "vault:{vault}:write",
756
+ endpoint: "/api/credential",
757
+ },
758
+ ]);
759
+ });
760
+
761
+ test("validator-produced manifests round-trip credentials (the real read path)", () => {
762
+ const validated = validateModuleManifest(
763
+ {
764
+ name: "surface",
765
+ manifestName: "parachute-surface",
766
+ port: 1946,
767
+ paths: ["/surface"],
768
+ health: "/healthz",
769
+ credentials: [
770
+ {
771
+ key: "vault",
772
+ title: "Standing vault credential",
773
+ scope: "vault:{vault}:read",
774
+ endpoint: "/api/credential",
775
+ },
776
+ ],
777
+ },
778
+ "test",
779
+ );
780
+ expect(validated.credentials?.length).toBe(1);
781
+ expect(validated.credentials?.[0]?.scope).toBe("vault:{vault}:read");
782
+ });
783
+ });
784
+
785
+ // ===========================================================================
786
+ // Claim / reconcile (surface#113)
787
+ // ===========================================================================
788
+
789
+ /**
790
+ * The live-incident shape (surface#113): a credential minted via the CLI
791
+ * (`parachute auth mint-token` → created_via "cli_mint") and POSTed straight
792
+ * to the module's delivery endpoint. Valid, REGISTERED, held by the module —
793
+ * but no ConnectionRecord exists, so jti-bound renewal 404s.
794
+ */
795
+ const DIRECT_TTL_SECONDS = 90 * 24 * 60 * 60;
796
+
797
+ async function mintDirectDelivered(opts: {
798
+ vault: string;
799
+ verb: "read" | "write";
800
+ tags?: string[];
801
+ /** Skip the tokens-registry row (the unregistered-jti refusal case). */
802
+ register?: boolean;
803
+ /** Mint the CLI shape: vault_scope [] — the pin rides scope + aud only
804
+ * (the live surface#113 credentials; see the claim's vault_scope note). */
805
+ emptyVaultScope?: boolean;
806
+ now?: () => Date;
807
+ }): Promise<{ token: string; jti: string }> {
808
+ const scope = `vault:${opts.vault}:${opts.verb}`;
809
+ const tags = opts.tags ?? [];
810
+ const signed = await signAccessToken(harness.db, {
811
+ sub: "operator-user",
812
+ scopes: [scope],
813
+ audience: `vault.${opts.vault}`,
814
+ clientId: "parachute-hub",
815
+ issuer: HUB_ORIGIN,
816
+ ttlSeconds: DIRECT_TTL_SECONDS,
817
+ vaultScope: opts.emptyVaultScope ? [] : [opts.vault],
818
+ ...(tags.length > 0 ? { extraClaims: { permissions: { scoped_tags: tags } } } : {}),
819
+ ...(opts.now ? { now: opts.now } : {}),
820
+ });
821
+ if (opts.register !== false) {
822
+ recordTokenMint(harness.db, {
823
+ jti: signed.jti,
824
+ createdVia: "cli_mint",
825
+ subject: "operator",
826
+ clientId: "parachute-hub",
827
+ scopes: [scope],
828
+ expiresAt: signed.expiresAt,
829
+ ...(tags.length > 0 ? { permissions: JSON.stringify({ scoped_tags: tags }) } : {}),
830
+ ...(opts.now ? { now: opts.now } : {}),
831
+ });
832
+ }
833
+ return { token: signed.token, jti: signed.jti };
834
+ }
835
+
836
+ function claimReq(id: string, body: Record<string, unknown>, bearer?: string): Request {
837
+ return new Request(`http://127.0.0.1/admin/connections/${id}/claim`, {
838
+ method: "POST",
839
+ headers: {
840
+ "content-type": "application/json",
841
+ ...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
842
+ },
843
+ body: JSON.stringify(body),
844
+ });
845
+ }
846
+
847
+ function approveReq(id: string, cookie?: string): Request {
848
+ return new Request(`http://127.0.0.1/admin/connections/${id}/approve`, {
849
+ method: "POST",
850
+ headers: cookie ? { cookie } : {},
851
+ });
852
+ }
853
+
854
+ const SURFACE_CLAIM = { module: "surface", key: "vault", vault: "default" };
855
+ const CLAIM_ID = "cred-surface-vault-default";
856
+
857
+ describe("credential connection — claim/reconcile (surface#113)", () => {
858
+ test("headline: directly-delivered credential has no record → renewal 404s (the pre-fix bug); claim → pending → approve → renewal succeeds", async () => {
859
+ const { fetchImpl } = mockFetch({});
860
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
861
+ const direct = await mintDirectDelivered({ vault: "default", verb: "read", tags: ["boulder"] });
862
+
863
+ // PRE-FIX pin (extends the "unknown connection id → 404" pin above): the
864
+ // credential is valid + registered, but with no hub-side record the
865
+ // jti-bound renewal 404s — exactly the surface#113 live failure.
866
+ const before = await handleConnections(
867
+ renewReq(CLAIM_ID, direct.token),
868
+ `/${CLAIM_ID}/renew`,
869
+ deps,
870
+ );
871
+ expect(before.status).toBe(404);
872
+
873
+ // Claim by proof of possession → 202 + a PENDING record derived from the
874
+ // SIGNED token (scope/tags) + the module's declaration (endpoint).
875
+ const claim = await handleConnections(
876
+ claimReq(CLAIM_ID, SURFACE_CLAIM, direct.token),
877
+ `/${CLAIM_ID}/claim`,
878
+ deps,
879
+ );
880
+ expect(claim.status).toBe(202);
881
+ const claimBody = (await claim.json()) as {
882
+ ok: boolean;
883
+ status: string;
884
+ connection_id: string;
885
+ };
886
+ expect(claimBody.ok).toBe(true);
887
+ expect(claimBody.status).toBe("pending");
888
+ expect(claimBody.connection_id).toBe(CLAIM_ID);
889
+
890
+ const rec = readConnections(harness.storePath)[0]!;
891
+ expect(rec.status).toBe("pending");
892
+ expect(rec.kind).toBe("credential");
893
+ expect(rec.provisioned.mintedJtis).toEqual([direct.jti]);
894
+ expect(rec.provisioned.scope).toBe("vault:default:read");
895
+ expect(rec.provisioned.scopedTags).toEqual(["boulder"]);
896
+ expect(rec.provisioned.credentialKey).toBe("vault");
897
+ expect(rec.provisioned.endpoint).toBe("/api/credential");
898
+ expect(rec.requestedBy).toBe("surface");
899
+
900
+ // A claim grants nothing: renewal still refused while pending (the
901
+ // pending state is revealed only after the possession proof).
902
+ const pendingRenew = await handleConnections(
903
+ renewReq(CLAIM_ID, direct.token),
904
+ `/${CLAIM_ID}/renew`,
905
+ deps,
906
+ );
907
+ expect(pendingRenew.status).toBe(403);
908
+ expect(((await pendingRenew.json()) as { error: string }).error).toBe("pending_approval");
909
+
910
+ // Approval is operator-gated: no session → 401, record stays pending.
911
+ const noSession = await handleConnections(approveReq(CLAIM_ID), `/${CLAIM_ID}/approve`, deps);
912
+ expect(noSession.status).toBe(401);
913
+ expect(readConnections(harness.storePath)[0]!.status).toBe("pending");
914
+
915
+ const { cookie } = await adminCookie();
916
+ const approved = await handleConnections(
917
+ approveReq(CLAIM_ID, cookie),
918
+ `/${CLAIM_ID}/approve`,
919
+ deps,
920
+ );
921
+ expect(approved.status).toBe(200);
922
+ expect(((await approved.json()) as { status: string }).status).toBe("active");
923
+ expect(readConnections(harness.storePath)[0]!.status).toBeUndefined();
924
+
925
+ // Renewal now proceeds through the UNCHANGED flow: same scope + tags
926
+ // re-minted, old jti revoked, record updated.
927
+ const renew = await handleConnections(
928
+ renewReq(CLAIM_ID, direct.token),
929
+ `/${CLAIM_ID}/renew`,
930
+ deps,
931
+ );
932
+ expect(renew.status).toBe(200);
933
+ const out = (await renew.json()) as { ok: boolean; credential: DeliveredCredential };
934
+ expect(out.credential.op).toBe("renewed");
935
+ expect(out.credential.scope).toBe("vault:default:read");
936
+ expect(out.credential.scoped_tags).toEqual(["boulder"]);
937
+ expect(out.credential.jti).not.toBe(direct.jti);
938
+ expect(findTokenRowByJti(harness.db, direct.jti)!.revokedAt).not.toBeNull();
939
+ expect(readConnections(harness.storePath)[0]!.provisioned.mintedJtis).toEqual([
940
+ out.credential.jti!,
941
+ ]);
942
+ });
943
+
944
+ test("the live docs shape: an UNTAGGED write credential is claimable verbatim (create's write-requires-tags guards NEW grants; the operator's approve sanctions the existing shape)", async () => {
945
+ const { fetchImpl } = mockFetch({});
946
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
947
+ const id = "cred-surface-vault-write-default";
948
+ const direct = await mintDirectDelivered({ vault: "default", verb: "write" });
949
+
950
+ const claim = await handleConnections(
951
+ claimReq(id, { module: "surface", key: "vault-write", vault: "default" }, direct.token),
952
+ `/${id}/claim`,
953
+ deps,
954
+ );
955
+ expect(claim.status).toBe(202);
956
+ const rec = readConnections(harness.storePath)[0]!;
957
+ expect(rec.provisioned.scope).toBe("vault:default:write");
958
+ expect(rec.provisioned.scopedTags).toEqual([]);
959
+
960
+ const { cookie } = await adminCookie();
961
+ await handleConnections(approveReq(id, cookie), `/${id}/approve`, deps);
962
+ const renew = await handleConnections(renewReq(id, direct.token), `/${id}/renew`, deps);
963
+ expect(renew.status).toBe(200);
964
+ const out = (await renew.json()) as { credential: DeliveredCredential };
965
+ expect(out.credential.scope).toBe("vault:default:write");
966
+ expect(out.credential.scoped_tags).toEqual([]);
967
+ const claims = decodeJwt(out.credential.token!) as { scope?: string; permissions?: unknown };
968
+ expect(claims.scope).toBe("vault:default:write");
969
+ expect(claims.permissions).toBeUndefined();
970
+ });
971
+
972
+ test("refusals are GENERIC and identical across causes — no oracle on registry contents", async () => {
973
+ const { fetchImpl } = mockFetch({});
974
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
975
+ const readCred = await mintDirectDelivered({ vault: "default", verb: "read" });
976
+ const unregistered = await mintDirectDelivered({
977
+ vault: "default",
978
+ verb: "read",
979
+ register: false,
980
+ });
981
+
982
+ const cases: { name: string; req: Request; subPath: string }[] = [
983
+ {
984
+ name: "unregistered jti",
985
+ req: claimReq(CLAIM_ID, SURFACE_CLAIM, unregistered.token),
986
+ subPath: `/${CLAIM_ID}/claim`,
987
+ },
988
+ {
989
+ name: "verb/scope mismatch (read token against the write key)",
990
+ req: claimReq(
991
+ "cred-surface-vault-write-default",
992
+ { module: "surface", key: "vault-write", vault: "default" },
993
+ readCred.token,
994
+ ),
995
+ subPath: "/cred-surface-vault-write-default/claim",
996
+ },
997
+ {
998
+ name: "id does not match module/key/vault",
999
+ req: claimReq("cred-surface-vault-other", SURFACE_CLAIM, readCred.token),
1000
+ subPath: "/cred-surface-vault-other/claim",
1001
+ },
1002
+ {
1003
+ name: "unknown module",
1004
+ req: claimReq(
1005
+ "cred-ghost-vault-default",
1006
+ { ...SURFACE_CLAIM, module: "ghost" },
1007
+ readCred.token,
1008
+ ),
1009
+ subPath: "/cred-ghost-vault-default/claim",
1010
+ },
1011
+ {
1012
+ name: "undeclared credential key",
1013
+ req: claimReq(
1014
+ "cred-surface-nope-default",
1015
+ { ...SURFACE_CLAIM, key: "nope" },
1016
+ readCred.token,
1017
+ ),
1018
+ subPath: "/cred-surface-nope-default/claim",
1019
+ },
1020
+ {
1021
+ name: "unknown vault",
1022
+ req: claimReq(
1023
+ "cred-surface-vault-ghost",
1024
+ { ...SURFACE_CLAIM, vault: "ghost" },
1025
+ readCred.token,
1026
+ ),
1027
+ subPath: "/cred-surface-vault-ghost/claim",
1028
+ },
1029
+ ];
1030
+
1031
+ const bodies: string[] = [];
1032
+ for (const c of cases) {
1033
+ const res = await handleConnections(c.req, c.subPath, deps);
1034
+ expect(res.status).toBe(403);
1035
+ bodies.push(await res.text());
1036
+ }
1037
+ // One indistinguishable refusal across every cause.
1038
+ expect(new Set(bodies).size).toBe(1);
1039
+ expect(JSON.parse(bodies[0]!)).toEqual({
1040
+ error: "claim_rejected",
1041
+ error_description: "the presented credential does not match a claimable connection",
1042
+ });
1043
+ // And no record was ever written.
1044
+ expect(readConnections(harness.storePath).length).toBe(0);
1045
+ });
1046
+
1047
+ test("expired or revoked credentials cannot be claimed — 401, no record written (re-link is the path)", async () => {
1048
+ const { fetchImpl } = mockFetch({});
1049
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
1050
+
1051
+ const past = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000);
1052
+ const expired = await mintDirectDelivered({ vault: "default", verb: "read", now: () => past });
1053
+ const expiredRes = await handleConnections(
1054
+ claimReq(CLAIM_ID, SURFACE_CLAIM, expired.token),
1055
+ `/${CLAIM_ID}/claim`,
1056
+ deps,
1057
+ );
1058
+ expect(expiredRes.status).toBe(401);
1059
+ expect(((await expiredRes.json()) as { error: string }).error).toBe("invalid_credential");
1060
+
1061
+ const revoked = await mintDirectDelivered({ vault: "default", verb: "read" });
1062
+ revokeTokenByJti(harness.db, revoked.jti, new Date());
1063
+ const revokedRes = await handleConnections(
1064
+ claimReq(CLAIM_ID, SURFACE_CLAIM, revoked.token),
1065
+ `/${CLAIM_ID}/claim`,
1066
+ deps,
1067
+ );
1068
+ expect(revokedRes.status).toBe(401);
1069
+
1070
+ const noBearer = await handleConnections(
1071
+ claimReq(CLAIM_ID, SURFACE_CLAIM),
1072
+ `/${CLAIM_ID}/claim`,
1073
+ deps,
1074
+ );
1075
+ expect(noBearer.status).toBe(401);
1076
+
1077
+ expect(readConnections(harness.storePath).length).toBe(0);
1078
+ });
1079
+
1080
+ test("idempotent re-claim → same single pending record, no dupes; a different valid credential supersedes the unapproved claim", async () => {
1081
+ const { fetchImpl } = mockFetch({});
1082
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
1083
+ const a = await mintDirectDelivered({ vault: "default", verb: "read", tags: ["x"] });
1084
+
1085
+ const first = await handleConnections(
1086
+ claimReq(CLAIM_ID, SURFACE_CLAIM, a.token),
1087
+ `/${CLAIM_ID}/claim`,
1088
+ deps,
1089
+ );
1090
+ expect(first.status).toBe(202);
1091
+ const again = await handleConnections(
1092
+ claimReq(CLAIM_ID, SURFACE_CLAIM, a.token),
1093
+ `/${CLAIM_ID}/claim`,
1094
+ deps,
1095
+ );
1096
+ expect(again.status).toBe(202);
1097
+ let records = readConnections(harness.storePath);
1098
+ expect(records.length).toBe(1);
1099
+ expect(records[0]!.provisioned.mintedJtis).toEqual([a.jti]);
1100
+
1101
+ // PENDING grants nothing, so a second fully-validated credential may
1102
+ // supersede it (last writer wins until the operator approves).
1103
+ const b = await mintDirectDelivered({ vault: "default", verb: "read", tags: ["y"] });
1104
+ const supersede = await handleConnections(
1105
+ claimReq(CLAIM_ID, SURFACE_CLAIM, b.token),
1106
+ `/${CLAIM_ID}/claim`,
1107
+ deps,
1108
+ );
1109
+ expect(supersede.status).toBe(202);
1110
+ records = readConnections(harness.storePath);
1111
+ expect(records.length).toBe(1);
1112
+ expect(records[0]!.status).toBe("pending");
1113
+ expect(records[0]!.provisioned.mintedJtis).toEqual([b.jti]);
1114
+ expect(records[0]!.provisioned.scopedTags).toEqual(["y"]);
1115
+ // The displaced jti is orphaned (can no longer renew — the record names
1116
+ // only b) but NOT revoked: a's holder keeps a valid token. Pinned so a
1117
+ // future "cleanup" doesn't add revocation here and punish the holder.
1118
+ expect(findTokenRowByJti(harness.db, a.jti)!.revokedAt).toBeNull();
1119
+ });
1120
+
1121
+ test("CLI-shape claim: vault_scope [] accepted when scope+aud pin the vault (the live surface#113 credentials)", async () => {
1122
+ const { fetchImpl } = mockFetch({});
1123
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
1124
+ const cli = await mintDirectDelivered({ vault: "default", verb: "read", emptyVaultScope: true });
1125
+ const res = await handleConnections(
1126
+ claimReq(CLAIM_ID, SURFACE_CLAIM, cli.token),
1127
+ `/${CLAIM_ID}/claim`,
1128
+ deps,
1129
+ );
1130
+ expect(res.status).toBe(202);
1131
+ const records = readConnections(harness.storePath);
1132
+ expect(records.length).toBe(1);
1133
+ expect(records[0]!.status).toBe("pending");
1134
+ });
1135
+
1136
+ test("a NON-empty vault_scope that omits the claimed vault is still refused (pinned elsewhere)", async () => {
1137
+ const { fetchImpl } = mockFetch({});
1138
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
1139
+ // Same scope/aud text would never pass for another vault, so forge the
1140
+ // mismatch the only way it can occur: vault_scope pinning a different
1141
+ // vault than scope/aud (defense-in-depth pin disagreement).
1142
+ const signed = await signAccessToken(harness.db, {
1143
+ sub: "operator-user",
1144
+ scopes: ["vault:default:read"],
1145
+ audience: "vault.default",
1146
+ clientId: "parachute-hub",
1147
+ issuer: HUB_ORIGIN,
1148
+ ttlSeconds: DIRECT_TTL_SECONDS,
1149
+ vaultScope: ["boulder"],
1150
+ });
1151
+ recordTokenMint(harness.db, {
1152
+ jti: signed.jti,
1153
+ createdVia: "cli_mint",
1154
+ subject: "operator",
1155
+ clientId: "parachute-hub",
1156
+ scopes: ["vault:default:read"],
1157
+ expiresAt: signed.expiresAt,
1158
+ });
1159
+ const res = await handleConnections(
1160
+ claimReq(CLAIM_ID, SURFACE_CLAIM, signed.token),
1161
+ `/${CLAIM_ID}/claim`,
1162
+ deps,
1163
+ );
1164
+ expect(res.status).toBe(403);
1165
+ expect(readConnections(harness.storePath).length).toBe(0);
1166
+ });
1167
+
1168
+ test("no mutation without approval: a pending renewal attempt mints nothing, revokes nothing, rewrites nothing", async () => {
1169
+ const { fetchImpl } = mockFetch({});
1170
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
1171
+ const direct = await mintDirectDelivered({ vault: "default", verb: "read", tags: ["t"] });
1172
+ await handleConnections(
1173
+ claimReq(CLAIM_ID, SURFACE_CLAIM, direct.token),
1174
+ `/${CLAIM_ID}/claim`,
1175
+ deps,
1176
+ );
1177
+
1178
+ const tokenCount = () =>
1179
+ (harness.db.query("SELECT COUNT(*) AS c FROM tokens").get() as { c: number }).c;
1180
+ const before = tokenCount();
1181
+ const recordBefore = JSON.stringify(readConnections(harness.storePath));
1182
+
1183
+ const renew = await handleConnections(
1184
+ renewReq(CLAIM_ID, direct.token),
1185
+ `/${CLAIM_ID}/renew`,
1186
+ deps,
1187
+ );
1188
+ expect(renew.status).toBe(403);
1189
+
1190
+ expect(tokenCount()).toBe(before); // no new mint registered
1191
+ expect(findTokenRowByJti(harness.db, direct.jti)!.revokedAt).toBeNull(); // not revoked
1192
+ expect(JSON.stringify(readConnections(harness.storePath))).toBe(recordBefore); // record untouched
1193
+ });
1194
+
1195
+ test("claiming an ACTIVE connection: the current holder learns 'active'; any other credential is refused and the record untouched", async () => {
1196
+ const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
1197
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
1198
+ const { cookie } = await adminCookie();
1199
+ const res = await postCredential(
1200
+ cookie,
1201
+ { credential: { module: "surface", key: "vault", vault: "default", tags: ["t"] } },
1202
+ deps,
1203
+ );
1204
+ expect(res.status).toBe(200);
1205
+ const cred = calls.find((c) => c.url === `${SURFACE_ORIGIN}/api/credential`)!
1206
+ .body as DeliveredCredential;
1207
+
1208
+ // Current holder re-claims → "active", record unchanged (no pending flip).
1209
+ const holder = await handleConnections(
1210
+ claimReq(CLAIM_ID, SURFACE_CLAIM, cred.token),
1211
+ `/${CLAIM_ID}/claim`,
1212
+ deps,
1213
+ );
1214
+ expect(holder.status).toBe(200);
1215
+ expect(((await holder.json()) as { status: string }).status).toBe("active");
1216
+ expect(readConnections(harness.storePath)[0]!.status).toBeUndefined();
1217
+
1218
+ // A DIFFERENT valid registered credential cannot claim over it.
1219
+ const other = await mintDirectDelivered({ vault: "default", verb: "read", tags: ["t"] });
1220
+ const refused = await handleConnections(
1221
+ claimReq(CLAIM_ID, SURFACE_CLAIM, other.token),
1222
+ `/${CLAIM_ID}/claim`,
1223
+ deps,
1224
+ );
1225
+ expect(refused.status).toBe(403);
1226
+ expect(((await refused.json()) as { error: string }).error).toBe("claim_rejected");
1227
+ expect(readConnections(harness.storePath)[0]!.provisioned.mintedJtis).toEqual([cred.jti!]);
1228
+ });
1229
+
1230
+ test("claim against an existing event→action connection id is refused generically", async () => {
1231
+ const { fetchImpl } = mockFetch({});
1232
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
1233
+ putConnection(harness.storePath, {
1234
+ id: CLAIM_ID,
1235
+ source: { module: "vault", vault: "default", event: "note.created" },
1236
+ sink: { module: "channel", action: "message.deliver" },
1237
+ provisioned: { type: "vault-trigger", vault: "default", triggerName: "t", mintedJtis: [] },
1238
+ createdAt: new Date().toISOString(),
1239
+ });
1240
+ const direct = await mintDirectDelivered({ vault: "default", verb: "read" });
1241
+ const res = await handleConnections(
1242
+ claimReq(CLAIM_ID, SURFACE_CLAIM, direct.token),
1243
+ `/${CLAIM_ID}/claim`,
1244
+ deps,
1245
+ );
1246
+ expect(res.status).toBe(403);
1247
+ expect(((await res.json()) as { error: string }).error).toBe("claim_rejected");
1248
+ // The event→action record is untouched.
1249
+ expect(readConnections(harness.storePath)[0]!.kind).toBeUndefined();
1250
+ });
1251
+
1252
+ test("approve: unknown id → 404; non-credential record → 400; re-approve is idempotent", async () => {
1253
+ const { fetchImpl } = mockFetch({});
1254
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
1255
+ const { cookie } = await adminCookie();
1256
+
1257
+ const unknown = await handleConnections(approveReq("ghost", cookie), "/ghost/approve", deps);
1258
+ expect(unknown.status).toBe(404);
1259
+
1260
+ putConnection(harness.storePath, {
1261
+ id: "ev-conn",
1262
+ source: { module: "vault", vault: "default", event: "note.created" },
1263
+ sink: { module: "channel", action: "message.deliver" },
1264
+ provisioned: { type: "vault-trigger", vault: "default", triggerName: "t", mintedJtis: [] },
1265
+ createdAt: new Date().toISOString(),
1266
+ });
1267
+ const nonCred = await handleConnections(
1268
+ approveReq("ev-conn", cookie),
1269
+ "/ev-conn/approve",
1270
+ deps,
1271
+ );
1272
+ expect(nonCred.status).toBe(400);
1273
+
1274
+ const direct = await mintDirectDelivered({ vault: "default", verb: "read" });
1275
+ await handleConnections(
1276
+ claimReq(CLAIM_ID, SURFACE_CLAIM, direct.token),
1277
+ `/${CLAIM_ID}/claim`,
1278
+ deps,
1279
+ );
1280
+ const once = await handleConnections(
1281
+ approveReq(CLAIM_ID, cookie),
1282
+ `/${CLAIM_ID}/approve`,
1283
+ deps,
1284
+ );
1285
+ expect(once.status).toBe(200);
1286
+ const twice = await handleConnections(
1287
+ approveReq(CLAIM_ID, cookie),
1288
+ `/${CLAIM_ID}/approve`,
1289
+ deps,
1290
+ );
1291
+ expect(twice.status).toBe(200);
1292
+ expect(((await twice.json()) as { status: string }).status).toBe("active");
1293
+ });
1294
+
1295
+ test("list projects status: 'pending' on claimed records (the SPA's approve affordance)", async () => {
1296
+ const { fetchImpl } = mockFetch({});
1297
+ const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
1298
+ const { cookie } = await adminCookie();
1299
+ const direct = await mintDirectDelivered({ vault: "default", verb: "read" });
1300
+ await handleConnections(
1301
+ claimReq(CLAIM_ID, SURFACE_CLAIM, direct.token),
1302
+ `/${CLAIM_ID}/claim`,
1303
+ deps,
1304
+ );
1305
+
1306
+ const list = await handleConnections(
1307
+ new Request("http://127.0.0.1/admin/connections", { method: "GET", headers: { cookie } }),
1308
+ "",
1309
+ deps,
1310
+ );
1311
+ expect(list.status).toBe(200);
1312
+ const body = (await list.json()) as {
1313
+ connections: { id: string; kind?: string; status?: string; requested_by?: string }[];
1314
+ };
1315
+ const row = body.connections.find((c) => c.id === CLAIM_ID)!;
1316
+ expect(row.kind).toBe("credential");
1317
+ expect(row.status).toBe("pending");
1318
+ expect(row.requested_by).toBe("surface");
1319
+ });
1320
+ });