@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.
- package/README.md +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- 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(
|
|
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
|
-
//
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
|
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
|
|
4294
|
+
expect(names).toContain("prune-schema");
|
|
4295
|
+
expect(names.length).toBe(11);
|
|
4215
4296
|
});
|
|
4216
4297
|
|
|
4217
|
-
test("legacy-derived full token sees all
|
|
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
|
|
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(
|
|
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#
|
|
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
|
|
4346
|
-
//
|
|
4347
|
-
//
|
|
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: [
|
|
4493
|
+
scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`, `vault:${vaultName}:admin`],
|
|
4351
4494
|
legacyDerived: false,
|
|
4352
|
-
scoped_tags:
|
|
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
|
|
4360
|
-
|
|
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
|
-
|
|
4369
|
-
expect(parsed.
|
|
4370
|
-
|
|
4371
|
-
expect(
|
|
4372
|
-
expect(
|
|
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
|
|
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
|
-
|
|
4380
|
-
const
|
|
4381
|
-
|
|
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("
|
|
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("
|
|
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
|
|
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
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
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("
|
|
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 {
|
|
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
|
-
//
|
|
4651
|
+
// Seed another session's ledger row — must NOT appear in this list.
|
|
4464
4652
|
const store = getVaultStore(vaultName);
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
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
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
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("
|
|
4490
|
-
|
|
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
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
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", () => {
|