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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vault.test.ts CHANGED
@@ -1803,6 +1803,191 @@ describe("scoped MCP wrapper", async () => {
1803
1803
  closeAllStores();
1804
1804
  });
1805
1805
 
1806
+ // vault#581 auth-review MUST-FIX, MCP door. Same contract as the REST
1807
+ // twins in the "HTTP tag-scope confidentiality" block: a scoped reader's
1808
+ // ambiguous-links answer is exactly what an unscoped reader would get on a
1809
+ // vault containing only the notes it can see.
1810
+ async function ambiguityVault(prefix: string, secondDupTags: string[]) {
1811
+ const { writeVaultConfig } = await import("./config.ts");
1812
+ const { getVaultStore } = await import("./vault-store.ts");
1813
+ const vaultName = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1814
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1815
+ const store = getVaultStore(vaultName);
1816
+ await store.createNote("work dup", { path: "wdup/Dup", tags: ["work"] });
1817
+ await store.createNote("other dup", { path: "odup/Dup", tags: secondDupTags });
1818
+ const src = await store.createNote("src [[Dup]]", { path: "work-src", tags: ["work"] });
1819
+ return { vaultName, src };
1820
+ }
1821
+
1822
+ test("MCP include_ambiguous_links does NOT reveal an out-of-scope colliding note", async () => {
1823
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1824
+ const { closeAllStores } = await import("./vault-store.ts");
1825
+ const { vaultName, src } = await ambiguityVault("tagscope-amb", ["personal"]);
1826
+
1827
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1828
+ const query = tools.find((t) => t.name === "query-notes")!;
1829
+ const result = await query.execute({ id: src.id, include_ambiguous_links: true }) as any;
1830
+ expect(result.ambiguous_links).toEqual([]);
1831
+ expect(JSON.stringify(result)).not.toContain("odup/Dup");
1832
+
1833
+ closeAllStores();
1834
+ });
1835
+
1836
+ test("MCP has_ambiguous_links does not let a scoped session sweep for cross-scope collisions", async () => {
1837
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1838
+ const { closeAllStores } = await import("./vault-store.ts");
1839
+ const { vaultName, src } = await ambiguityVault("tagscope-amb-sweep", ["personal"]);
1840
+
1841
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1842
+ const query = tools.find((t) => t.name === "query-notes")!;
1843
+ const truthy = await query.execute({ has_ambiguous_links: true }) as any[];
1844
+ expect(truthy.map((n: any) => n.id)).not.toContain(src.id);
1845
+ expect(truthy).toEqual([]);
1846
+ // ...and the same fact must not leak by ABSENCE from the false query.
1847
+ const falsy = await query.execute({ has_ambiguous_links: false }) as any[];
1848
+ expect(falsy.map((n: any) => n.id)).toContain(src.id);
1849
+
1850
+ closeAllStores();
1851
+ });
1852
+
1853
+ test("MCP POSITIVE CONTROL: both colliding notes in scope → real ambiguity, count 2", async () => {
1854
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1855
+ const { closeAllStores } = await import("./vault-store.ts");
1856
+ const { vaultName, src } = await ambiguityVault("tagscope-amb-pos", ["work"]);
1857
+
1858
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1859
+ const query = tools.find((t) => t.name === "query-notes")!;
1860
+ const result = await query.execute({ id: src.id, include_ambiguous_links: true }) as any;
1861
+ expect(result.ambiguous_links)
1862
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
1863
+ expect(((await query.execute({ has_ambiguous_links: true })) as any[]).map((n: any) => n.id))
1864
+ .toEqual([src.id]);
1865
+
1866
+ closeAllStores();
1867
+ });
1868
+
1869
+ test("UNSCOPED MCP still sees the cross-scope collision in full (regression)", async () => {
1870
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1871
+ const { closeAllStores } = await import("./vault-store.ts");
1872
+ const { vaultName, src } = await ambiguityVault("tagscope-amb-unscoped", ["personal"]);
1873
+
1874
+ const tools = generateScopedMcpTools(vaultName);
1875
+ const query = tools.find((t) => t.name === "query-notes")!;
1876
+ const result = await query.execute({ id: src.id, include_ambiguous_links: true }) as any;
1877
+ expect(result.ambiguous_links)
1878
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
1879
+ expect(((await query.execute({ has_ambiguous_links: true })) as any[]).map((n: any) => n.id))
1880
+ .toEqual([src.id]);
1881
+
1882
+ closeAllStores();
1883
+ });
1884
+
1885
+ // ---------------------------------------------------------------------
1886
+ // vault#239 / vault#707 review sub-case — the DELETE self-heal is
1887
+ // scope-blind. `refreshAmbiguousLinks` demotes an ambiguous row to
1888
+ // `unresolved_wikilinks` once its last candidate is deleted, so a
1889
+ // `work`-scoped reader's own note flips from "no broken links" to
1890
+ // "has a broken link" purely because notes it can never see were
1891
+ // deleted — a timing oracle for exactly the cross-scope naming
1892
+ // collision vault#707's `candidate_count` narrowing was added to hide.
1893
+ //
1894
+ // The stable, sub-vault-correct answer (vault#707's rule: a scoped
1895
+ // reader gets what an unscoped reader would get on a vault containing
1896
+ // only the notes it can see) is BROKEN in BOTH states: `[[Dup]]`
1897
+ // matches zero VISIBLE notes before the deletes and zero after.
1898
+ async function hiddenOnlyDupVault(prefix: string) {
1899
+ const { writeVaultConfig } = await import("./config.ts");
1900
+ const { getVaultStore } = await import("./vault-store.ts");
1901
+ const vaultName = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1902
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1903
+ const store = getVaultStore(vaultName);
1904
+ const h1 = await store.createNote("hidden dup one", { path: "p1/Dup", tags: ["personal"] });
1905
+ const h2 = await store.createNote("hidden dup two", { path: "p2/Dup", tags: ["personal"] });
1906
+ const src = await store.createNote("src [[Dup]]", { path: "work-src", tags: ["work"] });
1907
+ return { vaultName, store, src, hidden: [h1, h2] };
1908
+ }
1909
+
1910
+ test("MCP: a [[Dup]] whose only candidates are out of scope reads as BROKEN for the scoped reader", async () => {
1911
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1912
+ const { closeAllStores } = await import("./vault-store.ts");
1913
+ const { vaultName, src } = await hiddenOnlyDupVault("tagscope-brk");
1914
+
1915
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1916
+ const query = tools.find((t) => t.name === "query-notes")!;
1917
+ const result = await query.execute({ id: src.id, include_broken_links: true }) as any;
1918
+ expect(result.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
1919
+ // ...and the presence filter agrees on both polarities.
1920
+ expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
1921
+ .toContain(src.id);
1922
+ expect(((await query.execute({ has_broken_links: false })) as any[]).map((n: any) => n.id))
1923
+ .not.toContain(src.id);
1924
+
1925
+ closeAllStores();
1926
+ });
1927
+
1928
+ test("MCP: deleting the last OUT-OF-SCOPE candidate does not change the scoped reader's broken-link view (vault#239)", async () => {
1929
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1930
+ const { closeAllStores } = await import("./vault-store.ts");
1931
+ const { vaultName, store, src, hidden } = await hiddenOnlyDupVault("tagscope-brk-heal");
1932
+
1933
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1934
+ const query = tools.find((t) => t.name === "query-notes")!;
1935
+ const snapshot = async () => ({
1936
+ broken: (await query.execute({ id: src.id, include_broken_links: true }) as any).broken_links,
1937
+ truthy: ((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id),
1938
+ falsy: ((await query.execute({ has_broken_links: false })) as any[]).map((n: any) => n.id),
1939
+ });
1940
+
1941
+ const before = await snapshot();
1942
+ for (const h of hidden) await store.deleteNote(h.id);
1943
+ const after = await snapshot();
1944
+
1945
+ expect(after).toEqual(before);
1946
+ expect(after.broken).toEqual([{ target: "Dup", relationship: "wikilink" }]);
1947
+
1948
+ closeAllStores();
1949
+ });
1950
+
1951
+ test("MCP NEGATIVE CONTROL: one IN-SCOPE candidate → not broken for the scoped reader", async () => {
1952
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1953
+ const { closeAllStores } = await import("./vault-store.ts");
1954
+ const { vaultName, src } = await ambiguityVault("tagscope-brk-neg", ["personal"]);
1955
+
1956
+ // `wdup/Dup` is in scope, `odup/Dup` is not — the reference resolves in
1957
+ // the reader's sub-vault, so it must NOT be reported broken.
1958
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1959
+ const query = tools.find((t) => t.name === "query-notes")!;
1960
+ expect((await query.execute({ id: src.id, include_broken_links: true }) as any).broken_links)
1961
+ .toEqual([]);
1962
+ expect(((await query.execute({ has_broken_links: true })) as any[]).map((n: any) => n.id))
1963
+ .not.toContain(src.id);
1964
+
1965
+ closeAllStores();
1966
+ });
1967
+
1968
+ test("UNSCOPED MCP is unchanged: ambiguous before the deletes, broken after (regression)", async () => {
1969
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1970
+ const { closeAllStores } = await import("./vault-store.ts");
1971
+ const { vaultName, store, src, hidden } = await hiddenOnlyDupVault("tagscope-brk-unscoped");
1972
+
1973
+ const tools = generateScopedMcpTools(vaultName);
1974
+ const query = tools.find((t) => t.name === "query-notes")!;
1975
+ const inc = { id: src.id, include_broken_links: true, include_ambiguous_links: true };
1976
+
1977
+ const before = await query.execute(inc) as any;
1978
+ expect(before.broken_links).toEqual([]);
1979
+ expect(before.ambiguous_links)
1980
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
1981
+
1982
+ for (const h of hidden) await store.deleteNote(h.id);
1983
+
1984
+ const after = await query.execute(inc) as any;
1985
+ expect(after.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
1986
+ expect(after.ambiguous_links).toEqual([]);
1987
+
1988
+ closeAllStores();
1989
+ });
1990
+
1806
1991
  test("MCP include_links strips out-of-scope NEIGHBOR summaries", async () => {
1807
1992
  const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1808
1993
  const { writeVaultConfig } = await import("./config.ts");
@@ -4841,6 +5026,264 @@ describe("HTTP tag-scope confidentiality (security review)", async () => {
4841
5026
  expect(targets).toContain("NoSuchPersonal");
4842
5027
  });
4843
5028
 
5029
+ // vault#581 auth-review MUST-FIX: `candidate_count` is derived VAULT-WIDE,
5030
+ // so the ambiguous-links surface must not tell a scoped reader that a note
5031
+ // it cannot see exists. The contract: a scoped reader's answer is exactly
5032
+ // what an unscoped reader would get on a vault containing only the notes it
5033
+ // can see. Each assertion MUST fail without the visible-candidate recompute.
5034
+
5035
+ /** One #work source `[[Dup]]`, one in-scope Dup, one out-of-scope Dup. */
5036
+ async function crossScopeDupFixture(secondDupTags: string[]) {
5037
+ await store.createNote("work dup", { path: "wdup/Dup", tags: ["work"] });
5038
+ await store.createNote("other dup", { path: "odup/Dup", tags: secondDupTags });
5039
+ return await store.createNote("src [[Dup]]", { path: "work-src", tags: ["work"] });
5040
+ }
5041
+
5042
+ test("include_ambiguous_links does NOT reveal an out-of-scope colliding note", async () => {
5043
+ // The reviewer's exact probe. Only ONE Dup is visible to `work`, so in
5044
+ // this reader's sub-vault `[[Dup]]` is not ambiguous at all.
5045
+ const src = await crossScopeDupFixture(["personal"]);
5046
+ const res = await handleNotes(
5047
+ mkReq("GET", `/notes?id=${src.id}&include_ambiguous_links=true`),
5048
+ store,
5049
+ "",
5050
+ "v",
5051
+ await scopeCtx(["work"]),
5052
+ );
5053
+ const body = await res.json() as any;
5054
+ expect(body.ambiguous_links).toEqual([]);
5055
+ expect(JSON.stringify(body)).not.toContain("odup/Dup");
5056
+ });
5057
+
5058
+ test("has_ambiguous_links=true does not let a scoped token sweep for cross-scope collisions", async () => {
5059
+ const src = await crossScopeDupFixture(["personal"]);
5060
+ const res = await handleNotes(
5061
+ mkReq("GET", "/notes?has_ambiguous_links=true"),
5062
+ store,
5063
+ "",
5064
+ "v",
5065
+ await scopeCtx(["work"]),
5066
+ );
5067
+ const body = await res.json() as any[];
5068
+ expect(body.map((n) => n.id)).not.toContain(src.id);
5069
+ expect(body).toEqual([]);
5070
+ });
5071
+
5072
+ test("has_ambiguous_links=false does not leak the same fact by ABSENCE", async () => {
5073
+ // The note is not ambiguous in this reader's sub-vault, so a `false`
5074
+ // query must RETURN it. Excluding it would leak the collision just as
5075
+ // loudly as including it in the `true` query.
5076
+ const src = await crossScopeDupFixture(["personal"]);
5077
+ const res = await handleNotes(
5078
+ mkReq("GET", "/notes?has_ambiguous_links=false"),
5079
+ store,
5080
+ "",
5081
+ "v",
5082
+ await scopeCtx(["work"]),
5083
+ );
5084
+ const body = await res.json() as any[];
5085
+ expect(body.map((n) => n.id)).toContain(src.id);
5086
+ });
5087
+
5088
+ test("aggregate over has_ambiguous_links does not leak the collision as a count", async () => {
5089
+ await crossScopeDupFixture(["personal"]);
5090
+ const res = await handleNotes(
5091
+ mkReq("GET", "/notes?has_ambiguous_links=true&aggregate[op]=count"),
5092
+ store,
5093
+ "",
5094
+ "v",
5095
+ await scopeCtx(["work"]),
5096
+ );
5097
+ // Ungrouped count comes back as `[{group: null, value: N}]`; N must be 0
5098
+ // — a 1 here would announce the invisible collision just as loudly.
5099
+ expect(await res.json()).toEqual([{ group: null, value: 0 }]);
5100
+ });
5101
+
5102
+ test("POSITIVE CONTROL: both colliding notes in scope → real ambiguity, count 2", async () => {
5103
+ // Same shape, both Dups #work. The scoped reader must get the FULL,
5104
+ // unredacted answer — otherwise the tests above would pass on a fix that
5105
+ // simply disabled the feature for scoped tokens.
5106
+ const src = await crossScopeDupFixture(["work"]);
5107
+ const single = await handleNotes(
5108
+ mkReq("GET", `/notes?id=${src.id}&include_ambiguous_links=true`),
5109
+ store,
5110
+ "",
5111
+ "v",
5112
+ await scopeCtx(["work"]),
5113
+ );
5114
+ expect((await single.json() as any).ambiguous_links)
5115
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
5116
+
5117
+ const swept = await handleNotes(
5118
+ mkReq("GET", "/notes?has_ambiguous_links=true"),
5119
+ store,
5120
+ "",
5121
+ "v",
5122
+ await scopeCtx(["work"]),
5123
+ );
5124
+ expect((await swept.json() as any[]).map((n) => n.id)).toEqual([src.id]);
5125
+ });
5126
+
5127
+ test("UNSCOPED still sees the cross-scope collision in full (regression)", async () => {
5128
+ const src = await crossScopeDupFixture(["personal"]);
5129
+ const single = await handleNotes(
5130
+ mkReq("GET", `/notes?id=${src.id}&include_ambiguous_links=true`),
5131
+ store,
5132
+ "",
5133
+ "v",
5134
+ NO_SCOPE,
5135
+ );
5136
+ expect((await single.json() as any).ambiguous_links)
5137
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
5138
+
5139
+ const swept = await handleNotes(
5140
+ mkReq("GET", "/notes?has_ambiguous_links=true"),
5141
+ store,
5142
+ "",
5143
+ "v",
5144
+ NO_SCOPE,
5145
+ );
5146
+ expect((await swept.json() as any[]).map((n) => n.id)).toEqual([src.id]);
5147
+ });
5148
+
5149
+ test("a partly-visible collision reports only the VISIBLE candidate count", async () => {
5150
+ // Three Dups: two #work (visible), one #personal (not). The reader's
5151
+ // sub-vault genuinely has an ambiguity — of degree 2, not 3.
5152
+ await store.createNote("w1", { path: "w1/Trio", tags: ["work"] });
5153
+ await store.createNote("w2", { path: "w2/Trio", tags: ["work"] });
5154
+ await store.createNote("p1", { path: "p1/Trio", tags: ["personal"] });
5155
+ const src = await store.createNote("src [[Trio]]", { path: "trio-src", tags: ["work"] });
5156
+
5157
+ const res = await handleNotes(
5158
+ mkReq("GET", `/notes?id=${src.id}&include_ambiguous_links=true`),
5159
+ store,
5160
+ "",
5161
+ "v",
5162
+ await scopeCtx(["work"]),
5163
+ );
5164
+ expect((await res.json() as any).ambiguous_links)
5165
+ .toEqual([{ target: "Trio", relationship: "wikilink", candidate_count: 2 }]);
5166
+ });
5167
+
5168
+ // vault#239 / vault#707 review sub-case — the DELETE self-heal is
5169
+ // scope-blind. `refreshAmbiguousLinks` demotes an ambiguous row into
5170
+ // `unresolved_wikilinks` once its last candidate is deleted, so a scoped
5171
+ // reader's own note flips from "no broken links" to "has a broken link"
5172
+ // purely because notes it can never see were deleted. That is a timing
5173
+ // oracle for exactly the cross-scope naming collision the block above
5174
+ // hides. The stable, sub-vault-correct answer is BROKEN in BOTH states:
5175
+ // `[[Dup]]` matches zero VISIBLE notes before the deletes and zero after.
5176
+
5177
+ /** One #work source `[[Dup]]`, with every Dup candidate out of scope. */
5178
+ async function hiddenOnlyDupFixture() {
5179
+ const h1 = await store.createNote("hidden dup one", { path: "p1/Dup", tags: ["personal"] });
5180
+ const h2 = await store.createNote("hidden dup two", { path: "p2/Dup", tags: ["personal"] });
5181
+ const src = await store.createNote("src [[Dup]]", { path: "work-src", tags: ["work"] });
5182
+ return { src, hidden: [h1, h2] };
5183
+ }
5184
+
5185
+ test("include_broken_links reports a reference whose only candidates are out of scope", async () => {
5186
+ const { src } = await hiddenOnlyDupFixture();
5187
+ const res = await handleNotes(
5188
+ mkReq("GET", `/notes?id=${src.id}&include_broken_links=true`),
5189
+ store,
5190
+ "",
5191
+ "v",
5192
+ await scopeCtx(["work"]),
5193
+ );
5194
+ const body = await res.json() as any;
5195
+ expect(body.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
5196
+ expect(JSON.stringify(body)).not.toContain("p1/Dup");
5197
+ });
5198
+
5199
+ test("deleting the last OUT-OF-SCOPE candidate does not change the scoped broken-link view (vault#239)", async () => {
5200
+ const { src, hidden } = await hiddenOnlyDupFixture();
5201
+ const scope = await scopeCtx(["work"]);
5202
+ const snapshot = async () => ({
5203
+ broken: (await (await handleNotes(
5204
+ mkReq("GET", `/notes?id=${src.id}&include_broken_links=true`), store, "", "v", scope,
5205
+ )).json() as any).broken_links,
5206
+ truthy: ((await (await handleNotes(
5207
+ mkReq("GET", "/notes?has_broken_links=true"), store, "", "v", scope,
5208
+ )).json()) as any[]).map((n) => n.id),
5209
+ falsy: ((await (await handleNotes(
5210
+ mkReq("GET", "/notes?has_broken_links=false"), store, "", "v", scope,
5211
+ )).json()) as any[]).map((n) => n.id),
5212
+ });
5213
+
5214
+ const before = await snapshot();
5215
+ for (const h of hidden) await store.deleteNote(h.id);
5216
+ const after = await snapshot();
5217
+
5218
+ expect(after).toEqual(before);
5219
+ expect(after.broken).toEqual([{ target: "Dup", relationship: "wikilink" }]);
5220
+ expect(after.truthy).toContain(src.id);
5221
+ expect(after.falsy).not.toContain(src.id);
5222
+ });
5223
+
5224
+ test("aggregate over has_broken_links does not leak the collision as a count either", async () => {
5225
+ const { hidden } = await hiddenOnlyDupFixture();
5226
+ const scope = await scopeCtx(["work"]);
5227
+ const rollup = async () => (await (await handleNotes(
5228
+ mkReq("GET", "/notes?has_broken_links=true&aggregate[op]=count"), store, "", "v", scope,
5229
+ )).json()) as any[];
5230
+
5231
+ const before = await rollup();
5232
+ for (const h of hidden) await store.deleteNote(h.id);
5233
+ expect(await rollup()).toEqual(before);
5234
+ });
5235
+
5236
+ test("/unresolved-wikilinks does not gain a row when an out-of-scope candidate is deleted", async () => {
5237
+ const { hidden } = await hiddenOnlyDupFixture();
5238
+ const scope = await scopeCtx(["work"]);
5239
+ const list = async () => {
5240
+ const body = await (handleUnresolvedWikilinks(
5241
+ mkReq("GET", "/unresolved-wikilinks"), store, scope,
5242
+ )).json() as any;
5243
+ return { targets: (body.unresolved as any[]).map((r) => r.target_path).sort(), count: body.count };
5244
+ };
5245
+
5246
+ const before = await list();
5247
+ expect(before.targets).toContain("Dup");
5248
+ for (const h of hidden) await store.deleteNote(h.id);
5249
+ expect(await list()).toEqual(before);
5250
+ });
5251
+
5252
+ test("NEGATIVE CONTROL: one IN-SCOPE candidate is not reported broken", async () => {
5253
+ // `wdup/Dup` is visible, `odup/Dup` is not — the reference resolves in
5254
+ // the reader's sub-vault, so it must NOT be reported broken. Guards
5255
+ // against a fix that simply reports every ambiguous row as broken.
5256
+ const src = await crossScopeDupFixture(["personal"]);
5257
+ const scope = await scopeCtx(["work"]);
5258
+ const body = await (await handleNotes(
5259
+ mkReq("GET", `/notes?id=${src.id}&include_broken_links=true`), store, "", "v", scope,
5260
+ )).json() as any;
5261
+ expect(body.broken_links).toEqual([]);
5262
+ const swept = (await (await handleNotes(
5263
+ mkReq("GET", "/notes?has_broken_links=true"), store, "", "v", scope,
5264
+ )).json()) as any[];
5265
+ expect(swept.map((n) => n.id)).not.toContain(src.id);
5266
+ });
5267
+
5268
+ test("UNSCOPED REST is unchanged: ambiguous before the deletes, broken after (regression)", async () => {
5269
+ const { src, hidden } = await hiddenOnlyDupFixture();
5270
+ const read = async () => (await (await handleNotes(
5271
+ mkReq("GET", `/notes?id=${src.id}&include_broken_links=true&include_ambiguous_links=true`),
5272
+ store, "", "v", NO_SCOPE,
5273
+ )).json()) as any;
5274
+
5275
+ const before = await read();
5276
+ expect(before.broken_links).toEqual([]);
5277
+ expect(before.ambiguous_links)
5278
+ .toEqual([{ target: "Dup", relationship: "wikilink", candidate_count: 2 }]);
5279
+
5280
+ for (const h of hidden) await store.deleteNote(h.id);
5281
+
5282
+ const after = await read();
5283
+ expect(after.broken_links).toEqual([{ target: "Dup", relationship: "wikilink" }]);
5284
+ expect(after.ambiguous_links).toEqual([]);
5285
+ });
5286
+
4844
5287
  // vault#555 auth-review CRITICAL: `if_exists` must NOT become a tag-scope
4845
5288
  // bypass. A scoped token naming an out-of-scope note's PATH must not read
4846
5289
  // (ignore), update, or replace it — treat it as a path_conflict (path taken,
@@ -6220,6 +6663,87 @@ describe("HTTP GET /notes — has_broken_links / include_broken_links (vault#555
6220
6663
  });
6221
6664
  });
6222
6665
 
6666
+ // vault#581 — has_ambiguous_links / include_ambiguous_links on GET /notes.
6667
+ // The queryable twin of the has_broken_links block above: before #581 an
6668
+ // ambiguous target existed only in the transient write-time warning, so REST
6669
+ // callers auditing a vault could not find one after the fact.
6670
+ describe("HTTP GET /notes — has_ambiguous_links / include_ambiguous_links (vault#581)", async () => {
6671
+ test("has_ambiguous_links=true filters to notes whose wikilink matched two notes", async () => {
6672
+ await store.createNote("first", { path: "ra1/Dup" });
6673
+ await store.createNote("second", { path: "ra2/Dup" });
6674
+ await store.createNote("[[Dup]]", { path: "rest-ambiguous" });
6675
+ await store.createNote("clean", { path: "rest-amb-clean" });
6676
+ const res = await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true&include_content=true"), store, "");
6677
+ const body = await res.json() as any[];
6678
+ expect(body.map((n) => n.path)).toEqual(["rest-ambiguous"]);
6679
+ });
6680
+
6681
+ test("has_ambiguous_links=false excludes them", async () => {
6682
+ await store.createNote("first", { path: "rb1/Twin" });
6683
+ await store.createNote("second", { path: "rb2/Twin" });
6684
+ await store.createNote("[[Twin]]", { path: "rest-ambiguous2" });
6685
+ await store.createNote("clean", { path: "rest-amb-clean2" });
6686
+ const res = await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=false&include_content=true"), store, "");
6687
+ const body = await res.json() as any[];
6688
+ expect(body.map((n) => n.path).sort()).toEqual(["rb1/Twin", "rb2/Twin", "rest-amb-clean2"]);
6689
+ });
6690
+
6691
+ test("an ambiguous link is not reported as a broken one (the filters are disjoint)", async () => {
6692
+ await store.createNote("first", { path: "rc1/Both" });
6693
+ await store.createNote("second", { path: "rc2/Both" });
6694
+ await store.createNote("[[Both]]", { path: "rest-amb-disjoint" });
6695
+ const broken = await (await handleNotes(mkReq("GET", "/notes?has_broken_links=true"), store, "")).json() as any[];
6696
+ expect(broken.map((n) => n.path)).toEqual([]);
6697
+ const ambiguous = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true"), store, "")).json() as any[];
6698
+ expect(ambiguous.map((n) => n.path)).toEqual(["rest-amb-disjoint"]);
6699
+ });
6700
+
6701
+ test("include_ambiguous_links on a single note surfaces {target, relationship, candidate_count}", async () => {
6702
+ await store.createNote("first", { path: "rd1/Fork" });
6703
+ await store.createNote("second", { path: "rd2/Fork" });
6704
+ await store.createNote("[[Fork]]", { path: "rest-single-ambiguous" });
6705
+ const res = await handleNotes(mkReq("GET", "/notes?id=rest-single-ambiguous&include_ambiguous_links=true"), store, "");
6706
+ const body = await res.json() as any;
6707
+ expect(body.ambiguous_links).toEqual([{ target: "Fork", relationship: "wikilink", candidate_count: 2 }]);
6708
+ });
6709
+
6710
+ test("include_ambiguous_links in list mode is batched per note", async () => {
6711
+ await store.createNote("first", { path: "re1/Echo" });
6712
+ await store.createNote("second", { path: "re2/Echo" });
6713
+ await store.createNote("[[Echo]]", { path: "rest-amb-list-a" });
6714
+ await store.createNote("clean", { path: "rest-amb-list-b" });
6715
+ const res = await handleNotes(mkReq("GET", "/notes?include_ambiguous_links=true&include_content=true"), store, "");
6716
+ const body = await res.json() as any[];
6717
+ const byPath = new Map(body.map((n: any) => [n.path, n.ambiguous_links]));
6718
+ expect(byPath.get("rest-amb-list-a")).toEqual([{ target: "Echo", relationship: "wikilink", candidate_count: 2 }]);
6719
+ expect(byPath.get("rest-amb-list-b")).toEqual([]);
6720
+ });
6721
+
6722
+ test("both filters are safe on a vault where no link has ever been ambiguous", async () => {
6723
+ await store.createNote("plain", { path: "rest-amb-never" });
6724
+ const truthy = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true"), store, "")).json() as any[];
6725
+ expect(truthy).toEqual([]);
6726
+ const falsy = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=false&include_content=true"), store, "")).json() as any[];
6727
+ expect(falsy.map((n) => n.path)).toEqual(["rest-amb-never"]);
6728
+ const single = await (await handleNotes(mkReq("GET", "/notes?id=rest-amb-never&include_ambiguous_links=true"), store, "")).json() as any;
6729
+ expect(single.ambiguous_links).toEqual([]);
6730
+ });
6731
+
6732
+ test("deleting one colliding candidate heals the ambiguity through the REST filter", async () => {
6733
+ const keep = await store.createNote("first", { path: "rf1/Heal" });
6734
+ const drop = await store.createNote("second", { path: "rf2/Heal" });
6735
+ await store.createNote("[[Heal]]", { path: "rest-amb-heal" });
6736
+ let body = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true"), store, "")).json() as any[];
6737
+ expect(body.map((n) => n.path)).toEqual(["rest-amb-heal"]);
6738
+
6739
+ await store.deleteNote(drop.id);
6740
+ body = await (await handleNotes(mkReq("GET", "/notes?has_ambiguous_links=true"), store, "")).json() as any[];
6741
+ expect(body).toEqual([]);
6742
+ const linked = await (await handleNotes(mkReq("GET", "/notes?id=rest-amb-heal&include_links=true"), store, "")).json() as any;
6743
+ expect(linked.links.map((l: any) => l.targetId)).toEqual([keep.id]);
6744
+ });
6745
+ });
6746
+
6223
6747
  // vault#309 — HTTP PATCH /notes/:id with if_missing: "create" mirrors
6224
6748
  // the MCP update-note path. Sync loops (Gitcoin Brain et al) use this
6225
6749
  // for idempotent upsert without a separate query-first round trip.
@@ -7137,6 +7661,167 @@ describe("stateless MCP transport", async () => {
7137
7661
  closeAllStores();
7138
7662
  });
7139
7663
 
7664
+ // vault#669: the MCP door's `description` write had no runtime type check
7665
+ // (an `as string` cast), so a write/admin caller could persist a non-string
7666
+ // and poison the next `initialize` (serverInstruction's `description.trim()`
7667
+ // throws → -32603 INTERNAL, the first frame, unrepairable in-session — the
7668
+ // bun half of cloud#87). The guard rejects non-string/non-null as a
7669
+ // structured -32602 INVALID_PARAMS (`error_type: invalid_description`), the
7670
+ // same shape every other MCP validation leaf takes. `null` still clears.
7671
+ describe("vault-info description type guard (vault#669 / cloud#87)", () => {
7672
+ for (const [label, value] of [
7673
+ ["a number", 123],
7674
+ ["an object", { text: "nope" }],
7675
+ ["an array", ["a", "b"]],
7676
+ ["a boolean", true],
7677
+ ] as const) {
7678
+ test(`tools/call vault-info with ${label} description → -32602 invalid_description, nothing persisted`, async () => {
7679
+ const { handleScopedMcp } = await import("./mcp-http.ts");
7680
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
7681
+ const { closeAllStores } = await import("./vault-store.ts");
7682
+
7683
+ const vaultName = `desc-type-${label.replace(/\s+/g, "-")}-${Date.now()}`;
7684
+ writeVaultConfig({
7685
+ name: vaultName,
7686
+ api_keys: [],
7687
+ created_at: new Date().toISOString(),
7688
+ description: "original",
7689
+ });
7690
+
7691
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
7692
+ method: "POST",
7693
+ headers: {
7694
+ "content-type": "application/json",
7695
+ "accept": "application/json, text/event-stream",
7696
+ },
7697
+ body: JSON.stringify({
7698
+ jsonrpc: "2.0",
7699
+ id: 1,
7700
+ method: "tools/call",
7701
+ params: { name: "vault-info", arguments: { description: value } },
7702
+ }),
7703
+ });
7704
+
7705
+ const res = await handleScopedMcp(req, vaultName, {
7706
+ permission: "full",
7707
+ scopes: ["vault:read", "vault:write", "vault:admin"],
7708
+ legacyDerived: false,
7709
+ scoped_tags: null,
7710
+ } as any);
7711
+
7712
+ expect(res.status).toBe(200);
7713
+ const body = await res.json() as any;
7714
+ // Structured JSON-RPC error (protocol-level), NOT the in-band
7715
+ // isError text: -32602 INVALID_PARAMS carrying error_type/field.
7716
+ expect(body.error).toBeDefined();
7717
+ expect(body.error.code).toBe(-32602);
7718
+ expect(body.error.data.error_type).toBe("invalid_description");
7719
+ expect(body.error.data.field).toBe("description");
7720
+ // Nothing persisted — no poison state introduced.
7721
+ expect(readVaultConfig(vaultName)?.description).toBe("original");
7722
+
7723
+ closeAllStores();
7724
+ });
7725
+ }
7726
+
7727
+ test("a refused non-string description leaves MCP initialize working (poison prevented)", async () => {
7728
+ const { handleScopedMcp } = await import("./mcp-http.ts");
7729
+ const { writeVaultConfig } = await import("./config.ts");
7730
+ const { closeAllStores } = await import("./vault-store.ts");
7731
+
7732
+ const vaultName = `desc-poison-${Date.now()}`;
7733
+ writeVaultConfig({
7734
+ name: vaultName,
7735
+ api_keys: [],
7736
+ created_at: new Date().toISOString(),
7737
+ description: "healthy brief",
7738
+ });
7739
+
7740
+ const admin = {
7741
+ permission: "full" as const,
7742
+ scopes: ["vault:read", "vault:write", "vault:admin"],
7743
+ legacyDerived: false,
7744
+ scoped_tags: null,
7745
+ };
7746
+
7747
+ // Attempt to poison with a number — must be rejected.
7748
+ const poisonReq = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
7749
+ method: "POST",
7750
+ headers: { "content-type": "application/json", "accept": "application/json, text/event-stream" },
7751
+ body: JSON.stringify({
7752
+ jsonrpc: "2.0",
7753
+ id: 1,
7754
+ method: "tools/call",
7755
+ params: { name: "vault-info", arguments: { description: 123 } },
7756
+ }),
7757
+ });
7758
+ const poisonRes = await handleScopedMcp(poisonReq, vaultName, admin as any);
7759
+ expect((await poisonRes.json() as any).error.code).toBe(-32602);
7760
+
7761
+ // initialize must still succeed — before the guard this answered
7762
+ // -32603 INTERNAL_ERROR (`description.trim is not a function`).
7763
+ const initReq = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
7764
+ method: "POST",
7765
+ headers: { "content-type": "application/json", "accept": "application/json, text/event-stream" },
7766
+ body: JSON.stringify({
7767
+ jsonrpc: "2.0",
7768
+ id: 2,
7769
+ method: "initialize",
7770
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1.0" } },
7771
+ }),
7772
+ });
7773
+ const initRes = await handleScopedMcp(initReq, vaultName, admin as any);
7774
+ expect(initRes.status).toBe(200);
7775
+ const initBody = await initRes.json() as any;
7776
+ expect(initBody.error).toBeUndefined();
7777
+ expect(initBody.result.instructions).toContain("healthy brief");
7778
+
7779
+ closeAllStores();
7780
+ });
7781
+
7782
+ test("null description clears it (still legal on the MCP door)", async () => {
7783
+ const { handleScopedMcp } = await import("./mcp-http.ts");
7784
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
7785
+ const { closeAllStores } = await import("./vault-store.ts");
7786
+
7787
+ const vaultName = `desc-null-${Date.now()}`;
7788
+ writeVaultConfig({
7789
+ name: vaultName,
7790
+ api_keys: [],
7791
+ created_at: new Date().toISOString(),
7792
+ description: "to be cleared",
7793
+ });
7794
+
7795
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
7796
+ method: "POST",
7797
+ headers: { "content-type": "application/json", "accept": "application/json, text/event-stream" },
7798
+ body: JSON.stringify({
7799
+ jsonrpc: "2.0",
7800
+ id: 1,
7801
+ method: "tools/call",
7802
+ params: { name: "vault-info", arguments: { description: null } },
7803
+ }),
7804
+ });
7805
+
7806
+ const res = await handleScopedMcp(req, vaultName, {
7807
+ permission: "full",
7808
+ scopes: ["vault:read", "vault:write", "vault:admin"],
7809
+ legacyDerived: false,
7810
+ scoped_tags: null,
7811
+ } as any);
7812
+
7813
+ expect(res.status).toBe(200);
7814
+ const body = await res.json() as any;
7815
+ expect(body.error).toBeUndefined();
7816
+ expect(body.result.isError).toBeFalsy();
7817
+ // Cleared — readVaultConfig returns null (or undefined) for a cleared
7818
+ // description, never the old string.
7819
+ expect(readVaultConfig(vaultName)?.description ?? null).toBeNull();
7820
+
7821
+ closeAllStores();
7822
+ });
7823
+ });
7824
+
7140
7825
  test("tools/call of create-note with vault:read scope is refused (not silently allowed)", async () => {
7141
7826
  const { handleScopedMcp } = await import("./mcp-http.ts");
7142
7827
  const { writeVaultConfig } = await import("./config.ts");