@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +51 -54
  2. package/core/src/core.test.ts +4 -1
  3. package/core/src/indexed-fields.test.ts +151 -0
  4. package/core/src/indexed-fields.ts +98 -0
  5. package/core/src/mcp.ts +66 -43
  6. package/core/src/notes.ts +26 -2
  7. package/core/src/portable-md.test.ts +52 -0
  8. package/core/src/portable-md.ts +48 -0
  9. package/core/src/schema.ts +87 -14
  10. package/core/src/store.ts +117 -0
  11. package/core/src/types.ts +28 -0
  12. package/package.json +2 -2
  13. package/src/auth-hub-jwt.test.ts +191 -11
  14. package/src/auth-status.ts +12 -5
  15. package/src/auth.test.ts +135 -219
  16. package/src/auth.ts +158 -107
  17. package/src/cli.ts +306 -224
  18. package/src/config.ts +12 -4
  19. package/src/export-watch.test.ts +23 -0
  20. package/src/export-watch.ts +14 -0
  21. package/src/git-preflight.test.ts +70 -0
  22. package/src/git-preflight.ts +68 -0
  23. package/src/hub-jwt.test.ts +27 -2
  24. package/src/hub-jwt.ts +10 -0
  25. package/src/init-summary.test.ts +4 -4
  26. package/src/init-summary.ts +36 -10
  27. package/src/mcp-config.test.ts +4 -2
  28. package/src/mcp-http.ts +24 -3
  29. package/src/mcp-install-interactive.test.ts +33 -71
  30. package/src/mcp-install-interactive.ts +23 -76
  31. package/src/mcp-install.test.ts +156 -55
  32. package/src/mcp-install.ts +109 -3
  33. package/src/mcp-tools.ts +249 -74
  34. package/src/mirror-config.test.ts +107 -0
  35. package/src/mirror-config.ts +275 -9
  36. package/src/mirror-credentials.test.ts +168 -17
  37. package/src/mirror-credentials.ts +155 -32
  38. package/src/mirror-deps.ts +25 -16
  39. package/src/mirror-import.test.ts +122 -16
  40. package/src/mirror-import.ts +50 -16
  41. package/src/mirror-manager.test.ts +51 -0
  42. package/src/mirror-manager.ts +116 -22
  43. package/src/mirror-per-vault.test.ts +519 -0
  44. package/src/mirror-registry.ts +91 -14
  45. package/src/mirror-routes.test.ts +81 -21
  46. package/src/mirror-routes.ts +90 -16
  47. package/src/routes.ts +39 -2
  48. package/src/routing.test.ts +203 -118
  49. package/src/routing.ts +46 -59
  50. package/src/scopes.test.ts +0 -86
  51. package/src/scopes.ts +9 -97
  52. package/src/server.ts +102 -34
  53. package/src/storage.test.ts +132 -7
  54. package/src/token-store.test.ts +88 -169
  55. package/src/token-store.ts +123 -249
  56. package/src/vault-create.test.ts +12 -4
  57. package/src/vault.test.ts +408 -103
  58. package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
  59. package/web/ui/dist/index.html +1 -1
  60. package/src/tokens-routes.test.ts +0 -727
  61. package/src/tokens-routes.ts +0 -392
  62. package/web/ui/dist/assets/index-Degr8snN.js +0 -60
package/src/vault.test.ts CHANGED
@@ -10,6 +10,7 @@ import { tmpdir } from "os";
10
10
  import { BunStore } from "./vault-store.ts";
11
11
  import { generateMcpTools } from "../core/src/mcp.ts";
12
12
  import { getLinksHydrated } from "../core/src/links.ts";
13
+ import { buildVaultProjection } from "../core/src/vault-projection.ts";
13
14
  import { handleNotes, handleTags, handleFindPath, handleVault } from "./routes.ts";
14
15
  import { extractApiKey } from "./auth.ts";
15
16
 
