@openparachute/vault 0.7.0-rc.8 → 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.
Files changed (38) hide show
  1. package/core/src/aggregate.test.ts +260 -0
  2. package/core/src/contract-taxonomy.test.ts +26 -2
  3. package/core/src/contract-typed-index.test.ts +4 -2
  4. package/core/src/core.test.ts +1151 -0
  5. package/core/src/cursor.ts +1 -0
  6. package/core/src/doctor.ts +23 -1
  7. package/core/src/indexed-fields.test.ts +4 -1
  8. package/core/src/indexed-fields.ts +6 -1
  9. package/core/src/links.ts +22 -0
  10. package/core/src/mcp.ts +587 -79
  11. package/core/src/notes.ts +371 -18
  12. package/core/src/query-warnings.ts +60 -12
  13. package/core/src/schema-defaults.ts +7 -1
  14. package/core/src/seed-packs.test.ts +14 -0
  15. package/core/src/seed-packs.ts +42 -5
  16. package/core/src/store.ts +129 -5
  17. package/core/src/tag-schemas.ts +20 -7
  18. package/core/src/types.ts +98 -0
  19. package/core/src/ulid.test.ts +56 -0
  20. package/core/src/ulid.ts +116 -0
  21. package/core/src/vault-projection.ts +19 -1
  22. package/core/src/wikilinks.test.ts +205 -1
  23. package/core/src/wikilinks.ts +244 -35
  24. package/package.json +1 -1
  25. package/src/aggregate-routes.test.ts +230 -0
  26. package/src/contract-errors.test.ts +2 -1
  27. package/src/contract-search.test.ts +17 -0
  28. package/src/mcp-link-warnings-scope.test.ts +144 -0
  29. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  30. package/src/mcp-tools.ts +100 -19
  31. package/src/routes.ts +469 -39
  32. package/src/routing.test.ts +167 -0
  33. package/src/routing.ts +47 -11
  34. package/src/tag-integrity-mcp.test.ts +45 -6
  35. package/src/tag-scope.ts +10 -1
  36. package/src/vault.test.ts +923 -21
  37. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  38. package/web/ui/dist/index.html +1 -1
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");
@@ -1815,6 +1888,155 @@ describe("scoped MCP wrapper", async () => {
1815
1888
 
1816
1889
  closeAllStores();
1817
1890
  });
