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