@openparachute/vault 0.7.0-rc.3 → 0.7.0-rc.5

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.
@@ -4,15 +4,16 @@
4
4
  * nine-persona deep test's WS5 findings (#554) as executable tests: PASSING
5
5
  * tests lock in the structured-error precedents that already exist
6
6
  * (`path_conflict`, `conflict` with current/expected timestamps,
7
- * `precondition_required`, `schema_validation`); `test.todo` entries
8
- * describe the target behavior for confirmed-broken cases, to be flipped to
9
- * real assertions in a later wave. See #554 for the full write-up.
7
+ * `precondition_required`, `schema_validation`). The Wave 4 (#554) error
8
+ * taxonomy sweep flipped the original `test.todo` entries into real
9
+ * assertions below see that describe block.
10
10
  */
11
11
 
12
- import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
12
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
13
13
  import { Database } from "bun:sqlite";
14
14
  import { BunStore } from "./vault-store.ts";
15
- import { handleNotes } from "./routes.ts";
15
+ import { handleNotes, handleTags } from "./routes.ts";
16
+ import { generateMcpTools, PreconditionRequiredError } from "../core/src/mcp.ts";
16
17
 
17
18
  let db: Database;
18
19
  let store: BunStore;
@@ -52,6 +53,7 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
52
53
  const body: any = await res.json();
53
54
  expect(body.error_type).toBe("path_conflict");
54
55
  expect(body.path).toBe("taken");
56
+ expect(typeof body.hint).toBe("string");
55
57
  });
56
58
 
57
59
  it("updating with a stale if_updated_at returns 409 conflict carrying current_updated_at + your_updated_at", async () => {
@@ -62,6 +64,7 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
62
64
  expect(body.error_type).toBe("conflict");
63
65
  expect(body.current_updated_at).toBe(note.updatedAt);
64
66
  expect(body.your_updated_at).toBe("2020-01-01T00:00:00.000Z");
67
+ expect(typeof body.hint).toBe("string");
65
68
  });
66
69
 
67
70
  it("a strict schema violation on write returns 422 schema_validation naming every violation", async () => {
@@ -76,6 +79,7 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
76
79
  expect(body.violations.length).toBeGreaterThan(0);
77
80
  expect(body.violations[0].field).toBe("status");
78
81
  expect(body.violations[0].reason).toBe("enum_mismatch");
82
+ expect(typeof body.hint).toBe("string");
79
83
  });
80
84
 
81
85
  it("updating a note without if_updated_at or force returns 428 precondition_required", async () => {
@@ -85,14 +89,176 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
85
89
  const body: any = await res.json();
86
90
  expect(body.error_type).toBe("precondition_required");
87
91
  expect(body.note_id).toBe(note.id);
92
+ expect(typeof body.hint).toBe("string");
88
93
  });
89
94
  });
90
95
 
