@openparachute/vault 0.7.0-rc.9 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * REST face of aggregation / rollup queries (top new-feature ask from a UX
3
+ * round) — `GET /notes?aggregate[group_by]=…&aggregate[op]=…&aggregate[field]=…`.
4
+ *
5
+ * Core-level SQL correctness (count/sum semantics, group_by "tag", NULL
6
+ * groups, the FIELD_NOT_INDEXED / INVALID_QUERY error contract) is pinned
7
+ * in `core/src/aggregate.test.ts`; this suite covers the HTTP wiring: param
8
+ * parsing, 400s, the response shape, and — the part that can't be tested at
9
+ * the core layer since core stays scope-unaware — that a tag-scoped caller's
10
+ * rollup is computed only over notes it can see (no out-of-scope leakage,
11
+ * and no undercounting of in-scope notes). Fully sandboxed — in-memory
12
+ * SQLite, no daemon.
13
+ */
14
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
15
+ import { Database } from "bun:sqlite";
16
+ import { BunStore } from "./vault-store.ts";
17
+ import { handleNotes, type TagScopeCtx } from "./routes.ts";
18
+
19
+ let db: Database;
20
+ let store: BunStore;
21
+
22
+ beforeEach(() => {
23
+ db = new Database(":memory:");
24
+ store = new BunStore(db);
25
+ });
26
+
27
+ afterEach(() => {
28
+ db.close();
29
+ });
30
+
31
+ const BASE = "http://localhost/api";
32
+ const NO_SCOPE: TagScopeCtx = { allowed: null, raw: null };
33
+
34
+ function get(path: string, tagScope: TagScopeCtx = NO_SCOPE): Promise<Response> {
35
+ return handleNotes(new Request(`${BASE}/notes${path}`, { method: "GET" }), store, "", undefined, tagScope);
36
+ }
37
+
38
+ describe("REST GET /notes — aggregate: count by an indexed enum field", () => {
39
+ it("returns [{group, value}] rows", async () => {
40
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
41
+ await store.createNote("a", { tags: ["task"], metadata: { status: "open" } });
42
+ await store.createNote("b", { tags: ["task"], metadata: { status: "open" } });
43
+ await store.createNote("c", { tags: ["task"], metadata: { status: "done" } });
44
+
45
+ const res = await get("?aggregate[group_by]=status&aggregate[op]=count");
46
+ expect(res.status).toBe(200);
47
+ const body: any = await res.json();
48
+ const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
49
+ expect(byGroup).toEqual({ open: 2, done: 1 });
50
+ });
51
+ });
52
+
53
+ describe("REST GET /notes — aggregate: sum a numeric field", () => {
54
+ it("sums per group", async () => {
55
+ await store.upsertTagRecord("expense", {
56
+ fields: {
57
+ category: { type: "string", indexed: true },
58
+ amount: { type: "integer", indexed: true },
59
+ },
60
+ });
61
+ await store.createNote("a", { tags: ["expense"], metadata: { category: "food", amount: 10 } });
62
+ await store.createNote("b", { tags: ["expense"], metadata: { category: "food", amount: 25 } });
63
+ await store.createNote("c", { tags: ["expense"], metadata: { category: "travel", amount: 100 } });
64
+
65
+ const res = await get("?aggregate[group_by]=category&aggregate[op]=sum&aggregate[field]=amount");
66
+ expect(res.status).toBe(200);
67
+ const body: any = await res.json();
68
+ const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
69
+ expect(byGroup).toEqual({ food: 35, travel: 100 });
70
+ });
71
+ });
72
+
73
+ describe("REST GET /notes — aggregate respects a prefilter", () => {
74
+ it("a tag= prefilter narrows the input set before aggregating", async () => {
75
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
76
+ await store.createNote("a", { tags: ["task", "work"], metadata: { status: "open" } });
77
+ await store.createNote("b", { tags: ["task", "personal"], metadata: { status: "open" } });
78
+ await store.createNote("c", { tags: ["task", "work"], metadata: { status: "done" } });
79
+
80
+ const res = await get("?tag=work&aggregate[group_by]=status&aggregate[op]=count");
81
+ expect(res.status).toBe(200);
82
+ const body: any = await res.json();
83
+ const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
84
+ expect(byGroup).toEqual({ open: 1, done: 1 });
85
+ });
86
+
87
+ it("a meta[field][op] prefilter narrows the input set before aggregating", async () => {
88
+ await store.upsertTagRecord("task", {
89
+ fields: {
90
+ status: { type: "string", indexed: true },
91
+ priority: { type: "string", indexed: true },
92
+ },
93
+ });
94
+ await store.createNote("a", { tags: ["task"], metadata: { status: "open", priority: "high" } });
95
+ await store.createNote("b", { tags: ["task"], metadata: { status: "open", priority: "low" } });
96
+ await store.createNote("c", { tags: ["task"], metadata: { status: "done", priority: "high" } });
97
+
98
+ const res = await get("?meta[priority][eq]=high&aggregate[group_by]=status&aggregate[op]=count");
99
+ expect(res.status).toBe(200);
100
+ const body: any = await res.json();
101
+ const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
102
+ expect(byGroup).toEqual({ open: 1, done: 1 });
103
+ });
104
+ });
105
+
106
+ describe("REST GET /notes — aggregate errors", () => {
107
+ it("400s on a non-indexed group_by (FIELD_NOT_INDEXED)", async () => {
108
+ await store.createNote("a", { metadata: { status: "open" } });
109
+ const res = await get("?aggregate[group_by]=status&aggregate[op]=count");
110
+ expect(res.status).toBe(400);
111
+ const body: any = await res.json();
112
+ expect(body.code).toBe("FIELD_NOT_INDEXED");
113
+ });
114
+
115
+ it("400s when op is \"sum\" without a field", async () => {
116
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
117
+ const res = await get("?aggregate[group_by]=status&aggregate[op]=sum");
118
+ expect(res.status).toBe(400);
119
+ const body: any = await res.json();
120
+ expect(body.field).toBe("aggregate.field");
121
+ });
122
+
123
+ it("400s on an unrecognized op", async () => {
124
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
125
+ const res = await get("?aggregate[group_by]=status&aggregate[op]=average");
126
+ expect(res.status).toBe(400);
127
+ });
128
+
129
+ it("400s when group_by is present but op is missing", async () => {
130
+ const res = await get("?aggregate[group_by]=status");
131
+ expect(res.status).toBe(400);
132
+ const body: any = await res.json();
133
+ expect(body.field).toBe("aggregate");
134
+ });
135
+
136
+ it("400s when aggregate is combined with cursor", async () => {
137
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
138
+ const res = await get("?aggregate[group_by]=status&aggregate[op]=count&cursor=");
139
+ expect(res.status).toBe(400);
140
+ const body: any = await res.json();
141
+ expect(body.field).toBe("aggregate");
142
+ });
143
+
144
+ it("400s when aggregate is combined with near", async () => {
145
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
146
+ const anchor = await store.createNote("anchor", { tags: ["task"], metadata: { status: "open" } });
147
+ const res = await get(`?aggregate[group_by]=status&aggregate[op]=count&near[note_id]=${anchor.id}`);
148
+ expect(res.status).toBe(400);
149
+ const body: any = await res.json();
150
+ expect(body.field).toBe("aggregate");
151
+ });
152
+ });
153
+
154
+ describe("REST GET /notes — aggregate: group_by \"tag\"", () => {
155
+ it("counts by tag membership", async () => {
156
+ await store.createNote("a", { tags: ["work", "urgent"] });
157
+ await store.createNote("b", { tags: ["work"] });
158
+ await store.createNote("c", { tags: ["personal"] });
159
+
160
+ const res = await get("?aggregate[group_by]=tag&aggregate[op]=count");
161
+ expect(res.status).toBe(200);
162
+ const body: any = await res.json();
163
+ const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
164
+ expect(byGroup).toEqual({ work: 2, urgent: 1, personal: 1 });
165
+ });
166
+ });
167
+
168
+ describe("REST GET /notes — aggregate: tag-scope respected (no out-of-scope leakage)", () => {
169
+ it("counts by an indexed field ONLY over notes the token can see", async () => {
170
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
171
+ // Two in-scope (#work) notes, two out-of-scope (#personal) notes — all
172
+ // sharing the SAME status values, so a leak would silently inflate the
173
+ // in-scope counts rather than surfacing as an obviously-wrong shape.
174
+ await store.createNote("a", { tags: ["task", "work"], metadata: { status: "open" } });
175
+ await store.createNote("b", { tags: ["task", "work"], metadata: { status: "done" } });
176
+ await store.createNote("c", { tags: ["task", "personal"], metadata: { status: "open" } });
177
+ await store.createNote("d", { tags: ["task", "personal"], metadata: { status: "open" } });
178
+
179
+ const scoped: TagScopeCtx = { allowed: new Set(["work"]), raw: ["work"] };
180
+ const res = await get("?aggregate[group_by]=status&aggregate[op]=count", scoped);
181
+ expect(res.status).toBe(200);
182
+ const body: any = await res.json();
183
+ const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
184
+ // Only the two #work notes — the #personal "open" pair must not leak
185
+ // into (or inflate) the "open" group.
186
+ expect(byGroup).toEqual({ open: 1, done: 1 });
187
+ });
188
+
189
+ it("group_by \"tag\" never surfaces an out-of-scope tag name — including an out-of-scope CO-TAG on an otherwise-visible note", async () => {
190
+ // Note "a" is visible (carries "work"), but ALSO carries "urgent" —
191
+ // an out-of-scope co-tag. Narrowing which NOTES count isn't enough:
192
+ // "urgent" must not surface as a group just because its note is
193
+ // visible via a different tag.
194
+ await store.createNote("a", { tags: ["work", "urgent"] });
195
+ await store.createNote("b", { tags: ["personal", "secret-project"] });
196
+
197
+ const scoped: TagScopeCtx = { allowed: new Set(["work"]), raw: ["work"] };
198
+ const res = await get("?aggregate[group_by]=tag&aggregate[op]=count", scoped);
199
+ expect(res.status).toBe(200);
200
+ const body: any = await res.json();
201
+ const groups = body.map((r: any) => r.group);
202
+ expect(groups).toEqual(["work"]);
203
+ expect(groups).not.toContain("urgent");
204
+ expect(groups).not.toContain("personal");
205
+ expect(groups).not.toContain("secret-project");
206
+ });
207
+
208
+ it("an out-of-scope token aggregating over a filter that matches NO visible notes returns an empty rollup, not an error", async () => {
209
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
210
+ await store.createNote("a", { tags: ["task", "personal"], metadata: { status: "open" } });
211
+
212
+ const scoped: TagScopeCtx = { allowed: new Set(["work"]), raw: ["work"] };
213
+ const res = await get("?aggregate[group_by]=status&aggregate[op]=count", scoped);
214
+ expect(res.status).toBe(200);
215
+ const body: any = await res.json();
216
+ expect(body).toEqual([]);
217
+ });
218
+
219
+ it("an unscoped token sees the full rollup (regression check against the scoped path above)", async () => {
220
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
221
+ await store.createNote("a", { tags: ["task", "work"], metadata: { status: "open" } });
222
+ await store.createNote("b", { tags: ["task", "personal"], metadata: { status: "open" } });
223
+
224
+ const res = await get("?aggregate[group_by]=status&aggregate[op]=count");
225
+ expect(res.status).toBe(200);
226
+ const body: any = await res.json();
227
+ const byGroup = Object.fromEntries(body.map((r: any) => [r.group, r.value]));
228
+ expect(byGroup).toEqual({ open: 2 });
229
+ });
230
+ });
@@ -315,7 +315,7 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
315
315
  expect(record?.fields ?? null).toBeFalsy();