1891
+
1892
+ // vault#555 auth-review CRITICAL: `if_exists` must NOT let a scoped MCP
1893
+ // session read/update/replace an out-of-scope note by naming its path.
1894
+ // The create-note wrapper pre-resolves the path and throws path_conflict on
1895
+ // an out-of-scope hit (byte-identical to a genuine conflict). Each assertion
1896
+ // MUST fail without the wrapper guard.
1897
+ test('scoped create-note if_exists:"ignore" does NOT return an out-of-scope note (throws path_conflict)', async () => {
1898
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1899
+ const { writeVaultConfig } = await import("./config.ts");
1900
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1901
+
1902
+ const vaultName = `tagscope-ifexists-ignore-${Date.now()}`;
1903
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1904
+ const store = getVaultStore(vaultName);
1905
+ await store.createNote("SECRET MCP PAYLOAD", { path: "Secret", tags: ["personal"] });
1906
+
1907
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1908
+ const create = tools.find((t) => t.name === "create-note")!;
1909
+ // in-scope incoming tag passes the item-tag pre-check; the OUT-OF-SCOPE
1910
+ // existing note at this path must still be blocked.
1911
+ await expect(
1912
+ create.execute({ content: "attempted read", path: "Secret", tags: ["work"], if_exists: "ignore" }),
1913
+ ).rejects.toThrow(/path_conflict/);
1914
+
1915
+ closeAllStores();
1916
+ });
1917
+
1918
+ test('scoped create-note if_exists:"update"/"replace" does NOT mutate an out-of-scope note', async () => {
1919
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1920
+ const { writeVaultConfig } = await import("./config.ts");
1921
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1922
+
1923
+ const vaultName = `tagscope-ifexists-mutate-${Date.now()}`;
1924
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1925
+ const store = getVaultStore(vaultName);
1926
+ const secret = await store.createNote("ORIGINAL SECRET", { path: "Secret", tags: ["personal"], metadata: { keep: "me" } });
1927
+
1928
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1929
+ const create = tools.find((t) => t.name === "create-note")!;
1930
+
1931
+ await expect(
1932
+ create.execute({ content: "OVERWRITE", path: "Secret", tags: ["work"], metadata: { injected: true }, if_exists: "update" }),
1933
+ ).rejects.toThrow(/path_conflict/);
1934
+ await expect(
1935
+ create.execute({ content: "REPLACE", path: "Secret", tags: ["work"], if_exists: "replace" }),
1936
+ ).rejects.toThrow(/path_conflict/);
1937
+
1938
+ // Ground truth: the out-of-scope note is untouched by both attempts.
1939
+ const onDisk = (await store.getNote(secret.id))!;
1940
+ expect(onDisk.content).toBe("ORIGINAL SECRET");
1941
+ expect(onDisk.metadata).toEqual({ keep: "me" });
1942
+ expect(onDisk.tags).toEqual(["personal"]);
1943
+
1944
+ closeAllStores();
1945
+ });
1946
+
1947
+ test("scoped create-note if_exists against an IN-scope note works normally (guard doesn't over-block)", async () => {
1948
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1949
+ const { writeVaultConfig } = await import("./config.ts");
1950
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1951
+
1952
+ const vaultName = `tagscope-ifexists-inscope-${Date.now()}`;
1953
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1954
+ const store = getVaultStore(vaultName);
1955
+ const existing = await store.createNote("WORK BODY", { path: "MyWork", tags: ["work"] });
1956
+
1957
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1958
+ const create = tools.find((t) => t.name === "create-note")!;
1959
+ const result = await create.execute({ content: "x", path: "MyWork", tags: ["work"], if_exists: "ignore" }) as any;
1960
+ expect(result.existed).toBe(true);
1961
+ expect(result.id).toBe(existing.id);
1962
+ expect(result.content).toBe("WORK BODY");
1963
+
1964
+ closeAllStores();
1965
+ });
1966
+
1967
+ // vault#555 auth-review CRITICAL (RACE PATH — the incomplete-first-fix gap).
1968
+ // The wrapper pre-check + core's proactive getNoteByPath can BOTH miss a note
1969
+ // that a concurrent writer INSERTs, after which core's race backstop
1970
+ // re-resolves the (now-existing, out-of-scope) winner and calls
1971
+ // applyExistingNote on it. Without the in-core `ifExistsVisible` guard the
1972
+ // out-of-scope content leaks / the note is mutated. We reproduce the exact
1973
+ // TOCTOU by monkeypatching store.createNote to (a) create the out-of-scope
1974
+ // note itself (simulating the concurrent winner) and (b) throw
1975
+ // PathConflictError — driving execution straight into the backstop. Both
1976
+ // assertions MUST fail without the in-core guard.
1977
+ async function raceVault(mode: "ignore" | "update" | "replace") {
1978
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1979
+ const { writeVaultConfig } = await import("./config.ts");
1980
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1981
+ const { PathConflictError } = await import("../core/src/notes.ts");
1982
+
1983
+ const vaultName = `tagscope-ifexists-race-${mode}-${Date.now()}`;
1984
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1985
+ const store = getVaultStore(vaultName);
1986
+
1987
+ // Monkeypatch createNote so the target path is created by a "concurrent
1988
+ // writer" with an OUT-OF-SCOPE tag, then the real INSERT loses the race.
1989
+ const origCreate = store.createNote.bind(store);
1990
+ let raced = false;
1991
+ (store as any).createNote = async (content: string, opts: any) => {
1992
+ if (opts?.path === "Secret" && !raced) {
1993
+ raced = true;
1994
+ await origCreate("TOP SECRET RACE PAYLOAD", { path: "Secret", tags: ["personal"] });
1995
+ throw new PathConflictError("Secret");
1996
+ }
1997
+ return origCreate(content, opts);
1998
+ };
1999
+
2000
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
2001
+ const create = tools.find((t) => t.name === "create-note")!;
2002
+ return { store, create, closeAllStores };
2003
+ }
2004
+
2005
+ test('scoped create-note if_exists:"ignore" RACE backstop does NOT leak out-of-scope content', async () => {
2006
+ const { store, create, closeAllStores } = await raceVault("ignore");
2007
+ await expect(
2008
+ create.execute({ content: "attempted", path: "Secret", tags: ["work"], if_exists: "ignore" }),
2009
+ ).rejects.toThrow(/path_conflict/);
2010
+ // The concurrently-created out-of-scope note is byte-unchanged and its
2011
+ // payload never reached the caller (the throw carries only the path).
2012
+ const onDisk = (await store.getNoteByPath("Secret"))!;
2013
+ expect(onDisk.content).toBe("TOP SECRET RACE PAYLOAD");
2014
+ expect(onDisk.tags).toEqual(["personal"]);
2015
+ closeAllStores();
2016
+ });
2017
+
2018
+ test('scoped create-note if_exists:"update" RACE backstop does NOT mutate the out-of-scope note', async () => {
2019
+ const { store, create, closeAllStores } = await raceVault("update");
2020
+ await expect(
2021
+ create.execute({ content: "OVERWRITE", path: "Secret", tags: ["work"], metadata: { injected: true }, if_exists: "update" }),
2022
+ ).rejects.toThrow(/path_conflict/);
2023
+ const onDisk = (await store.getNoteByPath("Secret"))!;
2024
+ expect(onDisk.content).toBe("TOP SECRET RACE PAYLOAD"); // unmutated
2025
+ expect(onDisk.metadata ?? {}).toEqual({});
2026
+ expect(onDisk.tags).toEqual(["personal"]);
2027
+ closeAllStores();
2028
+ });
2029
+
2030
+ test('scoped create-note if_exists:"replace" RACE backstop does NOT mutate the out-of-scope note', async () => {
2031
+ const { store, create, closeAllStores } = await raceVault("replace");
2032
+ await expect(
2033
+ create.execute({ content: "REPLACE", path: "Secret", tags: ["work"], if_exists: "replace" }),
2034
+ ).rejects.toThrow(/path_conflict/);
2035
+ const onDisk = (await store.getNoteByPath("Secret"))!;
2036
+ expect(onDisk.content).toBe("TOP SECRET RACE PAYLOAD"); // unmutated
2037
+ expect(onDisk.tags).toEqual(["personal"]);
2038
+ closeAllStores();
2039
+ });
1818
2040
  });
