@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.
- package/core/src/contract-taxonomy.test.ts +193 -17
- package/core/src/contract-typed-index.test.ts +92 -3
- package/core/src/core.test.ts +29 -6
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +330 -0
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +190 -47
- package/core/src/notes.ts +107 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/store.ts +17 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +231 -4
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/mcp-http.ts +122 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +137 -8
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +350 -79
- package/src/routing.ts +29 -0
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +109 -0
- package/src/vault.test.ts +25 -8
|
@@ -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`)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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,
|
|
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 —
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
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
|
+
});
|