@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.4

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/core.test.ts +546 -0
  2. package/core/src/cursor.ts +1 -0
  3. package/core/src/link-count.test.ts +29 -0
  4. package/core/src/links.ts +43 -0
  5. package/core/src/mcp-manifest.ts +10 -6
  6. package/core/src/mcp.ts +129 -44
  7. package/core/src/notes.ts +51 -2
  8. package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
  9. package/core/src/schema.ts +28 -1
  10. package/core/src/store.ts +180 -57
  11. package/core/src/txn.test.ts +33 -1
  12. package/core/src/txn.ts +80 -7
  13. package/core/src/types.ts +11 -0
  14. package/core/src/vault-projection.ts +8 -1
  15. package/core/src/wikilinks.ts +873 -44
  16. package/package.json +2 -2
  17. package/src/aggregate-routes.test.ts +76 -0
  18. package/src/config.ts +8 -2
  19. package/src/mcp-http.test.ts +51 -1
  20. package/src/mcp-tools.ts +106 -5
  21. package/src/mirror-routes.test.ts +47 -0
  22. package/src/release-plan.test.ts +90 -1
  23. package/src/routes.ts +280 -66
  24. package/src/routing.test.ts +67 -0
  25. package/src/tag-scope-query-tag.test.ts +374 -0
  26. package/src/tag-scope.ts +116 -0
  27. package/src/test-support/vault-714-find-path.json +58 -0
  28. package/src/test-support/vault-714-graph.json +27 -0
  29. package/src/test-support/vault-714-has_links.json +96 -0
  30. package/src/test-support/vault-714-include_broken_links.json +118 -0
  31. package/src/test-support/vault-714-include_link_count.json +348 -0
  32. package/src/test-support/vault-714-include_links.json +86 -0
  33. package/src/test-support/vault-714-near.json +78 -0
  34. package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
  35. package/src/vault.test.ts +928 -1
  36. package/src/write-warnings-scope.test.ts +344 -0
  37. package/src/ws-server.ts +16 -3
  38. package/src/ws-subscribe.test.ts +76 -2
package/src/vault.test.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, test, expect, beforeEach, afterEach } from "bun:test";
6
6
  import { Database } from "bun:sqlite";
7
- import { mkdirSync, rmSync, existsSync, writeFileSync } from "fs";
7
+ import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "fs";
8
8
  import { join } from "path";
9
9
  import { tmpdir } from "os";
10
10
  import { BunStore } from "./vault-store.ts";
@@ -35,6 +35,138 @@ afterEach(() => {
35
35
  rmSync(tmpDir, { recursive: true, force: true });
36
36
  });
37
37
 