1819
2041
 
1820
2042
  describe("auth permissions", () => {
@@ -2184,6 +2406,37 @@ describe("HTTP /notes", async () => {
2184
2406
  expect(body.content).toBe("hello");
2185
2407
  });
2186
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
+
2187
2440
  test("GET /notes/:id?include_content=false returns lean shape", async () => {
2188
2441
  await store.createNote("hello", { id: "x" });
2189
2442
  const res = await handleNotes(mkReq("GET", "/notes/x?include_content=false"), store, "/x");
@@ -3988,6 +4241,111 @@ describe("HTTP tag-scope confidentiality (security review)", async () => {
3988
4241
  expect(targets).toContain("NoSuchPersonal");
3989
4242
  });
3990
4243
 
4244
+ // vault#555 auth-review CRITICAL: `if_exists` must NOT become a tag-scope
4245
+ // bypass. A scoped token naming an out-of-scope note's PATH must not read
4246
+ // (ignore), update, or replace it — treat it as a path_conflict (path taken,
4247
+ // invisible to this caller). Each assertion MUST fail without the guard in
4248
+ // applyExistingNote.
4249
+ test('if_exists:"ignore" does NOT return an out-of-scope note by path (POST /notes)', async () => {
4250
+ await store.createNote("SECRET WORK PAYLOAD", { path: "Secret", tags: ["personal"] });
4251
+
4252
+ const res = await handleNotes(
4253
+ mkReq("POST", "/notes", {
4254
+ content: "attempted read",
4255
+ path: "Secret",
4256
+ tags: ["work"], // in-scope incoming tag — passes the item-tag pre-check
4257
+ if_exists: "ignore",
4258
+ }),
4259
+ store,
4260
+ "",
4261
+ "v",
4262
+ await scopeCtx(["work"]),
4263
+ );
4264
+ // Path is taken but invisible to this caller → 409 path_conflict, and the
4265
+ // secret content appears NOWHERE in the response.
4266
+ expect(res.status).toBe(409);
4267
+ const text = await res.text();
4268
+ expect(text).not.toContain("SECRET WORK PAYLOAD");
4269
+ expect(JSON.parse(text).error_type).toBe("path_conflict");
4270
+ });
4271
+
4272
+ test('if_exists:"update" does NOT mutate an out-of-scope note by path (POST /notes)', async () => {
4273
+ const secret = await store.createNote("ORIGINAL SECRET", { path: "Secret", tags: ["personal"], metadata: { keep: "me" } });
4274
+
4275
+ const res = await handleNotes(
4276
+ mkReq("POST", "/notes", {
4277
+ content: "OVERWRITE ATTEMPT",
4278
+ path: "Secret",
4279
+ tags: ["work"],
4280
+ metadata: { injected: true },
4281
+ if_exists: "update",
4282
+ }),
4283
+ store,
4284
+ "",
4285
+ "v",
4286
+ await scopeCtx(["work"]),
4287
+ );
4288
+ expect(res.status).toBe(409);
4289
+ // Ground truth: the out-of-scope note is byte-for-byte unchanged.
4290
+ const onDisk = (await store.getNote(secret.id))!;
4291
+ expect(onDisk.content).toBe("ORIGINAL SECRET");
4292
+ expect(onDisk.metadata).toEqual({ keep: "me" });
4293
+ expect(onDisk.tags).toEqual(["personal"]); // "work" never applied
4294
+ });
4295
+
4296
+ test('if_exists:"replace" does NOT mutate an out-of-scope note by path (POST /notes)', async () => {
4297
+ const secret = await store.createNote("ORIGINAL SECRET", { path: "Secret", tags: ["personal"], metadata: { a: 1 } });
4298
+
4299
+ const res = await handleNotes(
4300
+ mkReq("POST", "/notes", {
4301
+ content: "REPLACE ATTEMPT",
4302
+ path: "Secret",
4303
+ tags: ["work"],
4304
+ if_exists: "replace",
4305
+ }),
4306
+ store,
4307
+ "",
4308
+ "v",
4309
+ await scopeCtx(["work"]),
4310
+ );
4311
+ expect(res.status).toBe(409);
4312
+ const onDisk = (await store.getNote(secret.id))!;
4313
+ expect(onDisk.content).toBe("ORIGINAL SECRET");
4314
+ expect(onDisk.metadata).toEqual({ a: 1 });
4315
+ });
4316
+
4317
+ test("UNSCOPED if_exists:ignore still returns the existing note (regression — guard is scope-gated)", async () => {
4318
+ const existing = await store.createNote("PLAIN BODY", { path: "Plain", tags: ["work"] });
4319
+ const res = await handleNotes(
4320
+ mkReq("POST", "/notes", { content: "x", path: "Plain", if_exists: "ignore" }),
4321
+ store,
4322
+ "",
4323
+ "v",
4324
+ NO_SCOPE,
4325
+ );
4326
+ expect(res.status).toBe(201);
4327
+ const body = await res.json() as any;
4328
+ expect(body.existed).toBe(true);
4329
+ expect(body.id).toBe(existing.id);
4330
+ expect(body.content).toBe("PLAIN BODY");
4331
+ });
4332
+
4333
+ test("in-scope if_exists:ignore against an in-scope note works normally (guard doesn't over-block)", async () => {
4334
+ const existing = await store.createNote("WORK BODY", { path: "MyWork", tags: ["work"] });
4335
+ const res = await handleNotes(
4336
+ mkReq("POST", "/notes", { content: "x", path: "MyWork", tags: ["work"], if_exists: "ignore" }),
4337
+ store,
4338
+ "",
4339
+ "v",
4340
+ await scopeCtx(["work"]),
4341
+ );
4342
+ expect(res.status).toBe(201);
4343
+ const body = await res.json() as any;
4344
+ expect(body.existed).toBe(true);
4345
+ expect(body.id).toBe(existing.id);
4346
+ expect(body.content).toBe("WORK BODY");
4347
+ });
4348
+
3991
4349
  });
3992
4350
 
3993
4351
  describe("HTTP /notes include_link_count + order_by=link_count (vault feedback #4)", async () => {
@@ -4418,6 +4776,104 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
4418
4776
  expect(links[0]!.relationship).toBe("knows");
4419
4777
  });
4420
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
+
4421
4877
  test("PATCH without a link mutation or flag does NOT include links", async () => {
4422
4878
  await store.createNote("a", { id: "a" });
4423
4879
  await store.createNote("b", { id: "b" });
@@ -4964,6 +5420,206 @@ describe("HTTP POST /notes — validation_status attachment (vault#287)", async
4964
5420
  });
4965
5421
  });
4966
5422
 
5423
+ // vault#555 — HTTP POST /notes with if_exists: idempotent upsert on a path
5424
+ // conflict. Mirrors the MCP create-note tool's if_exists contract exactly
5425
+ // (independent REST-layer reimplementation, same core primitives).
5426
+ describe("HTTP POST /notes — if_exists (vault#555)", async () => {
5427
+ test("default (no if_exists) still 409s — back-compat", async () => {
5428
+ await store.createNote("first", { path: "Inbox/rest-dup" });
5429
+ const res = await handleNotes(
5430
+ mkReq("POST", "/notes", { content: "second", path: "Inbox/rest-dup" }),
5431
+ store,
5432
+ "",
5433
+ );
5434
+ expect(res.status).toBe(409);
5435
+ const body = await res.json() as any;
5436
+ expect(body.error_type).toBe("path_conflict");
5437
+ });
5438
+
5439
+ test('if_exists:"ignore" returns 201 with the existing note untouched + existed:true', async () => {
5440
+ const first = await store.createNote("original", { path: "Inbox/rest-ignore", metadata: { v: 1 } });
5441
+ const res = await handleNotes(
5442
+ mkReq("POST", "/notes", {
5443
+ content: "attempted overwrite",
5444
+ path: "Inbox/rest-ignore",
5445
+ metadata: { v: 2 },
5446
+ if_exists: "ignore",
5447
+ }),
5448
+ store,
5449
+ "",
5450
+ );
5451
+ expect(res.status).toBe(201);
5452
+ const body = await res.json() as any;
5453
+ expect(body.existed).toBe(true);
5454
+ expect(body.id).toBe(first.id);
5455
+ expect(body.content).toBe("original");
5456
+ expect(body.metadata?.v).toBe(1);
5457
+ });
5458
+
5459
+ test('if_exists:"update" full-replaces content and RFC-7386-merges metadata', async () => {
5460
+ await store.createNote("v1", { path: "Inbox/rest-update", metadata: { a: 1, b: 2 } });
5461
+ const res = await handleNotes(
5462
+ mkReq("POST", "/notes", {
5463
+ content: "v2",
5464
+ path: "Inbox/rest-update",
5465
+ metadata: { b: null, c: 3 },
5466
+ if_exists: "update",
5467
+ }),
5468
+ store,
5469
+ "",
5470
+ );
5471
+ expect(res.status).toBe(201);
5472
+ const body = await res.json() as any;
5473
+ expect(body.existed).toBe(true);
5474
+ expect(body.content).toBe("v2");
5475
+ expect(body.metadata).toEqual({ a: 1, c: 3 });
5476
+ });
5477
+
5478
+ test('if_exists:"replace" wholesale-overwrites content + metadata, keeps id/createdAt', async () => {
5479
+ const first = await store.createNote("v1", { path: "Inbox/rest-replace", metadata: { a: 1, b: 2 } });
5480
+ const res = await handleNotes(
5481
+ mkReq("POST", "/notes", {
5482
+ content: "v2",
5483
+ path: "Inbox/rest-replace",
5484
+ metadata: { c: 3 },
5485
+ if_exists: "replace",
5486
+ }),
5487
+ store,
5488
+ "",
5489
+ );
5490
+ expect(res.status).toBe(201);
5491
+ const body = await res.json() as any;
5492
+ expect(body.existed).toBe(true);
5493
+ expect(body.id).toBe(first.id);
5494
+ expect(body.createdAt).toBe(first.createdAt);
5495
+ expect(body.content).toBe("v2");
5496
+ expect(body.metadata).toEqual({ c: 3 });
5497
+ });
5498
+
5499
+ test("plain POST (no if_exists) sees zero response-shape change", async () => {
5500
+ const res = await handleNotes(mkReq("POST", "/notes", { content: "plain" }), store, "");
5501
+ const body = await res.json() as any;
5502
+ expect("existed" in body).toBe(false);
5503
+ });
5504
+
5505
+ test("batch: if_exists is per-item — a top-level default is NOT inherited", async () => {
5506
+ await store.createNote("existing", { path: "Inbox/rest-batch-conflict" });
5507
+ const res = await handleNotes(
5508
+ mkReq("POST", "/notes", {
5509
+ if_exists: "ignore",
5510
+ notes: [{ content: "conflict", path: "Inbox/rest-batch-conflict" }],
5511
+ }),
5512
+ store,
5513
+ "",
5514
+ );
5515
+ expect(res.status).toBe(409);
5516
+ });
5517
+
5518
+ test("summary:true returns {created, ids, failed} for a batch", async () => {
5519
+ await store.createNote("pre-existing", { path: "Inbox/rest-sum-existing" });
5520
+ const res = await handleNotes(
5521
+ mkReq("POST", "/notes", {
5522
+ summary: true,
5523
+ notes: [
5524
+ { content: "fresh", path: "Inbox/rest-sum-fresh", if_exists: "ignore" },
5525
+ { content: "ignored", path: "Inbox/rest-sum-existing", if_exists: "ignore" },
5526
+ ],
5527
+ }),
5528
+ store,
5529
+ "",
5530
+ );
5531
+ expect(res.status).toBe(201);
5532
+ const body = await res.json() as any;
5533
+ expect(body.created).toBe(1);
5534
+ expect(body.ids).toHaveLength(2);
5535
+ expect(body.failed).toEqual([]);
5536
+ });
5537
+
5538
+ test("summary is ignored on a single-note POST", async () => {
5539
+ const res = await handleNotes(mkReq("POST", "/notes", { content: "solo", summary: true }), store, "");
5540
+ const body = await res.json() as any;
5541
+ expect(body.content).toBe("solo");
5542
+ expect(body.created).toBeUndefined();
5543
+ });
5544
+
5545
+ // vault#555 CRITICAL 2 (REST tripwire — the generalist-flagged coverage gap:
5546
+ // the core-side tests didn't guard the REST gate, so reverting routes.ts's
5547
+ // gate alone left all tests green). REST parity of the two core tests.
5548
+ test('if_exists:"update" with ONLY tags added still advances updated_at (POST /notes)', async () => {
5549
+ const first = await store.createNote("body", { path: "rest-tagonly", tags: ["alpha"] });
5550
+ const before = first.updatedAt!;
5551
+ await new Promise((r) => setTimeout(r, 5));
5552
+
5553
+ const res = await handleNotes(
5554
+ mkReq("POST", "/notes", { path: "rest-tagonly", tags: ["beta"], if_exists: "update" }),
5555
+ store,
5556
+ "",
5557
+ );
5558
+ expect(res.status).toBe(201);
5559
+ const body = await res.json() as any;
5560
+ expect(body.existed).toBe(true);
5561
+ expect(body.tags?.sort()).toEqual(["alpha", "beta"]);
5562
+ const after = (await store.getNote(first.id))!.updatedAt!;
5563
+ expect(after > before).toBe(true);
5564
+ });
5565
+
5566
+ test('if_exists:"update" with ONLY links added still advances updated_at (POST /notes)', async () => {
5567
+ await store.createNote("target", { id: "rest-tgt-linkonly", path: "Targets/rest-linkonly" });
5568
+ const first = await store.createNote("body", { path: "rest-linkonly", tags: ["alpha"] });
5569
+ const before = first.updatedAt!;
5570
+ await new Promise((r) => setTimeout(r, 5));
5571
+
5572
+ const res = await handleNotes(
5573
+ mkReq("POST", "/notes", {
5574
+ path: "rest-linkonly",
5575
+ links: [{ target: "rest-tgt-linkonly", relationship: "relates-to" }],
5576
+ if_exists: "update",
5577
+ }),
5578
+ store,
5579
+ "",
5580
+ );
5581
+ expect(res.status).toBe(201);
5582
+ const after = (await store.getNote(first.id))!.updatedAt!;
5583
+ expect(after > before).toBe(true);
5584
+ });
5585
+ });
5586
+
5587
+ // vault#555 — has_broken_links / include_broken_links on GET /notes.
5588
+ describe("HTTP GET /notes — has_broken_links / include_broken_links (vault#555)", async () => {
5589
+ test("has_broken_links=true filters to notes with a dangling wikilink", async () => {
5590
+ await store.createNote("[[Nowhere]]", { path: "rest-broken" });
5591
+ await store.createNote("clean", { path: "rest-clean" });
5592
+ const res = await handleNotes(mkReq("GET", "/notes?has_broken_links=true&include_content=true"), store, "");
5593
+ const body = await res.json() as any[];
5594
+ expect(body.map((n) => n.path)).toEqual(["rest-broken"]);
5595
+ });
5596
+
5597
+ test("has_broken_links=false excludes them", async () => {
5598
+ await store.createNote("[[Nowhere]]", { path: "rest-broken2" });
5599
+ await store.createNote("clean", { path: "rest-clean2" });
5600
+ const res = await handleNotes(mkReq("GET", "/notes?has_broken_links=false&include_content=true"), store, "");
5601
+ const body = await res.json() as any[];
5602
+ expect(body.map((n) => n.path)).toEqual(["rest-clean2"]);
5603
+ });
5604
+
5605
+ test("include_broken_links on a single note surfaces {target, relationship}", async () => {
5606
+ await store.createNote("[[Ghost]]", { path: "rest-single-broken" });
5607
+ const res = await handleNotes(mkReq("GET", "/notes?id=rest-single-broken&include_broken_links=true"), store, "");
5608
+ const body = await res.json() as any;
5609
+ expect(body.broken_links).toEqual([{ target: "Ghost", relationship: "wikilink" }]);
5610
+ });
5611
+
5612
+ test("include_broken_links in list mode is batched per note", async () => {
5613
+ await store.createNote("[[Ghost A]]", { path: "rest-list-a" });
5614
+ await store.createNote("clean", { path: "rest-list-b" });
5615
+ const res = await handleNotes(mkReq("GET", "/notes?include_broken_links=true&include_content=true"), store, "");
5616
+ const body = await res.json() as any[];
5617
+ const byPath = new Map(body.map((n: any) => [n.path, n.broken_links]));
5618
+ expect(byPath.get("rest-list-a")).toEqual([{ target: "Ghost A", relationship: "wikilink" }]);
5619
+ expect(byPath.get("rest-list-b")).toEqual([]);
5620
+ });
5621
+ });
5622
+
4967
5623
  // vault#309 — HTTP PATCH /notes/:id with if_missing: "create" mirrors
4968
5624
  // the MCP update-note path. Sync loops (Gitcoin Brain et al) use this
4969
5625
  // for idempotent upsert without a separate query-first round trip.
@@ -5707,19 +6363,24 @@ describe("stateless MCP transport", async () => {
5707
6363
  expect(toolNames).toContain("list-tags");
5708
6364
  expect(toolNames).toContain("find-path");
5709
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");
5710
6369
  // Mutation tools are hidden — filter applied before advertising
5711
6370
  expect(toolNames).not.toContain("create-note");
5712
6371
  expect(toolNames).not.toContain("update-note");
5713
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.
5714
6376
  expect(toolNames).not.toContain("update-tag");
5715
6377
  expect(toolNames).not.toContain("delete-tag");
5716
6378
  expect(toolNames).not.toContain("rename-tag");
5717
6379
  expect(toolNames).not.toContain("merge-tags");
5718
6380
  // Admin tools (vault#376) are hidden too
5719
6381
  expect(toolNames).not.toContain("manage-token");
5720
- expect(toolNames).not.toContain("doctor");
5721
- // Read tier is exactly 4 tools.
5722
- expect(toolNames.length).toBe(4);
6382
+ // Read tier is exactly 5 tools (doctor added by the re-tier).
6383
+ expect(toolNames.length).toBe(5);
5723
6384
 
5724
6385
  closeAllStores();
5725
6386
  });
@@ -5767,7 +6428,7 @@ describe("stateless MCP transport", async () => {
5767
6428
  // the required scope — the inner guard fired even though the outer tool
5768
6429
  // gate allowed read-only callers through for stats.
5769
6430
  expect(body.result.isError).toBe(true);
5770
- expect(body.result.content[0].text).toContain("vault:write");
6431
+ expect(body.result.content[0].text).toContain("vault:admin");
5771
6432
 
5772
6433
  // And critically: the vault description must NOT have been mutated.
5773
6434
  const cfg = readVaultConfig(vaultName);
@@ -5776,7 +6437,7 @@ describe("stateless MCP transport", async () => {
5776
6437
  closeAllStores();
5777
6438
  });
5778
6439
 
5779
- 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 () => {
5780
6441
  const { handleScopedMcp } = await import("./mcp-http.ts");
5781
6442
  const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
5782
6443
  const { closeAllStores } = await import("./vault-store.ts");
@@ -5813,10 +6474,58 @@ describe("stateless MCP transport", async () => {
5813
6474
  scoped_tags: null,
5814
6475
  });
5815
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
+
5816
6525
  expect(res.status).toBe(200);
5817
6526
  const body = await res.json() as any;
5818
6527
  expect(body.result.isError).toBeFalsy();
5819
- expect(readVaultConfig(vaultName)?.description).toBe("updated via write scope");
6528
+ expect(readVaultConfig(vaultName)?.description).toBe("updated via admin scope");
5820
6529
 
5821
6530
  closeAllStores();
5822
6531
  });
@@ -5952,15 +6661,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
5952
6661
  return names;
5953
6662
  }
5954
6663
 
5955
- 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 () => {
5956
6665
  const names = await listToolNames(["vault:read"]);
5957
6666
  expect(new Set(names)).toEqual(
5958
- new Set(["query-notes", "list-tags", "find-path", "vault-info"]),
6667
+ new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor"]),
5959
6668
  );
5960
- expect(names.length).toBe(4);
6669
+ expect(names.length).toBe(5);
5961
6670
  });
