@openparachute/vault 0.5.1 → 0.5.2-rc.2
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/core.test.ts +183 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +77 -0
- package/core/src/mcp.ts +130 -22
- package/core/src/notes.ts +36 -0
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/schema.ts +7 -4
- package/core/src/store.ts +1 -1
- package/core/src/tag-schemas.ts +59 -44
- package/core/src/types.ts +31 -3
- package/package.json +1 -1
- package/src/auth.test.ts +37 -1
- package/src/auth.ts +29 -0
- package/src/cli.ts +286 -68
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- package/src/init-summary.test.ts +77 -5
- package/src/init-summary.ts +37 -19
- package/src/mcp-tools.ts +60 -6
- package/src/routes.ts +486 -53
- package/src/routing.test.ts +185 -0
- package/src/routing.ts +32 -2
- package/src/server.ts +7 -0
- package/src/storage.test.ts +162 -0
- package/src/tag-scope.ts +68 -1
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +298 -11
- package/src/vault.test.ts +1064 -7
package/core/src/core.test.ts
CHANGED
|
@@ -972,6 +972,33 @@ describe("vault stats", async () => {
|
|
|
972
972
|
expect(stats.topTags).toEqual([]);
|
|
973
973
|
expect(stats.tagCount).toBe(0);
|
|
974
974
|
expect(stats.linkCount).toBe(0);
|
|
975
|
+
expect(stats.contentBytes).toBe(0);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("contentBytes sums ASCII content as raw byte length", async () => {
|
|
979
|
+
// "hello" = 5 bytes, "world!" = 6 bytes — for pure ASCII, bytes == chars.
|
|
980
|
+
await store.createNote("hello");
|
|
981
|
+
await store.createNote("world!");
|
|
982
|
+
const stats = await store.getVaultStats();
|
|
983
|
+
expect(stats.contentBytes).toBe(11);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it("contentBytes counts UTF-8 BYTES, not characters (multibyte)", async () => {
|
|
987
|
+
// The whole point of CAST(content AS BLOB): SQLite's bare LENGTH() would
|
|
988
|
+
// return the CHARACTER count, undercounting multibyte content.
|
|
989
|
+
// - "é" is U+00E9 → 2 bytes in UTF-8 (1 char)
|
|
990
|
+
// - "你好" is 2 CJK chars → 6 bytes (3 bytes each)
|
|
991
|
+
// - "😀" is 1 grapheme / 2 UTF-16 code units → 4 bytes in UTF-8
|
|
992
|
+
const content = "é你好😀";
|
|
993
|
+
const expectedBytes = Buffer.byteLength(content, "utf8"); // 2 + 6 + 4 = 12
|
|
994
|
+
expect(expectedBytes).toBe(12);
|
|
995
|
+
// And it's strictly MORE than the JS character count — proves we're not
|
|
996
|
+
// accidentally counting chars.
|
|
997
|
+
expect(expectedBytes).toBeGreaterThan([...content].length);
|
|
998
|
+
|
|
999
|
+
await store.createNote(content);
|
|
1000
|
+
const stats = await store.getVaultStats();
|
|
1001
|
+
expect(stats.contentBytes).toBe(expectedBytes);
|
|
975
1002
|
});
|
|
976
1003
|
|
|
977
1004
|
it("counts total notes and tagCount", async () => {
|
|
@@ -1041,6 +1068,7 @@ describe("vault stats", async () => {
|
|
|
1041
1068
|
expect(stats).toHaveProperty("topTags");
|
|
1042
1069
|
expect(stats).toHaveProperty("tagCount");
|
|
1043
1070
|
expect(stats).toHaveProperty("linkCount");
|
|
1071
|
+
expect(stats).toHaveProperty("contentBytes");
|
|
1044
1072
|
});
|
|
1045
1073
|
|
|
1046
1074
|
it("counts resolved wikilinks in linkCount", async () => {
|
|
@@ -2131,6 +2159,113 @@ describe("MCP tools", async () => {
|
|
|
2131
2159
|
expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
|
|
2132
2160
|
});
|
|
2133
2161
|
|
|
2162
|
+
// vault feedback #8 — echo hydrated links on the update response when the
|
|
2163
|
+
// request mutated links OR `include_links` is set, so callers don't re-query.
|
|
2164
|
+
it("update-note echoes hydrated links when the update mutates links", async () => {
|
|
2165
|
+
await store.createNote("A", { id: "a" });
|
|
2166
|
+
await store.createNote("B", { id: "b", path: "beta", tags: ["t"] });
|
|
2167
|
+
const tools = generateMcpTools(store);
|
|
2168
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2169
|
+
const result = await updateNote.execute({
|
|
2170
|
+
id: "a",
|
|
2171
|
+
links: { add: [{ target: "b", relationship: "mentions" }] },
|
|
2172
|
+
force: true,
|
|
2173
|
+
}) as any;
|
|
2174
|
+
expect(Array.isArray(result.links)).toBe(true);
|
|
2175
|
+
expect(result.links).toHaveLength(1);
|
|
2176
|
+
// Hydrated shape matches query-notes' include_links output.
|
|
2177
|
+
const link = result.links[0];
|
|
2178
|
+
expect(link.sourceId).toBe("a");
|
|
2179
|
+
expect(link.targetId).toBe("b");
|
|
2180
|
+
expect(link.relationship).toBe("mentions");
|
|
2181
|
+
expect(link.targetNote.path).toBe("beta");
|
|
2182
|
+
expect(link.targetNote.tags).toEqual(["t"]);
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
it("update-note echoes links on removal (post-removal set)", async () => {
|
|
2186
|
+
await store.createNote("A", { id: "a" });
|
|
2187
|
+
await store.createNote("B", { id: "b" });
|
|
2188
|
+
await store.createNote("C", { id: "c" });
|
|
2189
|
+
await store.createLink("a", "b", "mentions");
|
|
2190
|
+
await store.createLink("a", "c", "mentions");
|
|
2191
|
+
const tools = generateMcpTools(store);
|
|
2192
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2193
|
+
const result = await updateNote.execute({
|
|
2194
|
+
id: "a",
|
|
2195
|
+
links: { remove: [{ target: "b", relationship: "mentions" }] },
|
|
2196
|
+
force: true,
|
|
2197
|
+
}) as any;
|
|
2198
|
+
expect(result.links).toHaveLength(1);
|
|
2199
|
+
expect(result.links[0].targetId).toBe("c");
|
|
2200
|
+
});
|
|
2201
|
+
|
|
2202
|
+
it("update-note with include_links echoes links even without a mutation", async () => {
|
|
2203
|
+
await store.createNote("A", { id: "a" });
|
|
2204
|
+
await store.createNote("B", { id: "b" });
|
|
2205
|
+
await store.createLink("a", "b", "mentions");
|
|
2206
|
+
const tools = generateMcpTools(store);
|
|
2207
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2208
|
+
const result = await updateNote.execute({
|
|
2209
|
+
id: "a",
|
|
2210
|
+
content: "updated",
|
|
2211
|
+
include_links: true,
|
|
2212
|
+
force: true,
|
|
2213
|
+
}) as any;
|
|
2214
|
+
expect(result.content).toBe("updated");
|
|
2215
|
+
expect(result.links).toHaveLength(1);
|
|
2216
|
+
expect(result.links[0].targetId).toBe("b");
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
it("update-note without a link mutation or flag does NOT echo links", async () => {
|
|
2220
|
+
await store.createNote("A", { id: "a" });
|
|
2221
|
+
await store.createNote("B", { id: "b" });
|
|
2222
|
+
await store.createLink("a", "b", "mentions");
|
|
2223
|
+
const tools = generateMcpTools(store);
|
|
2224
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2225
|
+
const result = await updateNote.execute({ id: "a", content: "updated", force: true }) as any;
|
|
2226
|
+
expect(result.content).toBe("updated");
|
|
2227
|
+
expect(result).not.toHaveProperty("links");
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
it("update-note batch echoes links per-item (only on the mutated/flagged item)", async () => {
|
|
2231
|
+
await store.createNote("A", { id: "a" });
|
|
2232
|
+
await store.createNote("B", { id: "b" });
|
|
2233
|
+
await store.createNote("C", { id: "c" });
|
|
2234
|
+
const tools = generateMcpTools(store);
|
|
2235
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2236
|
+
const result = await updateNote.execute({
|
|
2237
|
+
notes: [
|
|
2238
|
+
// Item 0 mutates links → echoes.
|
|
2239
|
+
{ id: "a", links: { add: [{ target: "b", relationship: "mentions" }] }, force: true },
|
|
2240
|
+
// Item 1 only edits content, no flag → no links key.
|
|
2241
|
+
{ id: "c", content: "C updated", force: true },
|
|
2242
|
+
],
|
|
2243
|
+
}) as any[];
|
|
2244
|
+
expect(result).toHaveLength(2);
|
|
2245
|
+
expect(result[0].links).toHaveLength(1);
|
|
2246
|
+
expect(result[0].links[0].targetId).toBe("b");
|
|
2247
|
+
expect(result[1]).not.toHaveProperty("links");
|
|
2248
|
+
});
|
|
2249
|
+
|
|
2250
|
+
it("update-note if_missing=create with include_links echoes links (no link mutation)", async () => {
|
|
2251
|
+
const tools = generateMcpTools(store);
|
|
2252
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2253
|
+
// The created note has no links yet and the payload declares none, so the
|
|
2254
|
+
// echo is driven purely by the explicit `include_links` flag — closing the
|
|
2255
|
+
// create-on-missing × flag-only matrix gap. Hydrated `links` key is present
|
|
2256
|
+
// (empty array), not absent.
|
|
2257
|
+
const result = await updateNote.execute({
|
|
2258
|
+
id: "fresh-note",
|
|
2259
|
+
content: "brand new",
|
|
2260
|
+
if_missing: "create",
|
|
2261
|
+
include_links: true,
|
|
2262
|
+
}) as any;
|
|
2263
|
+
expect(result.created).toBe(true);
|
|
2264
|
+
expect(result.content).toBe("brand new");
|
|
2265
|
+
expect(Array.isArray(result.links)).toBe(true);
|
|
2266
|
+
expect(result.links).toHaveLength(0);
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2134
2269
|
it("update-note removes wikilink brackets when removing wikilink-type link", async () => {
|
|
2135
2270
|
await store.createNote("Target", { id: "target", path: "People/Alice" });
|
|
2136
2271
|
const source = await store.createNote("See [[People/Alice]] for details", { id: "source" });
|
|
@@ -4932,43 +5067,65 @@ describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
|
4932
5067
|
expect(idxZebra).toBeGreaterThan(idxAlpha);
|
|
4933
5068
|
});
|
|
4934
5069
|
|
|
4935
|
-
|
|
5070
|
+
// ---- relationships is an opaque vocabulary map (vault#428) ----
|
|
5071
|
+
// Vault no longer enforces the historical { target_tag, cardinality }
|
|
5072
|
+
// shape. Apps (the Weaver / structural-link picker) declare a freeform
|
|
5073
|
+
// vocabulary; vault stores + returns it verbatim. The old typed shape is
|
|
5074
|
+
// still a valid value, so the loosening is a backwards-compatible superset.
|
|
5075
|
+
|
|
5076
|
+
it("update-tag MCP persists the opaque relationship-vocabulary map verbatim (vault#428)", async () => {
|
|
4936
5077
|
const tools = generateMcpTools(store);
|
|
4937
5078
|
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
5079
|
+
const vocab = {
|
|
5080
|
+
"works-on": { from: "person", to: "project" },
|
|
5081
|
+
"member-of": { from: "person", to: "organization" },
|
|
5082
|
+
"partner-of": { from: "person", to: "person" },
|
|
5083
|
+
"based-at": { from: "project", to: "place" },
|
|
5084
|
+
};
|
|
5085
|
+
await update.execute({ tag: "person", relationships: vocab });
|
|
5086
|
+
const r = await store.getTagRecord("person");
|
|
5087
|
+
expect(r?.relationships).toEqual(vocab);
|
|
4946
5088
|
});
|
|
4947
5089
|
|
|
4948
|
-
it("update-tag MCP
|
|
5090
|
+
it("update-tag MCP round-trips nested arbitrary relationship values verbatim", async () => {
|
|
4949
5091
|
const tools = generateMcpTools(store);
|
|
4950
5092
|
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
5093
|
+
const vocab = {
|
|
5094
|
+
rel: { from: "a", to: "b", note: "freeform", weight: 3, optional: true, tags: ["x", "y"] },
|
|
5095
|
+
};
|
|
5096
|
+
await update.execute({ tag: "thing", relationships: vocab });
|
|
5097
|
+
const r = await store.getTagRecord("thing");
|
|
5098
|
+
expect(r?.relationships).toEqual(vocab);
|
|
5099
|
+
});
|
|
5100
|
+
|
|
5101
|
+
it("update-tag MCP still accepts the historical typed shape (backwards-compat)", async () => {
|
|
5102
|
+
const tools = generateMcpTools(store);
|
|
5103
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
5104
|
+
const typed = { owned_by: { target_tag: "person", cardinality: "one", description: "DRI" } };
|
|
5105
|
+
await update.execute({ tag: "project", relationships: typed });
|
|
5106
|
+
const r = await store.getTagRecord("project");
|
|
5107
|
+
expect(r?.relationships).toEqual(typed);
|
|
5108
|
+
});
|
|
5109
|
+
|
|
5110
|
+
it("update-tag MCP accepts what used to be rejected (no inner-shape enforcement)", async () => {
|
|
5111
|
+
const tools = generateMcpTools(store);
|
|
5112
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
5113
|
+
// Formerly rejected: non-vocabulary cardinality + missing target_tag.
|
|
5114
|
+
const formerlyInvalid = {
|
|
5115
|
+
a: { target_tag: "person", cardinality: "bogus" },
|
|
5116
|
+
b: { cardinality: "one" },
|
|
5117
|
+
};
|
|
5118
|
+
await update.execute({ tag: "loose", relationships: formerlyInvalid });
|
|
5119
|
+
const r = await store.getTagRecord("loose");
|
|
5120
|
+
expect(r?.relationships).toEqual(formerlyInvalid);
|
|
4961
5121
|
});
|
|
4962
5122
|
|
|
4963
|
-
it("update-tag MCP rejects a
|
|
5123
|
+
it("update-tag MCP rejects a top-level array for relationships (must be a map)", async () => {
|
|
4964
5124
|
const tools = generateMcpTools(store);
|
|
4965
5125
|
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4966
5126
|
await expect(
|
|
4967
|
-
update.execute({
|
|
4968
|
-
|
|
4969
|
-
relationships: { owned_by: { cardinality: "one" } },
|
|
4970
|
-
}),
|
|
4971
|
-
).rejects.toThrow(/target_tag/);
|
|
5127
|
+
update.execute({ tag: "project", relationships: ["not", "a", "map"] as unknown as Record<string, unknown> }),
|
|
5128
|
+
).rejects.toThrow();
|
|
4972
5129
|
});
|
|
4973
5130
|
|
|
4974
5131
|
it("update-tag MCP sets parent_names and the hierarchy invalidates", async () => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { initSchema } from "./schema.js";
|
|
4
|
+
import { createNote } from "./notes.js";
|
|
5
|
+
import { expandContent, type ExpandContext } from "./expand.js";
|
|
6
|
+
import type { Note } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Security regression (vault security review — tag-scope confidentiality).
|
|
10
|
+
*
|
|
11
|
+
* `expandContent`'s optional `isVisible` predicate is the seam that keeps
|
|
12
|
+
* core scope-unaware while letting the server enforce tag scope during
|
|
13
|
+
* wikilink inlining. These tests pin the load-bearing invariant: when the
|
|
14
|
+
* predicate rejects a target, the wikilink is left UNRESOLVED — byte-for-byte
|
|
15
|
+
* identical to a genuinely-missing target, so the response can't reveal that
|
|
16
|
+
* the out-of-scope note exists. They MUST fail if the predicate is removed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let db: Database;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
db = new Database(":memory:");
|
|
23
|
+
initSchema(db);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function ctx(over: Partial<ExpandContext> = {}): ExpandContext {
|
|
27
|
+
return { db, mode: "full", expanded: new Set<string>(), ...over };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("expandContent isVisible predicate (tag-scope confidentiality)", () => {
|
|
31
|
+
it("inlines an in-scope target's content when no predicate is set (baseline)", () => {
|
|
32
|
+
createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
|
|
33
|
+
const out = expandContent("see [[Secret]]", ctx(), 1);
|
|
34
|
+
expect(out).toContain("SECRET BODY");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("leaves an out-of-scope wikilink unresolved — no content inlined", () => {
|
|
38
|
+
createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
|
|
39
|
+
// Predicate: only #work notes are visible. The target is #personal.
|
|
40
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
41
|
+
const out = expandContent("see [[Secret]]", ctx({ isVisible }), 1);
|
|
42
|
+
expect(out).not.toContain("SECRET BODY");
|
|
43
|
+
// The wikilink stays literal.
|
|
44
|
+
expect(out).toBe("see [[Secret]]");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("out-of-scope target is INDISTINGUISHABLE from a missing target", () => {
|
|
48
|
+
// Case A: target exists but is out of scope.
|
|
49
|
+
createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
|
|
50
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
51
|
+
const outOfScope = expandContent("see [[Secret]]", ctx({ isVisible }), 1);
|
|
52
|
+
|
|
53
|
+
// Case B: target genuinely does not exist (fresh db, same predicate).
|
|
54
|
+
const db2 = new Database(":memory:");
|
|
55
|
+
initSchema(db2);
|
|
56
|
+
const missing = expandContent("see [[Secret]]", { db: db2, mode: "full", expanded: new Set(), isVisible }, 1);
|
|
57
|
+
|
|
58
|
+
// Byte-identical output → existence is not leaked.
|
|
59
|
+
expect(outOfScope).toBe(missing);
|
|
60
|
+
expect(outOfScope).toBe("see [[Secret]]");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("a second reference to an out-of-scope note does NOT render `(expanded above)`", () => {
|
|
64
|
+
// Pins that an out-of-scope note never enters the `expanded` set — else a
|
|
65
|
+
// repeat reference would render `(expanded above)` and leak existence via
|
|
66
|
+
// a different output than a missing target.
|
|
67
|
+
createNote(db, "SECRET BODY", { path: "Secret", tags: ["personal"] });
|
|
68
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
69
|
+
const out = expandContent("[[Secret]] and again [[Secret]]", ctx({ isVisible }), 1);
|
|
70
|
+
expect(out).toBe("[[Secret]] and again [[Secret]]");
|
|
71
|
+
expect(out).not.toContain("expanded above");
|
|
72
|
+
expect(out).not.toContain("SECRET BODY");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("multi-hop (depth>1) does not leak out-of-scope content at any depth", () => {
|
|
76
|
+
// in-scope #work note → wikilinks an out-of-scope #personal note.
|
|
77
|
+
createNote(db, "DEEP SECRET", { path: "Deep", tags: ["personal"] });
|
|
78
|
+
createNote(db, "intro [[Deep]]", { path: "Mid", tags: ["work"] });
|
|
79
|
+
createNote(db, "top [[Mid]]", { path: "Top", tags: ["work"] });
|
|
80
|
+
|
|
81
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
82
|
+
// Depth 3 — would walk Top → Mid → Deep without the predicate.
|
|
83
|
+
const out = expandContent("[[Top]]", ctx({ isVisible }), 3);
|
|
84
|
+
// The in-scope Mid IS inlined; the out-of-scope Deep is NOT.
|
|
85
|
+
expect(out).toContain("intro");
|
|
86
|
+
expect(out).not.toContain("DEEP SECRET");
|
|
87
|
+
// The Deep wikilink stays literal inside Mid's inlined content.
|
|
88
|
+
expect(out).toContain("[[Deep]]");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("predicate is also honored in summary mode", () => {
|
|
92
|
+
createNote(db, "x", {
|
|
93
|
+
path: "Secret",
|
|
94
|
+
tags: ["personal"],
|
|
95
|
+
metadata: { summary: "SECRET SUMMARY" },
|
|
96
|
+
});
|
|
97
|
+
const isVisible = (n: Note) => (n.tags ?? []).includes("work");
|
|
98
|
+
const out = expandContent("see [[Secret]]", ctx({ mode: "summary", isVisible }), 1);
|
|
99
|
+
expect(out).not.toContain("SECRET SUMMARY");
|
|
100
|
+
expect(out).toBe("see [[Secret]]");
|
|
101
|
+
});
|
|
102
|
+
});
|
package/core/src/expand.ts
CHANGED
|
@@ -22,6 +22,22 @@ export interface ExpandContext {
|
|
|
22
22
|
mode: ExpandMode;
|
|
23
23
|
/** Note IDs already expanded in this query. Shared across all expansions. */
|
|
24
24
|
expanded: Set<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Optional visibility predicate (tag-scope confidentiality, vault security
|
|
27
|
+
* review). When set and it returns `false` for a resolved target note, the
|
|
28
|
+
* wikilink is left UNRESOLVED — byte-identical to the not-found/unresolved
|
|
29
|
+
* branch, so an out-of-scope target is indistinguishable from a missing one
|
|
30
|
+
* (we never reveal existence differently across the scope boundary).
|
|
31
|
+
*
|
|
32
|
+
* Core stays scope-unaware: the server constructs this predicate from its
|
|
33
|
+
* tag-scope machinery and injects it; core only ever *calls* it. When
|
|
34
|
+
* unset (every unscoped caller), expansion behaves exactly as before.
|
|
35
|
+
*
|
|
36
|
+
* Applied at every hop — multi-hop (`expand_depth > 1`) expansion runs the
|
|
37
|
+
* predicate on each resolved target before inlining, so a deep chain can't
|
|
38
|
+
* tunnel out-of-scope content through an in-scope intermediary.
|
|
39
|
+
*/
|
|
40
|
+
isVisible?: (note: Note) => boolean;
|
|
25
41
|
}
|
|
26
42
|
|
|
27
43
|
/**
|
|
@@ -62,14 +78,26 @@ export function expandContent(
|
|
|
62
78
|
const noteId = resolveWikilink(ctx.db, target);
|
|
63
79
|
if (!noteId) return match; // unresolved or ambiguous — leave as-is
|
|
64
80
|
|
|
81
|
+
// Resolve the target BEFORE the dedup check so the visibility gate can run
|
|
82
|
+
// first — an out-of-scope target must never enter `expanded` (otherwise a
|
|
83
|
+
// second reference to it would render `(expanded above)` and leak its
|
|
84
|
+
// existence via a different output than a genuinely-missing target).
|
|
85
|
+
const note = noteOps.getNote(ctx.db, noteId);
|
|
86
|
+
if (!note) return match; // shouldn't happen, but be safe
|
|
87
|
+
|
|
88
|
+
// Tag-scope confidentiality (vault security review): if a visibility
|
|
89
|
+
// predicate is installed and the resolved target is out of scope, leave
|
|
90
|
+
// the wikilink UNRESOLVED — byte-identical to the not-found / unresolved
|
|
91
|
+
// branches above. The out-of-scope case must be indistinguishable from a
|
|
92
|
+
// missing target so the response can't leak the target's existence. Runs
|
|
93
|
+
// at every hop, so multi-hop expansion can't tunnel out-of-scope content.
|
|
94
|
+
if (ctx.isVisible && !ctx.isVisible(note)) return match;
|
|
95
|
+
|
|
65
96
|
if (ctx.expanded.has(noteId)) {
|
|
66
97
|
return `${match} (expanded above)`;
|
|
67
98
|
}
|
|
68
99
|
ctx.expanded.add(noteId);
|
|
69
100
|
|
|
70
|
-
const note = noteOps.getNote(ctx.db, noteId);
|
|
71
|
-
if (!note) return match; // shouldn't happen, but be safe
|
|
72
|
-
|
|
73
101
|
if (ctx.mode === "summary") {
|
|
74
102
|
// Summary mode doesn't recurse: depth > 1 has no additional effect.
|
|
75
103
|
return renderSummary(note);
|