@openparachute/vault 0.7.0-rc.9 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vault.test.ts CHANGED
@@ -675,6 +675,34 @@ describe("scoped MCP wrapper", async () => {
675
675
  closeAllStores();
676
676
  });
677
677
 
678
+ test("vault-info includes a compact structural map WITHOUT include_stats (front-door)", async () => {
679
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
680
+ const { writeVaultConfig } = await import("./config.ts");
681
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
682
+
683
+ const vaultName = `map-${Date.now()}`;
684
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
685
+
686
+ const vaultStore = getVaultStore(vaultName);
687
+ await vaultStore.createNote("a", { tags: ["person"], path: "People/Alice" });
688
+ await vaultStore.createNote("b", { tags: ["person"] }); // no path
689
+
690
+ const tools = generateScopedMcpTools(vaultName);
691
+ const vaultInfo = tools.find((t) => t.name === "vault-info")!;
692
+
693
+ // No `include_stats` flag — the map must still be present (that's the
694
+ // whole point: orient in ONE call, no flag needed).
695
+ const result = await vaultInfo.execute({}) as any;
696
+ expect(result.stats).toBeUndefined();
697
+ expect(result.map).toBeTruthy();
698
+ expect(result.map.total_notes).toBe(2);
699
+ expect(result.map.tags).toEqual([{ name: "person", count: 2 }]);
700
+ expect(result.map.path_buckets).toEqual([{ name: "People", count: 1 }]);
701
+ expect(result.map.unfiled_notes).toBe(1);
702
+
703
+ closeAllStores();
704
+ });
705
+
678
706
  test("getServerInstruction renders projection markdown for a populated vault (vault#271)", async () => {
679
707
  const { getServerInstruction } = await import("./mcp-tools.ts");
680
708
  const { writeVaultConfig } = await import("./config.ts");
@@ -1267,6 +1295,51 @@ describe("scoped MCP wrapper", async () => {
1267
1295
  closeAllStores();
1268
1296
  });
1269
1297
 
1298
+ test("scoped vault-info's map covers only notes reachable through an in-scope tag (front-door)", async () => {
1299
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1300
+ const { writeVaultConfig } = await import("./config.ts");
1301
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1302
+
1303
+ const vaultName = `tagscope-vault-info-map-${Date.now()}`;
1304
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1305
+
1306
+ const store0 = getVaultStore(vaultName);
1307
+ await store0.createNote("a", { tags: ["work"], path: "Work/One" });
1308
+ await store0.createNote("b", { tags: ["work"] }); // no path
1309
+ await store0.createNote("c", { tags: ["personal"], path: "Personal/Two" });
1310
+
1311
+ // Scoped to `work` only.
1312
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1313
+ const result = await tools.find((t) => t.name === "vault-info")!.execute({}) as any;
1314
+
1315
+ expect(result.map.total_notes).toBe(2); // a, b — "c" (personal) excluded
1316
+ expect(result.map.tags).toEqual([{ name: "work", count: 2 }]);
1317
+ expect(result.map.path_buckets).toEqual([{ name: "Work", count: 1 }]);
1318
+ expect(result.map.unfiled_notes).toBe(1);
1319
+
1320
+ closeAllStores();
1321
+ });
1322
+
1323
+ test("scoped vault-info with an allowlist matching nothing returns an all-zero map, not the full vault", async () => {
1324
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1325
+ const { writeVaultConfig } = await import("./config.ts");
1326
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1327
+
1328
+ const vaultName = `tagscope-vault-info-map-empty-${Date.now()}`;
1329
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1330
+
1331
+ const store0 = getVaultStore(vaultName);
1332
+ await store0.createNote("a", { tags: ["work"], path: "Work/One" });
1333
+
1334
+ // Scoped to a tag that doesn't exist in this vault at all.
1335
+ const tools = generateScopedMcpTools(vaultName, authForTags(["nonexistent"]) as any);
1336
+ const result = await tools.find((t) => t.name === "vault-info")!.execute({}) as any;
1337
+
1338
+ expect(result.map).toEqual({ total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 });
1339
+
1340
+ closeAllStores();
1341
+ });
1342
+
1270
1343
  test("scoped create-note rejects a note whose tags fall outside the allowlist", async () => {
1271
1344
  const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1272
1345
  const { writeVaultConfig } = await import("./config.ts");
@@ -2333,6 +2406,37 @@ describe("HTTP /notes", async () => {
2333
2406
  expect(body.content).toBe("hello");
2334
2407
  });
2335
2408
 
2409
+ // ---- Title-fallback resolution (additive — id/path/basename still win first) ----
2410
+
2411
+ test("GET /notes/:idOrPath resolves via H1 title when id and path both miss", async () => {
2412
+ await store.createNote("# My Great Note\n\nBody.", { path: "Inbox/2026-07-10-xyz" });
2413
+ const enc = encodeURIComponent("My Great Note");
2414
+ const res = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
2415
+ expect(res.status).toBe(200);
2416
+ const body = await res.json() as any;
2417
+ expect(body.path).toBe("Inbox/2026-07-10-xyz");
2418
+ });
2419
+
2420
+ test("GET /notes/:idOrPath exact path still wins over a same-named title on another note", async () => {
2421
+ const byPath = await store.createNote("Path note", { path: "My Great Note" });
2422
+ await store.createNote("# My Great Note\n\nOther body.", { path: "Inbox/other" });
2423
+ const enc = encodeURIComponent("My Great Note");
2424
+ const res = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
2425
+ const body = await res.json() as any;
2426
+ expect(body.id).toBe(byPath.id);
2427
+ expect(body.content).toBe("Path note");
2428
+ });
2429
+
2430
+ test("GET /notes/:idOrPath stays 404 when 2+ notes share the same H1 title", async () => {
2431
+ await store.createNote("# Dup Note\n\nA.", { path: "Inbox/dup-a" });
2432
+ await store.createNote("# Dup Note\n\nB.", { path: "Inbox/dup-b" });
2433
+ const enc = encodeURIComponent("Dup Note");
2434
+ const res = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
2435
+ expect(res.status).toBe(404);
2436
+ const body = await res.json() as any;
2437
+ expect(body.error_type).toBe("not_found");
2438
+ });
2439
+
2336
2440
  test("GET /notes/:id?include_content=false returns lean shape", async () => {
2337
2441
  await store.createNote("hello", { id: "x" });
2338
2442
  const res = await handleNotes(mkReq("GET", "/notes/x?include_content=false"), store, "/x");
@@ -4672,6 +4776,104 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
4672
4776
  expect(links[0]!.relationship).toBe("knows");
4673
4777
  });
4674
4778
 
4779
+ // vault#570 — content-parsed [[wikilinks]] to a missing target used to
4780
+ // fire NO write-time warning over REST either, mirroring the MCP fix.
4781
+ test("POST /notes with a content [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
4782
+ const res = await handleNotes(
4783
+ mkReq("POST", "/notes", { content: "See [[Nowhere REST]] for details." }),
4784
+ store,
4785
+ "",
4786
+ );
4787
+ expect(res.status).toBe(201);
4788
+ const body = await res.json() as any;
4789
+ expect(body.warnings).toBeDefined();
4790
+ expect(body.warnings[0].code).toBe("unresolved_link");
4791
+ expect(body.warnings[0].target).toBe("Nowhere REST");
4792
+ expect(await store.getLinks(body.id, { direction: "outbound" })).toHaveLength(0);
4793
+ });
4794
+
4795
+ // vault#570 — a target matching ≥2 notes is a distinct situation from a
4796
+ // genuine miss: `ambiguous_link`, not `unresolved_link`, and no edge.
4797
+ test("POST /notes with a content [[wikilink]] to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
4798
+ await store.createNote("A", { path: "Folder1/RestDup" });
4799
+ await store.createNote("B", { path: "Folder2/RestDup" });
4800
+ const res = await handleNotes(
4801
+ mkReq("POST", "/notes", { content: "See [[RestDup]] for details." }),
4802
+ store,
4803
+ "",
4804
+ );
4805
+ expect(res.status).toBe(201);
4806
+ const body = await res.json() as any;
4807
+ expect(body.warnings).toBeDefined();
4808
+ expect(body.warnings[0].code).toBe("ambiguous_link");
4809
+ expect(body.warnings[0].target).toBe("RestDup");
4810
+ expect(body.warnings[0].candidate_count).toBe(2);
4811
+ expect(await store.getLinks(body.id, { direction: "outbound" })).toHaveLength(0);
4812
+ });
4813
+
4814
+ // vault#570 — a structured `links` entry against an ambiguous target gets
4815
+ // the same treatment over REST (shared core implementation).
4816
+ test("POST /notes with a structured link to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
4817
+ await store.createNote("A", { path: "Folder1/RestDup2" });
4818
+ await store.createNote("B", { path: "Folder2/RestDup2" });
4819
+ const res = await handleNotes(
4820
+ mkReq("POST", "/notes", {
4821
+ content: "no wikilinks here",
4822
+ links: [{ target: "RestDup2", relationship: "knows" }],
4823
+ }),
4824
+ store,
4825
+ "",
4826
+ );
4827
+ expect(res.status).toBe(201);
4828
+ const body = await res.json() as any;
4829
+ expect(body.warnings).toBeDefined();
4830
+ expect(body.warnings[0].code).toBe("ambiguous_link");
4831
+ expect(body.warnings[0].candidate_count).toBe(2);
4832
+ expect(await store.getLinks(body.id, { direction: "outbound" })).toHaveLength(0);
4833
+ });
4834
+
4835
+ test("PATCH content update with a [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
4836
+ await store.createNote("plain", { id: "patchable", path: "PatchableRest" });
4837
+ const res = await handleNotes(
4838
+ mkReq("PATCH", "/notes/patchable", { content: "now references [[Not Yet Real REST]]", force: true }),
4839
+ store,
4840
+ "/patchable",
4841
+ );
4842
+ expect(res.status).toBe(200);
4843
+ const body = await res.json() as any;
4844
+ expect(body.warnings).toBeDefined();
4845
+ expect(body.warnings[0].code).toBe("unresolved_link");
4846
+ expect(body.warnings[0].target).toBe("Not Yet Real REST");
4847
+
4848
+ // A tags-only follow-up PATCH must not re-surface the warning.
4849
+ const tagOnly = await handleNotes(
4850
+ mkReq("PATCH", "/notes/patchable", { tags: { add: ["x"] }, force: true }),
4851
+ store,
4852
+ "/patchable",
4853
+ );
4854
+ const tagOnlyBody = await tagOnly.json() as any;
4855
+ expect(tagOnlyBody.warnings).toBeUndefined();
4856
+ });
4857
+
4858
+ // vault#570 — `if_missing: "create"` is a distinct create-shaped code
4859
+ // path in routes.ts (separate from POST /notes); it needs the same fix.
4860
+ test("PATCH if_missing:create with a [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
4861
+ const res = await handleNotes(
4862
+ mkReq("PATCH", "/notes/brand-new-rest", {
4863
+ if_missing: "create",
4864
+ content: "See [[Nowhere Upsert REST]].",
4865
+ }),
4866
+ store,
4867
+ "/brand-new-rest",
4868
+ );
4869
+ expect(res.status).toBe(200);
4870
+ const body = await res.json() as any;
4871
+ expect(body.created).toBe(true);
4872
+ expect(body.warnings).toBeDefined();
4873
+ expect(body.warnings[0].code).toBe("unresolved_link");
4874
+ expect(body.warnings[0].target).toBe("Nowhere Upsert REST");
4875
+ });
4876
+
4675
4877
  test("PATCH without a link mutation or flag does NOT include links", async () => {
4676
4878
  await store.createNote("a", { id: "a" });
4677
4879
  await store.createNote("b", { id: "b" });
@@ -6161,19 +6363,24 @@ describe("stateless MCP transport", async () => {
6161
6363
  expect(toolNames).toContain("list-tags");
6162
6364
  expect(toolNames).toContain("find-path");
6163
6365
  expect(toolNames).toContain("vault-info");
6366
+ // `doctor` moved admin → read (re-tier): a read-only, tag-scope-
6367
+ // restricted diagnostic, visible to a plain vault:read session.
6368
+ expect(toolNames).toContain("doctor");
6164
6369
  // Mutation tools are hidden — filter applied before advertising
6165
6370
  expect(toolNames).not.toContain("create-note");
6166
6371
  expect(toolNames).not.toContain("update-note");
6167
6372
  expect(toolNames).not.toContain("delete-note");
6373
+ // Tag-schema/taxonomy tools moved write → admin (re-tier): hidden from
6374
+ // a vault:read (and, per the next describe block, a plain vault:write)
6375
+ // session — they now need vault:admin.
6168
6376
  expect(toolNames).not.toContain("update-tag");
6169
6377
  expect(toolNames).not.toContain("delete-tag");
6170
6378
  expect(toolNames).not.toContain("rename-tag");
6171
6379
  expect(toolNames).not.toContain("merge-tags");
6172
6380
  // Admin tools (vault#376) are hidden too
6173
6381
  expect(toolNames).not.toContain("manage-token");
6174
- expect(toolNames).not.toContain("doctor");
6175
- // Read tier is exactly 4 tools.
6176
- expect(toolNames.length).toBe(4);
6382
+ // Read tier is exactly 5 tools (doctor added by the re-tier).
6383
+ expect(toolNames.length).toBe(5);
6177
6384
 
6178
6385
  closeAllStores();
6179
6386
  });
@@ -6221,7 +6428,7 @@ describe("stateless MCP transport", async () => {
6221
6428
  // the required scope — the inner guard fired even though the outer tool
6222
6429
  // gate allowed read-only callers through for stats.
6223
6430
  expect(body.result.isError).toBe(true);
6224
- expect(body.result.content[0].text).toContain("vault:write");
6431
+ expect(body.result.content[0].text).toContain("vault:admin");
6225
6432
 
6226
6433
  // And critically: the vault description must NOT have been mutated.
6227
6434
  const cfg = readVaultConfig(vaultName);
@@ -6230,7 +6437,7 @@ describe("stateless MCP transport", async () => {
6230
6437
  closeAllStores();
6231
6438
  });
6232
6439
 
6233
- test("tools/call of vault-info with description arg and vault:write scope is allowed", async () => {
6440
+ test("tools/call of vault-info with description arg and vault:write scope is refused (re-tier: description-write now needs admin)", async () => {
6234
6441
  const { handleScopedMcp } = await import("./mcp-http.ts");
6235
6442
  const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
6236
6443
  const { closeAllStores } = await import("./vault-store.ts");
@@ -6267,10 +6474,58 @@ describe("stateless MCP transport", async () => {
6267
6474
  scoped_tags: null,
6268
6475
  });
6269
6476
 
6477
+ expect(res.status).toBe(200);
6478
+ const body = await res.json() as any;
6479
+ // Was `isError: false` pre-re-tier — a plain vault:write session could
6480
+ // update the vault's own description. Now needs vault:admin.
6481
+ expect(body.result.isError).toBe(true);
6482
+ expect(body.result.content[0].text).toContain("vault:admin");
6483
+ expect(readVaultConfig(vaultName)?.description).toBe("original");
6484
+
6485
+ closeAllStores();
6486
+ });
6487
+
6488
+ test("tools/call of vault-info with description arg and vault:admin scope is allowed", async () => {
6489
+ const { handleScopedMcp } = await import("./mcp-http.ts");
6490
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
6491
+ const { closeAllStores } = await import("./vault-store.ts");
6492
+
6493
+ const vaultName = `scope-vault-info-admin-${Date.now()}`;
6494
+ writeVaultConfig({
6495
+ name: vaultName,
6496
+ api_keys: [],
6497
+ created_at: new Date().toISOString(),
6498
+ description: "original",
6499
+ });
6500
+
6501
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
6502
+ method: "POST",
6503
+ headers: {
6504
+ "content-type": "application/json",
6505
+ "accept": "application/json, text/event-stream",
6506
+ },
6507
+ body: JSON.stringify({
6508
+ jsonrpc: "2.0",
6509
+ id: 1,
6510
+ method: "tools/call",
6511
+ params: {
6512
+ name: "vault-info",
6513
+ arguments: { description: "updated via admin scope" },
6514
+ },
6515
+ }),
6516
+ });
6517
+
6518
+ const res = await handleScopedMcp(req, vaultName, {
6519
+ permission: "full",
6520
+ scopes: ["vault:read", "vault:write", "vault:admin"],
6521
+ legacyDerived: false,
6522
+ scoped_tags: null,
6523
+ });
6524
+
6270
6525
  expect(res.status).toBe(200);
6271
6526
  const body = await res.json() as any;
6272
6527
  expect(body.result.isError).toBeFalsy();
6273
- expect(readVaultConfig(vaultName)?.description).toBe("updated via write scope");
6528
+ expect(readVaultConfig(vaultName)?.description).toBe("updated via admin scope");
6274
6529
 
6275
6530
  closeAllStores();
6276
6531
  });
@@ -6406,15 +6661,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6406
6661
  return names;
6407
6662
  }
6408
6663
 
6409
- test("vault:read sees exactly the 4 read tools", async () => {
6664
+ test("vault:read sees exactly the 5 read tools (doctor moved admin → read)", async () => {
6410
6665
  const names = await listToolNames(["vault:read"]);
6411
6666
  expect(new Set(names)).toEqual(
6412
- new Set(["query-notes", "list-tags", "find-path", "vault-info"]),
6667
+ new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor"]),
6413
6668
  );
6414
- expect(names.length).toBe(4);
6669
+ expect(names.length).toBe(5);
6415
6670
  });
6416
6671
 
6417
- test("vault:read + vault:write sees the 11 read+write tools", async () => {
6672
+ test("vault:read + vault:write sees the 8 read+write tools (tag-schema tools moved write → admin)", async () => {
6418
6673
  const names = await listToolNames(["vault:read", "vault:write"]);
6419
6674
  expect(new Set(names)).toEqual(
6420
6675
  new Set([
@@ -6422,30 +6677,31 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6422
6677
  "list-tags",
6423
6678
  "find-path",
6424
6679
  "vault-info",
6680
+ "doctor",
6425
6681
  "create-note",
6426
6682
  "update-note",
6427
6683
  "delete-note",
6428
- "update-tag",
6429
- "delete-tag",
6430
- // vault#552: MCP parity with the pre-existing REST rename/merge engine.
6431
- "rename-tag",
6432
- "merge-tags",
6433
6684
  ]),
6434
6685
  );
6435
- expect(names.length).toBe(11);
6686
+ expect(names.length).toBe(8);
6436
6687
  expect(names).not.toContain("manage-token");
6437
- expect(names).not.toContain("doctor");
6438
- // Aaron 2026-05-27: delete-* are write-tier (same destructive verb as
6439
- // update). Only manage-token/prune-schema/doctor are admin-gated.
6688
+ // Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
6689
+ // admin-tier structure/taxonomy curation, not content authorship.
6690
+ // Only delete-note (content) stays write-tier.
6691
+ expect(names).not.toContain("update-tag");
6692
+ expect(names).not.toContain("delete-tag");
6693
+ expect(names).not.toContain("rename-tag");
6694
+ expect(names).not.toContain("merge-tags");
6440
6695
  expect(names).toContain("delete-note");
6441
- expect(names).toContain("delete-tag");
6442
6696
  });
6443
6697
 
6444
- test("vault:admin sees all 14 tools including manage-token + prune-schema + doctor", async () => {
6698
+ test("vault:admin sees all 14 tools including manage-token + prune-schema + the tag-schema tools", async () => {
6445
6699
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
6446
6700
  expect(names).toContain("manage-token");
6447
6701
  expect(names).toContain("prune-schema");
6448
6702
  expect(names).toContain("doctor");
6703
+ expect(names).toContain("update-tag");
6704
+ expect(names).toContain("delete-tag");
6449
6705
  expect(names).toContain("rename-tag");
6450
6706
  expect(names).toContain("merge-tags");
6451
6707
  expect(names.length).toBe(14);
@@ -6535,6 +6791,144 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6535
6791
  });
6536
6792
  });
6537
6793
 
6794
+ // ===========================================================================
6795
+ // Write/admin re-tier — schema/taxonomy-curation tools moved write → admin,
6796
+ // doctor moved admin → read. Content-authorship (write) is now separate from
6797
+ // structure/taxonomy/schema-curation (admin). No new scope — same
6798
+ // read/write/admin vocabulary, only which tier each tool requires moves.
6799
+ // ===========================================================================
6800
+
6801
+ describe("MCP write/admin re-tier — scope enforcement at the tools/call layer", () => {
6802
+ async function callTool(
6803
+ vaultName: string,
6804
+ scopes: string[],
6805
+ name: string,
6806
+ args: Record<string, unknown>,
6807
+ ): Promise<any> {
6808
+ const { handleScopedMcp } = await import("./mcp-http.ts");
6809
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
6810
+ method: "POST",
6811
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
6812
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
6813
+ });
6814
+ const res = await handleScopedMcp(req, vaultName, {
6815
+ permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
6816
+ scopes,
6817
+ legacyDerived: false,
6818
+ scoped_tags: null,
6819
+ } as any);
6820
+ return res.json();
6821
+ }
6822
+
6823
+ test("vault:write CAN create-note but is DENIED rename-tag/merge-tags/delete-tag/update-tag (now admin-tier)", async () => {
6824
+ const { writeVaultConfig } = await import("./config.ts");
6825
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
6826
+
6827
+ const vaultName = `retier-write-${Date.now()}`;
6828
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
6829
+ const store = getVaultStore(vaultName);
6830
+ await store.createNote("seed", { tags: ["mine"] });
6831
+
6832
+ const WRITE = ["vault:read", "vault:write"];
6833
+
6834
+ // Allowed: content authorship.
6835
+ const create = await callTool(vaultName, WRITE, "create-note", { content: "hello", tags: ["mine"] });
6836
+ expect(create.result?.isError).toBeFalsy();
6837
+
6838
+ // Denied: each now requires vault:admin, not vault:write.
6839
+ const rename = await callTool(vaultName, WRITE, "rename-tag", { old_name: "mine", new_name: "mine2" });
6840
+ expect(rename.result?.isError).toBe(true);
6841
+ expect(rename.result.content[0].text).toContain("Unknown tool");
6842
+
6843
+ const merge = await callTool(vaultName, WRITE, "merge-tags", { sources: ["mine"], target: "mine2" });
6844
+ expect(merge.result?.isError).toBe(true);
6845
+ expect(merge.result.content[0].text).toContain("Unknown tool");
6846
+
6847
+ const del = await callTool(vaultName, WRITE, "delete-tag", { tag: "mine" });
6848
+ expect(del.result?.isError).toBe(true);
6849
+ expect(del.result.content[0].text).toContain("Unknown tool");
6850
+
6851
+ const upd = await callTool(vaultName, WRITE, "update-tag", { tag: "mine", description: "hijacked" });
6852
+ expect(upd.result?.isError).toBe(true);
6853
+ expect(upd.result.content[0].text).toContain("Unknown tool");
6854
+
6855
+ // Untouched — the denied calls above never reached core. "mine" already
6856
+ // has a bare identity row (auto-inserted when the seed note was tagged),
6857
+ // but its description is still unset — update-tag never ran.
6858
+ expect((await store.getTagRecord("mine"))?.description ?? null).toBeNull();
6859
+ const stillThere = await store.listTags();
6860
+ expect(stillThere.some((t) => t.name === "mine")).toBe(true);
6861
+
6862
+ closeAllStores();
6863
+ });
6864
+
6865
+ test("vault:read CAN run doctor (now read-tier)", async () => {
6866
+ const { writeVaultConfig } = await import("./config.ts");
6867
+ const { closeAllStores } = await import("./vault-store.ts");
6868
+
6869
+ const vaultName = `retier-read-doctor-${Date.now()}`;
6870
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
6871
+
6872
+ const result = await callTool(vaultName, ["vault:read"], "doctor", {});
6873
+ expect(result.result?.isError).toBeFalsy();
6874
+ const report = JSON.parse(result.result.content[0].text);
6875
+ expect(report.findings).toBeDefined();
6876
+ expect(report.summary).toBeDefined();
6877
+
6878
+ closeAllStores();
6879
+ });
6880
+
6881
+ test("vault:write is DENIED the vault-info description write (now admin-tier)", async () => {
6882
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
6883
+ const { closeAllStores } = await import("./vault-store.ts");
6884
+
6885
+ const vaultName = `retier-write-vaultinfo-${Date.now()}`;
6886
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString(), description: "orig" });
6887
+
6888
+ const result = await callTool(vaultName, ["vault:read", "vault:write"], "vault-info", { description: "nope" });
6889
+ expect(result.result?.isError).toBe(true);
6890
+ expect(result.result.content[0].text).toContain("vault:admin");
6891
+ expect(readVaultConfig(vaultName)?.description).toBe("orig");
6892
+
6893
+ closeAllStores();
6894
+ });
6895
+
6896
+ test("vault:admin CAN do all of the above — create-note, rename/merge/delete/update-tag, doctor, and the vault-info description write", async () => {
6897
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
6898
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
6899
+
6900
+ const vaultName = `retier-admin-${Date.now()}`;
6901
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString(), description: "orig" });
6902
+ const store = getVaultStore(vaultName);
6903
+ await store.upsertTagRecord("a", {});
6904
+ await store.upsertTagRecord("b", {});
6905
+ await store.createNote("seed", { tags: ["a"] });
6906
+
6907
+ const ADMIN = ["vault:read", "vault:write", "vault:admin"];
6908
+
6909
+ const create = await callTool(vaultName, ADMIN, "create-note", { content: "hello", tags: ["a"] });
6910
+ expect(create.result?.isError).toBeFalsy();
6911
+
6912
+ const upd = await callTool(vaultName, ADMIN, "update-tag", { tag: "a", description: "updated" });
6913
+ expect(upd.result?.isError).toBeFalsy();
6914
+
6915
+ const doctor = await callTool(vaultName, ADMIN, "doctor", {});
6916
+ expect(doctor.result?.isError).toBeFalsy();
6917
+
6918
+ const vinfo = await callTool(vaultName, ADMIN, "vault-info", { description: "updated via admin" });
6919
+ expect(vinfo.result?.isError).toBeFalsy();
6920
+ expect(readVaultConfig(vaultName)?.description).toBe("updated via admin");
6921
+
6922
+ const merge = await callTool(vaultName, ADMIN, "merge-tags", { sources: ["b"], target: "a" });
6923
+ expect(merge.result?.isError).toBeFalsy();
6924
+
6925
+ const del = await callTool(vaultName, ADMIN, "delete-tag", { tag: "a" });
6926
+ expect(del.result?.isError).toBeFalsy();
6927
+
6928
+ closeAllStores();
6929
+ });
6930
+ });
6931
+
6538
6932
  // ===========================================================================
6539
6933
  // vault#376 — Change 2: manage-token mint/revoke/list
6540
6934
  // ===========================================================================
@@ -7317,3 +7711,57 @@ describe("handleVault: transcription capability (scribe-fold Phase 1)", async ()
7317
7711
  });
