@openparachute/vault 0.7.0-rc.1 → 0.7.0-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.
@@ -1,19 +1,22 @@
1
1
  /**
2
- * Contract suite — honest queries at the REST boundary (Wave 1 of the
3
- * Reliability & Usability Program, umbrella #556). Encodes the 2026-07-09
4
- * nine-persona deep test's WS1 findings (#550) as executable tests: PASSING
5
- * tests lock in behavior that is correct today; `test.todo` entries describe
6
- * the target behavior for confirmed-broken cases, to be flipped to real
7
- * assertions in a later wave. See #550 for the full write-up — this file
8
- * covers the `src/routes.ts` REST surface specifically (the cursor-bootstrap
9
- * gap lives at routes.ts:1102/1106, the tags/{name} 404 gap at the GET /tags
10
- * handler in `handleTags`).
2
+ * Contract suite — honest queries at the REST + MCP boundary (Wave 1's
3
+ * findings, Wave 2's fixes — Reliability & Usability Program, umbrella
4
+ * #556). Encodes the 2026-07-09 nine-persona deep test's WS1 findings
5
+ * (#550) as executable tests: PASSING tests lock in behavior that is
6
+ * correct today; the #550 `test.todo` entries this file used to carry have
7
+ * all been flipped to real assertions against the WS1 fixes (warnings
8
+ * channel, structured invalids, cursor bootstrap, tags 404, expanded_count)
9
+ * see #550 for the full write-up. Covers `src/routes.ts` (the REST
10
+ * surface) plus a handful of MCP-parity checks (`core/src/mcp.ts`) where
11
+ * the two surfaces share one implementation and a REST-only test wouldn't
12
+ * prove the MCP side actually got the fix.
11
13
  */
12
14
 
13
15
  import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
14
16
  import { Database } from "bun:sqlite";
15
17
  import { BunStore } from "./vault-store.ts";
16
- import { handleNotes, handleTags } from "./routes.ts";
18
+ import { handleNotes, handleTags, handleFindPath } from "./routes.ts";
19
+ import { generateMcpTools } from "../core/src/mcp.ts";
17
20
 
18
21
  let db: Database;
19
22
  let store: BunStore;
@@ -28,6 +31,21 @@ function getTags(qs: string): Promise<Response> {
28
31
  return handleTags(new Request(`${BASE}/tags?${qs}`, { method: "GET" }), store, "");
29
32
  }
30
33
 
34
+ function getTagByName(name: string): Promise<Response> {
35
+ return handleTags(new Request(`${BASE}/tags/${name}`, { method: "GET" }), store, `/${name}`);
36
+ }
37
+
38
+ function findPath(source: string, target: string): Promise<Response> {
39
+ return handleFindPath(new Request(`${BASE}/find-path?source=${source}&target=${target}`, { method: "GET" }), store);
40
+ }
41
+
42
+ /** Decode the `X-Parachute-Warnings` header (vault#550 — percent-encoded JSON; see `jsonWithWarnings` in routes.ts). */
43
+ function decodeWarningsHeader(res: Response): any[] | null {
44
+ const raw = res.headers.get("X-Parachute-Warnings");
45
+ if (!raw) return null;
46
+ return JSON.parse(decodeURIComponent(raw));
47
+ }
48
+
31
49
  beforeEach(() => {
32
50
  db = new Database(":memory:");
33
51
  store = new BunStore(db);
@@ -42,8 +60,9 @@ describe("contract: honest queries — passing (lock in current behavior)", () =
42
60
  const res = await getNotes("tag=zzznonexistenttag");
43
61
  expect(res.status).toBe(200);
44
62
  const body = await res.json();
45
- // Shape may gain a `warnings` field later (#550) — assert only status +
46
- // array shape now so that addition doesn't break this contract test.
63
+ // Shape may gain a `warnings` HEADER (vault#550, additive) — assert
64
+ // only status + array shape here so the header addition doesn't break
65
+ // this contract test. The header itself is exercised below.
47
66
  expect(Array.isArray(body)).toBe(true);
48
67
  expect(body).toEqual([]);
49
68
  });
@@ -63,13 +82,13 @@ describe("contract: honest queries — passing (lock in current behavior)", () =
63
82
  expect(body.note_id).toBe("does-not-exist");
64
83
  });
65
84
 