@@ -478,7 +479,7 @@ describe("deeper link queries", async () => {
478
479
  describe("MCP tools", async () => {
479
480
  test("generates the consolidated tool set", () => {
480
481
  const tools = generateMcpTools(store);
481
- expect(tools.length).toBe(9);
482
+ expect(tools.length).toBe(10);
482
483
 
483
484
  const names = tools.map((t) => t.name);
484
485
  expect(names).toContain("query-notes");
@@ -490,6 +491,8 @@ describe("MCP tools", async () => {
490
491
  expect(names).toContain("delete-tag");
491
492
  expect(names).toContain("find-path");
492
493
  expect(names).toContain("vault-info");
494
+ // prune-schema (admin) — drops orphaned indexed-field columns.
495
+ expect(names).toContain("prune-schema");
493
496
  // Six note-schema MCP tools (list/update/delete-note-schema +
494
497
  // list/set/delete-schema-mapping) retired in v17 — vault#267.
495
498
  expect(names).not.toContain("list-note-schemas");
@@ -1368,25 +1371,30 @@ describe("scoped MCP wrapper", async () => {
1368
1371
 
1369
1372
  // -- Q5: MCP delete-tag dependency check -------------------------------
1370
1373
 
1371
- test("MCP delete-tag returns tag_in_use_by_tokens when a tag-scoped token references the tag", async () => {
1374
+ test("MCP delete-tag returns tag_in_use_by_tokens when a vestigial tag-scoped token row references the tag", async () => {
1372
1375
  const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1373
1376
  const { writeVaultConfig } = await import("./config.ts");
1374
1377
  const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1375
- const { generateToken, createToken } = await import("./token-store.ts");
1376
1378
 
1377
1379
  const vaultName = `tagscope-dep-${Date.now()}`;
1378
1380
  writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1379
1381
  const store = getVaultStore(vaultName);
1380
1382
  await store.createNote("h", { tags: ["health"] });
1381
1383
 
1382
- // Mint a tag-scoped token that references "health".
1383
- const { fullToken } = generateToken();
1384
- createToken(store.db, fullToken, {
1385
- label: "health-claw",
1386
- permission: "read",
1387
- scopes: ["vault:read"],
1388
- scoped_tags: ["health"],
1389
- });
1384
+ // Seed a vestigial tag-scoped row referencing "health" (raw INSERT —
1385
+ // vault no longer mints these post-0.5.0, but findTokensReferencingTag
1386
+ // still guards the tag-delete path against leftover rows). vault#282.
1387
+ store.db
1388
+ .prepare(
1389
+ "INSERT INTO tokens (token_hash, label, permission, scoped_tags, created_at) VALUES (?, ?, ?, ?, ?)",
1390
+ )
1391
+ .run(
1392
+ `sha256:health-claw-${Math.random().toString(36).slice(2)}`,
1393
+ "health-claw",
1394
+ "read",
1395
+ JSON.stringify(["health"]),
1396
+ new Date().toISOString(),
1397
+ );
1390
1398
 
1391
1399
  // Unscoped admin attempts to delete `health` via MCP — should 409.
1392
1400
  const tools = generateScopedMcpTools(vaultName);
@@ -3622,6 +3630,78 @@ describe("HTTP /tags", async () => {
3622
3630
  expect(body.description).toBe("A person");
3623
3631
  });
3624
3632
 
3633
+ // P1 regression (vault#398 review) — the REST PUT path calls
3634
+ // store.upsertTagRecord directly. Before the lifecycle was centralized in
3635
+ // the store, PUT {fields:null} or an indexed:false toggle left the
3636
+ // generated column orphaned (the MCP path released, REST didn't). Assert
3637
+ // via the same PRAGMA table_xinfo / buildVaultProjection introspection the
3638
+ // core lifecycle tests use.
3639
+ function notesMetaCols(): string[] {
3640
+ return (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[])
3641
+ .map((r) => r.name)
3642
+ .filter((n) => n.startsWith("meta_"));
3643
+ }
3644
+
3645
+ test("PUT /tags/:name {fields:null} drops the orphaned generated column", async () => {
3646
+ // Declare an indexed field via REST PUT — column materializes.
3647
+ await handleTags(
3648
+ mkReq("PUT", "/tags/project", { fields: { status: { type: "string", indexed: true } } }),
3649
+ store,
3650
+ "/project",
3651
+ );
3652
+ expect(notesMetaCols()).toContain("meta_status");
3653
+ expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).toContain("status");
3654
+
3655
+ // Clear all fields via REST PUT {fields:null} — column must drop.
3656
+ const res = await handleTags(
3657
+ mkReq("PUT", "/tags/project", { fields: null }),
3658
+ store,
3659
+ "/project",
3660
+ );
3661
+ expect(res.status).toBe(200);
3662
+ expect(notesMetaCols()).not.toContain("meta_status");
3663
+ const idxs = (db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'").all() as { name: string }[]).map((r) => r.name);
3664
+ expect(idxs).not.toContain("idx_meta_status");
3665
+ expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("status");
3666
+ });
3667
+
3668
+ test("PUT /tags/:name indexed:false toggle drops the generated column", async () => {
3669
+ await handleTags(
3670
+ mkReq("PUT", "/tags/project", { fields: { status: { type: "string", indexed: true } } }),
3671
+ store,
3672
+ "/project",
3673
+ );
3674
+ expect(notesMetaCols()).toContain("meta_status");
3675
+
3676
+ // Re-PUT the same field with indexed:false — REST merges, so the field
3677
+ // stays in the schema but loses its index → column drops.
3678
+ const res = await handleTags(
3679
+ mkReq("PUT", "/tags/project", { fields: { status: { type: "string", indexed: false } } }),
3680
+ store,
3681
+ "/project",
3682
+ );
3683
+ expect(res.status).toBe(200);
3684
+ expect(notesMetaCols()).not.toContain("meta_status");
3685
+ expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("status");
3686
+ });
3687
+
3688
+ test("PUT /tags/:name respects the co-declaration guard (keeps column for a live co-declarer)", async () => {
3689
+ await handleTags(
3690
+ mkReq("PUT", "/tags/asset", { fields: { aspect_ratio: { type: "string", indexed: true } } }),
3691
+ store,
3692
+ "/asset",
3693
+ );
3694
+ await handleTags(
3695
+ mkReq("PUT", "/tags/storyboard", { fields: { aspect_ratio: { type: "string", indexed: true } } }),
3696
+ store,
3697
+ "/storyboard",
3698
+ );
3699
+ // Clear asset's fields — storyboard still declares aspect_ratio → keep.
3700
+ await handleTags(mkReq("PUT", "/tags/asset", { fields: null }), store, "/asset");
3701
+ expect(notesMetaCols()).toContain("meta_aspect_ratio");
3702
+ expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).toContain("aspect_ratio");
3703
+ });
3704
+
3625
3705
  test("PUT /tags/:name returns 400 with error_type: invalid_relationships on bad shape", async () => {
3626
3706
  const res = await handleTags(
3627
3707
  mkReq("PUT", "/tags/person", {
@@ -4208,13 +4288,14 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
4208
4288
  expect(names).toContain("delete-tag");
4209
4289
  });
4210
4290
 
4211
- test("vault:admin sees all 10 tools including manage-token", async () => {
4291
+ test("vault:admin sees all 11 tools including manage-token + prune-schema", async () => {
4212
4292
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
4213
4293
  expect(names).toContain("manage-token");
4214
- expect(names.length).toBe(10);
4294
+ expect(names).toContain("prune-schema");
4295
+ expect(names.length).toBe(11);
4215
4296
  });
4216
4297
 
4217
- test("legacy-derived full token sees all 10 tools (back-compat)", async () => {
4298
+ test("legacy-derived full token sees all 11 tools (back-compat)", async () => {
4218
4299
  const { handleScopedMcp } = await import("./mcp-http.ts");
4219
4300
  const { writeVaultConfig } = await import("./config.ts");
4220
4301
  const { closeAllStores } = await import("./vault-store.ts");
@@ -4238,8 +4319,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
4238
4319
  // Legacy permission-derived token: legacyDerived=true, scopes carry the
4239
4320
  // full admin set per `legacyPermissionToScopes("full")`. Compat shim
4240
4321
  // means the operator's existing pvt_* tokens minted pre-scope-column
4241
- // see the full surface (including manage-token), not just the 9 they
4242
- // had before.
4322
+ // see the full admin surface (including manage-token + prune-schema).
4243
4323
  const res = await handleScopedMcp(req, vaultName, {
4244
4324
  permission: "full",
4245
4325
  scopes: ["vault:read", "vault:write", "vault:admin"],
@@ -4248,8 +4328,9 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
4248
4328
  } as any);
4249
4329
  const body = await res.json() as any;
4250
4330
  const names: string[] = body.result.tools.map((t: any) => t.name);
4251
- expect(names.length).toBe(10);
4331
+ expect(names.length).toBe(11);
4252
4332
  expect(names).toContain("manage-token");
4333
+ expect(names).toContain("prune-schema");
4253
4334
  closeAllStores();
4254
4335
  });
4255
4336
 
@@ -4301,12 +4382,73 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
4301
4382
  // vault#376 — Change 2: manage-token mint/revoke/list
4302
4383
  // ===========================================================================
4303
4384
 
4304
- describe("manage-token MCP tool (vault#376)", () => {
4385
+ describe("manage-token MCP tool (vault#403, MGT — hub-JWT attenuation proxy)", () => {
4386
+ // A JWT-shaped bearer the session presents; the manage-token mint path only
4387
+ // forwards JWT-shaped credentials, so tests must thread one through.
4388
+ const JWT_BEARER = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1In0.sig";
4389
+
4390
+ // --- Hub fetch stub --------------------------------------------------------
4391
+ // mintAction/revokeAction call mintHubJwt/revokeHubJwt, which use global
4392
+ // fetch (no fetchImpl seam on that path). Each test installs a stub that
4393
+ // records the requests and returns a canned hub response. We capture every
4394
+ // call so assertions can inspect the URL / Authorization header / body.
4395
+ let realFetch: typeof globalThis.fetch;
4396
+ let hubCalls: Array<{ url: string; init: RequestInit | undefined; body: any }>;
4397
+ let mintSeq: number;
4398
+
4399
+ beforeEach(() => {
4400
+ realFetch = globalThis.fetch;
4401
+ hubCalls = [];
4402
+ mintSeq = 0;
4403
+ });
4404
+ afterEach(() => {
4405
+ globalThis.fetch = realFetch;
4406
+ });
4407
+
4408
+ /** Install a hub stub that mints a fresh jti per mint + idempotent revoke. */
4409
+ function installHubStub(opts?: { revokeFails?: "network" | "api-error" }) {
4410
+ globalThis.fetch = (async (input: any, init?: RequestInit) => {
4411
+ const url = String(input);
4412
+ let body: any = undefined;
4413
+ try { body = init?.body ? JSON.parse(String(init.body)) : undefined; } catch {}
4414
+ hubCalls.push({ url, init, body });
4415
+ if (url.endsWith("/api/auth/mint-token")) {
4416
+ const jti = `hub_jti_${++mintSeq}`;
4417
+ const ttl = typeof body?.expires_in === "number" ? body.expires_in : 900;
4418
+ return new Response(
4419
+ JSON.stringify({
4420
+ jti,
4421
+ token: `eyJ.minted.${jti}`,
4422
+ expires_at: new Date(Date.now() + ttl * 1000).toISOString(),
4423
+ scope: body?.scope ?? "",
4424
+ ...(body?.permissions ? { permissions: body.permissions } : {}),
4425
+ }),
4426
+ { status: 200, headers: { "Content-Type": "application/json" } },
4427
+ );
4428
+ }
4429
+ if (url.endsWith("/api/auth/revoke-token")) {
4430
+ if (opts?.revokeFails === "network") throw new Error("connection refused");
4431
+ if (opts?.revokeFails === "api-error") {
4432
+ return new Response(
4433
+ JSON.stringify({ error: "insufficient_scope", error_description: "bearer lacks parachute:host:auth" }),
4434
+ { status: 403, headers: { "Content-Type": "application/json" } },
4435
+ );
4436
+ }
4437
+ return new Response(
4438
+ JSON.stringify({ jti: body?.jti, revoked_at: new Date().toISOString() }),
4439
+ { status: 200, headers: { "Content-Type": "application/json" } },
4440
+ );
4441
+ }
4442
+ throw new Error(`unexpected fetch in test: ${url}`);
4443
+ }) as typeof globalThis.fetch;
4444
+ }
4445
+
4305
4446
  async function callTool(
4306
4447
  vaultName: string,
4307
4448
  auth: any,
4308
4449
  toolName: string,
4309
4450
  args: Record<string, unknown>,
4451
+ callerBearer: string | null = JWT_BEARER,
4310
4452
  ) {
4311
4453
  const { handleScopedMcp } = await import("./mcp-http.ts");
4312
4454
  const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
@@ -4314,6 +4456,7 @@ describe("manage-token MCP tool (vault#376)", () => {
4314
4456
  headers: {
4315
4457
  "content-type": "application/json",
4316
4458
  "accept": "application/json, text/event-stream",
4459
+ ...(callerBearer ? { authorization: `Bearer ${callerBearer}` } : {}),
4317
4460
  },
4318
4461
  body: JSON.stringify({
4319
4462
  jsonrpc: "2.0",
@@ -4322,7 +4465,7 @@ describe("manage-token MCP tool (vault#376)", () => {
4322
4465
  params: { name: toolName, arguments: args },
4323
4466
  }),
4324
4467
  });
4325
- const res = await handleScopedMcp(req, vaultName, auth);
4468
+ const res = await handleScopedMcp(req, vaultName, auth, callerBearer);
4326
4469
  const body = await res.json() as any;
4327
4470
  if (body.result?.content?.[0]?.text) {
4328
4471
  try {
@@ -4334,7 +4477,7 @@ describe("manage-token MCP tool (vault#376)", () => {
4334
4477
  return { isError: false, parsed: null, raw: body };
4335
4478
  }
4336
4479
 
4337
- async function setupAdminSession(prefix: string) {
4480
+ async function setupAdminSession(prefix: string, scopedTags: string[] | null = null) {
4338
4481
  const { writeVaultConfig } = await import("./config.ts");
4339
4482
  const vaultName = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4340
4483
  writeVaultConfig({
@@ -4342,139 +4485,179 @@ describe("manage-token MCP tool (vault#376)", () => {
4342
4485
  api_keys: [],
4343
4486
  created_at: new Date().toISOString(),
4344
4487
  });
4345
- // Stable caller_jti so list/revoke can find the mints; we don't go
4346
- // through the actual auth flow here (that would require a real pvt_
4347
- // token; the unit-level test point is the manage-token logic itself).
4488
+ // Stable caller_jti so the session-pinned ledger can attribute mints.
4489
+ // Scopes carry the resource-narrowed admin scope so validateMintedScopes +
4490
+ // the visibility filter pass for THIS vault.
4348
4491
  const auth: any = {
4349
4492
  permission: "full",
4350
- scopes: ["vault:read", "vault:write", "vault:admin"],
4493
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`, `vault:${vaultName}:admin`],
4351
4494
  legacyDerived: false,
4352
- scoped_tags: null,
4495
+ scoped_tags: scopedTags,
4353
4496
  vault_name: vaultName,
4354
4497
  caller_jti: `t_session${Math.random().toString(36).slice(2, 12)}`,
4355
4498
  };
4356
4499
  return { vaultName, auth };
4357
4500
  }
4358
4501
 
4359
- test("mint with default TTL returns valid token + jti + expires_at ~15min out", async () => {
4360
- const { vaultName, auth } = await setupAdminSession("mint-default");
4502
+ test("mint proxies to hub mint-token with caller bearer + narrowed scope + ttl", async () => {
4503
+ installHubStub();
4504
+ const { vaultName, auth } = await setupAdminSession("mint-proxy");
4361
4505
  const { closeAllStores } = await import("./vault-store.ts");
4362
- const before = Date.now();
4363
4506
  const { parsed } = await callTool(vaultName, auth, "manage-token", {
4364
4507
  action: "mint",
4365
4508
  scope: "vault:read",
4366
4509
  });
4367
4510
  expect(parsed.action).toBe("mint");
4368
- expect(parsed.token).toMatch(/^pvt_/);
4369
- expect(parsed.jti).toMatch(/^t_/);
4370
- const expiresAt = Date.parse(parsed.expires_at);
4371
- expect(expiresAt - before).toBeGreaterThan(890 * 1000);
4372
- expect(expiresAt - before).toBeLessThan(910 * 1000);
4511
+ // Token is now a hub JWT (not pvt_*); jti is hub's returned jti.
4512
+ expect(parsed.token).toMatch(/^eyJ\.minted\./);
4513
+ expect(parsed.jti).toBe("hub_jti_1");
4514
+ expect(parsed.scopes).toEqual([`vault:${vaultName}:read`]);
4515
+ expect(parsed.vault_name).toBe(vaultName);
4516
+ // One hub mint-token call carrying the caller bearer + narrowed scope.
4517
+ const mint = hubCalls.find((c) => c.url.endsWith("/api/auth/mint-token"));
4518
+ expect(mint).toBeDefined();
4519
+ const headers = new Headers(mint!.init?.headers);
4520
+ expect(headers.get("authorization")).toBe(`Bearer ${JWT_BEARER}`);
4521
+ expect(mint!.body.scope).toBe(`vault:${vaultName}:read`);
4522
+ expect(mint!.body.expires_in).toBe(900);
4523
+ expect(mint!.body.permissions).toBeUndefined(); // unscoped caller
4373
4524
  closeAllStores();
4374
4525
  });
4375
4526
 
4376
- test("mint with custom TTL=3600 returns expires_at ~1 hour out", async () => {
4527
+ test("mint with custom TTL=3600 forwards expires_in=3600", async () => {
4528
+ installHubStub();
4377
4529
  const { vaultName, auth } = await setupAdminSession("mint-max");
4378
4530
  const { closeAllStores } = await import("./vault-store.ts");
4379
- const before = Date.now();
4380
- const { parsed } = await callTool(vaultName, auth, "manage-token", {
4381
- action: "mint",
4382
- scope: "vault:read",
4383
- ttl_seconds: 3600,
4384
- });
4385
- expect(parsed.action).toBe("mint");
4386
- const expiresAt = Date.parse(parsed.expires_at);
4387
- expect(expiresAt - before).toBeGreaterThan(3590 * 1000);
4388
- expect(expiresAt - before).toBeLessThan(3610 * 1000);
4531
+ await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", ttl_seconds: 3600 });
4532
+ const mint = hubCalls.find((c) => c.url.endsWith("/api/auth/mint-token"));
4533
+ expect(mint!.body.expires_in).toBe(3600);
4389
4534
  closeAllStores();
4390
4535
  });
4391
4536
 
4392
- test("mint with TTL=0 is rejected", async () => {
4537
+ test("tag-scoped caller's mint includes permissions.scoped_tags", async () => {
4538
+ installHubStub();
4539
+ const { vaultName, auth } = await setupAdminSession("mint-scoped", ["task", "project"]);
4540
+ const { closeAllStores } = await import("./vault-store.ts");
4541
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" });
4542
+ expect(parsed.scoped_tags).toEqual(["task", "project"]);
4543
+ const mint = hubCalls.find((c) => c.url.endsWith("/api/auth/mint-token"));
4544
+ expect(mint!.body.permissions).toEqual({ scoped_tags: ["task", "project"] });
4545
+ closeAllStores();
4546
+ });
4547
+
4548
+ test("mint with TTL=0 is rejected locally (no hub call)", async () => {
4549
+ installHubStub();
4393
4550
  const { vaultName, auth } = await setupAdminSession("mint-zero");
4394
4551
  const { closeAllStores } = await import("./vault-store.ts");
4395
- const { parsed } = await callTool(vaultName, auth, "manage-token", {
4396
- action: "mint",
4397
- scope: "vault:read",
4398
- ttl_seconds: 0,
4399
- });
4552
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", ttl_seconds: 0 });
4400
4553
  expect(parsed.error).toBe("invalid_request");
4554
+ expect(hubCalls.length).toBe(0);
4401
4555
  closeAllStores();
4402
4556
  });
4403
4557
 
4404
- test("mint with TTL=3601 is rejected (over the 3600 cap)", async () => {
4558
+ test("mint with TTL=3601 is rejected locally (over the 3600 cap)", async () => {
4559
+ installHubStub();
4405
4560
  const { vaultName, auth } = await setupAdminSession("mint-over");
4406
4561
  const { closeAllStores } = await import("./vault-store.ts");
4407
- const { parsed } = await callTool(vaultName, auth, "manage-token", {
4408
- action: "mint",
4409
- scope: "vault:read",
4410
- ttl_seconds: 3601,
4411
- });
4562
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", ttl_seconds: 3601 });
4412
4563
  expect(parsed.error).toBe("invalid_request");
4564
+ expect(hubCalls.length).toBe(0);
4413
4565
  closeAllStores();
4414
4566
  });
4415
4567
 
4416
- test("mint with scope outside caller's subset is rejected", async () => {
4568
+ test("cross-vault / over-scope request is rejected locally (no hub call)", async () => {
4569
+ installHubStub();
4417
4570
  const { vaultName, auth } = await setupAdminSession("mint-subset");
4418
4571
  const { closeAllStores } = await import("./vault-store.ts");
4419
- // Caller's auth carries admin/write/read for THIS vault. Asking for a
4420
- // scope naming a different vault is the canonical privilege-escalation
4421
- // surface — must reject.
4422
4572
  const { parsed } = await callTool(vaultName, auth, "manage-token", {
4423
4573
  action: "mint",
4424
4574
  scope: "vault:other-vault:write",
4425
4575
  });
4426
4576
  expect(parsed.error).toBe("forbidden");
4427
4577
  expect(parsed.rejected).toBeDefined();
4578
+ expect(hubCalls.length).toBe(0);
4579
+ closeAllStores();
4580
+ });
4581
+
4582
+ test("non-forwardable session (no JWT bearer) → clear mint error, no hub call", async () => {
4583
+ installHubStub();
4584
+ const { vaultName, auth } = await setupAdminSession("mint-nonfwd");
4585
+ const { closeAllStores } = await import("./vault-store.ts");
4586
+ // Env-var operator path: caller_jti present but the presented credential
4587
+ // is a non-JWT secret → mint must refuse with a hub-JWT-required message.
4588
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" }, "operator-env-secret");
4589
+ expect(parsed.error).toBe("forbidden");
4590
+ expect(parsed.message).toContain("hub-JWT session");
4591
+ expect(hubCalls.length).toBe(0);
4428
4592
  closeAllStores();
4429
4593
  });
4430
4594
 
4431
- test("revoke own minted token returns ok=true; second revoke is idempotent", async () => {
4595
+ test("revoke own minted jti posts to hub revoke-token + idempotent second revoke", async () => {
4596
+ installHubStub();
4432
4597
  const { vaultName, auth } = await setupAdminSession("revoke-idem");
4433
4598
  const { closeAllStores } = await import("./vault-store.ts");
4434
- const mint = await callTool(vaultName, auth, "manage-token", {
4435
- action: "mint",
4436
- scope: "vault:read",
4437
- });
4599
+ const mint = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" });
4438
4600
  const jti = mint.parsed.jti;
4439
- const first = await callTool(vaultName, auth, "manage-token", {
4440
- action: "revoke",
4441
- jti,
4442
- });
4601
+ const first = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti });
4443
4602
  expect(first.parsed.ok).toBe(true);
4444
4603
  expect(first.parsed.already_revoked).toBe(false);
4445
- const second = await callTool(vaultName, auth, "manage-token", {
4446
- action: "revoke",
4447
- jti,
4448
- });
4604
+ // First revoke posted to hub revoke-token with the caller bearer + jti.
4605
+ const revoke = hubCalls.find((c) => c.url.endsWith("/api/auth/revoke-token"));
4606
+ expect(revoke).toBeDefined();
4607
+ expect(revoke!.body.jti).toBe(jti);
4608
+ expect(new Headers(revoke!.init?.headers).get("authorization")).toBe(`Bearer ${JWT_BEARER}`);
4609
+ // Second revoke is idempotent and does NOT re-hit hub (already revoked locally).
4610
+ const revokeCallsBefore = hubCalls.filter((c) => c.url.endsWith("/api/auth/revoke-token")).length;
4611
+ const second = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti });
4449
4612
  expect(second.parsed.ok).toBe(true);
4450
4613
  expect(second.parsed.already_revoked).toBe(true);
4614
+ const revokeCallsAfter = hubCalls.filter((c) => c.url.endsWith("/api/auth/revoke-token")).length;
4615
+ expect(revokeCallsAfter).toBe(revokeCallsBefore);
4451
4616
  closeAllStores();
4452
4617
  });
4453
4618
 
4454
- test("list returns this session's mints, not other sessions' or CLI mints", async () => {
4619
+ test("cannot revoke another session's jti (session-pinned)", async () => {
4620
+ installHubStub();
4621
+ const { vaultName, auth } = await setupAdminSession("revoke-other");
4622
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4623
+ const { recordMcpMintLedger } = await import("./token-store.ts");
4624
+ // Seed a ledger row attributed to a DIFFERENT session.
4625
+ const store = getVaultStore(vaultName);
4626
+ recordMcpMintLedger(store.db, {
4627
+ jti: "hub_jti_other",
4628
+ parentJti: "t_othersession",
4629
+ vaultName,
4630
+ label: "other",
4631
+ scopes: [`vault:${vaultName}:read`],
4632
+ scopedTags: null,
4633
+ expiresAt: new Date(Date.now() + 900_000).toISOString(),
4634
+ });
4635
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti: "hub_jti_other" });
4636
+ // Not in THIS session's ledger → idempotent ok=true, but NO hub revoke call.
4637
+ expect(parsed.ok).toBe(true);
4638
+ expect(hubCalls.filter((c) => c.url.endsWith("/api/auth/revoke-token")).length).toBe(0);
4639
+ closeAllStores();
4640
+ });
4641
+
4642
+ test("list returns this session's hub-JWT mints, not other sessions'", async () => {
4643
+ installHubStub();
4455
4644
  const { vaultName, auth } = await setupAdminSession("list-session");
4456
4645
  const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4457
- const { createToken, generateToken } = await import("./token-store.ts");
4646
+ const { recordMcpMintLedger } = await import("./token-store.ts");
4458
4647
 
4459
- // Mint two via manage-token in THIS session.
4460
4648
  const m1 = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", description: "alpha" });
4461
4649
  const m2 = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", description: "beta" });
4462
4650
 
4463
- // Mint one CLI-style (no created_via) and one from another session.
4651
+ // Seed another session's ledger row must NOT appear in this list.
4464
4652
  const store = getVaultStore(vaultName);
4465
- createToken(store.db, generateToken().fullToken, {
4466
- label: "cli-token",
4467
- permission: "full",
4468
- scopes: ["vault:read"],
4469
- vault_name: vaultName,
4470
- });
4471
- createToken(store.db, generateToken().fullToken, {
4653
+ recordMcpMintLedger(store.db, {
4654
+ jti: "hub_jti_foreign",
4655
+ parentJti: "t_othersession",
4656
+ vaultName,
4472
4657
  label: "other-session-mint",
4473
- permission: "full",
4474
- scopes: ["vault:read"],
4475
- vault_name: vaultName,
4476
- created_via: "mcp_mint",
4477
- parent_jti: "t_othersession",
4658
+ scopes: [`vault:${vaultName}:read`],
4659
+ scopedTags: null,
4660
+ expiresAt: null,
4478
4661
  });
4479
4662
 
4480
4663
  const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "list" });
@@ -4482,30 +4665,152 @@ describe("manage-token MCP tool (vault#376)", () => {
4482
4665
  const jtis = parsed.tokens.map((t: any) => t.jti);
4483
4666
  expect(jtis).toContain(m1.parsed.jti);
4484
4667
  expect(jtis).toContain(m2.parsed.jti);
4668
+ expect(jtis).not.toContain("hub_jti_foreign");
4485
4669
  expect(parsed.tokens.length).toBe(2);
4486
4670
  closeAllStores();
4487
4671
  });
4488
4672
 
4489
- test("audit-log integration: minted row carries created_via='mcp_mint' and parent_jti", async () => {
4490
- const { vaultName, auth } = await setupAdminSession("audit");
4673
+ test("ledger row records parent_jti + hub jti for the minting session", async () => {
4674
+ installHubStub();
4675
+ const { vaultName, auth } = await setupAdminSession("ledger");
4491
4676
  const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4492
-
4493
- const { parsed } = await callTool(vaultName, auth, "manage-token", {
4494
- action: "mint",
4495
- scope: "vault:read",
4496
- });
4497
- const jti = parsed.jti;
4677
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" });
4498
4678
  const store = getVaultStore(vaultName);
4499
- const hashPrefix = jti.slice(2);
4500
- const row = store.db.prepare(`
4501
- SELECT created_via, parent_jti, vault_name FROM tokens
4502
- WHERE token_hash LIKE ?
4503
- `).get(`sha256:${hashPrefix}%`) as { created_via: string | null; parent_jti: string | null; vault_name: string | null };
4504
- expect(row.created_via).toBe("mcp_mint");
4679
+ const row = store.db.prepare(
4680
+ "SELECT jti, parent_jti, vault_name FROM mcp_mint_ledger WHERE jti = ?",
4681
+ ).get(parsed.jti) as { jti: string; parent_jti: string; vault_name: string };
4682
+ expect(row.jti).toBe(parsed.jti);
4505
4683
  expect(row.parent_jti).toBe(auth.caller_jti);
4506
4684
  expect(row.vault_name).toBe(vaultName);
4507
4685
  closeAllStores();
4508
4686
  });
4687
+
4688
+ // hub#454 made `vault:<N>:admin` sufficient to revoke an in-authority jti
4689
+ // (capability attenuation, symmetric to mint), so the hub round-trip is now
4690
+ // the expected-SUCCESS path. This asserts the success contract end-to-end:
4691
+ // the caller's `vault:<N>:admin` bearer is forwarded, hub returns 200, and
4692
+ // the local ledger row is flipped to revoked.
4693
+ test("revoke success path: caller's vault:admin bearer revokes at hub + marks ledger (hub#454)", async () => {
4694
+ installHubStub();
4695
+ const { vaultName, auth } = await setupAdminSession("revoke-success");
4696
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4697
+ const mint = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:admin" });
4698
+ const jti = mint.parsed.jti;
4699
+
4700
+ const res = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti });
4701
+ expect(res.parsed.ok).toBe(true);
4702
+ expect(res.parsed.already_revoked).toBe(false);
4703
+ expect(res.parsed.error).toBeUndefined();
4704
+
4705
+ // Hub revoke-token was called with the caller's vault:admin bearer + jti.
4706
+ const revoke = hubCalls.find((c) => c.url.endsWith("/api/auth/revoke-token"));
4707
+ expect(revoke).toBeDefined();
4708
+ expect(revoke!.body.jti).toBe(jti);
4709
+ expect(new Headers(revoke!.init?.headers).get("authorization")).toBe(`Bearer ${JWT_BEARER}`);
4710
+
4711
+ // Local ledger row is now marked revoked.
4712
+ const store = getVaultStore(vaultName);
4713
+ const row = store.db.prepare(
4714
+ "SELECT revoked_at FROM mcp_mint_ledger WHERE jti = ?",
4715
+ ).get(jti) as { revoked_at: string | null };
4716
+ expect(row.revoked_at).not.toBeNull();
4717
+ closeAllStores();
4718
+ });
4719
+
4720
+ test("caller_jti: null session → list returns empty", async () => {
4721
+ installHubStub();
4722
+ const { vaultName, auth } = await setupAdminSession("null-list");
4723
+ const { closeAllStores } = await import("./vault-store.ts");
4724
+ // Env-var operator / legacy session: no stable session id.
4725
+ auth.caller_jti = null;
4726
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "list" });
4727
+ expect(parsed.action).toBe("list");
4728
+ expect(parsed.tokens).toEqual([]);
4729
+ closeAllStores();
4730
+ });
4731
+
4732
+ test("caller_jti: null session → revoke returns not_found, no hub call", async () => {
4733
+ installHubStub();
4734
+ const { vaultName, auth } = await setupAdminSession("null-revoke");
4735
+ const { closeAllStores } = await import("./vault-store.ts");
4736
+ auth.caller_jti = null;
4737
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti: "hub_jti_anything" });
4738
+ expect(parsed.ok).toBe(false);
4739
+ expect(parsed.error).toBe("not_found");
4740
+ expect(hubCalls.filter((c) => c.url.endsWith("/api/auth/revoke-token")).length).toBe(0);
4741
+ closeAllStores();
4742
+ });
4743
+
4744
+ test("list isolation across vaults: vault-A session never sees a vault-B ledger row", async () => {
4745
+ installHubStub();
4746
+ const { vaultName, auth } = await setupAdminSession("list-vault-iso");
4747
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4748
+ const { recordMcpMintLedger } = await import("./token-store.ts");
4749
+
4750
+ // Mint one in THIS (vault-A) session.
4751
+ const m1 = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" });
4752
+
4753
+ // Seed a ledger row attributed to the SAME parent_jti but a DIFFERENT
4754
+ // vault — the list query scopes on (parent_jti, vault_name), so this
4755
+ // foreign-vault row must not leak into vault-A's list.
4756
+ const store = getVaultStore(vaultName);
4757
+ recordMcpMintLedger(store.db, {
4758
+ jti: "hub_jti_vaultB",
4759
+ parentJti: auth.caller_jti, // same session id, different vault
4760
+ vaultName: `${vaultName}-OTHER`,
4761
+ label: "vault-B mint",
4762
+ scopes: [`vault:${vaultName}-OTHER:read`],
4763
+ scopedTags: null,
4764
+ expiresAt: null,
4765
+ });
4766
+
4767
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "list" });
4768
+ const jtis = parsed.tokens.map((t: any) => t.jti);
4769
+ expect(jtis).toContain(m1.parsed.jti);
4770
+ expect(jtis).not.toContain("hub_jti_vaultB");
4771
+ expect(parsed.tokens.length).toBe(1);
4772
+ closeAllStores();
4773
+ });
4774
+
4775
+ // INSERT OR IGNORE (not OR REPLACE): a duplicate jti record (a hub jti
4776
+ // collision — shouldn't happen) must NOT overwrite the existing row, because
4777
+ // that would silently reset a previously-set revoked_at and resurrect a
4778
+ // revoked token.
4779
+ test("recordMcpMintLedger duplicate jti preserves the existing row's revoked_at", async () => {
4780
+ const { vaultName, auth } = await setupAdminSession("ledger-dup");
4781
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4782
+ const { recordMcpMintLedger, markMcpMintLedgerRevoked, findMcpMintLedgerEntry } =
4783
+ await import("./token-store.ts");
4784
+ const store = getVaultStore(vaultName);
4785
+
4786
+ recordMcpMintLedger(store.db, {
4787
+ jti: "hub_jti_dup",
4788
+ parentJti: auth.caller_jti,
4789
+ vaultName,
4790
+ label: "first",
4791
+ scopes: [`vault:${vaultName}:read`],
4792
+ scopedTags: null,
4793
+ expiresAt: null,
4794
+ });
4795
+ // Revoke it (sets revoked_at).
4796
+ markMcpMintLedgerRevoked(store.db, "hub_jti_dup", auth.caller_jti, vaultName);
4797
+ const before = findMcpMintLedgerEntry(store.db, "hub_jti_dup", auth.caller_jti, vaultName);
4798
+ expect(before!.revoked_at).not.toBeNull();
4799
+
4800
+ // A second record with the same jti must be IGNORED — revoked_at survives.
4801
+ recordMcpMintLedger(store.db, {
4802
+ jti: "hub_jti_dup",
4803
+ parentJti: auth.caller_jti,
4804
+ vaultName,
4805
+ label: "second (collision)",
4806
+ scopes: [`vault:${vaultName}:read`],
4807
+ scopedTags: null,
4808
+ expiresAt: null,
4809
+ });
4810
+ const after = findMcpMintLedgerEntry(store.db, "hub_jti_dup", auth.caller_jti, vaultName);
4811
+ expect(after!.revoked_at).toBe(before!.revoked_at); // unchanged, not reset to NULL
4812
+ closeAllStores();
4813
+ });
4509
4814
  });
4510
4815
 
4511
4816
  describe("extractApiKey", () => {