7318
7712
  });
7319
7713
 
7714
+ describe("handleVault: front-door structural map", async () => {
7715
+ test("GET always includes `map` — no ?include_stats needed", async () => {
7716
+ await store.createNote("a", { tags: ["person"], path: "People/Alice" });
7717
+ await store.createNote("b", { tags: ["person"] }); // no path
7718
+
7719
+ const cfg = { name: "default" } as { name: string };
7720
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
7721
+ expect(res.status).toBe(200);
7722
+ const body = await res.json() as any;
7723
+ expect(body.stats).toBeUndefined();
7724
+ expect(body.map).toBeTruthy();
7725
+ expect(body.map.total_notes).toBe(2);
7726
+ expect(body.map.tags).toEqual([{ name: "person", count: 2 }]);
7727
+ expect(body.map.path_buckets).toEqual([{ name: "People", count: 1 }]);
7728
+ expect(body.map.unfiled_notes).toBe(1);
7729
+ });
7730
+
7731
+ test("?include_stats=true adds stats ALONGSIDE map, not instead of it", async () => {
7732
+ await store.createNote("a", { tags: ["person"] });
7733
+ const cfg = { name: "default" } as { name: string };
7734
+ const res = await handleVault(mkReq("GET", "/vault?include_stats=true"), store, cfg as any);
7735
+ const body = await res.json() as any;
7736
+ expect(body.stats).toBeTruthy();
7737
+ expect(body.map).toBeTruthy();
7738
+ });
7739
+
7740
+ test("a tag-scoped caller's map covers only notes reachable through an in-scope tag", async () => {
7741
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
7742
+ await store.createNote("b", { tags: ["work"] }); // no path
7743
+ await store.createNote("c", { tags: ["personal"], path: "Personal/Two" });
7744
+
7745
+ const cfg = { name: "default" } as { name: string };
7746
+ const scope: TagScopeCtx = { allowed: await expandTokenTagScope(store, ["work"]), raw: ["work"] };
7747
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any, undefined, undefined, scope);
7748
+ const body = await res.json() as any;
7749
+
7750
+ expect(body.map.total_notes).toBe(2); // a, b — "c" (personal) excluded
7751
+ expect(body.map.tags).toEqual([{ name: "work", count: 2 }]);
7752
+ expect(body.map.path_buckets).toEqual([{ name: "Work", count: 1 }]);
7753
+ expect(body.map.unfiled_notes).toBe(1);
7754
+ });
7755
+
7756
+ test("a tag-scoped caller whose allowlist matches nothing gets an all-zero map, not the full vault", async () => {
7757
+ await store.createNote("a", { tags: ["work"], path: "Work/One" });
7758
+
7759
+ const cfg = { name: "default" } as { name: string };
7760
+ const scope: TagScopeCtx = { allowed: await expandTokenTagScope(store, ["nonexistent"]), raw: ["nonexistent"] };
7761
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any, undefined, undefined, scope);
7762
+ const body = await res.json() as any;
7763
+
7764
+ expect(body.map).toEqual({ total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 });
7765
+ });
7766
+ });
7767
+