5962
6671
 
5963
- 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 () => {
5964
6673
  const names = await listToolNames(["vault:read", "vault:write"]);
5965
6674
  expect(new Set(names)).toEqual(
5966
6675
  new Set([
@@ -5968,30 +6677,31 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
5968
6677
  "list-tags",
5969
6678
  "find-path",
5970
6679
  "vault-info",
6680
+ "doctor",
5971
6681
  "create-note",
5972
6682
  "update-note",
5973
6683
  "delete-note",
5974
- "update-tag",
5975
- "delete-tag",
5976
- // vault#552: MCP parity with the pre-existing REST rename/merge engine.
5977
- "rename-tag",
5978
- "merge-tags",
5979
6684
  ]),
5980
6685
  );
5981
- expect(names.length).toBe(11);
6686
+ expect(names.length).toBe(8);
5982
6687
  expect(names).not.toContain("manage-token");
5983
- expect(names).not.toContain("doctor");
5984
- // Aaron 2026-05-27: delete-* are write-tier (same destructive verb as
5985
- // 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");
5986
6695
  expect(names).toContain("delete-note");
5987
- expect(names).toContain("delete-tag");
5988
6696
  });
5989
6697
 
5990
- 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 () => {
5991
6699
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
5992
6700
  expect(names).toContain("manage-token");
5993
6701
  expect(names).toContain("prune-schema");
5994
6702
  expect(names).toContain("doctor");
6703
+ expect(names).toContain("update-tag");
6704
+ expect(names).toContain("delete-tag");
5995
6705
  expect(names).toContain("rename-tag");
5996
6706
  expect(names).toContain("merge-tags");
5997
6707
  expect(names.length).toBe(14);
@@ -6081,6 +6791,144 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6081
6791
  });
6082
6792
  });
6083
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
+
6084
6932
  // ===========================================================================
6085
6933
  // vault#376 — Change 2: manage-token mint/revoke/list
6086
6934
  // ===========================================================================
@@ -6863,3 +7711,57 @@ describe("handleVault: transcription capability (scribe-fold Phase 1)", async ()
6863
7711
  });
6864
7712
  });
6865
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
+