@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.
- package/core/src/aggregate.test.ts +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +1151 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/doctor.ts +23 -1
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +587 -79
- package/core/src/notes.ts +371 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +42 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +98 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +244 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +100 -19
- package/src/routes.ts +469 -39
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +923 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- 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
|
-
|
|
5721
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
6669
|
+
expect(names.length).toBe(5);
|
|
5961
6670
|
});
|
|
5962
6671
|
|
|
5963
|
-
test("vault:read + vault:write sees the
|
|
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(
|
|
6686
|
+
expect(names.length).toBe(8);
|
|
5982
6687
|
expect(names).not.toContain("manage-token");
|
|
5983
|
-
|
|
5984
|
-
//
|
|
5985
|
-
//
|
|
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 +
|
|
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
|
+
|