38
+
39
+ // vault#714: one deterministic fixture through the real scoped MCP wrapper and
40
+ // REST handlers. The unscoped JSON fixture was captured on 20d0f6f before fixes.
41
+ async function edgeSuppressionFixture(door: "MCP" | "REST", name: string, scoped = true, shape = "F1", semantic = false) {
42
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
43
+ const { writeVaultConfig } = await import("./config.ts");
44
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
45
+ const vaultName = `tagscope-edge-${name.toLowerCase().replace(/[^a-z0-9]/g, "-")}`;
46
+ let fixtureStore = store;
47
+ if (door === "MCP") {
48
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: "2026-01-01T00:00:00.000Z" });
49
+ fixtureStore = getVaultStore(vaultName);
50
+ }
51
+ const note = (id: string, path: string, content: string, tag: string) =>
52
+ fixtureStore.createNote(content, { id, path, tags: [tag], created_at: "2026-01-01T00:00:00.000Z" });
53
+ const hidden = await note("hidden-dup", "p1/Dup", "personal dup", "personal");
54
+ if (shape === "F1") {
55
+ const competing = await note("visible-dup", "wdup/Dup", "work dup", "work");
56
+ await note("work-src", "work-src", "src [[Dup]]", "work");
57
+ await fixtureStore.deleteNote(competing.id);
58
+ } else {
59
+ await note("work-src", "work-src", "src", "work");
60
+ if (shape === "structured") await fixtureStore.createLink("work-src", hidden.id, "references");
61
+ if (shape === "F2") {
62
+ await note("work-tgt", "work-tgt", "target", "work");
63
+ await note("hidden-in", "p2/In", "see [[work-tgt]]", "personal");
64
+ }
65
+ if (shape === "F3") {
66
+ await note("work-tgt", "work-tgt", "target", "work");
67
+ await note("hidden-mid", "p3/Mid", "mid", "personal");
68
+ await fixtureStore.createLink("work-src", "hidden-mid", "references");
69
+ await fixtureStore.createLink("hidden-mid", "work-tgt", "references");
70
+ }
71
+ }
72
+ if (semantic) {
73
+ // Exercise the real semantic engine with deterministic local vectors.
74
+ fixtureStore = new BunStore(fixtureStore.db, { embeddingProvider: {
75
+ name: "mock", model: "vault-714-test", dims: 4,
76
+ available: async () => ({ ok: true }),
77
+ embed: async (input) => ({ vectors: input.texts.map(() => new Float32Array([1, 0, 0, 0])), model: "vault-714-test", dims: 4 }),
78
+ } });
79
+ const { encodeVector } = await import("../core/src/embedding/vector-codec.ts");
80
+ for (const n of await fixtureStore.queryNotes({})) {
81
+ fixtureStore.db.prepare("INSERT INTO note_vectors (note_id, chunk_ix, vector, dims, model, content_hash, embedded_at) VALUES (?, 0, ?, 4, ?, ?, ?)")
82
+ .run(n.id, encodeVector(new Float32Array([1, 0, 0, 0])), "vault-714-test", "hash", "2026-01-01T00:00:00.000Z");
83
+ }
84
+ }
85
+ fixtureStore.db.prepare("UPDATE notes SET updated_at = created_at").run();
86
+ fixtureStore.db.prepare("UPDATE links SET created_at = ?").run("2026-01-01T00:00:00.000Z");
87
+ const authForTags = (tags: string[]) => ({
88
+ scopes: ["vault:read", "vault:write", "vault:admin"], legacyDerived: false, scoped_tags: tags,
89
+ });
90
+ const tools = door === "MCP" ? generateScopedMcpTools(vaultName, scoped ? authForTags(["work"]) as any : undefined) : [];
91
+ const scope: TagScopeCtx = scoped
92
+ ? { allowed: await expandTokenTagScope(fixtureStore, ["work"]), raw: ["work"] }
93
+ : { allowed: null, raw: null };
94
+ const restQuery = (params: Record<string, any>) => {
95
+ const search = new URLSearchParams();
96
+ for (const [key, value] of Object.entries(params)) {
97
+ if (typeof value === "object") {
98
+ for (const [k, v] of Object.entries(value)) search.set(`${key}[${k}]`, String(v));
99
+ } else search.set(key, String(value));
100
+ }
101
+ return handleNotes(mkReq("GET", `/notes?${search}`), fixtureStore, "", "v", scope);
102
+ };
103
+ const query = async (params: Record<string, any>): Promise<any> => door === "MCP"
104
+ ? tools.find((t) => t.name === "query-notes")!.execute(params)
105
+ : (await restQuery(params)).json();
106
+ const find = async (target: string): Promise<any> => door === "MCP"
107
+ ? tools.find((t) => t.name === "find-path")!.execute({ source: "work-src", target })
108
+ : (await handleFindPath(mkReq("GET", `/find-path?source=work-src&target=${encodeURIComponent(target)}`), fixtureStore, scope)).json();
109
+ const unresolved = async (): Promise<any> => handleUnresolvedWikilinks(mkReq("GET", "/unresolved-wikilinks"), fixtureStore, scope).json();
110
+ const leak = (resp: unknown) => {
111
+ const json = JSON.stringify(resp);
112
+ for (const secret of [hidden.id, "p1/Dup", "hidden-mid", "p3/Mid", "hidden-in", "p2/In"]) expect(json).not.toContain(secret);
113
+ };
114
+ return { door, query, find, unresolved, leak, hidden, store: fixtureStore, scope, close: closeAllStores };
115
+ }
116
+
117
+ type EdgeFixture = Awaited<ReturnType<typeof edgeSuppressionFixture>>;
118
+ const edgeScopedPins: { name: string; shape?: string; run: (f: EdgeFixture) => Promise<void> }[] = [
119
+ { name: "both degree suppresses hidden outbound", run: async (f) => {
120
+ const r = await f.query({ id: "work-src", include_link_count: true }); f.leak(r); expect(r.linkCount).toBe(0);
121
+ const list = await f.query({ include_link_count: true }); f.leak(list); expect(list[0].linkCount).toBe(0);
122
+ } },
123
+ { name: "outbound degree suppresses hidden target", run: async (f) => {
124
+ const r = await f.query({ id: "work-src", include_link_count: true, link_count_direction: "outbound" }); f.leak(r); expect(r.linkCount).toBe(0);
125
+ } },
126
+ { name: "inbound degree suppresses hidden source", shape: "F2", run: async (f) => {
127
+ const r = await f.query({ id: "work-tgt", include_link_count: true, link_count_direction: "inbound" }); f.leak(r); expect(r.linkCount).toBe(0);
128
+ } },
129
+ { name: "has_links true excludes hidden-only edge", run: async (f) => {
130
+ const r = await f.query({ has_links: true }); f.leak(r); expect(r.map((n: any) => n.id)).not.toContain("work-src");
131
+ } },
132
+ { name: "has_links false includes hidden-only edge", run: async (f) => {
133
+ const r = await f.query({ has_links: false }); f.leak(r); expect(r.map((n: any) => n.id)).toContain("work-src");
134
+ } },
135
+ { name: "has_links aggregate counts visible edges", run: async (f) => {
136
+ const r = await f.query({ has_links: true, aggregate: { op: "count" } }); f.leak(r); expect(r).toEqual([{ group: null, value: 0 }]);
137
+ } },
138
+ { name: "content link to hidden target is broken", run: async (f) => {
139
+ const r = await f.query({ id: "work-src", include_broken_links: true }); f.leak(r); expect(r.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
140
+ // A visible same-named candidate prevents brokenness, but PR1 must not
141
+ // synthesize an edge to it. Force the stale resolved-row state explicitly.
142
+ await f.store.createNote("visible dup", { id: "visible-dup", path: "wdup/Dup", tags: ["work"] });
143
+ f.store.db.prepare("DELETE FROM links WHERE source_id = ?").run("work-src");
144
+ await f.store.createLink("work-src", f.hidden.id, "wikilink");
145
+ const stale = await f.query({ id: "work-src", include_broken_links: true, include_link_count: true, include_links: true });
146
+ f.leak(stale); expect(stale.broken_links).toEqual([]); expect(stale.linkCount).toBe(0); expect(stale.links).toEqual([]);
147
+ } },
148
+ { name: "has_broken_links true includes hidden target", run: async (f) => {
149
+ const r = await f.query({ has_broken_links: true }); f.leak(r); expect(r.map((n: any) => n.id)).toContain("work-src");
150
+ } },
151
+ { name: "has_broken_links false excludes hidden target", run: async (f) => {
152
+ const r = await f.query({ has_broken_links: false }); f.leak(r); expect(r.map((n: any) => n.id)).not.toContain("work-src");
153
+ } },
154
+ { name: "structured hidden edge suppressed without naming it broken", shape: "structured", run: async (f) => {
155
+ const r = await f.query({ id: "work-src", include_link_count: true, include_broken_links: true, include_links: true }); f.leak(r);
156
+ expect(r.broken_links).toEqual([]); expect(r.links).toEqual([]); expect(r.linkCount).toBe(0);
157
+ expect(await f.query({ has_links: true })).toEqual([]);
158
+ expect((await f.query({ has_links: false })).map((n: any) => n.id)).toContain("work-src");
159
+ } },
160
+ { name: "confirming include_links and near stay walled", run: async (f) => {
161
+ const r = await f.query({ id: "work-src", include_links: true }); f.leak(r); expect(r.links).toEqual([]);
162
+ const near = await f.query({ near: { note_id: "work-src", depth: 1 } }); f.leak(near);
163
+ if (f.door === "MCP") { const path = await f.find("p1/Dup"); f.leak(path); expect(path).toBeNull(); }
164
+ } },
165
+ { name: "confirming hidden intermediate path stays null", shape: "F3", run: async (f) => {
166
+ const r = await f.find("work-tgt"); f.leak(r); expect(r).toBeNull();
167
+ } },
168
+ ];
169
+
38
170
  describe("BunStore", async () => {
39
171
  test("creates and retrieves a note", async () => {
40
172
  const note = await store.createNote("Hello world");
@@ -1175,6 +1307,13 @@ describe("scoped MCP wrapper", async () => {
1175
1307
  // baseline scoped MCP tests above; here we only assert the *scoped*
1176
1308
  // path's deltas.
1177
1309
 
1310
+ for (const pin of edgeScopedPins) {
1311
+ test(`vault#714 MCP: ${pin.name}`, async () => {
1312
+ const f = await edgeSuppressionFixture("MCP", `MCP-${pin.name}`, true, pin.shape);
1313
+ try { await pin.run(f); } finally { f.close(); }
1314
+ });
1315
+ }
1316
+
1178
1317
  function authForTags(tags: string[]) {
1179
1318
  return {
1180
1319
  scopes: ["vault:read", "vault:write", "vault:admin"],
@@ -1803,6 +1942,191 @@ describe("scoped MCP wrapper", async () => {
1803
1942
  closeAllStores();
1804
1943
  });
1805
1944
 
1945
+ // vault#581 auth-review MUST-FIX, MCP door. Same contract as the REST
1946
+ // twins in the "HTTP tag-scope confidentiality" block: a scoped reader's
1947
+ // ambiguous-links answer is exactly what an unscoped reader would get on a
1948
+ // vault containing only the notes it can see.
1949
+ async function ambiguityVault(prefix: string, secondDupTags: string[]) {
1950
+ const { writeVaultConfig } = await import("./config.ts");
1951
+ const { getVaultStore } = await import("./vault-store.ts");
1952
+ const vaultName = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1953
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1954
+ const store = getVaultStore(vaultName);
1955
+ await store.createNote("work dup", { path: "wdup/Dup", tags: ["work"] });
1956
+ await store.createNote("other dup", { path: "odup/Dup", tags: secondDupTags });
1957
+ const src = await store.createNote("src [[Dup]]", { path: "work-src", tags: ["work"] });
1958
+ return { vaultName, src };
1959
+ }
1960
+
1961
+ test("MCP include_ambiguous_links does NOT reveal an out-of-scope colliding note", async () => {
1962
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1963
+ const { closeAllStores } = await import("./vault-store.ts");
1964
+ const { vaultName, src } = await ambiguityVault("tagscope-amb", ["personal"]);
1965
+
1966
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1967
+ const query = tools.find((t) => t.name === "query-notes")!;
1968
+ const result = await query.execute({ id: src.id, include_ambiguous_links: true }) as any;
1969
+ expect(result.ambiguous_links).toEqual([]);
1970
+ expect(JSON.stringify(result)).not.toContain("odup/Dup");
1971
+
1972
+ closeAllStores();
1973
+ });
1974
+
1975
+ test("MCP has_ambiguous_links does not let a scoped session sweep for cross-scope collisions", async () => {
1976
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1977
+ const { closeAllStores } = await import("./vault-store.ts");
1978
+ const { vaultName, src } = await ambiguityVault("tagscope-amb-sweep", ["personal"]);
1979
+
1980
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1981
+ const query = tools.find((t) => t.name === "query-notes")!;
1982
+ const truthy = await query.execute({ has_ambiguous_links: true }) as any[];
1983
+ expect(truthy.map((n: any) => n.id)).not.toContain(src.id);
1984
+ expect(truthy).toEqual([]);
1985
+ // ...and the same fact must not leak by ABSENCE from the false query.
1986
+ const falsy = await query.execute({ has_ambiguous_links: false }) as any[];
1987
+ expect(falsy.map((n: any) => n.id)).toContain(src.id);
1988
+
1989
+ closeAllStores();
1990
+ });
1991
+
1992
+ test("MCP POSITIVE CONTROL: both colliding notes in scope → real ambiguity, count 2", async () => {
1993
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1994
+ const { closeAllStores } = await import("./vault-store.ts");
1995
+ const { vaultName, src } = await ambiguityVault("tagscope-amb-pos", ["work"]);
1996
+
1997
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1998
+ const query = tools.find((t) => t.name === "query-notes")!;
1999
+ const result = await query.execute({ id: src.id, include_ambiguous_links: true }) as any;
2000
+ expect(result.ambiguous_links)
2001
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
2002
+ expect(((await query.execute({ has_ambiguous_links: true })) as any[]).map((n: any) => n.id))
2003
+ .toEqual([src.id]);
2004
+
2005
+ closeAllStores();
2006
+ });
2007
+
2008
+ test("UNSCOPED MCP still sees the cross-scope collision in full (regression)", async () => {
2009
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
2010
+ const { closeAllStores } = await import("./vault-store.ts");
2011
+ const { vaultName, src } = await ambiguityVault("tagscope-amb-unscoped", ["personal"]);
2012
+
2013
+ const tools = generateScopedMcpTools(vaultName);
2014
+ const query = tools.find((t) => t.name === "query-notes")!;
2015
+ const result = await query.execute({ id: src.id, include_ambiguous_links: true }) as any;
2016
+ expect(result.ambiguous_links)
2017
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
2018
+ expect(((await query.execute({ has_ambiguous_links: true })) as any[]).map((n: any) => n.id))
2019
+ .toEqual([src.id]);
2020
+
2021
+ closeAllStores();
2022
+ });
2023
+
2024
+ // ---------------------------------------------------------------------
2025
+ // vault#239 / vault#707 review sub-case — the DELETE self-heal is
2026
+ // scope-blind. `refreshAmbiguousLinks` demotes an ambiguous row to
2027
+ // `unresolved_wikilinks` once its last candidate is deleted, so a
2028
+ // `work`-scoped reader's own note flips from "no broken links" to
2029
+ // "has a broken link" purely because notes it can never see were
2030
+ // deleted — a timing oracle for exactly the cross-scope naming
2031
+ // collision vault#707's `candidate_count` narrowing was added to hide.
2032
+ //
2033
+ // The stable, sub-vault-correct answer (vault#707's rule: a scoped
2034
+ // reader gets what an unscoped reader would get on a vault containing
2035
+ // only the notes it can see) is BROKEN in BOTH states: `[[Dup]]`
2036
+ // matches zero VISIBLE notes before the deletes and zero after.
2037
+ async function hiddenOnlyDupVault(prefix: string) {
2038
+ const { writeVaultConfig } = await import("./config.ts");
2039
+ const { getVaultStore } = await import("./vault-store.ts");
2040
+ const vaultName = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2041
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
2042
+ const store = getVaultStore(vaultName);
2043
+ const h1 = await store.createNote("hidden dup one", { path: "p1/Dup", tags: ["personal"] });
2044
+ const h2 = await store.createNote("hidden dup two", { path: "p2/Dup", tags: ["personal"] });
2045
+ const src = await store.createNote("src [[Dup]]", { path: "work-src", tags: ["work"] });
2046
+ return { vaultName, store, src, hidden: [h1, h2] };
2047
+ }
2048
+
2049
+ test("MCP: a [[Dup]] whose only candidates are out of scope reads as BROKEN for the scoped reader", async () => {
2050
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
2051
+ const { closeAllStores } = await import("./vault-store.ts");
2052
+ const { vaultName, src } = await hiddenOnlyDupVault("tagscope-brk");
2053
+
2054
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
2055
+ const query = tools.find((t) => t.name === "query-notes")!;
2056
+ const result = await query.execute({ id: src.id, include_broken_links: true }) as any;
2057
+ expect(result.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
2058
+ // ...and the presence filter agrees on both polarities.
2059
+ expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
2060
+ .toContain(src.id);
2061
+ expect(((await query.execute({ has_broken_links: false })) as any[]).map((n: any) => n.id))
2062
+ .not.toContain(src.id);
2063
+
2064
+ closeAllStores();
2065
+ });
2066
+
2067
+ test("MCP: deleting the last OUT-OF-SCOPE candidate does not change the scoped reader's broken-link view (vault#239)", async () => {
2068
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
2069
+ const { closeAllStores } = await import("./vault-store.ts");
2070
+ const { vaultName, store, src, hidden } = await hiddenOnlyDupVault("tagscope-brk-heal");
2071
+
2072
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
2073
+ const query = tools.find((t) => t.name === "query-notes")!;
2074
+ const snapshot = async () => ({
2075
+ broken: (await query.execute({ id: src.id, include_broken_links: true }) as any).broken_links,
2076
+ truthy: ((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id),
2077
+ falsy: ((await query.execute({ has_broken_links: false })) as any[]).map((n: any) => n.id),
2078
+ });
2079
+
2080
+ const before = await snapshot();
2081
+ for (const h of hidden) await store.deleteNote(h.id);
2082
+ const after = await snapshot();
2083
+
2084
+ expect(after).toEqual(before);
2085
+ expect(after.broken).toEqual([{ target: "Dup", relationship: "wikilink" }]);
2086
+
2087
+ closeAllStores();
2088
+ });
2089
+
2090
+ test("MCP NEGATIVE CONTROL: one IN-SCOPE candidate → not broken for the scoped reader", async () => {
2091
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
2092
+ const { closeAllStores } = await import("./vault-store.ts");
2093
+ const { vaultName, src } = await ambiguityVault("tagscope-brk-neg", ["personal"]);
2094
+
2095
+ // `wdup/Dup` is in scope, `odup/Dup` is not — the reference resolves in
2096
+ // the reader's sub-vault, so it must NOT be reported broken.
2097
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
2098
+ const query = tools.find((t) => t.name === "query-notes")!;
2099
+ expect((await query.execute({ id: src.id, include_broken_links: true }) as any).broken_links)
2100
+ .toEqual([]);
2101
+ expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
2102
+ .not.toContain(src.id);
2103
+
2104
+ closeAllStores();
2105
+ });
2106
+
2107
+ test("UNSCOPED MCP is unchanged: ambiguous before the deletes, broken after (regression)", async () => {
2108
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
2109
+ const { closeAllStores } = await import("./vault-store.ts");
2110
+ const { vaultName, store, src, hidden } = await hiddenOnlyDupVault("tagscope-brk-unscoped");
2111
+
2112
+ const tools = generateScopedMcpTools(vaultName);
2113
+ const query = tools.find((t) => t.name === "query-notes")!;
2114
+ const inc = { id: src.id, include_broken_links: true, include_ambiguous_links: true };
2115
+
2116
+ const before = await query.execute(inc) as any;
2117
+ expect(before.broken_links).toEqual([]);
2118
+ expect(before.ambiguous_links)
2119
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
2120
+
2121
+ for (const h of hidden) await store.deleteNote(h.id);
2122
+
2123
+ const after = await query.execute(inc) as any;
2124
+ expect(after.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
2125
+ expect(after.ambiguous_links).toEqual([]);
2126
+
2127
+ closeAllStores();
2128
+ });
2129
+
1806
2130
  test("MCP include_links strips out-of-scope NEIGHBOR summaries", async () => {
1807
2131
  const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1808
2132
  const { writeVaultConfig } = await import("./config.ts");
@@ -4718,6 +5042,71 @@ describe("REST transport hardening — malformed/wrong-shape/oversize JSON bodie
4718
5042
  // security assertion MUST fail without the fix.
4719
5043
  // ---------------------------------------------------------------------------
4720
5044
  describe("HTTP tag-scope confidentiality (security review)", async () => {
5045
+ for (const pin of edgeScopedPins) {
5046
+ test(`vault#714 REST: ${pin.name}`, async () => {
5047
+ const f = await edgeSuppressionFixture("REST", `REST-${pin.name}`, true, pin.shape);
5048
+ try { await pin.run(f); } finally { f.close(); }
5049
+ });
5050
+ }
5051
+
5052
+ test("vault#714 REST: resolved hidden wikilink appears in unresolved listing", async () => {
5053
+ const f = await edgeSuppressionFixture("REST", "unresolved");
5054
+ try {
5055
+ const r = await f.unresolved(); f.leak(r);
5056
+ expect(r.unresolved).toEqual([{ source_id: "work-src", source_path: "work-src", target_path: "Dup", relationship: "wikilink" }]);
5057
+ expect(r.count).toBe(1);
5058
+ } finally { f.close(); }
5059
+ });
5060
+ test("vault#714 REST: confirming graph and hidden endpoint not-found identity", async () => {
5061
+ const f = await edgeSuppressionFixture("REST", "graph-endpoint");
5062
+ try {
5063
+ const graph = await f.query({ format: "graph", include_links: true }); f.leak(graph); expect(graph.edges).toEqual([]);
5064
+ // Same caller string before/after deletion, so the entire body must match.
5065
+ const req = () => mkReq("GET", "/find-path?source=work-src&target=Dup");
5066
+ const hidden = await handleFindPath(req(), f.store, f.scope);
5067
+ expect(hidden.status).toBe(404);
5068
+ const before = await hidden.text(); f.leak(JSON.parse(before));
5069
+ expect(JSON.parse(before)).toEqual({ error: 'Note not found: "Dup"', error_type: "not_found", note_id: "Dup" });
5070
+ await f.store.deleteNote(f.hidden.id);
5071
+ const absent = await handleFindPath(req(), f.store, f.scope);
5072
+ expect(absent.status).toBe(404); expect(await absent.text()).toBe(before);
5073
+ } finally { f.close(); }
5074
+ });
5075
+
5076
+ for (const semantic of [false, true]) {
5077
+ for (const wanted of [false, true]) {
5078
+ test(`vault#714 REST: ${semantic ? "semantic" : "search"} has_links=${wanted}`, async () => {
5079
+ const f = await edgeSuppressionFixture("REST", `search-${semantic}-${wanted}`, true, "F1", semantic);
5080
+ try {
5081
+ const mode = semantic ? { semantic: true, near_text: "src" } : { search: "src" };
5082
+ const r = await f.query({ ...mode, has_links: wanted }); f.leak(r);
5083
+ expect(r.map((n: any) => n.id)).toEqual(wanted ? [] : ["work-src"]);
5084
+ } finally { f.close(); }
5085
+ });
5086
+ }
5087
+ }
5088
+ test("vault#714 REST: search has_ambiguous_links narrows hidden collision", async () => {
5089
+ const f = await edgeSuppressionFixture("REST", "search-ambiguity");
5090
+ try {
5091
+ await f.store.createNote("work dup", { id: "visible-dup", path: "wdup/Dup", tags: ["work"] });
5092
+ await f.store.deleteNote("work-src");
5093
+ await f.store.createNote("src [[Dup]]", { id: "work-src", path: "work-src", tags: ["work"] });
5094
+ const r = await f.query({ search: "src", has_ambiguous_links: true }); f.leak(r);
5095
+ expect(r).toEqual([]);
5096
+ } finally { f.close(); }
5097
+ });
5098
+ test("vault#714 REST: search has_broken_links false excludes hidden-only candidates", async () => {
5099
+ const f = await edgeSuppressionFixture("REST", "search-broken");
5100
+ try {
5101
+ // Existing #712 ambiguous-to-broken behavior, independent of PR1's fold.
5102
+ await f.store.createNote("another hidden dup", { path: "p2/Dup", tags: ["personal"] });
5103
+ await f.store.deleteNote("work-src");
5104
+ await f.store.createNote("src [[Dup]]", { id: "work-src", path: "work-src", tags: ["work"] });
5105
+ const r = await f.query({ search: "src", has_broken_links: false }); f.leak(r);
5106
+ expect(r).toEqual([]);
5107
+ } finally { f.close(); }
5108
+ });
5109
+
4721
5110
  // Build a TagScopeCtx the same way routing.ts does, so handlers see the
4722
5111
  // exact shape a real tag-scoped request produces.
4723
5112
  async function scopeCtx(roots: string[]): Promise<TagScopeCtx> {
@@ -4841,6 +5230,264 @@ describe("HTTP tag-scope confidentiality (security review)", async () => {
4841
5230
  expect(targets).toContain("NoSuchPersonal");
4842
5231
  });
4843
5232
 
5233
+ // vault#581 auth-review MUST-FIX: `candidate_count` is derived VAULT-WIDE,
5234
+ // so the ambiguous-links surface must not tell a scoped reader that a note
5235
+ // it cannot see exists. The contract: a scoped reader's answer is exactly
5236
+ // what an unscoped reader would get on a vault containing only the notes it
5237
+ // can see. Each assertion MUST fail without the visible-candidate recompute.
5238
+
5239
+ /** One #work source `[[Dup]]`, one in-scope Dup, one out-of-scope Dup. */
5240
+ async function crossScopeDupFixture(secondDupTags: string[]) {
5241
+ await store.createNote("work dup", { path: "wdup/Dup", tags: ["work"] });
5242
+ await store.createNote("other dup", { path: "odup/Dup", tags: secondDupTags });
5243
+ return await store.createNote("src [[Dup]]", { path: "work-src", tags: ["work"] });
5244
+ }
5245
+
5246
+ test("include_ambiguous_links does NOT reveal an out-of-scope colliding note", async () => {
5247
+ // The reviewer's exact probe. Only ONE Dup is visible to `work`, so in
5248
+ // this reader's sub-vault `[[Dup]]` is not ambiguous at all.
5249
+ const src = await crossScopeDupFixture(["personal"]);
5250
+ const res = await handleNotes(
5251
+ mkReq("GET", `/notes?id=${src.id}&include_ambiguous_links=true`),
5252
+ store,
5253
+ "",
5254
+ "v",
5255
+ await scopeCtx(["work"]),
5256
+ );
5257
+ const body = await res.json() as any;
5258
+ expect(body.ambiguous_links).toEqual([]);
5259
+ expect(JSON.stringify(body)).not.toContain("odup/Dup");
5260
+ });
5261
+
5262
+ test("has_ambiguous_links=true does not let a scoped token sweep for cross-scope collisions", async () => {
5263
+ const src = await crossScopeDupFixture(["personal"]);
5264
+ const res = await handleNotes(
5265
+ mkReq("GET", "/notes?has_ambiguous_links=true"),
5266
+ store,
5267
+ "",
5268
+ "v",
5269
+ await scopeCtx(["work"]),
5270
+ );
5271
+ const body = await res.json() as any[];
5272
+ expect(body.map((n) => n.id)).not.toContain(src.id);
5273
+ expect(body).toEqual([]);
5274
+ });
5275
+
5276
+ test("has_ambiguous_links=false does not leak the same fact by ABSENCE", async () => {
5277
+ // The note is not ambiguous in this reader's sub-vault, so a `false`
5278
+ // query must RETURN it. Excluding it would leak the collision just as
5279
+ // loudly as including it in the `true` query.
5280
+ const src = await crossScopeDupFixture(["personal"]);
5281
+ const res = await handleNotes(
5282
+ mkReq("GET", "/notes?has_ambiguous_links=false"),
5283
+ store,
5284
+ "",
5285
+ "v",
5286
+ await scopeCtx(["work"]),
5287
+ );
5288
+ const body = await res.json() as any[];
5289
+ expect(body.map((n) => n.id)).toContain(src.id);
5290
+ });
5291
+
5292
+ test("aggregate over has_ambiguous_links does not leak the collision as a count", async () => {
5293
+ await crossScopeDupFixture(["personal"]);
5294
+ const res = await handleNotes(
5295
+ mkReq("GET", "/notes?has_ambiguous_links=true&aggregate[op]=count"),
5296
+ store,
5297
+ "",
5298
+ "v",
5299
+ await scopeCtx(["work"]),
5300
+ );
5301
+ // Ungrouped count comes back as `[{group: null, value: N}]`; N must be 0
5302
+ // — a 1 here would announce the invisible collision just as loudly.
5303
+ expect(await res.json()).toEqual([{ group: null, value: 0 }]);
5304
+ });
5305
+
5306
+ test("POSITIVE CONTROL: both colliding notes in scope → real ambiguity, count 2", async () => {
5307
+ // Same shape, both Dups #work. The scoped reader must get the FULL,
5308
+ // unredacted answer — otherwise the tests above would pass on a fix that
5309
+ // simply disabled the feature for scoped tokens.
5310
+ const src = await crossScopeDupFixture(["work"]);
5311
+ const single = await handleNotes(
5312
+ mkReq("GET", `/notes?id=${src.id}&include_ambiguous_links=true`),
5313
+ store,
5314
+ "",
5315
+ "v",
5316
+ await scopeCtx(["work"]),
5317
+ );
5318
+ expect((await single.json() as any).ambiguous_links)
5319
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
5320
+
5321
+ const swept = await handleNotes(
5322
+ mkReq("GET", "/notes?has_ambiguous_links=true"),
5323
+ store,
5324
+ "",
5325
+ "v",
5326
+ await scopeCtx(["work"]),
5327
+ );
5328
+ expect((await swept.json() as any[]).map((n) => n.id)).toEqual([src.id]);
5329
+ });
5330
+
5331
+ test("UNSCOPED still sees the cross-scope collision in full (regression)", async () => {
5332
+ const src = await crossScopeDupFixture(["personal"]);
5333
+ const single = await handleNotes(
5334
+ mkReq("GET", `/notes?id=${src.id}&include_ambiguous_links=true`),
5335
+ store,
5336
+ "",
5337
+ "v",
5338
+ NO_SCOPE,
5339
+ );
5340
+ expect((await single.json() as any).ambiguous_links)
5341
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
5342
+
5343
+ const swept = await handleNotes(
5344
+ mkReq("GET", "/notes?has_ambiguous_links=true"),
5345
+ store,
5346
+ "",
5347
+ "v",
5348
+ NO_SCOPE,
5349
+ );
5350
+ expect((await swept.json() as any[]).map((n) => n.id)).toEqual([src.id]);
5351
+ });
5352
+
5353
+ test("a partly-visible collision reports only the VISIBLE candidate count", async () => {
5354
+ // Three Dups: two #work (visible), one #personal (not). The reader's
5355
+ // sub-vault genuinely has an ambiguity — of degree 2, not 3.
5356
+ await store.createNote("w1", { path: "w1/Trio", tags: ["work"] });
5357
+ await store.createNote("w2", { path: "w2/Trio", tags: ["work"] });
5358
+ await store.createNote("p1", { path: "p1/Trio", tags: ["personal"] });
5359
+ const src = await store.createNote("src [[Trio]]", { path: "trio-src", tags: ["work"] });
5360
+
5361
+ const res = await handleNotes(
5362
+ mkReq("GET", `/notes?id=${src.id}&include_ambiguous_links=true`),
5363
+ store,
5364
+ "",
5365
+ "v",
5366
+ await scopeCtx(["work"]),
5367
+ );
5368
+ expect((await res.json() as any).ambiguous_links)
5369
+ .toEqual([{ target: "Trio", relationship: "wikilink", candidate_count: 2 }]);
5370
+ });
5371
+
5372
+ // vault#239 / vault#707 review sub-case — the DELETE self-heal is
5373
+ // scope-blind. `refreshAmbiguousLinks` demotes an ambiguous row into
5374
+ // `unresolved_wikilinks` once its last candidate is deleted, so a scoped
5375
+ // reader's own note flips from "no broken links" to "has a broken link"
5376
+ // purely because notes it can never see were deleted. That is a timing
5377
+ // oracle for exactly the cross-scope naming collision the block above
5378
+ // hides. The stable, sub-vault-correct answer is BROKEN in BOTH states:
5379
+ // `[[Dup]]` matches zero VISIBLE notes before the deletes and zero after.
5380
+
5381
+ /** One #work source `[[Dup]]`, with every Dup candidate out of scope. */
5382
+ async function hiddenOnlyDupFixture() {
5383
+ const h1 = await store.createNote("hidden dup one", { path: "p1/Dup", tags: ["personal"] });
5384
+ const h2 = await store.createNote("hidden dup two", { path: "p2/Dup", tags: ["personal"] });
5385
+ const src = await store.createNote("src [[Dup]]", { path: "work-src", tags: ["work"] });
5386
+ return { src, hidden: [h1, h2] };
5387
+ }
5388
+
5389
+ test("include_broken_links reports a reference whose only candidates are out of scope", async () => {
5390
+ const { src } = await hiddenOnlyDupFixture();
5391
+ const res = await handleNotes(
5392
+ mkReq("GET", `/notes?id=${src.id}&include_broken_links=true`),
5393
+ store,
5394
+ "",
5395
+ "v",
5396
+ await scopeCtx(["work"]),
5397
+ );
5398
+ const body = await res.json() as any;
5399
+ expect(body.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
5400
+ expect(JSON.stringify(body)).not.toContain("p1/Dup");
5401
+ });
5402
+
5403
+ test("deleting the last OUT-OF-SCOPE candidate does not change the scoped broken-link view (vault#239)", async () => {
5404
+ const { src, hidden } = await hiddenOnlyDupFixture();
5405
+ const scope = await scopeCtx(["work"]);
5406
+ const snapshot = async () => ({
5407
+ broken: (await (await handleNotes(
5408
+ mkReq("GET", `/notes?id=${src.id}&include_broken_links=true`), store, "", "v", scope,
5409
+ )).json() as any).broken_links,
5410
+ truthy: ((await (await handleNotes(
5411
+ mkReq("GET", "/notes?has_broken_links=true"), store, "", "v", scope,
5412
+ )).json()) as any[]).map((n) => n.id),
5413
+ falsy: ((await (await handleNotes(
5414
+ mkReq("GET", "/notes?has_broken_links=false"), store, "", "v", scope,
5415
+ )).json()) as any[]).map((n) => n.id),
5416
+ });
5417
+
5418
+ const before = await snapshot();
5419
+ for (const h of hidden) await store.deleteNote(h.id);
5420
+ const after = await snapshot();
5421
+
5422
+ expect(after).toEqual(before);
5423
+ expect(after.broken).toEqual([{ target: "Dup", relationship: "wikilink" }]);
5424
+ expect(after.truthy).toContain(src.id);
5425
+ expect(after.falsy).not.toContain(src.id);
5426
+ });
5427
+
5428
+ test("aggregate over has_broken_links does not leak the collision as a count either", async () => {
5429
+ const { hidden } = await hiddenOnlyDupFixture();
5430
+ const scope = await scopeCtx(["work"]);
5431
+ const rollup = async () => (await (await handleNotes(
5432
+ mkReq("GET", "/notes?has_broken_links=true&aggregate[op]=count"), store, "", "v", scope,
5433
+ )).json()) as any[];
5434
+
5435
+ const before = await rollup();
5436
+ for (const h of hidden) await store.deleteNote(h.id);
5437
+ expect(await rollup()).toEqual(before);
5438
+ });
5439
+
5440
+ test("/unresolved-wikilinks does not gain a row when an out-of-scope candidate is deleted", async () => {
5441
+ const { hidden } = await hiddenOnlyDupFixture();
5442
+ const scope = await scopeCtx(["work"]);
5443
+ const list = async () => {
5444
+ const body = await (handleUnresolvedWikilinks(
5445
+ mkReq("GET", "/unresolved-wikilinks"), store, scope,
5446
+ )).json() as any;
5447
+ return { targets: (body.unresolved as any[]).map((r) => r.target_path).sort(), count: body.count };
5448
+ };
5449
+
5450
+ const before = await list();
5451
+ expect(before.targets).toContain("Dup");
5452
+ for (const h of hidden) await store.deleteNote(h.id);
5453
+ expect(await list()).toEqual(before);
5454
+ });
5455
+
5456
+ test("NEGATIVE CONTROL: one IN-SCOPE candidate is not reported broken", async () => {
5457
+ // `wdup/Dup` is visible, `odup/Dup` is not — the reference resolves in
5458
+ // the reader's sub-vault, so it must NOT be reported broken. Guards
5459
+ // against a fix that simply reports every ambiguous row as broken.
5460
+ const src = await crossScopeDupFixture(["personal"]);
5461
+ const scope = await scopeCtx(["work"]);
5462
+ const body = await (await handleNotes(
5463
+ mkReq("GET", `/notes?id=${src.id}&include_broken_links=true`), store, "", "v", scope,
5464
+ )).json() as any;
5465
+ expect(body.broken_links).toEqual([]);
5466
+ const swept = (await (await handleNotes(
5467
+ mkReq("GET", "/notes?has_broken_links=true"), store, "", "v", scope,
5468
+ )).json()) as any[];
5469
+ expect(swept.map((n) => n.id)).not.toContain(src.id);
5470
+ });
5471
+
5472
+ test("UNSCOPED REST is unchanged: ambiguous before the deletes, broken after (regression)", async () => {
5473
+ const { src, hidden } = await hiddenOnlyDupFixture();
5474
+ const read = async () => (await (await handleNotes(
5475
+ mkReq("GET", `/notes?id=${src.id}&include_broken_links=true&include_ambiguous_links=true`),
5476
+ store, "", "v", NO_SCOPE,
5477
+ )).json()) as any;
5478
+
5479
+ const before = await read();
5480
+ expect(before.broken_links).toEqual([]);
5481
+ expect(before.ambiguous_links)
5482
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
5483
+
5484
+ for (const h of hidden) await store.deleteNote(h.id);
5485
+
5486
+ const after = await read();
5487
+ expect(after.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
5488
+ expect(after.ambiguous_links).toEqual([]);
5489
+ });
5490
+
4844
5491
  // vault#555 auth-review CRITICAL: `if_exists` must NOT become a tag-scope
4845
5492
  // bypass. A scoped token naming an out-of-scope note's PATH must not read
4846
5493
  // (ignore), update, or replace it — treat it as a path_conflict (path taken,
@@ -6220,6 +6867,87 @@ describe("HTTP GET /notes — has_broken_links / include_broken_links (vault#555
6220
6867
  });
6221
6868
  });
6222
6869
 
6870
+ // vault#581 — has_ambiguous_links / include_ambiguous_links on GET /notes.
6871
+ // The queryable twin of the has_broken_links block above: before #581 an
6872
+ // ambiguous target existed only in the transient write-time warning, so REST
6873
+ // callers auditing a vault could not find one after the fact.
6874
+ describe("HTTP GET /notes — has_ambiguous_links / include_ambiguous_links (vault#581)", async () => {
6875
+ test("has_ambiguous_links=true filters to notes whose wikilink matched two notes", async () => {
6876
+ await store.createNote("first", { path: "ra1/Dup" });
6877
+ await store.createNote("second", { path: "ra2/Dup" });
6878
+ await store.createNote("[[Dup]]", { path: "rest-ambiguous" });
6879
+ await store.createNote("clean", { path: "rest-amb-clean" });
6880
+ const res = await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true&include_content=true"), store, "");
6881
+ const body = await res.json() as any[];
6882
+ expect(body.map((n) => n.path)).toEqual(["rest-ambiguous"]);
6883
+ });
6884
+
6885
+ test("has_ambiguous_links=false excludes them", async () => {
6886
+ await store.createNote("first", { path: "rb1/Twin" });
6887
+ await store.createNote("second", { path: "rb2/Twin" });
6888
+ await store.createNote("[[Twin]]", { path: "rest-ambiguous2" });
6889
+ await store.createNote("clean", { path: "rest-amb-clean2" });
6890
+ const res = await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=false&include_content=true"), store, "");
6891
+ const body = await res.json() as any[];
6892
+ expect(body.map((n) => n.path).sort()).toEqual(["rb1/Twin", "rb2/Twin", "rest-amb-clean2"]);
6893
+ });
6894
+
6895
+ test("an ambiguous link is not reported as a broken one (the filters are disjoint)", async () => {
6896
+ await store.createNote("first", { path: "rc1/Both" });
6897
+ await store.createNote("second", { path: "rc2/Both" });
6898
+ await store.createNote("[[Both]]", { path: "rest-amb-disjoint" });
6899
+ const broken = await (await handleNotes(mkReq("GET", "/notes?has_broken_links=true"), store, "")).json() as any[];
6900
+ expect(broken.map((n) => n.path)).toEqual([]);
6901
+ const ambiguous = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true"), store, "")).json() as any[];
6902
+ expect(ambiguous.map((n) => n.path)).toEqual(["rest-amb-disjoint"]);
6903
+ });
6904
+
6905
+ test("include_ambiguous_links on a single note surfaces {target, relationship, candidate_count}", async () => {
6906
+ await store.createNote("first", { path: "rd1/Fork" });
6907
+ await store.createNote("second", { path: "rd2/Fork" });
6908
+ await store.createNote("[[Fork]]", { path: "rest-single-ambiguous" });
6909
+ const res = await handleNotes(mkReq("GET", "/notes?id=rest-single-ambiguous&include_ambiguous_links=true"), store, "");
6910
+ const body = await res.json() as any;
6911
+ expect(body.ambiguous_links).toEqual([{ target: "Fork", relationship: "wikilink", candidate_count: 2 }]);
6912
+ });
6913
+
6914
+ test("include_ambiguous_links in list mode is batched per note", async () => {
6915
+ await store.createNote("first", { path: "re1/Echo" });
6916
+ await store.createNote("second", { path: "re2/Echo" });
6917
+ await store.createNote("[[Echo]]", { path: "rest-amb-list-a" });
6918
+ await store.createNote("clean", { path: "rest-amb-list-b" });
6919
+ const res = await handleNotes(mkReq("GET", "/notes?include_ambiguous_links=true&include_content=true"), store, "");
6920
+ const body = await res.json() as any[];
6921
+ const byPath = new Map(body.map((n: any) => [n.path, n.ambiguous_links]));
6922
+ expect(byPath.get("rest-amb-list-a")).toEqual([{ target: "Echo", relationship: "wikilink", candidate_count: 2 }]);
6923
+ expect(byPath.get("rest-amb-list-b")).toEqual([]);
6924
+ });
6925
+
6926
+ test("both filters are safe on a vault where no link has ever been ambiguous", async () => {
6927
+ await store.createNote("plain", { path: "rest-amb-never" });
6928
+ const truthy = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true"), store, "")).json() as any[];
6929
+ expect(truthy).toEqual([]);
6930
+ const falsy = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=false&include_content=true"), store, "")).json() as any[];
6931
+ expect(falsy.map((n) => n.path)).toEqual(["rest-amb-never"]);
6932
+ const single = await (await handleNotes(mkReq("GET", "/notes?id=rest-amb-never&include_ambiguous_links=true"), store, "")).json() as any;
6933
+ expect(single.ambiguous_links).toEqual([]);
6934
+ });
6935
+
6936
+ test("deleting one colliding candidate heals the ambiguity through the REST filter", async () => {
6937
+ const keep = await store.createNote("first", { path: "rf1/Heal" });
6938
+ const drop = await store.createNote("second", { path: "rf2/Heal" });
6939
+ await store.createNote("[[Heal]]", { path: "rest-amb-heal" });
6940
+ let body = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true"), store, "")).json() as any[];
6941
+ expect(body.map((n) => n.path)).toEqual(["rest-amb-heal"]);
6942
+
6943
+ await store.deleteNote(drop.id);
6944
+ body = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true"), store, "")).json() as any[];
6945
+ expect(body).toEqual([]);
6946
+ const linked = await (await handleNotes(mkReq("GET", "/notes?id=rest-amb-heal&include_links=true"), store, "")).json() as any;
6947
+ expect(linked.links.map((l: any) => l.targetId)).toEqual([keep.id]);
6948
+ });
6949
+ });
6950
+
6223
6951
  // vault#309 — HTTP PATCH /notes/:id with if_missing: "create" mirrors
6224
6952
  // the MCP update-note path. Sync loops (Gitcoin Brain et al) use this
6225
6953
  // for idempotent upsert without a separate query-first round trip.
@@ -7137,6 +7865,167 @@ describe("stateless MCP transport", async () => {
7137
7865
  closeAllStores();
7138
7866
  });
7139
7867
 
7868
+ // vault#669: the MCP door's `description` write had no runtime type check
7869
+ // (an `as string` cast), so a write/admin caller could persist a non-string
7870
+ // and poison the next `initialize` (serverInstruction's `description.trim()`
7871
+ // throws → -32603 INTERNAL, the first frame, unrepairable in-session — the
7872
+ // bun half of cloud#87). The guard rejects non-string/non-null as a
7873
+ // structured -32602 INVALID_PARAMS (`error_type: invalid_description`), the
7874
+ // same shape every other MCP validation leaf takes. `null` still clears.
7875
+ describe("vault-info description type guard (vault#669 / cloud#87)", () => {
7876
+ for (const [label, value] of [
7877
+ ["a number", 123],
7878
+ ["an object", { text: "nope" }],
7879
+ ["an array", ["a", "b"]],
7880
+ ["a boolean", true],
7881
+ ] as const) {
7882
+ test(`tools/call vault-info with ${label} description → -32602 invalid_description, nothing persisted`, async () => {
7883
+ const { handleScopedMcp } = await import("./mcp-http.ts");
7884
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
7885
+ const { closeAllStores } = await import("./vault-store.ts");
7886
+
7887
+ const vaultName = `desc-type-${label.replace(/\s+/g, "-")}-${Date.now()}`;
7888
+ writeVaultConfig({
7889
+ name: vaultName,
7890
+ api_keys: [],
7891
+ created_at: new Date().toISOString(),
7892
+ description: "original",
7893
+ });
7894
+
7895
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
7896
+ method: "POST",
7897
+ headers: {
7898
+ "content-type": "application/json",
7899
+ "accept": "application/json, text/event-stream",
7900
+ },
7901
+ body: JSON.stringify({
7902
+ jsonrpc: "2.0",
7903
+ id: 1,
7904
+ method: "tools/call",
7905
+ params: { name: "vault-info", arguments: { description: value } },
7906
+ }),
7907
+ });
7908
+
7909
+ const res = await handleScopedMcp(req, vaultName, {
7910
+ permission: "full",
7911
+ scopes: ["vault:read", "vault:write", "vault:admin"],
7912
+ legacyDerived: false,
7913
+ scoped_tags: null,
7914
+ } as any);
7915
+
7916
+ expect(res.status).toBe(200);
7917
+ const body = await res.json() as any;
7918
+ // Structured JSON-RPC error (protocol-level), NOT the in-band
7919
+ // isError text: -32602 INVALID_PARAMS carrying error_type/field.
7920
+ expect(body.error).toBeDefined();
7921
+ expect(body.error.code).toBe(-32602);
7922
+ expect(body.error.data.error_type).toBe("invalid_description");
7923
+ expect(body.error.data.field).toBe("description");
7924
+ // Nothing persisted — no poison state introduced.
7925
+ expect(readVaultConfig(vaultName)?.description).toBe("original");
7926
+
7927
+ closeAllStores();
7928
+ });
7929
+ }
7930
+
7931
+ test("a refused non-string description leaves MCP initialize working (poison prevented)", async () => {
7932
+ const { handleScopedMcp } = await import("./mcp-http.ts");
7933
+ const { writeVaultConfig } = await import("./config.ts");
7934
+ const { closeAllStores } = await import("./vault-store.ts");
7935
+
7936
+ const vaultName = `desc-poison-${Date.now()}`;
7937
+ writeVaultConfig({
7938
+ name: vaultName,
7939
+ api_keys: [],
7940
+ created_at: new Date().toISOString(),
7941
+ description: "healthy brief",
7942
+ });
7943
+
7944
+ const admin = {
7945
+ permission: "full" as const,
7946
+ scopes: ["vault:read", "vault:write", "vault:admin"],
7947
+ legacyDerived: false,
7948
+ scoped_tags: null,
7949
+ };
7950
+
7951
+ // Attempt to poison with a number — must be rejected.
7952
+ const poisonReq = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
7953
+ method: "POST",
7954
+ headers: { "content-type": "application/json", "accept": "application/json, text/event-stream" },
7955
+ body: JSON.stringify({
7956
+ jsonrpc: "2.0",
7957
+ id: 1,
7958
+ method: "tools/call",
7959
+ params: { name: "vault-info", arguments: { description: 123 } },
7960
+ }),
7961
+ });
7962
+ const poisonRes = await handleScopedMcp(poisonReq, vaultName, admin as any);
7963
+ expect((await poisonRes.json() as any).error.code).toBe(-32602);
7964
+
7965
+ // initialize must still succeed — before the guard this answered
7966
+ // -32603 INTERNAL_ERROR (`description.trim is not a function`).
7967
+ const initReq = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
7968
+ method: "POST",
7969
+ headers: { "content-type": "application/json", "accept": "application/json, text/event-stream" },
7970
+ body: JSON.stringify({
7971
+ jsonrpc: "2.0",
7972
+ id: 2,
7973
+ method: "initialize",
7974
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1.0" } },
7975
+ }),
7976
+ });
7977
+ const initRes = await handleScopedMcp(initReq, vaultName, admin as any);
7978
+ expect(initRes.status).toBe(200);
7979
+ const initBody = await initRes.json() as any;
7980
+ expect(initBody.error).toBeUndefined();
7981
+ expect(initBody.result.instructions).toContain("healthy brief");
7982
+
7983
+ closeAllStores();
7984
+ });
7985
+
7986
+ test("null description clears it (still legal on the MCP door)", async () => {
7987
+ const { handleScopedMcp } = await import("./mcp-http.ts");
7988
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
7989
+ const { closeAllStores } = await import("./vault-store.ts");
7990
+
7991
+ const vaultName = `desc-null-${Date.now()}`;
7992
+ writeVaultConfig({
7993
+ name: vaultName,
7994
+ api_keys: [],
7995
+ created_at: new Date().toISOString(),
7996
+ description: "to be cleared",
7997
+ });
7998
+
7999
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
8000
+ method: "POST",
8001
+ headers: { "content-type": "application/json", "accept": "application/json, text/event-stream" },
8002
+ body: JSON.stringify({
8003
+ jsonrpc: "2.0",
8004
+ id: 1,
8005
+ method: "tools/call",
8006
+ params: { name: "vault-info", arguments: { description: null } },
8007
+ }),
8008
+ });
8009
+
8010
+ const res = await handleScopedMcp(req, vaultName, {
8011
+ permission: "full",
8012
+ scopes: ["vault:read", "vault:write", "vault:admin"],
8013
+ legacyDerived: false,
8014
+ scoped_tags: null,
8015
+ } as any);
8016
+
8017
+ expect(res.status).toBe(200);
8018
+ const body = await res.json() as any;
8019
+ expect(body.error).toBeUndefined();
8020
+ expect(body.result.isError).toBeFalsy();
8021
+ // Cleared — readVaultConfig returns null (or undefined) for a cleared
8022
+ // description, never the old string.
8023
+ expect(readVaultConfig(vaultName)?.description ?? null).toBeNull();
8024
+
8025
+ closeAllStores();
8026
+ });
8027
+ });
8028
+
7140
8029
  test("tools/call of create-note with vault:read scope is refused (not silently allowed)", async () => {
7141
8030
  const { handleScopedMcp } = await import("./mcp-http.ts");
7142
8031
  const { writeVaultConfig } = await import("./config.ts");
@@ -8474,3 +9363,41 @@ describe("handleVault: front-door structural map", async () => {
8474
9363
  });
8475
9364
  });
8476
9365
 
9366
+
9367
+
9368
+ describe("vault#714 unscoped full-response controls", () => {
9369
+ const surfaces = ["include_links", "include_link_count", "has_links", "include_broken_links", "near", "find-path", "unresolved-wikilinks", "graph"];
9370
+ for (const surface of surfaces) {
9371
+ test(`vault#714 UNSCOPED: ${surface}`, async () => {
9372
+ const actual: Record<string, unknown> = {};
9373
+ for (const door of ["MCP", "REST"] as const) {
9374
+ if (door === "MCP" && ["unresolved-wikilinks", "graph"].includes(surface)) continue;
9375
+ const f = await edgeSuppressionFixture(door, `control-${surface}`, false);
9376
+ try {
9377
+ if (surface === "find-path") actual[door] = await f.find("p1/Dup");
9378
+ else if (surface === "unresolved-wikilinks") actual[door] = await f.unresolved();
9379
+ else if (surface === "graph") actual[door] = await f.query({ format: "graph", include_links: true });
9380
+ else if (surface === "near") actual[door] = await f.query({ near: { note_id: "work-src", depth: 1 } });
9381
+ else if (surface === "has_links") actual[door] = await Promise.all([
9382
+ f.query({ has_links: true }), f.query({ has_links: false }),
9383
+ f.query({ has_links: true, aggregate: { op: "count" } }),
9384
+ ]);
9385
+ else if (surface === "include_broken_links") actual[door] = await Promise.all([
9386
+ f.query({ id: "work-src", include_broken_links: true }),
9387
+ f.query({ has_broken_links: true }), f.query({ has_broken_links: false }),
9388
+ ]);
9389
+ else if (surface === "include_link_count") actual[door] = await Promise.all(
9390
+ ["both", "outbound", "inbound"].flatMap((direction) => [
9391
+ f.query({ id: "work-src", include_link_count: true, link_count_direction: direction }),
9392
+ f.query({ include_link_count: true, link_count_direction: direction }),
9393
+ ]),
9394
+ );
9395
+ else actual[door] = await f.query({ id: "work-src", include_links: true });
9396
+ } finally { f.close(); }
9397
+ }
9398
+ const fixture = new URL(`./test-support/vault-714-${surface}.json`, import.meta.url);
9399
+ // Full JSON captured on unmodified production base 20d0f6f (Step 0).
9400
+ expect(JSON.parse(JSON.stringify(actual))).toEqual(JSON.parse(readFileSync(fixture, "utf8")));
9401
+ });
9402
+ }
9403
+ });