66
- // removed in W2 — once #550's cursor-bootstrap fix lands, an omitted
67
- // `cursor` param will (per the documented intent) still return a flat
68
- // array for callers who never opt into cursor pagination, so this
69
- // specific assertion should survive; the paired todo below is the one
70
- // that flips when the bootstrap gap closes. Kept both here so the fix
71
- // is a conscious, reviewed change rather than an incidental snapshot.
72
- it("a cursor-less limit query returns a flat array (today's shape) removed in W2", async () => {
85
+ // The cursor-bootstrap fix (vault#550) means `cursor` is now keyed on
86
+ // PRESENCE, not truthiness `?cursor=` (present, empty) now engages the
87
+ // envelope (see the "cursor bootstrap" test below). An OMITTED `cursor`
88
+ // param is a completely different thing no pagination intent at all —
89
+ // and still returns today's flat array. This test pins that omission
90
+ // case specifically, distinct from bootstrap.
91
+ it("a cursor-less limit query (cursor param OMITTED entirely) returns a flat array", async () => {
73
92
  await store.createNote("only note");
74
93
  const res = await getNotes("limit=5");
75
94
  expect(res.status).toBe(200);
@@ -78,23 +97,316 @@ describe("contract: honest queries — passing (lock in current behavior)", () =
78
97
  });
79
98
  });
80
99
 
81
- describe("contract: honest queries — todo (#550)", () => {
82
- test.todo(
83
- "#550: limit=-1 returns a structured error instead of silently meaning \"unlimited\" (today: SQLite's negative-LIMIT-means-no-limit semantics leak straight through to the caller)",
84
- );
85
- test.todo(
86
- "#550: query responses carry a warnings: [] channel, populated with a did_you_mean suggestion for an unknown tag name",
87
- );
88
- test.todo(
89
- "#550: an invalid date_filter value (unparseable date string) returns a structured error instead of silently matching nothing or everything",
90
- );
91
- test.todo(
92
- "#550: GET /api/tags/{nonexistent} returns 404 instead of 200 with an all-null synthesized record",
93
- );
94
- test.todo(
95
- "#550: list-tags reports expanded_count (rollup through parent_names descendants) alongside the literal per-tag count — today a parent tag with only child-tagged notes reports count: 0",
96
- );
97
- test.todo(
98
- "#550: cursor bootstrap — a first call that expresses cursor intent (no `cursor` param yet, but pagination is desired) returns a {notes, next_cursor} envelope, and a second call passing that cursor back sees only notes written since the first call (today: routes.ts only wraps the response in {notes, next_cursor} when a cursor param is ALREADY present, so the very first call can never obtain one — core/src/mcp.ts:331 documents a bootstrap flow that is not actually reachable)",
99
- );
100
+ describe("contract: honest queries — structured invalids (#550)", () => {
101
+ it("limit=-1 returns a structured invalid_query error instead of silently meaning \"unlimited\"", async () => {
102
+ await store.createNote("a note");
103
+ const res = await getNotes("limit=-1");
104
+ expect(res.status).toBe(400);
105
+ const body: any = await res.json();
106
+ expect(body.error_type).toBe("invalid_query");
107
+ expect(body.field).toBe("limit");
108
+ expect(body.got).toBe("-1");
109
+ expect(typeof body.hint).toBe("string");
110
+ });
111
+
112
+ it("limit=abc (non-numeric) returns invalid_query instead of silently falling back to the default", async () => {
113
+ const res = await getNotes("limit=abc");
114
+ expect(res.status).toBe(400);
115
+ const body: any = await res.json();
116
+ expect(body.error_type).toBe("invalid_query");
117
+ expect(body.field).toBe("limit");
118
+ expect(body.got).toBe("abc");
119
+ });
120
+
121
+ it("offset=-1 (negative offset) returns a structured invalid_query error", async () => {
122
+ const res = await getNotes("offset=-1");
123
+ expect(res.status).toBe(400);
124
+ const body: any = await res.json();
125
+ expect(body.error_type).toBe("invalid_query");
126
+ expect(body.field).toBe("offset");
127
+ expect(body.got).toBe("-1");
128
+ expect(typeof body.hint).toBe("string");
129
+ });
130
+
131
+ it("an unparseable date value in a bracket date filter returns invalid_query instead of silently matching nothing or everything", async () => {
132
+ const res = await getNotes("meta[created_at][gte]=not-a-real-date");
133
+ expect(res.status).toBe(400);
134
+ const body: any = await res.json();
135
+ expect(body.error_type).toBe("invalid_query");
136
+ expect(body.got).toBe("not-a-real-date");
137
+ expect(typeof body.hint).toBe("string");
138
+ expect(body.hint.toLowerCase()).toContain("iso");
139
+ });
140
+
141
+ it("a malformed cursor's error message states the bootstrap flow explicitly", async () => {
142
+ const res = await getNotes("cursor=not-a-valid-cursor!!!");
143
+ expect(res.status).toBe(400);
144
+ const body: any = await res.json();
145
+ expect(body.code).toBe("cursor_invalid");
146
+ // Must name the recovery flow, not just "this is broken" — the P1
147
+ // fix's whole point is that the bootstrap shape is DISCOVERABLE from
148
+ // the error, not just from documentation the caller may never read.
149
+ expect(body.error).toContain('cursor:""');
150
+ expect(body.error.toLowerCase()).toContain("next_cursor");
151
+ });
152
+ });
153
+
154
+ describe("contract: honest queries — warnings channel (#550)", () => {
155
+ it("an unrecognized tag surfaces an unknown_tag warning (with did_you_mean) via the X-Parachute-Warnings header, bare-array body unchanged", async () => {
156
+ await store.createNote("a note", { tags: ["project"] });
157
+ const res = await getNotes("tag=projet"); // typo: missing the second "c"
158
+ expect(res.status).toBe(200);
159
+ const body = await res.json();
160
+ expect(Array.isArray(body)).toBe(true); // shape stays a bare array
161
+ expect(body).toEqual([]); // the typo'd tag genuinely matches nothing
162
+
163
+ const warnings = decodeWarningsHeader(res);
164
+ expect(warnings).not.toBeNull();
165
+ expect(warnings).toHaveLength(1);
166
+ expect(warnings![0].code).toBe("unknown_tag");
167
+ expect(warnings![0].tag).toBe("projet");
168
+ expect(warnings![0].did_you_mean).toBe("project");
169
+ });
170
+
171
+ it("a tag that has notes via subtype expansion (a real descendant) does NOT warn, even though the literal tag has zero direct matches", async () => {
172
+ await store.upsertTagRecord("child", { parent_names: ["parent"] });
173
+ await store.createNote("a note", { tags: ["child"] });
174
+ const res = await getNotes("tag=parent");
175
+ expect(res.status).toBe(200);
176
+ const warnings = decodeWarningsHeader(res);
177
+ expect(warnings).toBeNull(); // "parent" resolves via expansion — not unknown
178
+ });
179
+
180
+ it("the flat date_field/date_from/date_to params (removed at 0.6.4) surface a removed_param warning per param, still silently ignored in the result set", async () => {
181
+ await store.createNote("in range", { created_at: "2020-06-01T00:00:00.000Z" });
182
+ await store.createNote("out of range", { created_at: "2099-01-01T00:00:00.000Z" });
183
+ const res = await getNotes("date_field=created_at&date_from=2020-01-01&date_to=2020-12-31");
184
+ expect(res.status).toBe(200);
185
+ const body: any[] = await res.json();
186
+ // Still unfiltered (existing behavior — bracket-style is the only
187
+ // supported date filter now; this locks in that the ignoring itself
188
+ // didn't change, only that it's no longer SILENT). Both notes come
189
+ // back even though `out of range` sits well outside the requested
190
+ // window — proof the flat params never touched the query.
191
+ expect(body.length).toBe(2);
192
+
193
+ const warnings = decodeWarningsHeader(res);
194
+ expect(warnings).not.toBeNull();
195
+ const codes = warnings!.map((w) => w.code);
196
+ expect(codes.filter((c) => c === "removed_param")).toHaveLength(3);
197
+ const params = warnings!.filter((w) => w.code === "removed_param").map((w) => w.param).sort();
198
+ expect(params).toEqual(["date_field", "date_from", "date_to"]);
199
+ });
200
+
201
+ it("cursor mode carries warnings INLINE in the envelope (in addition to the header)", async () => {
202
+ const res = await getNotes("cursor=&tag=doesnotexist");
203
+ expect(res.status).toBe(200);
204
+ const body: any = await res.json();
205
+ expect(Array.isArray(body.notes)).toBe(true);
206
+ expect(typeof body.next_cursor).toBe("string");
207
+ expect(Array.isArray(body.warnings)).toBe(true);
208
+ expect(body.warnings[0].code).toBe("unknown_tag");
209
+ // header present too, same content
210
+ const headerWarnings = decodeWarningsHeader(res);
211
+ expect(headerWarnings).toEqual(body.warnings);
212
+ });
213
+
214
+ it("format=graph carries warnings INLINE in the {nodes, edges} envelope (in addition to the header)", async () => {
215
+ await store.createNote("a note", { tags: ["real"] });
216
+ const res = await getNotes("format=graph&tag=doesnotexist");
217
+ expect(res.status).toBe(200);
218
+ const body: any = await res.json();
219
+ expect(Array.isArray(body.nodes)).toBe(true);
220
+ expect(Array.isArray(body.edges)).toBe(true);
221
+ expect(Array.isArray(body.warnings)).toBe(true);
222
+ expect(body.warnings[0].code).toBe("unknown_tag");
223
+ const headerWarnings = decodeWarningsHeader(res);
224
+ expect(headerWarnings).toEqual(body.warnings);
225
+ });
226
+
227
+ it("format=graph with no warnings carries NO warnings key and no header (shape unchanged)", async () => {
228
+ await store.createNote("a note", { tags: ["real"] });
229
+ const res = await getNotes("format=graph&tag=real");
230
+ expect(res.status).toBe(200);
231
+ const body: any = await res.json();
232
+ expect(body.warnings).toBeUndefined();
233
+ expect(res.headers.get("X-Parachute-Warnings")).toBeNull();
234
+ });
235
+
236
+ it("unknown_tag warnings are capped at 8 with a warnings_truncated marker (garbage tags array can't inflate the header unboundedly)", async () => {
237
+ const junkTags = Array.from({ length: 12 }, (_, i) => `zz-junk-tag-${i}`);
238
+ const res = await getNotes(`tag=${junkTags.join(",")}`);
239
+ expect(res.status).toBe(200);
240
+ const warnings = decodeWarningsHeader(res);
241
+ expect(warnings).not.toBeNull();
242
+ const unknown = warnings!.filter((w) => w.code === "unknown_tag");
243
+ const truncated = warnings!.filter((w) => w.code === "warnings_truncated");
244
+ expect(unknown).toHaveLength(8);
245
+ expect(truncated).toHaveLength(1);
246
+ expect(truncated[0].suppressed).toBe(4); // 12 junk tags − 8 reported
247
+ expect(truncated[0].limit).toBe(8);
248
+ });
249
+ });
250
+
251
+ describe("contract: honest queries — cursor bootstrap (#550, the P1)", () => {
252
+ it("an empty cursor (`?cursor=`) engages the {notes, next_cursor} envelope on the VERY FIRST call, and the returned next_cursor sees only notes written after it", async () => {
253
+ const n1 = await store.createNote("existing note");
254
+
255
+ const first = await getNotes("cursor=");
256
+ expect(first.status).toBe(200);
257
+ const firstBody: any = await first.json();
258
+ expect(Array.isArray(firstBody)).toBe(false);
259
+ expect(Array.isArray(firstBody.notes)).toBe(true);
260
+ expect(firstBody.notes.map((n: any) => n.id)).toEqual([n1.id]);
261
+ expect(typeof firstBody.next_cursor).toBe("string");
262
+ expect(firstBody.next_cursor.length).toBeGreaterThan(0);
263
+
264
+ const n2 = await store.createNote("new note");
265
+ const second = await getNotes(`cursor=${encodeURIComponent(firstBody.next_cursor)}`);
266
+ expect(second.status).toBe(200);
267
+ const secondBody: any = await second.json();
268
+ expect(secondBody.notes.map((n: any) => n.id)).toEqual([n2.id]);
269
+ });
270
+
271
+ it("MCP query-notes: cursor: \"\" likewise bootstraps the envelope on the first call", async () => {
272
+ const n1 = await store.createNote("existing note");
273
+ const tools = generateMcpTools(store);
274
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
275
+
276
+ const first = await queryNotes.execute({ cursor: "" }) as any;
277
+ expect(Array.isArray(first)).toBe(false);
278
+ expect(Array.isArray(first.notes)).toBe(true);
279
+ expect(first.notes.map((n: any) => n.id)).toEqual([n1.id]);
280
+ expect(typeof first.next_cursor).toBe("string");
281
+
282
+ const n2 = await store.createNote("new note");
283
+ const second = await queryNotes.execute({ cursor: first.next_cursor }) as any;
284
+ expect(second.notes.map((n: any) => n.id)).toEqual([n2.id]);
285
+ });
286
+
287
+ it("omitting `cursor` entirely (MCP) is NOT the same as bootstrapping — no envelope, no next_cursor", async () => {
288
+ await store.createNote("only note");
289
+ const tools = generateMcpTools(store);
290
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
291
+ const result = await queryNotes.execute({}) as any;
292
+ expect(Array.isArray(result)).toBe(true);
293
+ });
294
+ });
295
+
296
+ describe("contract: honest queries — tags 404 (#550)", () => {
297
+ it("GET /api/tags/{nonexistent} returns 404 tag_not_found instead of a synthesized all-null 200", async () => {
298
+ const res = await getTagByName("zzznonexistenttag");
299
+ expect(res.status).toBe(404);
300
+ const body: any = await res.json();
301
+ expect(body.error_type).toBe("tag_not_found");
302
+ expect(body.tag).toBe("zzznonexistenttag");
303
+ });
304
+
305
+ it("GET /api/tags?tag=<nonexistent> returns the same 404 tag_not_found shape, with did_you_mean when a close match exists", async () => {
306
+ await store.upsertTagRecord("project", {});
307
+ const res = await getTags("tag=projcet"); // transposed letters
308
+ expect(res.status).toBe(404);
309
+ const body: any = await res.json();
310
+ expect(body.error_type).toBe("tag_not_found");
311
+ expect(body.tag).toBe("projcet");
312
+ expect(body.did_you_mean).toBe("project");
313
+ });
314
+
315
+ it("a tag with an identity row but zero notes is still a real 200 (not 404) — an identity row alone is enough", async () => {
316
+ await store.upsertTagRecord("declared-but-unused", { description: "reserved for later" });
317
+ const res = await getTagByName("declared-but-unused");
318
+ expect(res.status).toBe(200);
319
+ const body: any = await res.json();
320
+ expect(body.count).toBe(0);
321
+ expect(body.expanded_count).toBe(0);
322
+ });
323
+
324
+ it("MCP list-tags with a nonexistent tag param returns a structured tag_not_found object (not a thrown error, not a synthesized 200)", async () => {
325
+ const tools = generateMcpTools(store);
326
+ const listTags = tools.find((t) => t.name === "list-tags")!;
327
+ const result = await listTags.execute({ tag: "zzznonexistenttag" }) as any;
328
+ expect(result.error_type).toBe("tag_not_found");
329
+ expect(result.tag).toBe("zzznonexistenttag");
330
+ });
331
+ });
332
+
333
+ describe("contract: honest queries — expanded_count (#550)", () => {
334
+ it("list-tags reports expanded_count reflecting the subtype rollup, while count stays the literal per-tag number", async () => {
335
+ // parent/child fixture: every note is tagged with the CHILD only, so
336
+ // the literal `count` on "parent" is 0 even though every one of those
337
+ // notes IS conceptually a "parent" via the declared hierarchy.
338
+ await store.upsertTagRecord("parent", {});
339
+ await store.upsertTagRecord("child", { parent_names: ["parent"] });
340
+ await store.createNote("n1", { tags: ["child"] });
341
+ await store.createNote("n2", { tags: ["child"] });
342
+ await store.createNote("n3", { tags: ["parent"] }); // also directly tagged
343
+
344
+ const res = await getTags("");
345
+ expect(res.status).toBe(200);
346
+ const body: any[] = await res.json();
347
+ const parentRow = body.find((t) => t.name === "parent")!;
348
+ const childRow = body.find((t) => t.name === "child")!;
349
+
350
+ expect(parentRow.count).toBe(1); // only n3 carries the literal "parent" tag
351
+ expect(parentRow.expanded_count).toBe(3); // n1, n2 (via child) + n3
352
+ expect(childRow.count).toBe(2);
353
+ expect(childRow.expanded_count).toBe(2); // "child" has no further descendants
354
+ });
355
+
356
+ it("GET /api/tags/{name} single-tag detail also carries expanded_count", async () => {
357
+ await store.upsertTagRecord("parent2", {});
358
+ await store.upsertTagRecord("child2", { parent_names: ["parent2"] });
359
+ await store.createNote("n1", { tags: ["child2"] });
360
+
361
+ const res = await getTagByName("parent2");
362
+ const body: any = await res.json();
363
+ expect(body.count).toBe(0);
364
+ expect(body.expanded_count).toBe(1);
365
+ });
366
+ });
367
+
368
+ describe("contract: honest queries — find-path hydration (#550, additive)", () => {
369
+ it("REST find-path hydrates `nodes` (id+path) and `edges` (source/target/relationship+paths) alongside the original path/relationships shape", async () => {
370
+ const a = await store.createNote("A", { id: "a", path: "People/Alice" });
371
+ const b = await store.createNote("B", { id: "b" }); // no path set
372
+ const c = await store.createNote("C", { id: "c", path: "Projects/X" });
373
+ await store.createLink("a", "b", "mentions");
374
+ await store.createLink("b", "c", "related-to");
375
+
376
+ const res = await findPath("a", "c");
377
+ expect(res.status).toBe(200);
378
+ const body: any = await res.json();
379
+
380
+ // Original shape, byte-identical — back-compat.
381
+ expect(body.path).toEqual(["a", "b", "c"]);
382
+ expect(body.relationships).toEqual(["mentions", "related-to"]);
383
+
384
+ // Additive hydration.
385
+ expect(body.nodes).toEqual([
386
+ { id: "a", path: "People/Alice" },
387
+ { id: "b", path: null },
388
+ { id: "c", path: "Projects/X" },
389
+ ]);
390
+ expect(body.edges).toEqual([
391
+ { source: "a", target: "b", relationship: "mentions", sourcePath: "People/Alice", targetPath: null },
392
+ { source: "b", target: "c", relationship: "related-to", sourcePath: null, targetPath: "Projects/X" },
393
+ ]);
394
+ });
395
+
396
+ it("MCP find-path hydrates the same nodes/edges shape", async () => {
397
+ await store.createNote("A", { id: "a", path: "People/Alice" });
398
+ await store.createNote("B", { id: "b", path: "Projects/X" });
399
+ await store.createLink("a", "b", "mentions");
400
+
401
+ const tools = generateMcpTools(store);
402
+ const findPathTool = tools.find((t) => t.name === "find-path")!;
403
+ const result = await findPathTool.execute({ source: "a", target: "b" }) as any;
404
+ expect(result.nodes).toEqual([
405
+ { id: "a", path: "People/Alice" },
406
+ { id: "b", path: "Projects/X" },
407
+ ]);
408
+ expect(result.edges).toEqual([
409
+ { source: "a", target: "b", relationship: "mentions", sourcePath: "People/Alice", targetPath: "Projects/X" },
410
+ ]);
411
+ });
100
412
  });
@@ -196,3 +196,20 @@ describe("live-match — predicate parity with the query engine", () => {
196
196
  await assertParity({ tags: ["t"], metadata: { kind: "note" } });
197
197
  });
198
198
  });
199
+
200
+ describe("live-match — unsupportedSubscriptionReason cursor guard (vault#550)", () => {
201
+ it("rejects a bootstrap empty-string cursor (presence, not truthiness)", async () => {
202
+ const { unsupportedSubscriptionReason } = await import("./live-match.ts");
203
+ // `cursor: ""` is cursor INTENT (the vault#550 bootstrap call) — a live
204
+ // subscription can't paginate, so it must be rejected the same as a
205
+ // real cursor, not silently treated as "no cursor requested."
206
+ expect(unsupportedSubscriptionReason({ cursor: "" } as QueryOpts)).toContain("cursor");
207
+ expect(unsupportedSubscriptionReason({ cursor: "eyJxaC" } as QueryOpts)).toContain("cursor");
208
+ });
209
+
210
+ it("an omitted/undefined cursor does not reject", async () => {
211
+ const { unsupportedSubscriptionReason } = await import("./live-match.ts");
212
+ expect(unsupportedSubscriptionReason({ cursor: undefined } as QueryOpts)).toBeNull();
213
+ expect(unsupportedSubscriptionReason({} as QueryOpts)).toBeNull();
214
+ });
215
+ });
package/src/live-match.ts CHANGED
@@ -76,7 +76,10 @@ export function unsupportedSubscriptionReason(opts: QueryOpts): string | null {
76
76
  // subscribe route detects them from raw query params. These guards catch the
77
77
  // remaining shapes that the matcher can't faithfully evaluate against a
78
78
  // single in-hand note (so snapshot and live would disagree).
79
- if (opts.cursor) {
79
+ // Presence, not truthiness (vault#550) — a bootstrap `cursor: ""` is
80
+ // still cursor intent and must still be rejected here, not silently
81
+ // treated as "no cursor requested."
82
+ if (opts.cursor !== undefined) {
80
83
  return "cursor pagination is not supported for live subscriptions";
81
84
  }
82
85
  if (opts.hasLinks !== undefined) {
package/src/mcp-http.ts CHANGED
@@ -156,7 +156,26 @@ async function handleMcp(
156
156
  to?: unknown;
157
157
  current?: unknown;
158
158
  violations?: unknown;
159
+ error_type?: string;
160
+ got?: unknown;
161
+ hint?: string;
159
162
  };
163
+ // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
164
+ // values that are structurally invalid rather than merely "no
165
+ // results." Gated on `error_type === "invalid_query"` specifically
166
+ // (not `code === "INVALID_QUERY"` broadly) so long-standing QueryError
167
+ // throws WITHOUT this field (FIELD_NOT_INDEXED, UNKNOWN_OPERATOR, the
168
+ // various cursor/near/search incompatibility errors, ...) keep their
169
+ // existing unstructured `isError: true` fallback — only the NEW #550
170
+ // call sites that explicitly set `error_type` opt into this shape.
171
+ if (e?.error_type === "invalid_query") {
172
+ throw new McpError(ErrorCode.InvalidParams, message, {
173
+ error_type: "invalid_query",
174
+ field: e.field,
175
+ got: e.got,
176
+ hint: e.hint,
177
+ });
178
+ }
160
179
  if (e?.code === "CONFLICT") {
161
180
  throw new McpError(ErrorCode.InvalidRequest, message, {
162
181
  error_type: "conflict",
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tag-scope enforcement on the MCP `list-tags` single-tag path (vault#550
3
+ * fold; also closes vault#560). Core's list-tags is scope-unaware by
4
+ * architecture — `applyTagScopeWrappers` in src/mcp-tools.ts is the
5
+ * enforcement layer. Before this fix the wrapper only filtered ARRAY
6
+ * results, so the single-tag (`{tag}` param) shape passed through
7
+ * unwrapped, leaking (a) the FULL record of an existing out-of-scope tag
8
+ * (#560), and (b) post-#550, a vault-wide `did_you_mean` suggestion that
9
+ * could name an out-of-scope tag.
10
+ *
11
+ * Fixture: PARACHUTE_HOME temp home + a hand-built AuthResult (no JWT
12
+ * infra needed — `generateScopedMcpTools` takes the resolved auth
13
+ * directly), same pattern as attribution-threading.test.ts's MCP section.
14
+ */
15
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
16
+ import { mkdirSync, rmSync, existsSync } from "fs";
17
+ import { join } from "path";
18
+ import { tmpdir } from "os";
19
+ import { writeVaultConfig } from "./config.ts";
20
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
21
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
22
+ import type { AuthResult } from "./auth.ts";
23
+
24
+ let tmpHome: string;
25
+ let prevHome: string | undefined;
26
+
27
+ beforeEach(() => {
28
+ tmpHome = join(tmpdir(), `vault-listtags-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
29
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
30
+ prevHome = process.env.PARACHUTE_HOME;
31
+ process.env.PARACHUTE_HOME = tmpHome;
32
+ clearVaultStoreCache();
33
+ });
34
+
35
+ afterEach(() => {
36
+ clearVaultStoreCache();
37
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
38
+ else process.env.PARACHUTE_HOME = prevHome;
39
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
40
+ });
41
+
42
+ function seedVault(name: string): void {
43
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
44
+ getVaultStore(name);
45
+ }
46
+
47
+ function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
48
+ return {
49
+ permission: "full",
50
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
51
+ legacyDerived: false,
52
+ scoped_tags: scopedTags,
53
+ vault_name: null,
54
+ caller_jti: null,
55
+ actor: "test-user",
56
+ via: "api",
57
+ };
58
+ }
59
+
60
+ async function listTagsTool(vaultName: string, scopedTags: string[] | null) {
61
+ const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
62
+ return tools.find((t) => t.name === "list-tags")!;
63
+ }
64
+
65
+ describe("MCP list-tags single-tag path — tag-scope enforcement (vault#550 fold, closes #560)", () => {
66
+ test("scoped token + EXISTING out-of-scope tag → tag_not_found with no record fields, no did_you_mean (#560's full-record leak)", async () => {
67
+ seedVault("journal");
68
+ const store = getVaultStore("journal");
69
+ await store.upsertTagRecord("work", { description: "top secret reorg planning" });
70
+ await store.createNote("w", { tags: ["work"] });
71
+ await store.createNote("h", { tags: ["health"] });
72
+
73
+ const tool = await listTagsTool("journal", ["health"]);
74
+ const result = (await tool.execute({ tag: "work" })) as any;
75
+
76
+ expect(result.error_type).toBe("tag_not_found");
77
+ expect(result.tag).toBe("work");
78
+ // No leak: none of the record's fields, no counts, no suggestion.
79
+ expect(result.description).toBeUndefined();
80
+ expect(result.count).toBeUndefined();
81
+ expect(result.expanded_count).toBeUndefined();
82
+ expect(result.fields).toBeUndefined();
83
+ expect(result.parent_names).toBeUndefined();
84
+ expect(result.did_you_mean).toBeUndefined();
85
+ });
86
+
87
+ test("scoped token + NONEXISTENT tag → tag_not_found without an out-of-scope did_you_mean", async () => {
88
+ seedVault("journal");
89
+ const store = getVaultStore("journal");
90
+ // "project" exists but is OUT of scope — it must not surface as a
91
+ // suggestion for the near-miss "projet".
92
+ await store.upsertTagRecord("project", {});
93
+ await store.createNote("h", { tags: ["health"] });
94
+
95
+ const tool = await listTagsTool("journal", ["health"]);
96
+ // "projet" is not in the allowlist → short-circuits to tag_not_found
97
+ // before core even computes a suggestion.
98
+ const result = (await tool.execute({ tag: "projet" })) as any;
99
+ expect(result.error_type).toBe("tag_not_found");
100
+ expect(result.tag).toBe("projet");
101
+ expect(result.did_you_mean).toBeUndefined();
102
+ });
103
+
104
+ test("scoped token + in-scope miss keeps did_you_mean only when the suggestion is also in-scope", async () => {
105
+ seedVault("journal");
106
+ const store = getVaultStore("journal");
107
+ // Declared hierarchy: health/food is a child of health, so the
108
+ // allowlist expansion covers both. A near-miss on the CHILD name is in
109
+ // the allowlist's expansion? No — "health/fod" itself isn't a known
110
+ // tag, but it IS within scope only if the allowlist contains it.
111
+ // expandTokenTagScope(["health"]) = {health, health/food}; the literal
112
+ // input "health/fod" is NOT in that set → short-circuit tag_not_found
113
+ // (no suggestion) — pinning that the wrapper gates on the allowlist,
114
+ // not on string prefixes.
115
+ await store.upsertTagRecord("health/food", { parent_names: ["health"] });
116
+ await store.createNote("hf", { tags: ["health/food"] });
117
+
118
+ const tool = await listTagsTool("journal", ["health"]);
119
+ const result = (await tool.execute({ tag: "health/fod" })) as any;
120
+ expect(result.error_type).toBe("tag_not_found");
121
+ expect(result.did_you_mean).toBeUndefined();
122
+ });
123
+
124
+ test("unscoped token unchanged — full record for existing tags, did_you_mean on misses", async () => {
125
+ seedVault("journal");
126
+ const store = getVaultStore("journal");
127
+ await store.upsertTagRecord("project", { description: "projects" });
128
+ await store.createNote("p", { tags: ["project"] });
129
+
130
+ const tool = await listTagsTool("journal", null);
131
+
132
+ const hit = (await tool.execute({ tag: "project" })) as any;
133
+ expect(hit.name).toBe("project");
134
+ expect(hit.count).toBe(1);
135
+ expect(hit.description).toBe("projects");
136
+
137
+ const miss = (await tool.execute({ tag: "projet" })) as any;
138
+ expect(miss.error_type).toBe("tag_not_found");
139
+ expect(miss.did_you_mean).toBe("project");
140
+ });
141
+
142
+ test("scoped token + array list still filters to the allowlist (regression: pre-existing behavior)", async () => {
143
+ seedVault("journal");
144
+ const store = getVaultStore("journal");
145
+ await store.createNote("h", { tags: ["health"] });
146
+ await store.createNote("w", { tags: ["work"] });
147
+
148
+ const tool = await listTagsTool("journal", ["health"]);
149
+ const result = (await tool.execute({})) as any[];
150
+ const names = result.map((t) => t.name);
151
+ expect(names).toContain("health");
152
+ expect(names).not.toContain("work");
153
+ });
154
+ });