91
- describe("contract: error taxonomy — todo (#554)", () => {
92
- test.todo(
93
- "#554: every error response carries a structured {error_type, hint} pair — today error_type exists on many paths but no response carries a `hint` field, and some error paths (e.g. bad_request/ambiguous/unprocessable_content in the PATCH content_edit branch) are still bare {error, message} strings with no error_type at all",
94
- );
95
- test.todo(
96
- "#554: batch update-note honors a top-level `force` (or `if_updated_at`) applied per-item today the batch entry point does `items = batch ?? [params]`, so a top-level force:true on the request never reaches the per-item precondition check (core/src/mcp.ts ~line 1114 reads item.force only) and each item without its OWN force/if_updated_at still throws precondition_required",
97
- );
96
+ describe("contract: error taxonomy — #554 (flipped from todo)", () => {
97
+ it("every error response on the PATCH content_edit branch carries a structured {error_type, hint} pair — previously bare {error, message} strings with no error_type at all", async () => {
98
+ const note = await store.createNote("the quick brown fox jumps over the fox", { id: "n1" });
99
+
100
+ // mutually_exclusive — content + append in the same call.
101
+ const mutEx = await patch(note.id, { content: "x", append: "y", force: true });
102
+ expect(mutEx.status).toBe(400);
103
+ const mutExBody: any = await mutEx.json();
104
+ expect(mutExBody.error_type).toBe("mutually_exclusive");
105
+ expect(typeof mutExBody.hint).toBe("string");
106
+
107
+ // invalid_content_edit — content_edit missing new_text.
108
+ const badShape = await patch(note.id, { content_edit: { old_text: "fox" }, force: true });
109
+ expect(badShape.status).toBe(400);
110
+ const badShapeBody: any = await badShape.json();
111
+ expect(badShapeBody.error_type).toBe("invalid_content_edit");
112
+ expect(badShapeBody.field).toBe("content_edit");
113
+ expect(typeof badShapeBody.hint).toBe("string");
114
+
115
+ // content_edit_not_found — old_text absent from the note's content.
116
+ const notFound = await patch(note.id, {
117
+ content_edit: { old_text: "giraffe", new_text: "zebra" },
118
+ force: true,
119
+ });
120
+ expect(notFound.status).toBe(422);
121
+ const notFoundBody: any = await notFound.json();
122
+ expect(notFoundBody.error_type).toBe("content_edit_not_found");
123
+ expect(notFoundBody.field).toBe("content_edit.old_text");
124
+ expect(typeof notFoundBody.hint).toBe("string");
125
+
126
+ // content_edit_ambiguous — old_text ("fox") matches twice in the seed content.
127
+ const ambiguous = await patch(note.id, {
128
+ content_edit: { old_text: "fox", new_text: "wolf" },
129
+ force: true,
130
+ });
131
+ expect(ambiguous.status).toBe(409);
132
+ const ambiguousBody: any = await ambiguous.json();
133
+ expect(ambiguousBody.error_type).toBe("content_edit_ambiguous");
134
+ expect(ambiguousBody.field).toBe("content_edit.old_text");
135
+ expect(typeof ambiguousBody.hint).toBe("string");
136
+
137
+ // invalid_state_transition — state_transition.field is an empty string.
138
+ const badTransition = await patch(note.id, { state_transition: { field: "", from: "a", to: "b" } });
139
+ expect(badTransition.status).toBe(400);
140
+ const badTransitionBody: any = await badTransition.json();
141
+ expect(badTransitionBody.error_type).toBe("invalid_state_transition");
142
+ expect(badTransitionBody.field).toBe("state_transition.field");
143
+ expect(typeof badTransitionBody.hint).toBe("string");
144
+ });
145
+
146
+ it("batch update-note honors a top-level `force` applied per-item as a DEFAULT, and an item's own value still wins", async () => {
147
+ const a = await store.createNote("a", { id: "a1" });
148
+ const b = await store.createNote("b", { id: "b1" });
149
+ const tools = generateMcpTools(store);
150
+ const updateNote = tools.find((t) => t.name === "update-note")!;
151
+
152
+ // Top-level force:true, item omits its own force/if_updated_at — the
153
+ // default applies and the write succeeds instead of throwing
154
+ // precondition_required. Before the fix, `items = batch ?? [params]`
155
+ // never merged the top-level field in, so this ALWAYS threw.
156
+ const result: any = await updateNote.execute({
157
+ force: true,
158
+ notes: [{ id: a.id, content: "a-updated" }],
159
+ });
160
+ expect(result[0].content).toBe("a-updated");
161
+
162
+ // Item-level values still win over the top-level default: this item
163
+ // explicitly sets `force: false` (overriding the batch default) and
164
+ // supplies no `if_updated_at` of its own, so the precondition gate
165
+ // still fires for it.
166
+ let caught: unknown;
167
+ try {
168
+ await updateNote.execute({
169
+ force: true,
170
+ notes: [{ id: b.id, content: "b-updated", force: false }],
171
+ });
172
+ } catch (e) {
173
+ caught = e;
174
+ }
175
+ expect(caught).toBeInstanceOf(PreconditionRequiredError);
176
+ });
177
+
178
+ it("REST PUT /api/tags/:name reports ALL cross-tag field violations in one call and states no changes were applied (#553 messaging, mirrors the MCP tool)", async () => {
179
+ // Tag "a" declares two fields; tag "b" redeclares BOTH with conflicting
180
+ // specs in the SAME PUT — a NON-indexed type conflict on "x" and an
181
+ // indexed-flag conflict on "y" (both were silent 200s on main; the
182
+ // both-indexed type-conflict case deliberately keeps its pre-existing
183
+ // 400 path — see the regression test below). Before this fix REST had
184
+ // no cross-tag pre-check at all here (a gap distinct from the MCP
185
+ // tool's old first-field-only throw); now both surfaces report
186
+ // identically.
187
+ await store.upsertTagRecord("a", {
188
+ fields: {
189
+ x: { type: "string" },
190
+ y: { type: "boolean", indexed: true },
191
+ },
192
+ });
193
+
194
+ const req = new Request("http://localhost/api/tags/b", {
195
+ method: "PUT",
196
+ body: JSON.stringify({
197
+ fields: {
198
+ x: { type: "integer" },
199
+ y: { type: "boolean", indexed: false },
200
+ },
201
+ }),
202
+ });
203
+ const res = await handleTags(req, store, "/b");
204
+ expect(res.status).toBe(422);
205
+ const body: any = await res.json();
206
+ expect(body.error_type).toBe("tag_field_conflict");
207
+ expect(body.violations).toHaveLength(2);
208
+ const byField = new Map(body.violations.map((v: any) => [v.field, v.reason]));
209
+ expect(byField.get("x")).toBe("type_conflict");
210
+ expect(byField.get("y")).toBe("indexed_flag_conflict");
211
+ expect(body.message).toContain("no changes were applied");
212
+ // Full detail for an unscoped caller — the conflicting declarer is named.
213
+ expect(body.violations[0].other_tag).toBe("a");
214
+
215
+ // Nothing partially landed.
216
+ const bRecord = await store.getTagRecord("b");
217
+ expect(bRecord?.fields ?? null).toBeFalsy();
218
+ });
219
+
220
+ it("REST PUT /api/tags/:name still returns 400 invalid_indexed_field for a solo bad field name (vault#478 contract unchanged)", async () => {
221
+ // No cross-tag conflict here — "meeting-type" is invalid on its OWN
222
+ // (kebab-case). This must stay on the pre-existing 400/invalid_indexed_field
223
+ // path, NOT the new 422/tag_field_conflict path — the cross-tag pre-check
224
+ // is deliberately scoped to type/indexed-flag agreement only (see
225
+ // `collectCrossTagFieldViolations`'s doc comment in core/src/tag-schemas.ts).
226
+ const req = new Request("http://localhost/api/tags/meeting", {
227
+ method: "PUT",
228
+ body: JSON.stringify({ fields: { "meeting-type": { type: "string", indexed: true } } }),
229
+ });
230
+ const res = await handleTags(req, store, "/meeting");
231
+ expect(res.status).toBe(400);
232
+ const body: any = await res.json();
233
+ expect(body.error_type).toBe("invalid_indexed_field");
234
+ });
235
+
236
+ it("REST PUT /api/tags/:name: a BOTH-INDEXED cross-tag type conflict stays 400 invalid_indexed_field, NOT 422 (wire-contract floor — pre-existing declareField behavior)", async () => {
237
+ // Exact regression from the wire review: tag "a" declares x
238
+ // string+indexed; tag "b" PUTs x integer+indexed. On main this
239
+ // returned 400 invalid_indexed_field (declareField's cross-declarer
240
+ // sqlite-type check inside store.upsertTagRecord); the vault#554
241
+ // pre-check must NOT intercept it as a 422 — statuses/error_types on
242
+ // previously-working calls are wire contract. See
243
+ // `collectCrossTagFieldViolations`'s doc-comment exclusion 2.
244
+ await store.upsertTagRecord("a", {
245
+ fields: { x: { type: "string", indexed: true } },
246
+ });
247
+
248
+ const req = new Request("http://localhost/api/tags/b", {
249
+ method: "PUT",
250
+ body: JSON.stringify({ fields: { x: { type: "integer", indexed: true } } }),
251
+ });
252
+ const res = await handleTags(req, store, "/b");
253
+ expect(res.status).toBe(400);
254
+ const body: any = await res.json();
255
+ expect(body.error_type).toBe("invalid_indexed_field");
256
+ // Unscoped caller: declareField's full message (naming the declarer)
257
+ // is preserved verbatim.
258
+ expect(body.error).toContain("tag(s) [a]");
259
+
260
+ // Nothing persisted — the store's transaction rolled back.
261
+ const bRecord = await store.getTagRecord("b");
262
+ expect(bRecord?.fields ?? null).toBeFalsy();
263
+ });
98
264
  });
package/src/mcp-http.ts CHANGED
@@ -139,13 +139,18 @@ async function handleMcp(
139
139
  content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
140
140
  };
141
141
  } catch (err) {
142
- // Domain errors from the core tools (conflict, missing precondition) get
143
- // surfaced as JSON-RPC errors with a structured `data` field so an
144
- // agent can key off `data.error_type` and the concurrency tokens.
145
- // Everything else falls through to an in-band tool error with
146
- // `isError: true` legible but unstructured.
142
+ // Domain errors from the core tools (conflict, missing precondition,
143
+ // path collisions, batch caps, cursor/query validation, tag-field
144
+ // conflicts, ...) get surfaced as JSON-RPC errors with a structured
145
+ // `data` field so an agent can key off `data.error_type` and any
146
+ // error-specific fields (concurrency tokens, violations, candidates,
147
+ // ...). vault#554: every domain error class is mapped below, either by
148
+ // a dedicated branch (full field fidelity) or the generic `error_type`
149
+ // catch-all near the end. Only a TRULY unknown error (no `error_type`
150
+ // anywhere on it) falls through to the in-band `isError: true` text.
147
151
  const message = err instanceof Error ? err.message : "Unknown error";
148
152
  const e = err as {
153
+ name?: string;
149
154
  code?: string;
150
155
  note_id?: string;
151
156
  note_path?: string | null;
@@ -159,6 +164,14 @@ async function handleMcp(
159
164
  error_type?: string;
160
165
  got?: unknown;
161
166
  hint?: string;
167
+ path?: string;
168
+ candidates?: unknown;
169
+ extension?: string;
170
+ reason?: string;
171
+ limit?: number;
172
+ tag?: string;
173
+ cycle?: unknown;
174
+ referencing_tags?: unknown;
162
175
  };
163
176
  // Honest-queries validation errors (vault#550) — `limit`/`offset`/date
164
177
  // values that are structurally invalid rather than merely "no
@@ -196,6 +209,7 @@ async function handleMcp(
196
209
  your_updated_at: e.expected_updated_at,
197
210
  path: e.note_path ?? null,
198
211
  note_id: e.note_id,
212
+ hint: "re-read the note (query-notes) and re-apply your change against its current updated_at, or pass force: true to overwrite",
199
213
  });
200
214
  }
201
215
  // State-transition compare-and-set conflict (vault#299 Part B) — a
@@ -210,6 +224,7 @@ async function handleMcp(
210
224
  expected_from: e.expected_from,
211
225
  to: e.to,
212
226
  current: e.current ?? null,
227
+ hint: "re-read the note's current value for this field and retry the transition from its actual current state",
213
228
  });
