@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.
- package/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- 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(
|
|
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
|
-
|
|
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", {
|