316
316
  });
317
317
 
318
- // Positive control — every one of the six recognized types (indexable or
318
+ // Positive control — every one of the seven recognized types (indexable or
319
319
  // not) is accepted without complaint.
320
320
  it("REST PUT /api/tags/:name accepts every recognized field type", async () => {
321
321
  const req = new Request("http://localhost/api/tags/widget", {
@@ -328,6 +328,7 @@ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
328
328
  d: { type: "boolean" },
329
329
  e: { type: "array" },
330
330
  f: { type: "object" },
331
+ g: { type: "reference" },
331
332
  },
332
333
  }),
333
334
  });
@@ -377,6 +377,23 @@ describe("contract: search — recall + ranking legibility (WS2B/C, #551, schema
377
377
  expect(w.did_you_mean).toBe("vasquez");
378
378
  });
379
379
 
380
+ it("did_you_mean suggests a real surface word, not a porter stem (vault#570)", async () => {
381
+ // "cactus" is porter-stemmed to "cactu" in notes_fts_vocab — the exact
382
+ // fragment cited as a live bug in issue #570. A close-but-wrong query
383
+ // must resolve back to the dictionary word a user actually typed
384
+ // ("cactus"), not the truncated stem.
385
+ await store.createNote("A desert cactus stores water efficiently.");
386
+ const res = await search("search=cactuz");
387
+ expect(res.status).toBe(200);
388
+ const body = await bodyOf(res);
389
+ expect(body).toEqual([]);
390
+ const warnings = decodeWarningsHeader(res);
391
+ expect(warnings).not.toBeNull();
392
+ const w = warnings!.find((x: any) => x.code === "search_did_you_mean");
393
+ expect(w).toBeDefined();
394
+ expect(w.did_you_mean).toBe("cactus");
395
+ });
396
+
380
397
  it("a genuinely zero-result search with NO close vocabulary match carries no did_you_mean warning", async () => {
381
398
  await store.createNote(NOTES.bothWords);
382
399
  const res = await search("search=zzzznonexistentword");
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Tag-scope interaction with create-note/update-note's `unresolved_link` /
3
+ * `ambiguous_link` warnings (vault#555, vault#570).
4
+ *
5
+ * `applyTagScopeWrappers`'s `query-notes` wrapper (src/mcp-tools.ts) DROPS
6
+ * its `warnings` array for a tag-scoped session — `unknown_tag`/
7
+ * `did_you_mean`/`search_did_you_mean` are computed against the FULL
8
+ * vault-wide tag catalog / FTS5 vocabulary, so surfacing them to a scoped
9
+ * caller would leak an out-of-scope tag's/note's existence (see
10
+ * docs/HTTP_API.md's "Tag-scoped tokens never see unknown_tag/did_you_mean
11
+ * or search_did_you_mean" paragraph, and docs/contracts/tag-scoped-tokens.md).
12
+ *
13
+ * `create-note`/`update-note`'s link warnings are a DIFFERENT case: they
14
+ * describe the CALLER'S OWN note's own outgoing link, named by the caller —
15
+ * not a vault-wide vocabulary scan the caller wouldn't otherwise see. The
16
+ * existing `unresolved_link` warning (structured `links`, vault#555) was
17
+ * never gated by `applyTagScopeWrappers`'s `create-note`/`update-note`
18
+ * wrappers (see `wrapReadTool(tools, "create-note", ...)` /
19
+ * `"update-note"` in src/mcp-tools.ts — neither calls `scrubNoteLinks` or
20
+ * strips `.warnings`), and docs/HTTP_API.md's "never see" list names only
21
+ * the vault-wide-vocabulary warnings above, NOT `unresolved_link`.
22
+ *
23
+ * This test locks in that vault#570's new content-wikilink `unresolved_link`
24
+ * and the new `ambiguous_link` warning (both content and structured) follow
25
+ * that SAME existing pattern — present under a tag-scoped session, exactly
26
+ * as they are unscoped — rather than silently introducing a NEW special
27
+ * case one way or the other.
28
+ */
29
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
30
+ import { mkdirSync, rmSync, existsSync } from "fs";
31
+ import { join } from "path";
32
+ import { tmpdir } from "os";
33
+ import { writeVaultConfig } from "./config.ts";
34
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
35
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
36
+ import type { AuthResult } from "./auth.ts";
37
+
38
+ let tmpHome: string;
39
+ let prevHome: string | undefined;
40
+
41
+ beforeEach(() => {
42
+ tmpHome = join(tmpdir(), `vault-link-warnings-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
43
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
44
+ prevHome = process.env.PARACHUTE_HOME;
45
+ process.env.PARACHUTE_HOME = tmpHome;
46
+ clearVaultStoreCache();
47
+ });
48
+
49
+ afterEach(() => {
50
+ clearVaultStoreCache();
51
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
52
+ else process.env.PARACHUTE_HOME = prevHome;
53
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
54
+ });
55
+
56
+ function seedVault(name: string): void {
57
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
58
+ getVaultStore(name);
59
+ }
60
+
61
+ function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
62
+ return {
63
+ permission: "full",
64
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
65
+ legacyDerived: false,
66
+ scoped_tags: scopedTags,
67
+ vault_name: null,
68
+ caller_jti: null,
69
+ actor: "test-user",
70
+ via: "api",
71
+ };
72
+ }
73
+
74
+ function toolsFor(vaultName: string, scopedTags: string[] | null) {
75
+ return generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
76
+ }
77
+
78
+ describe("create-note/update-note link warnings under a tag-scoped session (vault#570)", () => {
79
+ test("scoped create-note: content [[wikilink]] to a missing target still warns (unresolved_link) — same as unscoped", async () => {
80
+ seedVault("journal");
81
+ const tools = toolsFor("journal", ["health"]);
82
+ const createNote = tools.find((t) => t.name === "create-note")!;
83
+
84
+ const result = (await createNote.execute({
85
+ content: "See [[Missing Health Note]].",
86
+ tags: ["health"],
87
+ })) as any;
88
+
89
+ expect(result.warnings).toBeDefined();
90
+ expect(result.warnings[0].code).toBe("unresolved_link");
91
+ expect(result.warnings[0].target).toBe("Missing Health Note");
92
+ });
93
+
94
+ test("scoped create-note: content [[wikilink]] to an ambiguous target still warns (ambiguous_link), no edge — same as unscoped", async () => {
95
+ seedVault("journal");
96
+ const store = getVaultStore("journal");
97
+ await store.createNote("A", { path: "Folder1/ScopedDup", tags: ["health"] });
98
+ await store.createNote("B", { path: "Folder2/ScopedDup", tags: ["health"] });
99
+
100
+ const tools = toolsFor("journal", ["health"]);
101
+ const createNote = tools.find((t) => t.name === "create-note")!;
102
+ const result = (await createNote.execute({
103
+ content: "See [[ScopedDup]].",
104
+ tags: ["health"],
105
+ })) as any;
106
+
107
+ expect(result.warnings).toBeDefined();
108
+ expect(result.warnings[0].code).toBe("ambiguous_link");
109
+ expect(result.warnings[0].candidate_count).toBe(2);
110
+ expect(await store.getLinks(result.id, { direction: "outbound" })).toHaveLength(0);
111
+ });
112
+
113
+ test("scoped update-note: content update introducing a [[wikilink]] to a missing target still warns", async () => {
114
+ seedVault("journal");
115
+ const store = getVaultStore("journal");
116
+ const note = await store.createNote("plain", { path: "ScopedUpdatable", tags: ["health"] });
117
+
118
+ const tools = toolsFor("journal", ["health"]);
119
+ const updateNote = tools.find((t) => t.name === "update-note")!;
120
+ const result = (await updateNote.execute({
121
+ id: note.id,
122
+ content: "now references [[Still Missing Scoped]]",
123
+ force: true,
124
+ })) as any;
125
+
126
+ expect(result.warnings).toBeDefined();
127
+ expect(result.warnings[0].code).toBe("unresolved_link");
128
+ expect(result.warnings[0].target).toBe("Still Missing Scoped");
129
+ });
130
+
131
+ test("unscoped session sees the identical warning shape (parity check)", async () => {
132
+ seedVault("journal");
133
+ const tools = toolsFor("journal", null);
134
+ const createNote = tools.find((t) => t.name === "create-note")!;
135
+
136
+ const result = (await createNote.execute({
137
+ content: "See [[Missing Health Note]].",
138
+ })) as any;
139
+
140
+ expect(result.warnings).toBeDefined();
141
+ expect(result.warnings[0].code).toBe("unresolved_link");
142
+ expect(result.warnings[0].target).toBe("Missing Health Note");
143
+ });
144
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tag-scope × `query-notes` `aggregate` mode (top new-feature ask from a UX
3
+ * round).
4
+ *
5
+ * A rollup response is `[{group, value}]` — no per-note `.tags` to post-hoc
6
+ * filter (`noteWithinTagScope`, `src/tag-scope.ts`), unlike every other
7
+ * `query-notes` shape. So `applyTagScopeWrappers`'s generic array-filter
8
+ * (`src/mcp-tools.ts`) is skipped for aggregate requests, and scope is
9
+ * instead enforced BEFORE aggregating via the `aggregateVisibility`
10
+ * predicate wired in `generateScopedMcpTools`: fetch every note the other
11
+ * filters match, narrow to the token's allowlist, THEN aggregate over just
12
+ * that visible id set (see `core/src/mcp.ts`'s aggregate branch). This
13
+ * suite is the self-verifying test for that path — mirrors the harness
14
+ * `src/mcp-query-notes-search-scope.test.ts` uses for the equivalent search
15
+ * × scope concern.
16
+ */
17
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
18
+ import { mkdirSync, rmSync, existsSync } from "fs";
19
+ import { join } from "path";
20
+ import { tmpdir } from "os";
21
+ import { writeVaultConfig } from "./config.ts";
22
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
23
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
24
+ import type { AuthResult } from "./auth.ts";
25
+
26
+ let tmpHome: string;
27
+ let prevHome: string | undefined;
28
+
29
+ beforeEach(() => {
30
+ tmpHome = join(tmpdir(), `vault-aggregate-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
31
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
32
+ prevHome = process.env.PARACHUTE_HOME;
33
+ process.env.PARACHUTE_HOME = tmpHome;
34
+ clearVaultStoreCache();
35
+ });
36
+
37
+ afterEach(() => {
38
+ clearVaultStoreCache();
39
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
40
+ else process.env.PARACHUTE_HOME = prevHome;
41
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
42
+ });
43
+
44
+ function seedVault(name: string): void {
45
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
46
+ getVaultStore(name);
47
+ }
48
+
49
+ function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
50
+ return {
51
+ permission: "full",
52
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
53
+ legacyDerived: false,
54
+ scoped_tags: scopedTags,
55
+ vault_name: null,
56
+ caller_jti: null,
57
+ actor: "test-user",
58
+ via: "api",
59
+ };
60
+ }
61
+
62
+ async function queryNotesTool(vaultName: string, scopedTags: string[] | null) {
63
+ const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
64
+ return tools.find((t) => t.name === "query-notes")!;
65
+ }
66
+
67
+ function byGroup(result: unknown): Record<string, number> {
68
+ expect(Array.isArray(result)).toBe(true);
69
+ return Object.fromEntries((result as any[]).map((r) => [String(r.group), r.value]));
70
+ }
71
+
72
+ describe("MCP query-notes aggregate × tag-scope — indexed-field group_by", () => {
73
+ test("count by an indexed field is computed ONLY over notes the scoped token can see", async () => {
74
+ seedVault("journal");
75
+ const store = getVaultStore("journal");
76
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
77
+
78
+ // Same status values on both sides of the scope boundary, so a leak
79
+ // would silently inflate the in-scope counts rather than producing an
80
+ // obviously-wrong shape.
81
+ await store.createNote("in-scope open 1", { tags: ["task", "health"], metadata: { status: "open" } });
82
+ await store.createNote("in-scope done", { tags: ["task", "health"], metadata: { status: "done" } });
83
+ await store.createNote("out-of-scope open 1", { tags: ["task", "work"], metadata: { status: "open" } });
84
+ await store.createNote("out-of-scope open 2", { tags: ["task", "work"], metadata: { status: "open" } });
85
+
86
+ const scopedTool = await queryNotesTool("journal", ["health"]);
87
+ const result = await scopedTool.execute({ aggregate: { group_by: "status", op: "count" } });
88
+ expect(byGroup(result)).toEqual({ open: 1, done: 1 });
89
+ });
90
+
91
+ test("sum of an indexed numeric field is computed ONLY over notes the scoped token can see", async () => {
92
+ seedVault("journal");
93
+ const store = getVaultStore("journal");
94
+ await store.upsertTagRecord("expense", {
95
+ fields: {
96
+ category: { type: "string", indexed: true },
97
+ amount: { type: "integer", indexed: true },
98
+ },
99
+ });
100
+ await store.createNote("in-scope", { tags: ["expense", "health"], metadata: { category: "food", amount: 10 } });
101
+ await store.createNote("out-of-scope", { tags: ["expense", "work"], metadata: { category: "food", amount: 1000 } });
102
+
103
+ const scopedTool = await queryNotesTool("journal", ["health"]);
104
+ const result = await scopedTool.execute({
105
+ aggregate: { group_by: "category", op: "sum", field: "amount" },
106
+ });
107
+ expect(byGroup(result)).toEqual({ food: 10 });
108
+ });
109
+
110
+ test("unscoped session sees the full rollup across both sides (control)", async () => {
111
+ seedVault("journal");
112
+ const store = getVaultStore("journal");
113
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
114
+ await store.createNote("a", { tags: ["task", "health"], metadata: { status: "open" } });
115
+ await store.createNote("b", { tags: ["task", "work"], metadata: { status: "open" } });
116
+
117
+ const unscopedTool = await queryNotesTool("journal", null);
118
+ const result = await unscopedTool.execute({ aggregate: { group_by: "status", op: "count" } });
119
+ expect(byGroup(result)).toEqual({ open: 2 });
120
+ });
121
+
122
+ test("a scoped token with no visible matches gets an empty rollup, not an error or a leak", async () => {
123
+ seedVault("journal");
124
+ const store = getVaultStore("journal");
125
+ await store.upsertTagRecord("task", { fields: { status: { type: "string", indexed: true } } });
126
+ await store.createNote("out-of-scope only", { tags: ["task", "work"], metadata: { status: "open" } });
127
+
128
+ const scopedTool = await queryNotesTool("journal", ["health"]);
129
+ const result = await scopedTool.execute({ aggregate: { group_by: "status", op: "count" } });
130
+ expect(result).toEqual([]);
131
+ });
132
+ });
133
+
134
+ describe("MCP query-notes aggregate × tag-scope — group_by \"tag\"", () => {
135
+ test("an out-of-scope tag name never appears as a group, even on a co-tagged in-scope note", async () => {
136
+ seedVault("journal");
137
+ const store = getVaultStore("journal");
138
+ // This note IS in scope (carries "health"), but also carries the
139
+ // out-of-scope tag "secret-project" — the rollup must not surface that
140
+ // second tag as a group just because the note itself is visible.
141
+ await store.createNote("co-tagged", { tags: ["health", "secret-project"] });
142
+ await store.createNote("out-of-scope only", { tags: ["work"] });
143
+
144
+ const scopedTool = await queryNotesTool("journal", ["health"]);
145
+ const result = await scopedTool.execute({ aggregate: { group_by: "tag", op: "count" } });
146
+ const groups = (result as any[]).map((r) => r.group);
147
+ expect(groups).toContain("health");
148
+ expect(groups).not.toContain("secret-project");
149
+ expect(groups).not.toContain("work");
150
+ // Belt-and-suspenders: the out-of-scope tag name must not appear ANYWHERE
151
+ // in the serialized response.
152
+ expect(JSON.stringify(result)).not.toContain("secret-project");
153
+ expect(JSON.stringify(result)).not.toContain("\"work\"");
154
+ });
155
+ });
156
+
157
+ describe("MCP query-notes aggregate — mutual exclusivity with search/near/cursor", () => {
158
+ test("aggregate + search is rejected", async () => {
159
+ seedVault("journal");
160
+ const tool = await queryNotesTool("journal", null);
161
+ await expect(
162
+ tool.execute({ search: "x", aggregate: { group_by: "tag", op: "count" } }),
163
+ ).rejects.toThrow();
164
+ });
165
+
166
+ test("aggregate + cursor is rejected", async () => {
167
+ seedVault("journal");
168
+ const tool = await queryNotesTool("journal", null);
169
+ await expect(
170
+ tool.execute({ cursor: "", aggregate: { group_by: "tag", op: "count" } }),
171
+ ).rejects.toThrow();
172
+ });
173
+
174
+ test("aggregate + near is rejected", async () => {
175
+ seedVault("journal");
176
+ const store = getVaultStore("journal");
177
+ const anchor = await store.createNote("anchor");
178
+ const tool = await queryNotesTool("journal", null);
179
+ await expect(
180
+ tool.execute({ near: { note_id: anchor.id }, aggregate: { group_by: "tag", op: "count" } }),
181
+ ).rejects.toThrow();
182
+ });
183
+ });