214
229
  }
215
230
  // Strict-schema rejection (vault#299 Part A) — one error carrying ALL
@@ -218,6 +233,7 @@ async function handleMcp(
218
233
  throw new McpError(ErrorCode.InvalidParams, message, {
219
234
  error_type: "schema_validation",
220
235
  violations: e.violations ?? [],
236
+ hint: "fix every field listed in violations and retry — none of this write was applied",
221
237
  });
222
238
  }
223
239
  if (e?.code === "PRECONDITION_REQUIRED") {
@@ -225,6 +241,107 @@ async function handleMcp(
225
241
  error_type: "precondition_required",
226
242
  note_id: e.note_id,
227
243
  path: e.note_path ?? null,
244
+ hint: "re-read the note, pass its updated_at as if_updated_at, or pass force: true to skip the check",
245
+ });
246
+ }
247
+ // Any other QueryError (vault#554) — FIELD_NOT_INDEXED, UNKNOWN_OPERATOR,
248
+ // INVALID_OPERATOR_VALUE, and the various cursor/near/search
249
+ // incompatibility throws that predate the vault#550/#551 `error_type`
250
+ // convention (which is why they weren't caught by the two branches
251
+ // above). Falls back to `error_type: "invalid_query"` — the umbrella
252
+ // REST already uses for this whole error class — so these no longer
253
+ // fall through to the unstructured `isError: true` text.
254
+ if (e?.name === "QueryError") {
255
+ throw new McpError(ErrorCode.InvalidParams, message, {
256
+ error_type: e.error_type ?? "invalid_query",
257
+ code: e.code,
258
+ field: e.field,
259
+ got: e.got,
260
+ hint: e.hint,
261
+ });
262
+ }
263
+ // Malformed / stale opaque cursor (vault#313, vault#554) — was
264
+ // entirely unmapped here before (REST already returns `{error, code}`
265
+ // — no `error_type` either; both surfaces get one now). `code` is
266
+ // already the exact `error_type` vocabulary: "cursor_invalid" or
267
+ // "cursor_query_mismatch".
268
+ if (e?.name === "CursorError" && typeof e.code === "string") {
269
+ throw new McpError(ErrorCode.InvalidParams, message, {
270
+ error_type: e.code,
271
+ });
272
+ }
273
+ // Path-rename/create collision (vault#126, vault#554) — schema's
274
+ // UNIQUE(path) tripped. Mirrors REST's 409 `path_conflict` shape.
275
+ if (e?.code === "PATH_CONFLICT") {
276
+ throw new McpError(ErrorCode.InvalidRequest, message, {
277
+ error_type: "path_conflict",
278
+ path: e.path,
279
+ });
280
+ }
281
+ // A path lookup matched more than one note (vault#330 S1, vault#554) —
282
+ // mirrors REST's 409 `ambiguous_path` shape (candidates = the
283
+ // disambiguating extensions).
284
+ if (e?.code === "AMBIGUOUS_PATH") {
285
+ throw new McpError(ErrorCode.InvalidRequest, message, {
286
+ error_type: "ambiguous_path",
287
+ path: e.path,
288
+ candidates: e.candidates,
289
+ });
290
+ }
291
+ // Bad `extension` value (vault#328, vault#554) — mirrors REST's 400
292
+ // `invalid_extension` shape.
293
+ if (e?.code === "INVALID_EXTENSION") {
294
+ throw new McpError(ErrorCode.InvalidParams, message, {
295
+ error_type: "invalid_extension",
296
+ extension: e.extension,
297
+ reason: e.reason,
298
+ });
299
+ }
300
+ // Batch cap exceeded (#213, vault#554) — mirrors REST's 413
301
+ // `batch_too_large` shape.
302
+ if (e?.code === "BATCH_TOO_LARGE") {
303
+ throw new McpError(ErrorCode.InvalidRequest, message, {
304
+ error_type: "batch_too_large",
305
+ limit: e.limit,
306
+ got: e.got,
307
+ });
308
+ }
309
+ // update-tag cross-tag field conflicts (vault#553/#554) — carries
310
+ // EVERY violation in one response (see `TagFieldConflictError`).
311
+ if (e?.code === "TAG_FIELD_CONFLICT") {
312
+ throw new McpError(ErrorCode.InvalidParams, message, {
313
+ error_type: "tag_field_conflict",
314
+ tag: e.tag,
315
+ violations: e.violations ?? [],
316
+ });
317
+ }
318
+ // parent_names cycle guard (vault#552) — `update-tag`'s write would
319
+ // close a cycle. Mirrors REST's 409 `parent_cycle` shape; the tool
320
+ // wrapper (src/mcp-tools.ts) scope-scrubs `cycle` before this throw
321
+ // for a tag-scoped caller, same as TAG_FIELD_CONFLICT above.
322
+ if (e?.code === "PARENT_CYCLE") {
323
+ throw new McpError(ErrorCode.InvalidRequest, message, {
324
+ error_type: "parent_cycle",
325
+ tag: e.tag,
326
+ cycle: e.cycle ?? [],
327
+ });
328
+ }
329
+ // Generic catch-all (vault#554): any remaining error that carries a
330
+ // stable `error_type` — either a domain error class that stamps it as
331
+ // an instance property (IndexedFieldError, and the classes handled by
332
+ // dedicated branches above as defense-in-depth) or a validation leaf
333
+ // built with `structuredError()` in core/src/mcp.ts (mutually_exclusive,
334
+ // invalid_content_edit, content_edit_not_found, content_edit_ambiguous,
335
+ // invalid_state_transition, invalid_parent_names, invalid_relationships,
336
+ // not_found, ...). This is the backstop that makes "nothing falls
337
+ // through to the unstructured isError text except a TRULY unknown
338
+ // error" true by construction: only errors nobody tagged with
339
+ // `error_type` still hit the fallback below.
340
+ if (typeof e?.error_type === "string") {
341
+ throw new McpError(ErrorCode.InvalidParams, message, {
342
+ error_type: e.error_type,
343
+ field: e.field,
344
+ hint: e.hint,
228
345
  });
229
346
  }
