@openparachute/vault 0.7.0-rc.1 → 0.7.0-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.
@@ -1,10 +1,16 @@
1
1
  /**
2
- * Contract suite — search (Wave 1 of the Reliability & Usability Program,
3
- * umbrella #556). Encodes the 2026-07-09 nine-persona deep test's search
4
- * findings (WS2, #551) as executable tests: PASSING tests lock in behavior
5
- * that is correct today; `test.todo` entries describe the target behavior
6
- * for confirmed-broken cases, to be flipped to real assertions in a later
7
- * wave. See #551 for the full write-up.
2
+ * Contract suite — search (Wave 1's findings, Wave 3's fix — Reliability &
3
+ * Usability Program, umbrella #556, WS2A #551). Encodes the 2026-07-09
4
+ * nine-persona deep test's search findings as executable tests: PASSING
5
+ * tests lock in behavior that is correct today; the #551 `test.todo`
6
+ * entries this file used to carry have all been flipped to real assertions
7
+ * against the literal-by-default fix (escaping, `search_mode: "advanced"`,
8
+ * honored `sort`, structured `invalid_search_syntax`) — see #551 for the
9
+ * full write-up. Covers `src/routes.ts` (the REST surface) plus a handful
10
+ * of MCP-parity checks (`core/src/mcp.ts`) where the two surfaces share one
11
+ * implementation (`core/src/notes.ts` `searchNotes` + `core/src/search-
12
+ * query.ts`) and a REST-only test wouldn't prove the MCP side actually got
13
+ * the fix — mirrors the pattern in `contract-honest-queries.test.ts`.
8
14
  *
9
15
  * Ground truth for every assertion here was re-verified live against this
10
16
  * repo's FTS5 search path (`core/src/notes.ts` searchNotes → REST `GET
@@ -16,6 +22,7 @@ import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
16
22
  import { Database } from "bun:sqlite";
17
23
  import { BunStore } from "./vault-store.ts";
18
24
  import { handleNotes } from "./routes.ts";
25
+ import { generateMcpTools } from "../core/src/mcp.ts";
19
26
 
20
27
  let db: Database;
21
28
  let store: BunStore;
@@ -26,6 +33,13 @@ function search(qs: string): Promise<Response> {
26
33
  return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
27
34
  }
28
35
 
36
+ /** Decode the `X-Parachute-Warnings` header (vault#550 — percent-encoded JSON; see `jsonWithWarnings` in routes.ts). */
37
+ function decodeWarningsHeader(res: Response): any[] | null {
38
+ const raw = res.headers.get("X-Parachute-Warnings");
39
+ if (!raw) return null;
40
+ return JSON.parse(decodeURIComponent(raw));
41
+ }
42
+
29
43
  /** Planted corpus — each note exists to exercise exactly one FTS5 quirk. */
