@openparachute/vault 0.4.8 → 0.4.9-rc.11

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 (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.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");
@@ -3622,6 +3625,78 @@ describe("HTTP /tags", async () => {
3622
3625
  expect(body.description).toBe("A person");
3623
3626
  });
3624
3627
 
3628
+ // P1 regression (vault#398 review) — the REST PUT path calls
3629
+ // store.upsertTagRecord directly. Before the lifecycle was centralized in
3630
+ // the store, PUT {fields:null} or an indexed:false toggle left the
3631
+ // generated column orphaned (the MCP path released, REST didn't). Assert
3632
+ // via the same PRAGMA table_xinfo / buildVaultProjection introspection the
3633
+ // core lifecycle tests use.
3634
+ function notesMetaCols(): string[] {
3635
+ return (db.prepare("PRAGMA table_xinfo(notes)").all() as { name: string }[])
3636
+ .map((r) => r.name)
3637
+ .filter((n) => n.startsWith("meta_"));
3638
+ }
3639
+
3640
+ test("PUT /tags/:name {fields:null} drops the orphaned generated column", async () => {
3641
+ // Declare an indexed field via REST PUT — column materializes.
3642
+ await handleTags(
3643
+ mkReq("PUT", "/tags/project", { fields: { status: { type: "string", indexed: true } } }),
3644
+ store,
3645
+ "/project",
3646
+ );
3647
+ expect(notesMetaCols()).toContain("meta_status");
3648
+ expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).toContain("status");
3649
+
3650
+ // Clear all fields via REST PUT {fields:null} — column must drop.
3651
+ const res = await handleTags(
3652
+ mkReq("PUT", "/tags/project", { fields: null }),
3653
+ store,
3654
+ "/project",
3655
+ );
3656
+ expect(res.status).toBe(200);
3657
+ expect(notesMetaCols()).not.toContain("meta_status");
3658
+ const idxs = (db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'").all() as { name: string }[]).map((r) => r.name);
3659
+ expect(idxs).not.toContain("idx_meta_status");
3660
+ expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("status");
3661
+ });
3662
+
3663
+ test("PUT /tags/:name indexed:false toggle drops the generated column", async () => {
3664
+ await handleTags(
3665
+ mkReq("PUT", "/tags/project", { fields: { status: { type: "string", indexed: true } } }),
3666
+ store,
3667
+ "/project",
3668
+ );
3669
+ expect(notesMetaCols()).toContain("meta_status");
3670
+
3671
+ // Re-PUT the same field with indexed:false — REST merges, so the field
3672
+ // stays in the schema but loses its index → column drops.
3673
+ const res = await handleTags(
3674
+ mkReq("PUT", "/tags/project", { fields: { status: { type: "string", indexed: false } } }),
3675
+ store,
3676
+ "/project",
3677
+ );
3678
+ expect(res.status).toBe(200);
3679
+ expect(notesMetaCols()).not.toContain("meta_status");
3680
+ expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).not.toContain("status");
3681
+ });
3682
+
3683
+ test("PUT /tags/:name respects the co-declaration guard (keeps column for a live co-declarer)", async () => {
3684
+ await handleTags(
3685
+ mkReq("PUT", "/tags/asset", { fields: { aspect_ratio: { type: "string", indexed: true } } }),
3686
+ store,
3687
+ "/asset",
3688
+ );
3689
+ await handleTags(
3690
+ mkReq("PUT", "/tags/storyboard", { fields: { aspect_ratio: { type: "string", indexed: true } } }),
3691
+ store,
3692
+ "/storyboard",
3693
+ );
3694
+ // Clear asset's fields — storyboard still declares aspect_ratio → keep.
3695
+ await handleTags(mkReq("PUT", "/tags/asset", { fields: null }), store, "/asset");
3696
+ expect(notesMetaCols()).toContain("meta_aspect_ratio");
3697
+ expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).toContain("aspect_ratio");
3698
+ });
3699
+
3625
3700
  test("PUT /tags/:name returns 400 with error_type: invalid_relationships on bad shape", async () => {
3626
3701
  const res = await handleTags(
3627
3702
  mkReq("PUT", "/tags/person", {
@@ -3941,6 +4016,10 @@ describe("stateless MCP transport", async () => {
3941
4016
  expect(toolNames).not.toContain("delete-note");
3942
4017
  expect(toolNames).not.toContain("update-tag");
3943
4018
  expect(toolNames).not.toContain("delete-tag");
4019
+ // Admin tools (vault#376) are hidden too
4020
+ expect(toolNames).not.toContain("manage-token");
4021
+ // Read tier is exactly 4 tools.
4022
+ expect(toolNames.length).toBe(4);
3944
4023
 
3945
4024
  closeAllStores();
3946
4025
  });
@@ -4077,7 +4156,13 @@ describe("stateless MCP transport", async () => {
4077
4156
  expect(res.status).toBe(200); // JSON-RPC envelope is 200 even for tool errors
4078
4157
  const body = await res.json() as any;
4079
4158
  expect(body.result.isError).toBe(true);
4080
- expect(body.result.content[0].text).toContain("vault:write");
4159
+ // Post-vault#376: hidden tools surface as "Unknown tool" rather than
4160
+ // a verb-specific Forbidden — see mcp-http.ts dispatch-against-
4161
+ // visibleTools rationale. The contract is: tools not in tools/list
4162
+ // also can't be called explicitly. (Differential errors would leak
4163
+ // the existence of admin-only tools to write-scope sessions.)
4164
+ expect(body.result.content[0].text).toContain("Unknown tool");
4165
+ expect(body.result.content[0].text).toContain("create-note");
4081
4166
 
4082
4167
  closeAllStores();
4083
4168
  });
@@ -4129,6 +4214,600 @@ describe("stateless MCP transport", async () => {
4129
4214
  });
4130
4215
  });
4131
4216
 
4217
+ // ===========================================================================
4218
+ // vault#376 — Change 1: scope-filtered tool listing across all three tiers
4219
+ // ===========================================================================
4220
+
4221
+ describe("MCP tools/list scope tiers (vault#376)", () => {
4222
+ async function listToolNames(scopes: string[], scopedTags: string[] | null = null, vaultPrefix = "scope-tier") {
4223
+ const { handleScopedMcp } = await import("./mcp-http.ts");
4224
+ const { writeVaultConfig } = await import("./config.ts");
4225
+ const { closeAllStores } = await import("./vault-store.ts");
4226
+
4227
+ const vaultName = `${vaultPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4228
+ writeVaultConfig({
4229
+ name: vaultName,
4230
+ api_keys: [],
4231
+ created_at: new Date().toISOString(),
4232
+ });
4233
+
4234
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
4235
+ method: "POST",
4236
+ headers: {
4237
+ "content-type": "application/json",
4238
+ "accept": "application/json, text/event-stream",
4239
+ },
4240
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
4241
+ });
4242
+
4243
+ const res = await handleScopedMcp(req, vaultName, {
4244
+ permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
4245
+ scopes,
4246
+ legacyDerived: false,
4247
+ scoped_tags: scopedTags,
4248
+ } as any);
4249
+ const body = await res.json() as any;
4250
+ const names: string[] = body.result.tools.map((t: any) => t.name);
4251
+ closeAllStores();
4252
+ return names;
4253
+ }
4254
+
4255
+ test("vault:read sees exactly the 4 read tools", async () => {
4256
+ const names = await listToolNames(["vault:read"]);
4257
+ expect(new Set(names)).toEqual(
4258
+ new Set(["query-notes", "list-tags", "find-path", "vault-info"]),
4259
+ );
4260
+ expect(names.length).toBe(4);
4261
+ });
4262
+
4263
+ test("vault:read + vault:write sees the 9 read+write tools", async () => {
4264
+ const names = await listToolNames(["vault:read", "vault:write"]);
4265
+ expect(new Set(names)).toEqual(
4266
+ new Set([
4267
+ "query-notes",
4268
+ "list-tags",
4269
+ "find-path",
4270
+ "vault-info",
4271
+ "create-note",
4272
+ "update-note",
4273
+ "delete-note",
4274
+ "update-tag",
4275
+ "delete-tag",
4276
+ ]),
4277
+ );
4278
+ expect(names.length).toBe(9);
4279
+ expect(names).not.toContain("manage-token");
4280
+ // Aaron 2026-05-27: delete-* are write-tier (same destructive verb as
4281
+ // update). Only manage-token is admin-gated.
4282
+ expect(names).toContain("delete-note");
4283
+ expect(names).toContain("delete-tag");
4284
+ });
4285
+
4286
+ test("vault:admin sees all 11 tools including manage-token + prune-schema", async () => {
4287
+ const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
4288
+ expect(names).toContain("manage-token");
4289
+ expect(names).toContain("prune-schema");
4290
+ expect(names.length).toBe(11);
4291
+ });
4292
+
4293
+ test("legacy-derived full token sees all 11 tools (back-compat)", async () => {
4294
+ const { handleScopedMcp } = await import("./mcp-http.ts");
4295
+ const { writeVaultConfig } = await import("./config.ts");
4296
+ const { closeAllStores } = await import("./vault-store.ts");
4297
+
4298
+ const vaultName = `legacy-token-${Date.now()}`;
4299
+ writeVaultConfig({
4300
+ name: vaultName,
4301
+ api_keys: [],
4302
+ created_at: new Date().toISOString(),
4303
+ });
4304
+
4305
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
4306
+ method: "POST",
4307
+ headers: {
4308
+ "content-type": "application/json",
4309
+ "accept": "application/json, text/event-stream",
4310
+ },
4311
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
4312
+ });
4313
+
4314
+ // Legacy permission-derived token: legacyDerived=true, scopes carry the
4315
+ // full admin set per `legacyPermissionToScopes("full")`. Compat shim
4316
+ // means the operator's existing pvt_* tokens minted pre-scope-column
4317
+ // see the full admin surface (including manage-token + prune-schema).
4318
+ const res = await handleScopedMcp(req, vaultName, {
4319
+ permission: "full",
4320
+ scopes: ["vault:read", "vault:write", "vault:admin"],
4321
+ legacyDerived: true,
4322
+ scoped_tags: null,
4323
+ } as any);
4324
+ const body = await res.json() as any;
4325
+ const names: string[] = body.result.tools.map((t: any) => t.name);
4326
+ expect(names.length).toBe(11);
4327
+ expect(names).toContain("manage-token");
4328
+ expect(names).toContain("prune-schema");
4329
+ closeAllStores();
4330
+ });
4331
+
4332
+ test("excluded tools surface as 'Unknown tool' if called explicitly", async () => {
4333
+ const { handleScopedMcp } = await import("./mcp-http.ts");
4334
+ const { writeVaultConfig } = await import("./config.ts");
4335
+ const { closeAllStores } = await import("./vault-store.ts");
4336
+
4337
+ const vaultName = `hidden-call-${Date.now()}`;
4338
+ writeVaultConfig({
4339
+ name: vaultName,
4340
+ api_keys: [],
4341
+ created_at: new Date().toISOString(),
4342
+ });
4343
+
4344
+ // Write-scope session calling manage-token (admin-only): should look
4345
+ // like the tool doesn't exist, not "Forbidden: requires vault:admin".
4346
+ // Differential messages would leak the admin tool's existence.
4347
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
4348
+ method: "POST",
4349
+ headers: {
4350
+ "content-type": "application/json",
4351
+ "accept": "application/json, text/event-stream",
4352
+ },
4353
+ body: JSON.stringify({
4354
+ jsonrpc: "2.0",
4355
+ id: 1,
4356
+ method: "tools/call",
4357
+ params: { name: "manage-token", arguments: { action: "list" } },
4358
+ }),
4359
+ });
4360
+
4361
+ const res = await handleScopedMcp(req, vaultName, {
4362
+ permission: "full",
4363
+ scopes: ["vault:read", "vault:write"],
4364
+ legacyDerived: false,
4365
+ scoped_tags: null,
4366
+ } as any);
4367
+ const body = await res.json() as any;
4368
+ expect(body.result.isError).toBe(true);
4369
+ expect(body.result.content[0].text).toContain("Unknown tool");
4370
+ expect(body.result.content[0].text).toContain("manage-token");
4371
+ expect(body.result.content[0].text).not.toContain("vault:admin");
4372
+ closeAllStores();
4373
+ });
4374
+ });
4375
+
4376
+ // ===========================================================================
4377
+ // vault#376 — Change 2: manage-token mint/revoke/list
4378
+ // ===========================================================================
4379
+
4380
+ describe("manage-token MCP tool (vault#403, MGT — hub-JWT attenuation proxy)", () => {
4381
+ // A JWT-shaped bearer the session presents; the manage-token mint path only
4382
+ // forwards JWT-shaped credentials, so tests must thread one through.
4383
+ const JWT_BEARER = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1In0.sig";
4384
+
4385
+ // --- Hub fetch stub --------------------------------------------------------
4386
+ // mintAction/revokeAction call mintHubJwt/revokeHubJwt, which use global
4387
+ // fetch (no fetchImpl seam on that path). Each test installs a stub that
4388
+ // records the requests and returns a canned hub response. We capture every
4389
+ // call so assertions can inspect the URL / Authorization header / body.
4390
+ let realFetch: typeof globalThis.fetch;
4391
+ let hubCalls: Array<{ url: string; init: RequestInit | undefined; body: any }>;
4392
+ let mintSeq: number;
4393
+
4394
+ beforeEach(() => {
4395
+ realFetch = globalThis.fetch;
4396
+ hubCalls = [];
4397
+ mintSeq = 0;
4398
+ });
4399
+ afterEach(() => {
4400
+ globalThis.fetch = realFetch;
4401
+ });
4402
+
4403
+ /** Install a hub stub that mints a fresh jti per mint + idempotent revoke. */
4404
+ function installHubStub(opts?: { revokeFails?: "network" | "api-error" }) {
4405
+ globalThis.fetch = (async (input: any, init?: RequestInit) => {
4406
+ const url = String(input);
4407
+ let body: any = undefined;
4408
+ try { body = init?.body ? JSON.parse(String(init.body)) : undefined; } catch {}
4409
+ hubCalls.push({ url, init, body });
4410
+ if (url.endsWith("/api/auth/mint-token")) {
4411
+ const jti = `hub_jti_${++mintSeq}`;
4412
+ const ttl = typeof body?.expires_in === "number" ? body.expires_in : 900;
4413
+ return new Response(
4414
+ JSON.stringify({
4415
+ jti,
4416
+ token: `eyJ.minted.${jti}`,
4417
+ expires_at: new Date(Date.now() + ttl * 1000).toISOString(),
4418
+ scope: body?.scope ?? "",
4419
+ ...(body?.permissions ? { permissions: body.permissions } : {}),
4420
+ }),
4421
+ { status: 200, headers: { "Content-Type": "application/json" } },
4422
+ );
4423
+ }
4424
+ if (url.endsWith("/api/auth/revoke-token")) {
4425
+ if (opts?.revokeFails === "network") throw new Error("connection refused");
4426
+ if (opts?.revokeFails === "api-error") {
4427
+ return new Response(
4428
+ JSON.stringify({ error: "insufficient_scope", error_description: "bearer lacks parachute:host:auth" }),
4429
+ { status: 403, headers: { "Content-Type": "application/json" } },
4430
+ );
4431
+ }
4432
+ return new Response(
4433
+ JSON.stringify({ jti: body?.jti, revoked_at: new Date().toISOString() }),
4434
+ { status: 200, headers: { "Content-Type": "application/json" } },
4435
+ );
4436
+ }
4437
+ throw new Error(`unexpected fetch in test: ${url}`);
4438
+ }) as typeof globalThis.fetch;
4439
+ }
4440
+
4441
+ async function callTool(
4442
+ vaultName: string,
4443
+ auth: any,
4444
+ toolName: string,
4445
+ args: Record<string, unknown>,
4446
+ callerBearer: string | null = JWT_BEARER,
4447
+ ) {
4448
+ const { handleScopedMcp } = await import("./mcp-http.ts");
4449
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
4450
+ method: "POST",
4451
+ headers: {
4452
+ "content-type": "application/json",
4453
+ "accept": "application/json, text/event-stream",
4454
+ ...(callerBearer ? { authorization: `Bearer ${callerBearer}` } : {}),
4455
+ },
4456
+ body: JSON.stringify({
4457
+ jsonrpc: "2.0",
4458
+ id: 1,
4459
+ method: "tools/call",
4460
+ params: { name: toolName, arguments: args },
4461
+ }),
4462
+ });
4463
+ const res = await handleScopedMcp(req, vaultName, auth, callerBearer);
4464
+ const body = await res.json() as any;
4465
+ if (body.result?.content?.[0]?.text) {
4466
+ try {
4467
+ return { isError: !!body.result.isError, parsed: JSON.parse(body.result.content[0].text), raw: body };
4468
+ } catch {
4469
+ return { isError: !!body.result.isError, parsed: null, raw: body, text: body.result.content[0].text };
4470
+ }
4471
+ }
4472
+ return { isError: false, parsed: null, raw: body };
4473
+ }
4474
+
4475
+ async function setupAdminSession(prefix: string, scopedTags: string[] | null = null) {
4476
+ const { writeVaultConfig } = await import("./config.ts");
4477
+ const vaultName = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4478
+ writeVaultConfig({
4479
+ name: vaultName,
4480
+ api_keys: [],
4481
+ created_at: new Date().toISOString(),
4482
+ });
4483
+ // Stable caller_jti so the session-pinned ledger can attribute mints.
4484
+ // Scopes carry the resource-narrowed admin scope so validateMintedScopes +
4485
+ // the visibility filter pass for THIS vault.
4486
+ const auth: any = {
4487
+ permission: "full",
4488
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`, `vault:${vaultName}:admin`],
4489
+ legacyDerived: false,
4490
+ scoped_tags: scopedTags,
4491
+ vault_name: vaultName,
4492
+ caller_jti: `t_session${Math.random().toString(36).slice(2, 12)}`,
4493
+ };
4494
+ return { vaultName, auth };
4495
+ }
4496
+
4497
+ test("mint proxies to hub mint-token with caller bearer + narrowed scope + ttl", async () => {
4498
+ installHubStub();
4499
+ const { vaultName, auth } = await setupAdminSession("mint-proxy");
4500
+ const { closeAllStores } = await import("./vault-store.ts");
4501
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
4502
+ action: "mint",
4503
+ scope: "vault:read",
4504
+ });
4505
+ expect(parsed.action).toBe("mint");
4506
+ // Token is now a hub JWT (not pvt_*); jti is hub's returned jti.
4507
+ expect(parsed.token).toMatch(/^eyJ\.minted\./);
4508
+ expect(parsed.jti).toBe("hub_jti_1");
4509
+ expect(parsed.scopes).toEqual([`vault:${vaultName}:read`]);
4510
+ expect(parsed.vault_name).toBe(vaultName);
4511
+ // One hub mint-token call carrying the caller bearer + narrowed scope.
4512
+ const mint = hubCalls.find((c) => c.url.endsWith("/api/auth/mint-token"));
4513
+ expect(mint).toBeDefined();
4514
+ const headers = new Headers(mint!.init?.headers);
4515
+ expect(headers.get("authorization")).toBe(`Bearer ${JWT_BEARER}`);
4516
+ expect(mint!.body.scope).toBe(`vault:${vaultName}:read`);
4517
+ expect(mint!.body.expires_in).toBe(900);
4518
+ expect(mint!.body.permissions).toBeUndefined(); // unscoped caller
4519
+ closeAllStores();
4520
+ });
4521
+
4522
+ test("mint with custom TTL=3600 forwards expires_in=3600", async () => {
4523
+ installHubStub();
4524
+ const { vaultName, auth } = await setupAdminSession("mint-max");
4525
+ const { closeAllStores } = await import("./vault-store.ts");
4526
+ await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", ttl_seconds: 3600 });
4527
+ const mint = hubCalls.find((c) => c.url.endsWith("/api/auth/mint-token"));
4528
+ expect(mint!.body.expires_in).toBe(3600);
4529
+ closeAllStores();
4530
+ });
4531
+
4532
+ test("tag-scoped caller's mint includes permissions.scoped_tags", async () => {
4533
+ installHubStub();
4534
+ const { vaultName, auth } = await setupAdminSession("mint-scoped", ["task", "project"]);
4535
+ const { closeAllStores } = await import("./vault-store.ts");
4536
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" });
4537
+ expect(parsed.scoped_tags).toEqual(["task", "project"]);
4538
+ const mint = hubCalls.find((c) => c.url.endsWith("/api/auth/mint-token"));
4539
+ expect(mint!.body.permissions).toEqual({ scoped_tags: ["task", "project"] });
4540
+ closeAllStores();
4541
+ });
4542
+
4543
+ test("mint with TTL=0 is rejected locally (no hub call)", async () => {
4544
+ installHubStub();
4545
+ const { vaultName, auth } = await setupAdminSession("mint-zero");
4546
+ const { closeAllStores } = await import("./vault-store.ts");
4547
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", ttl_seconds: 0 });
4548
+ expect(parsed.error).toBe("invalid_request");
4549
+ expect(hubCalls.length).toBe(0);
4550
+ closeAllStores();
4551
+ });
4552
+
4553
+ test("mint with TTL=3601 is rejected locally (over the 3600 cap)", async () => {
4554
+ installHubStub();
4555
+ const { vaultName, auth } = await setupAdminSession("mint-over");
4556
+ const { closeAllStores } = await import("./vault-store.ts");
4557
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", ttl_seconds: 3601 });
4558
+ expect(parsed.error).toBe("invalid_request");
4559
+ expect(hubCalls.length).toBe(0);
4560
+ closeAllStores();
4561
+ });
4562
+
4563
+ test("cross-vault / over-scope request is rejected locally (no hub call)", async () => {
4564
+ installHubStub();
4565
+ const { vaultName, auth } = await setupAdminSession("mint-subset");
4566
+ const { closeAllStores } = await import("./vault-store.ts");
4567
+ const { parsed } = await callTool(vaultName, auth, "manage-token", {
4568
+ action: "mint",
4569
+ scope: "vault:other-vault:write",
4570
+ });
4571
+ expect(parsed.error).toBe("forbidden");
4572
+ expect(parsed.rejected).toBeDefined();
4573
+ expect(hubCalls.length).toBe(0);
4574
+ closeAllStores();
4575
+ });
4576
+
4577
+ test("non-forwardable session (no JWT bearer) → clear mint error, no hub call", async () => {
4578
+ installHubStub();
4579
+ const { vaultName, auth } = await setupAdminSession("mint-nonfwd");
4580
+ const { closeAllStores } = await import("./vault-store.ts");
4581
+ // Env-var operator path: caller_jti present but the presented credential
4582
+ // is a non-JWT secret → mint must refuse with a hub-JWT-required message.
4583
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" }, "operator-env-secret");
4584
+ expect(parsed.error).toBe("forbidden");
4585
+ expect(parsed.message).toContain("hub-JWT session");
4586
+ expect(hubCalls.length).toBe(0);
4587
+ closeAllStores();
4588
+ });
4589
+
4590
+ test("revoke own minted jti posts to hub revoke-token + idempotent second revoke", async () => {
4591
+ installHubStub();
4592
+ const { vaultName, auth } = await setupAdminSession("revoke-idem");
4593
+ const { closeAllStores } = await import("./vault-store.ts");
4594
+ const mint = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" });
4595
+ const jti = mint.parsed.jti;
4596
+ const first = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti });
4597
+ expect(first.parsed.ok).toBe(true);
4598
+ expect(first.parsed.already_revoked).toBe(false);
4599
+ // First revoke posted to hub revoke-token with the caller bearer + jti.
4600
+ const revoke = hubCalls.find((c) => c.url.endsWith("/api/auth/revoke-token"));
4601
+ expect(revoke).toBeDefined();
4602
+ expect(revoke!.body.jti).toBe(jti);
4603
+ expect(new Headers(revoke!.init?.headers).get("authorization")).toBe(`Bearer ${JWT_BEARER}`);
4604
+ // Second revoke is idempotent and does NOT re-hit hub (already revoked locally).
4605
+ const revokeCallsBefore = hubCalls.filter((c) => c.url.endsWith("/api/auth/revoke-token")).length;
4606
+ const second = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti });
4607
+ expect(second.parsed.ok).toBe(true);
4608
+ expect(second.parsed.already_revoked).toBe(true);
4609
+ const revokeCallsAfter = hubCalls.filter((c) => c.url.endsWith("/api/auth/revoke-token")).length;
4610
+ expect(revokeCallsAfter).toBe(revokeCallsBefore);
4611
+ closeAllStores();
4612
+ });
4613
+
4614
+ test("cannot revoke another session's jti (session-pinned)", async () => {
4615
+ installHubStub();
4616
+ const { vaultName, auth } = await setupAdminSession("revoke-other");
4617
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4618
+ const { recordMcpMintLedger } = await import("./token-store.ts");
4619
+ // Seed a ledger row attributed to a DIFFERENT session.
4620
+ const store = getVaultStore(vaultName);
4621
+ recordMcpMintLedger(store.db, {
4622
+ jti: "hub_jti_other",
4623
+ parentJti: "t_othersession",
4624
+ vaultName,
4625
+ label: "other",
4626
+ scopes: [`vault:${vaultName}:read`],
4627
+ scopedTags: null,
4628
+ expiresAt: new Date(Date.now() + 900_000).toISOString(),
4629
+ });
4630
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti: "hub_jti_other" });
4631
+ // Not in THIS session's ledger → idempotent ok=true, but NO hub revoke call.
4632
+ expect(parsed.ok).toBe(true);
4633
+ expect(hubCalls.filter((c) => c.url.endsWith("/api/auth/revoke-token")).length).toBe(0);
4634
+ closeAllStores();
4635
+ });
4636
+
4637
+ test("list returns this session's hub-JWT mints, not other sessions'", async () => {
4638
+ installHubStub();
4639
+ const { vaultName, auth } = await setupAdminSession("list-session");
4640
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4641
+ const { recordMcpMintLedger } = await import("./token-store.ts");
4642
+
4643
+ const m1 = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", description: "alpha" });
4644
+ const m2 = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read", description: "beta" });
4645
+
4646
+ // Seed another session's ledger row — must NOT appear in this list.
4647
+ const store = getVaultStore(vaultName);
4648
+ recordMcpMintLedger(store.db, {
4649
+ jti: "hub_jti_foreign",
4650
+ parentJti: "t_othersession",
4651
+ vaultName,
4652
+ label: "other-session-mint",
4653
+ scopes: [`vault:${vaultName}:read`],
4654
+ scopedTags: null,
4655
+ expiresAt: null,
4656
+ });
4657
+
4658
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "list" });
4659
+ expect(parsed.action).toBe("list");
4660
+ const jtis = parsed.tokens.map((t: any) => t.jti);
4661
+ expect(jtis).toContain(m1.parsed.jti);
4662
+ expect(jtis).toContain(m2.parsed.jti);
4663
+ expect(jtis).not.toContain("hub_jti_foreign");
4664
+ expect(parsed.tokens.length).toBe(2);
4665
+ closeAllStores();
4666
+ });
4667
+
4668
+ test("ledger row records parent_jti + hub jti for the minting session", async () => {
4669
+ installHubStub();
4670
+ const { vaultName, auth } = await setupAdminSession("ledger");
4671
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4672
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" });
4673
+ const store = getVaultStore(vaultName);
4674
+ const row = store.db.prepare(
4675
+ "SELECT jti, parent_jti, vault_name FROM mcp_mint_ledger WHERE jti = ?",
4676
+ ).get(parsed.jti) as { jti: string; parent_jti: string; vault_name: string };
4677
+ expect(row.jti).toBe(parsed.jti);
4678
+ expect(row.parent_jti).toBe(auth.caller_jti);
4679
+ expect(row.vault_name).toBe(vaultName);
4680
+ closeAllStores();
4681
+ });
4682
+
4683
+ // hub#454 made `vault:<N>:admin` sufficient to revoke an in-authority jti
4684
+ // (capability attenuation, symmetric to mint), so the hub round-trip is now
4685
+ // the expected-SUCCESS path. This asserts the success contract end-to-end:
4686
+ // the caller's `vault:<N>:admin` bearer is forwarded, hub returns 200, and
4687
+ // the local ledger row is flipped to revoked.
4688
+ test("revoke success path: caller's vault:admin bearer revokes at hub + marks ledger (hub#454)", async () => {
4689
+ installHubStub();
4690
+ const { vaultName, auth } = await setupAdminSession("revoke-success");
4691
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4692
+ const mint = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:admin" });
4693
+ const jti = mint.parsed.jti;
4694
+
4695
+ const res = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti });
4696
+ expect(res.parsed.ok).toBe(true);
4697
+ expect(res.parsed.already_revoked).toBe(false);
4698
+ expect(res.parsed.error).toBeUndefined();
4699
+
4700
+ // Hub revoke-token was called with the caller's vault:admin bearer + jti.
4701
+ const revoke = hubCalls.find((c) => c.url.endsWith("/api/auth/revoke-token"));
4702
+ expect(revoke).toBeDefined();
4703
+ expect(revoke!.body.jti).toBe(jti);
4704
+ expect(new Headers(revoke!.init?.headers).get("authorization")).toBe(`Bearer ${JWT_BEARER}`);
4705
+
4706
+ // Local ledger row is now marked revoked.
4707
+ const store = getVaultStore(vaultName);
4708
+ const row = store.db.prepare(
4709
+ "SELECT revoked_at FROM mcp_mint_ledger WHERE jti = ?",
4710
+ ).get(jti) as { revoked_at: string | null };
4711
+ expect(row.revoked_at).not.toBeNull();
4712
+ closeAllStores();
4713
+ });
4714
+
4715
+ test("caller_jti: null session → list returns empty", async () => {
4716
+ installHubStub();
4717
+ const { vaultName, auth } = await setupAdminSession("null-list");
4718
+ const { closeAllStores } = await import("./vault-store.ts");
4719
+ // Env-var operator / legacy session: no stable session id.
4720
+ auth.caller_jti = null;
4721
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "list" });
4722
+ expect(parsed.action).toBe("list");
4723
+ expect(parsed.tokens).toEqual([]);
4724
+ closeAllStores();
4725
+ });
4726
+
4727
+ test("caller_jti: null session → revoke returns not_found, no hub call", async () => {
4728
+ installHubStub();
4729
+ const { vaultName, auth } = await setupAdminSession("null-revoke");
4730
+ const { closeAllStores } = await import("./vault-store.ts");
4731
+ auth.caller_jti = null;
4732
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "revoke", jti: "hub_jti_anything" });
4733
+ expect(parsed.ok).toBe(false);
4734
+ expect(parsed.error).toBe("not_found");
4735
+ expect(hubCalls.filter((c) => c.url.endsWith("/api/auth/revoke-token")).length).toBe(0);
4736
+ closeAllStores();
4737
+ });
4738
+
4739
+ test("list isolation across vaults: vault-A session never sees a vault-B ledger row", async () => {
4740
+ installHubStub();
4741
+ const { vaultName, auth } = await setupAdminSession("list-vault-iso");
4742
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4743
+ const { recordMcpMintLedger } = await import("./token-store.ts");
4744
+
4745
+ // Mint one in THIS (vault-A) session.
4746
+ const m1 = await callTool(vaultName, auth, "manage-token", { action: "mint", scope: "vault:read" });
4747
+
4748
+ // Seed a ledger row attributed to the SAME parent_jti but a DIFFERENT
4749
+ // vault — the list query scopes on (parent_jti, vault_name), so this
4750
+ // foreign-vault row must not leak into vault-A's list.
4751
+ const store = getVaultStore(vaultName);
4752
+ recordMcpMintLedger(store.db, {
4753
+ jti: "hub_jti_vaultB",
4754
+ parentJti: auth.caller_jti, // same session id, different vault
4755
+ vaultName: `${vaultName}-OTHER`,
4756
+ label: "vault-B mint",
4757
+ scopes: [`vault:${vaultName}-OTHER:read`],
4758
+ scopedTags: null,
4759
+ expiresAt: null,
4760
+ });
4761
+
4762
+ const { parsed } = await callTool(vaultName, auth, "manage-token", { action: "list" });
4763
+ const jtis = parsed.tokens.map((t: any) => t.jti);
4764
+ expect(jtis).toContain(m1.parsed.jti);
4765
+ expect(jtis).not.toContain("hub_jti_vaultB");
4766
+ expect(parsed.tokens.length).toBe(1);
4767
+ closeAllStores();
4768
+ });
4769
+
4770
+ // INSERT OR IGNORE (not OR REPLACE): a duplicate jti record (a hub jti
4771
+ // collision — shouldn't happen) must NOT overwrite the existing row, because
4772
+ // that would silently reset a previously-set revoked_at and resurrect a
4773
+ // revoked token.
4774
+ test("recordMcpMintLedger duplicate jti preserves the existing row's revoked_at", async () => {
4775
+ const { vaultName, auth } = await setupAdminSession("ledger-dup");
4776
+ const { closeAllStores, getVaultStore } = await import("./vault-store.ts");
4777
+ const { recordMcpMintLedger, markMcpMintLedgerRevoked, findMcpMintLedgerEntry } =
4778
+ await import("./token-store.ts");
4779
+ const store = getVaultStore(vaultName);
4780
+
4781
+ recordMcpMintLedger(store.db, {
4782
+ jti: "hub_jti_dup",
4783
+ parentJti: auth.caller_jti,
4784
+ vaultName,
4785
+ label: "first",
4786
+ scopes: [`vault:${vaultName}:read`],
4787
+ scopedTags: null,
4788
+ expiresAt: null,
4789
+ });
4790
+ // Revoke it (sets revoked_at).
4791
+ markMcpMintLedgerRevoked(store.db, "hub_jti_dup", auth.caller_jti, vaultName);
4792
+ const before = findMcpMintLedgerEntry(store.db, "hub_jti_dup", auth.caller_jti, vaultName);
4793
+ expect(before!.revoked_at).not.toBeNull();
4794
+
4795
+ // A second record with the same jti must be IGNORED — revoked_at survives.
4796
+ recordMcpMintLedger(store.db, {
4797
+ jti: "hub_jti_dup",
4798
+ parentJti: auth.caller_jti,
4799
+ vaultName,
4800
+ label: "second (collision)",
4801
+ scopes: [`vault:${vaultName}:read`],
4802
+ scopedTags: null,
4803
+ expiresAt: null,
4804
+ });
4805
+ const after = findMcpMintLedgerEntry(store.db, "hub_jti_dup", auth.caller_jti, vaultName);
4806
+ expect(after!.revoked_at).toBe(before!.revoked_at); // unchanged, not reset to NULL
4807
+ closeAllStores();
4808
+ });
4809
+ });
4810
+
4132
4811
  describe("extractApiKey", () => {
4133
4812
  test("extracts from Authorization: Bearer header", () => {
4134
4813
  const req = new Request("http://localhost/api/notes", {