230
347
  return {
@@ -151,4 +151,40 @@ describe("MCP list-tags single-tag path — tag-scope enforcement (vault#550 fol
151
151
  expect(names).toContain("health");
152
152
  expect(names).not.toContain("work");
153
153
  });
154
+
155
+ // vault#554 carry-forward: the wrapper's SECOND scrub branch (the one
156
+ // AFTER the early out-of-scope short-circuit above) fires when the queried
157
+ // tag itself IS in the allowlist but core still reports tag_not_found —
158
+ // the hollow-tag case (vault#550: no identity row, no notes directly
159
+ // carrying it) reached ONLY via a child's `parent_names`. Core computes
160
+ // `did_you_mean` vault-wide (scope-unaware by architecture), so it can
161
+ // name an out-of-scope tag; the wrapper must strip that suggestion even
162
+ // though the queried tag itself passed the allowlist gate. This branch was
163
+ // execution-verified in a prior review; this test commits it.
164
+ test("scoped token + in-scope HOLLOW tag (reachable only via a child's parent_names) scrubs an out-of-scope did_you_mean", async () => {
165
+ seedVault("journal");
166
+ const store = getVaultStore("journal");
167
+
168
+ // "health" itself has NO identity row and NO note directly carrying it —
169
+ // it's in the allowlist expansion ONLY because "kale" declares it as a
170
+ // parent. "kale" is deliberately NOT lexically close to "health" (the
171
+ // mission's "non-close child") so it can't win did_you_mean and mask
172
+ // the bug this test targets.
173
+ await store.upsertTagRecord("kale", { parent_names: ["health"] });
174
+ await store.createNote("k", { tags: ["kale"] });
175
+
176
+ // Out-of-scope decoy, lexically close to "health" (prefix match) — this
177
+ // is what core's vault-wide suggestSimilarTag would pick if nothing
178
+ // scrubbed it.
179
+ await store.createNote("hy", { tags: ["healthy"] });
180
+
181
+ const tool = await listTagsTool("journal", ["health"]);
182
+ const result = (await tool.execute({ tag: "health" })) as any;
183
+
184
+ expect(result.error_type).toBe("tag_not_found");
185
+ expect(result.tag).toBe("health");
186
+ // The scrub: did_you_mean must NOT leak the out-of-scope "healthy",
187
+ // even though "health" (the query) itself passed the allowlist gate.
188
+ expect(result.did_you_mean).toBeUndefined();
189
+ });
154
190
  });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Combined tag-scope × full-text-search enforcement (vault#554 carry-forward
3
+ * — "auth-561" review traced the code clean; this commits the self-verifying
4
+ * test the review promised).
5
+ *
6
+ * `applyTagScopeWrappers`'s `query-notes` wrapper (`src/mcp-tools.ts`) runs
7
+ * the ENTIRE search — content matching, `search_mode` escaping, `sort` — via
8
+ * core (scope-unaware by architecture), then filters the returned notes
9
+ * against the token's allowlist as a post-hoc array `.filter()`
10
+ * (`noteWithinTagScope`, `src/tag-scope.ts`). Because the filter is applied
11
+ * uniformly AFTER core returns, in principle no single (mode, sort)
12
+ * combination is special — but a regression in one code path (e.g. a search
13
+ * branch that bypasses the wrapper, or a shape the filter doesn't recognize)
14
+ * could still slip an out-of-scope note through for SOME combination. This
15
+ * test exercises the full `search_mode × sort` matrix and asserts the
16
+ * out-of-scope note never appears in any of them.
17
+ *
18
+ * Fixture: two notes both match the search term "widget" in punctuation-
19
+ * bearing content (so literal-mode escaping and advanced-mode raw FTS5 both
20
+ * still match it) — one tagged in-scope (`health`), one out-of-scope
21
+ * (`work`). A tag-scoped session ["health"] must see only the in-scope note
22
+ * across every (search_mode, sort) pairing.
23
+ */
24
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
25
+ import { mkdirSync, rmSync, existsSync } from "fs";
26
+ import { join } from "path";
27
+ import { tmpdir } from "os";
28
+ import { writeVaultConfig } from "./config.ts";
29
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
30
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
31
+ import type { AuthResult } from "./auth.ts";
32
+ import { SEARCH_MODES } from "../core/src/search-query.ts";
33
+
34
+ let tmpHome: string;
35
+ let prevHome: string | undefined;
36
+
37
+ beforeEach(() => {
38
+ tmpHome = join(tmpdir(), `vault-search-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
39
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
40
+ prevHome = process.env.PARACHUTE_HOME;
41
+ process.env.PARACHUTE_HOME = tmpHome;
42
+ clearVaultStoreCache();
43
+ });
44
+
45
+ afterEach(() => {
46
+ clearVaultStoreCache();
47
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
48
+ else process.env.PARACHUTE_HOME = prevHome;
49
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
50
+ });
51
+
52
+ function seedVault(name: string): void {
53
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
54
+ getVaultStore(name);
55
+ }
56
+
57
+ function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
58
+ return {
59
+ permission: "full",
60
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
61
+ legacyDerived: false,
62
+ scoped_tags: scopedTags,
63
+ vault_name: null,
64
+ caller_jti: null,
65
+ actor: "test-user",
66
+ via: "api",
67
+ };
68
+ }
69
+
70
+ async function queryNotesTool(vaultName: string, scopedTags: string[] | null) {
71
+ const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
72
+ return tools.find((t) => t.name === "query-notes")!;
73
+ }
74
+
75
+ // Normalize the tool's response (bare array, or {notes, next_cursor?,
76
+ // warnings?} in cursor/warnings mode) down to the note-id list actually
77
+ // returned — search is never cursor mode, but stays defensive to shape.
78
+ function idsOf(result: unknown): string[] {
79
+ if (Array.isArray(result)) return result.map((n: any) => n.id);
80
+ const notes = (result as any)?.notes;
81
+ return Array.isArray(notes) ? notes.map((n: any) => n.id) : [];
82
+ }
83
+
84
+ describe("MCP query-notes search × tag-scope — combined enforcement (vault#554 carry-forward)", () => {
85
+ test("out-of-scope notes never appear across every search_mode × sort combination", async () => {
86
+ seedVault("journal");
87
+ const store = getVaultStore("journal");
88
+
89
+ // Punctuation-bearing content so literal-mode's escaping and
90
+ // advanced-mode's raw FTS5 passthrough both still match "widget"
91
+ // cleanly (advanced mode has no operator characters to trip on here).
92
+ const inScope = await store.createNote("didn't fix the widget yet", { tags: ["health"] });
93
+ const outOfScope = await store.createNote("ordered a replacement widget", { tags: ["work"] });
94
+
95
+ const tool = await queryNotesTool("journal", ["health"]);
96
+
97
+ const sorts: (undefined | "asc" | "desc")[] = [undefined, "asc", "desc"];
98
+ const modes: (undefined | (typeof SEARCH_MODES)[number])[] = [undefined, ...SEARCH_MODES];
99
+
100
+ for (const search_mode of modes) {
101
+ for (const sort of sorts) {
102
+ const params: Record<string, unknown> = { search: "widget" };
103
+ if (search_mode !== undefined) params.search_mode = search_mode;
104
+ if (sort !== undefined) params.sort = sort;
105
+
106
+ const result = await tool.execute(params);
107
+ const ids = idsOf(result);
108
+
109
+ expect(ids).toContain(inScope.id);
110
+ expect(ids).not.toContain(outOfScope.id);
111
+ }
112
+ }
113
+ });
114
+
115
+ test("unscoped session sees both notes across the same matrix (control)", async () => {
116
+ seedVault("journal");
117
+ const store = getVaultStore("journal");
118
+
119
+ const a = await store.createNote("didn't fix the widget yet", { tags: ["health"] });
120
+ const b = await store.createNote("ordered a replacement widget", { tags: ["work"] });
121
+
122
+ const tool = await queryNotesTool("journal", null);
123
+
124
+ for (const search_mode of [undefined, ...SEARCH_MODES]) {
125
+ for (const sort of [undefined, "asc", "desc"] as const) {
126
+ const params: Record<string, unknown> = { search: "widget" };
127
+ if (search_mode !== undefined) params.search_mode = search_mode;
128
+ if (sort !== undefined) params.sort = sort;
129
+
130
+ const ids = idsOf(await tool.execute(params));
131
+ expect(ids).toContain(a.id);
132
+ expect(ids).toContain(b.id);
133
+ }
134
+ }
135
+ });
136
+ });