30
44
  const NOTES = {
31
45
  hyphenPhrase: "The rollout had an eleven-day capping delay before it shipped.",
@@ -76,22 +90,36 @@ describe("contract: search — passing (lock in current behavior)", () => {
76
90
  expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
77
91
  });
78
92
 
79
- it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay"', async () => {
80
- const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&include_content=true`);
93
+ // The next three tests manually quote the search string, expecting raw
94
+ // FTS5 phrase syntax to be honored. That's advanced-mode behavior as of
95
+ // #551 (literal-by-default escapes a caller's own `"` characters as
96
+ // ordinary content instead of honoring them as phrase syntax) — updated
97
+ // to pass `search_mode: "advanced"` explicitly. (Note for reviewers:
98
+ // empirically the bare literal-mode escaping of these SAME manually-
99
+ // quoted strings still resolves to the identical result set for this
100
+ // corpus — FTS5's tokenizer strips the embedded `"` characters the same
101
+ // way it strips other punctuation — but the semantic guarantee these
102
+ // tests were written to pin (phrase ADJACENCY honored as syntax) only
103
+ // holds under advanced mode; literal mode no longer enforces adjacency
104
+ // at all, it just happens to agree here because no other note contains
105
+ // a subset of the same words. See #551 test coverage for the literal-
106
+ // mode equivalents of these same strings, unquoted, further down.)
107
+ it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay" (search_mode: "advanced")', async () => {
108
+ const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&search_mode=advanced&include_content=true`);
81
109
  expect(res.status).toBe(200);
82
110
  const body = await bodyOf(res);
83
111
  expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
84
112
  });
85
113
 
86
- it('quoted decimal literal finds it: "18.6"', async () => {
87
- const res = await search(`search=${encodeURIComponent('"18.6"')}&include_content=true`);
114
+ it('quoted decimal literal finds it: "18.6" (search_mode: "advanced")', async () => {
115
+ const res = await search(`search=${encodeURIComponent('"18.6"')}&search_mode=advanced&include_content=true`);
88
116
  expect(res.status).toBe(200);
89
117
  const body = await bodyOf(res);
90
118
  expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
91
119
  });
92
120
 
93
- it('quoted contraction finds it: "didn\'t"', async () => {
94
- const res = await search(`search=${encodeURIComponent(`"didn't"`)}&include_content=true`);
121
+ it('quoted contraction finds it: "didn\'t" (search_mode: "advanced")', async () => {
122
+ const res = await search(`search=${encodeURIComponent(`"didn't"`)}&search_mode=advanced&include_content=true`);
95
123
  expect(res.status).toBe(200);
96
124
  const body = await bodyOf(res);
97
125
  expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
@@ -105,23 +133,227 @@ describe("contract: search — passing (lock in current behavior)", () => {
105
133
  });
106
134
  });
107
135
 
108
- describe("contract: search — todo (#551, literal-by-default + recall + ranking)", () => {
109
- test.todo(
110
- `#551: unquoted search: "didn't" finds the contraction content (literal-by-default — today the bare apostrophe splits into two AND'd tokens and returns [])`,
111
- );
112
- test.todo(
113
- `#551: unquoted search: "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — today the bare hyphenated word tokenizes into separate AND'd terms and returns [])`,
114
- );
115
- test.todo(
116
- `#551: unquoted search: "18.6" finds the decimal note (literal-by-default — today the bare decimal tokenizes into separate AND'd terms and returns [])`,
117
- );
118
- test.todo(
119
- `#551: search_mode:"advanced" preserves raw FTS5 query syntax (AND/OR/NOT, phrase, prefix) once literal-by-default escaping ships as the new default`,
120
- );
121
- test.todo(
122
- `#551: sort:"asc"/"desc" changes result order under search (today silently ignored — FTS5 rank order wins regardless of the sort param)`,
123
- );
124
- test.todo(
125
- `#551: malformed FTS syntax (e.g. an unbalanced quote) yields a structured warning or error, not a silently swallowed [] (today searchNotes catches every FTS5 syntax error and returns [])`,
126
- );
136
+ describe('contract: search — literal-by-default (#551, flipped from todo)', () => {
137
+ it(`unquoted search "didn't" finds the contraction content (literal-by-default — the bare apostrophe used to split into two AND'd tokens and return [])`, async () => {
138
+ const res = await search(`search=${encodeURIComponent("didn't")}&include_content=true`);
139
+ expect(res.status).toBe(200);
140
+ const body = await bodyOf(res);
141
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
142
+ });
143
+
144
+ it(`unquoted search "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — the bare hyphenated word used to tokenize into a NOT-operator and return [])`, async () => {
145
+ const res = await search(`search=${encodeURIComponent("eleven-day capping delay")}&include_content=true`);
146
+ expect(res.status).toBe(200);
147
+ const body = await bodyOf(res);
148
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
149
+ });
150
+
151
+ it(`unquoted search "18.6" finds the decimal note (literal-by-default — the bare decimal used to break the FTS5 parse and return [])`, async () => {
152
+ const res = await search(`search=${encodeURIComponent("18.6")}&include_content=true`);
153
+ expect(res.status).toBe(200);
154
+ const body = await bodyOf(res);
155
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
156
+ });
157
+
158
+ it('search_mode:"advanced" preserves raw FTS5 query syntax (boolean AND/NOT, prefix *)', async () => {
159
+ const and = await bodyOf(
160
+ await search(`search=${encodeURIComponent("widgets AND gadgets")}&search_mode=advanced&include_content=true`),
161
+ );
162
+ expect(and.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
163
+
164
+ const not = await bodyOf(
165
+ await search(`search=${encodeURIComponent("widgets NOT gadgets")}&search_mode=advanced&include_content=true`),
166
+ );
167
+ expect(not.map((n: any) => n.content)).toEqual([NOTES.widgetsOnly]);
168
+
169
+ const prefix = await bodyOf(
170
+ await search(`search=${encodeURIComponent("wid*")}&search_mode=advanced&include_content=true`),
171
+ );
172
+ const prefixContents = new Set(prefix.map((n: any) => n.content));
173
+ expect(prefixContents.has(NOTES.widgetsOnly)).toBe(true);
174
+ expect(prefixContents.has(NOTES.bothWords)).toBe(true);
175
+ expect(prefixContents.has(NOTES.gadgetsOnly)).toBe(false);
176
+ });
177
+
178
+ it('sort:"asc"/"desc" changes result order under search (previously silently ignored — FTS5 rank order won regardless of the sort param)', async () => {
179
+ await store.createNote("sortprobe alpha content", { created_at: "2020-01-01T00:00:00.000Z" });
180
+ await store.createNote("sortprobe beta content", { created_at: "2022-06-15T00:00:00.000Z" });
181
+ await store.createNote("sortprobe gamma content", { created_at: "2024-12-31T00:00:00.000Z" });
182
+
183
+ const asc = await bodyOf(await search("search=sortprobe&sort=asc&include_content=true"));
184
+ expect(asc.map((n: any) => n.content)).toEqual([
185
+ "sortprobe alpha content",
186
+ "sortprobe beta content",
187
+ "sortprobe gamma content",
188
+ ]);
189
+
190
+ const desc = await bodyOf(await search("search=sortprobe&sort=desc&include_content=true"));
191
+ expect(desc.map((n: any) => n.content)).toEqual([
192
+ "sortprobe gamma content",
193
+ "sortprobe beta content",
194
+ "sortprobe alpha content",
195
+ ]);
196
+ });
197
+
198
+ it("malformed FTS syntax under search_mode:\"advanced\" yields a structured 400 invalid_search_syntax error, not a silently swallowed []", async () => {
199
+ const res = await search(`search=${encodeURIComponent('"unbalanced')}&search_mode=advanced`);
200
+ expect(res.status).toBe(400);
201
+ const body = await bodyOf(res);
202
+ expect(body.code).toBe("INVALID_QUERY");
203
+ expect(body.error_type).toBe("invalid_search_syntax");
204
+ expect(body.field).toBe("search");
205
+ expect(body.got).toBe('"unbalanced');
206
+ expect(typeof body.hint).toBe("string");
207
+ });
208
+
209
+ it("the SAME malformed string in literal mode (default) returns an honest result, not an error — escaping makes it syntactically valid FTS5", async () => {
210
+ const res = await search(`search=${encodeURIComponent('"unbalanced')}`);
211
+ expect(res.status).toBe(200);
212
+ const body = await bodyOf(res);
213
+ expect(Array.isArray(body)).toBe(true);
214
+ expect(body).toEqual([]);
215
+ });
216
+
217
+ // Review fold: a NUL byte inside a literal-mode token used to crash FTS5's
218
+ // C-string parser (`SQLiteError: unterminated string`) → raw rethrow →
219
+ // unstructured 500. Sanitization (control chars → separators) makes it a
220
+ // normal search. `%00` is the URL-encoding of a NUL byte.
221
+ it("search=hello%00world (NUL byte in a token) does NOT 500 — literal mode cannot throw", async () => {
222
+ await store.createNote("hello world reachable across the NUL split", { tags: ["nul"] });
223
+ const res = await search("search=hello%00world&include_content=true");
224
+ expect(res.status).toBe(200);
225
+ const body = await bodyOf(res);
226
+ expect(Array.isArray(body)).toBe(true);
227
+ // The NUL split the token into "hello" AND "world" — both searchable —
228
+ // so the planted note (containing both words) is found.
229
+ expect(body.some((n: any) => n.content.includes("hello world reachable"))).toBe(true);
230
+ });
231
+
232
+ it("a NUL-only search short-circuits to [] with an empty_search warning, no 500", async () => {
233
+ const res = await search("search=%00");
234
+ expect(res.status).toBe(200);
235
+ const body = await bodyOf(res);
236
+ expect(body).toEqual([]);
237
+ const warnings = decodeWarningsHeader(res);
238
+ expect(warnings).not.toBeNull();
239
+ expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
240
+ });
241
+
242
+ it("sort under search is STABLE at identical created_at (deterministic n.id tiebreaker, both directions)", async () => {
243
+ // Two notes at the SAME millisecond — without the id tiebreaker their
244
+ // relative order would be arbitrary/unstable (review nit).
245
+ const ts = "2023-03-03T03:03:03.030Z";
246
+ await store.createNote("tiebreak content one", { id: "tie-aaa", created_at: ts });
247
+ await store.createNote("tiebreak content two", { id: "tie-bbb", created_at: ts });
248
+
249
+ const asc = await bodyOf(await search("search=tiebreak&sort=asc&include_content=true"));
250
+ const ascIds = asc.map((n: any) => n.id);
251
+ // ASC id tiebreaker → tie-aaa before tie-bbb.
252
+ expect(ascIds).toEqual(["tie-aaa", "tie-bbb"]);
253
+
254
+ const desc = await bodyOf(await search("search=tiebreak&sort=desc&include_content=true"));
255
+ const descIds = desc.map((n: any) => n.id);
256
+ // DESC id tiebreaker → tie-bbb before tie-aaa (the exact reverse).
257
+ expect(descIds).toEqual(["tie-bbb", "tie-aaa"]);
258
+ });
259
+ });
260
+
261
+ describe("contract: search — escaping edge cases, tag-scope, warnings (#551)", () => {
262
+ it("a double space between tokens still finds the note containing both (whitespace runs collapse)", async () => {
263
+ const res = await search(`search=${encodeURIComponent("widgets gadgets")}&include_content=true`);
264
+ expect(res.status).toBe(200);
265
+ const body = await bodyOf(res);
266
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
267
+ });
268
+
269
+ it("whitespace-only search short-circuits to [] with an empty_search warning, no FTS5 call/error", async () => {
270
+ const res = await search(`search=${encodeURIComponent(" ")}`);
271
+ expect(res.status).toBe(200);
272
+ const body = await bodyOf(res);
273
+ expect(body).toEqual([]);
274
+ const warnings = decodeWarningsHeader(res);
275
+ expect(warnings).not.toBeNull();
276
+ expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
277
+ });
278
+
279
+ it('a quotes-only search (e.g. a lone `"`) short-circuits to [] with an empty_search warning', async () => {
280
+ const res = await search(`search=${encodeURIComponent('"')}`);
281
+ expect(res.status).toBe(200);
282
+ const body = await bodyOf(res);
283
+ expect(body).toEqual([]);
284
+ const warnings = decodeWarningsHeader(res);
285
+ expect(warnings).not.toBeNull();
286
+ expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
287
+ });
288
+
289
+ it("tag-filtered search also gets literal escaping (the tag-scoped FTS prepared statement)", async () => {
290
+ await store.createNote(NOTES.hyphenPhrase, { tags: ["probe"] });
291
+ const res = await search(
292
+ `search=${encodeURIComponent("eleven-day capping delay")}&tag=probe&include_content=true`,
293
+ );
294
+ expect(res.status).toBe(200);
295
+ const body = await bodyOf(res);
296
+ expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
297
+ expect(body[0].tags).toContain("probe");
298
+ });
299
+
300
+ it("search_mode without search is a no-op that surfaces an ignored_param warning (structured-query path)", async () => {
301
+ const res = await search("search_mode=advanced");
302
+ expect(res.status).toBe(200);
303
+ const warnings = decodeWarningsHeader(res);
304
+ expect(warnings).not.toBeNull();
305
+ expect(warnings!.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
306
+ });
307
+
308
+ it("an unrecognized search_mode value is a structured 400 invalid_query", async () => {
309
+ const res = await search("search=widgets&search_mode=bogus");
310
+ expect(res.status).toBe(400);
311
+ const body = await bodyOf(res);
312
+ expect(body.code).toBe("INVALID_QUERY");
313
+ expect(body.error_type).toBe("invalid_query");
314
+ expect(body.field).toBe("search_mode");
315
+ expect(body.got).toBe("bogus");
316
+ });
317
+ });
318
+
319
+ describe("contract: search — MCP parity (#551)", () => {
320
+ it("query-notes: literal-by-default finds punctuation content the same as REST", async () => {
321
+ const tools = generateMcpTools(store);
322
+ const query = tools.find((t) => t.name === "query-notes")!;
323
+ const result = (await query.execute({ search: "didn't", include_content: true })) as any[];
324
+ expect(result.map((n: any) => n.content)).toEqual([NOTES.contraction]);
325
+ });
326
+
327
+ it("query-notes: search_mode validation rejects an unknown value", async () => {
328
+ const tools = generateMcpTools(store);
329
+ const query = tools.find((t) => t.name === "query-notes")!;
330
+ await expect(query.execute({ search: "widgets", search_mode: "bogus" })).rejects.toThrow(/search_mode/);
331
+ });
332
+
333
+ it("query-notes: search_mode without search surfaces an ignored_param warning", async () => {
334
+ const tools = generateMcpTools(store);
335
+ const query = tools.find((t) => t.name === "query-notes")!;
336
+ const result = (await query.execute({ search_mode: "advanced" })) as any;
337
+ expect(result.warnings).toBeDefined();
338
+ expect(result.warnings.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
339
+ });
340
+
341
+ it("query-notes: search_mode:\"advanced\" + malformed syntax throws a structured invalid_search_syntax error", async () => {
342
+ const tools = generateMcpTools(store);
343
+ const query = tools.find((t) => t.name === "query-notes")!;
344
+ await expect(
345
+ query.execute({ search: '"unbalanced', search_mode: "advanced" }),
346
+ ).rejects.toMatchObject({ error_type: "invalid_search_syntax" });
347
+ });
348
+
349
+ it("query-notes: sort:\"asc\"/\"desc\" changes order under search (parity with REST)", async () => {
350
+ await store.createNote("mcpsortprobe alpha", { created_at: "2020-01-01T00:00:00.000Z" });
351
+ await store.createNote("mcpsortprobe beta", { created_at: "2024-01-01T00:00:00.000Z" });
352
+ const tools = generateMcpTools(store);
353
+ const query = tools.find((t) => t.name === "query-notes")!;
354
+ const asc = (await query.execute({ search: "mcpsortprobe", sort: "asc", include_content: true })) as any[];
355
+ expect(asc.map((n: any) => n.content)).toEqual(["mcpsortprobe alpha", "mcpsortprobe beta"]);
356
+ const desc = (await query.execute({ search: "mcpsortprobe", sort: "desc", include_content: true })) as any[];
357
+ expect(desc.map((n: any) => n.content)).toEqual(["mcpsortprobe beta", "mcpsortprobe alpha"]);
358
+ });
127
359
  });
@@ -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,39 @@ 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
+ }
179
+ // Advanced-mode full-text search syntax error (vault#551) — a
180
+ // raw-passthrough FTS5 query that FTS5 itself rejected. Same shape as
181
+ // `invalid_query` (field/got/hint) but a DISTINCT `error_type` so a
182
+ // caller can tell "your search_mode:\"advanced\" syntax is malformed"
183
+ // apart from "your query PARAMS are malformed."
184
+ if (e?.error_type === "invalid_search_syntax") {
185
+ throw new McpError(ErrorCode.InvalidParams, message, {
186
+ error_type: "invalid_search_syntax",
187
+ field: e.field,
188
+ got: e.got,
189
+ hint: e.hint,
190
+ });
191
+ }
160
192
  if (e?.code === "CONFLICT") {
161
193
  throw new McpError(ErrorCode.InvalidRequest, message, {
162
194
  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
+ });
package/src/mcp-tools.ts CHANGED
@@ -308,9 +308,11 @@ function applyTagScopeWrappers(
308
308
  const allowed = await getAllowed();
309
309
  const result = await orig(params);
310
310
  if (!allowed) return result;
311
- // Three possible response shapes:
312
- // - Array (legacy list, no cursor)
311
+ // Possible response shapes (vault#550 added the `warnings` variants):
312
+ // - Array (legacy list, no cursor, no warnings)
313
313
  // - `{notes, next_cursor}` (cursor mode, vault#313)
314
+ // - `{notes, warnings}` (warnings present, not in cursor mode)
315
+ // - `{notes, next_cursor, warnings}` (both)
314
316
  // - `{...note}` with `id`+`tags` (single-note by id)
315
317
  if (Array.isArray(result)) {
316
318
  return result
@@ -321,15 +323,22 @@ function applyTagScopeWrappers(
321
323
  result &&
322
324
  typeof result === "object" &&
323
325
  "notes" in result &&
324
- Array.isArray((result as any).notes) &&
325
- "next_cursor" in result
326
+ Array.isArray((result as any).notes)
326
327
  ) {
327
- const r = result as { notes: any[]; next_cursor: string | null };
328
+ const r = result as { notes: any[]; next_cursor?: string | null; warnings?: unknown };
328
329
  return {
329
330
  notes: r.notes
330
331
  .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
331
332
  .map(scrubNoteLinks),
332
- next_cursor: r.next_cursor,
333
+ ...("next_cursor" in r ? { next_cursor: r.next_cursor } : {}),
334
+ // `warnings` intentionally DROPPED for a tag-scoped session: core's
335
+ // `collectUnknownTagWarnings` (core/src/query-warnings.ts) resolves
336
+ // `did_you_mean` against the FULL vault-wide tag catalog, which
337
+ // would leak an out-of-scope tag's existence/name to a token that
338
+ // can't otherwise see it — the same "no leak across the scope
339
+ // boundary" stance this file takes everywhere else (see
340
+ // docs/contracts/tag-scoped-tokens.md). Unscoped sessions keep
341
+ // `warnings` untouched via the `!allowed` early return above.
333
342
  };
334
343
  }
335
344
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
@@ -342,9 +351,41 @@ function applyTagScopeWrappers(
342
351
 
343
352
  wrapReadTool(tools, "list-tags", async (orig, params) => {
344
353
  const allowed = await getAllowed();
354
+ if (!allowed) return await orig(params);
355
+ // Single-tag detail (`{tag}` param) — the non-array shape the old
356
+ // wrapper silently passed through, which leaked BOTH the full record of
357
+ // an existing out-of-scope tag (vault#560) AND, post-#550, a vault-wide
358
+ // `did_you_mean` suggestion for a nonexistent one. This wrapper is the
359
+ // enforcement layer for tag scope on this tool; core's list-tags stays
360
+ // scope-unaware by architecture (same division as every other wrapper
361
+ // in this file).
362
+ const singleTag = typeof (params as any).tag === "string" ? ((params as any).tag as string) : null;
363
+ if (singleTag !== null && !allowed.has(singleTag)) {
364
+ // Out-of-scope name — whether the tag exists or not. Same
365
+ // "tag_not_found, no leak" shape as the REST handlers' early-return,
366
+ // and deliberately NO did_you_mean (any suggestion would be computed
367
+ // vault-wide). Short-circuits BEFORE core runs, so the full record /
368
+ // suggestion is never even computed.
369
+ return { error: "Tag not found", error_type: "tag_not_found", tag: singleTag };
370
+ }
345
371
  const result = await orig(params);
346
- if (!allowed || !Array.isArray(result)) return result;
347
- return result.filter((t: any) => allowed.has(t.name));
372
+ if (Array.isArray(result)) {
373
+ return result.filter((t: any) => allowed.has(t.name));
374
+ }
375
+ // In-scope single-tag miss (nonexistent but allowlisted name): core's
376
+ // tag_not_found may carry a vault-wide `did_you_mean` — keep it only
377
+ // when the suggestion itself is inside the allowlist.
378
+ if (
379
+ result &&
380
+ typeof result === "object" &&
381
+ (result as any).error_type === "tag_not_found" &&
382
+ typeof (result as any).did_you_mean === "string" &&
383
+ !allowed.has((result as any).did_you_mean)
384
+ ) {
385
+ const { did_you_mean: _dropped, ...scrubbed } = result as Record<string, unknown>;
386
+ return scrubbed;
387
+ }
388
+ return result;
348
389
  });
349
390
 
350
391
  wrapReadTool(tools, "find-path", async (orig, params) => {