@openparachute/vault 0.6.5 → 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/contract-concurrency.test.ts +117 -0
- package/core/src/contract-taxonomy.test.ts +119 -0
- package/core/src/contract-typed-index.test.ts +106 -0
- 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-errors.test.ts +98 -0
- package/src/contract-honest-queries.test.ts +412 -0
- package/src/contract-search.test.ts +127 -0
- 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
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
|
+
});
|
package/src/mcp-tools.ts
CHANGED
|
@@ -308,9 +308,11 @@ function applyTagScopeWrappers(
|
|
|
308
308
|
const allowed = await getAllowed();
|
|
309
309
|
const result = await orig(params);
|
|
310
310
|
if (!allowed) return result;
|
|
311
|
-
//
|
|
312
|
-
// - Array (legacy list, no cursor)
|
|
311
|
+
// Possible response shapes (vault#550 added the `warnings` variants):
|
|
312
|
+
// - Array (legacy list, no cursor, no warnings)
|
|
313
313
|
// - `{notes, next_cursor}` (cursor mode, vault#313)
|
|
314
|
+
// - `{notes, warnings}` (warnings present, not in cursor mode)
|
|
315
|
+
// - `{notes, next_cursor, warnings}` (both)
|
|
314
316
|
// - `{...note}` with `id`+`tags` (single-note by id)
|
|
315
317
|
if (Array.isArray(result)) {
|
|
316
318
|
return result
|
|
@@ -321,15 +323,22 @@ function applyTagScopeWrappers(
|
|
|
321
323
|
result &&
|
|
322
324
|
typeof result === "object" &&
|
|
323
325
|
"notes" in result &&
|
|
324
|
-
Array.isArray((result as any).notes)
|
|
325
|
-
"next_cursor" in result
|
|
326
|
+
Array.isArray((result as any).notes)
|
|
326
327
|
) {
|
|
327
|
-
const r = result as { notes: any[]; next_cursor
|
|
328
|
+
const r = result as { notes: any[]; next_cursor?: string | null; warnings?: unknown };
|
|
328
329
|
return {
|
|
329
330
|
notes: r.notes
|
|
330
331
|
.filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
|
|
331
332
|
.map(scrubNoteLinks),
|
|
332
|
-
next_cursor: r.next_cursor,
|
|
333
|
+
...("next_cursor" in r ? { next_cursor: r.next_cursor } : {}),
|
|
334
|
+
// `warnings` intentionally DROPPED for a tag-scoped session: core's
|
|
335
|
+
// `collectUnknownTagWarnings` (core/src/query-warnings.ts) resolves
|
|
336
|
+
// `did_you_mean` against the FULL vault-wide tag catalog, which
|
|
337
|
+
// would leak an out-of-scope tag's existence/name to a token that
|
|
338
|
+
// can't otherwise see it — the same "no leak across the scope
|
|
339
|
+
// boundary" stance this file takes everywhere else (see
|
|
340
|
+
// docs/contracts/tag-scoped-tokens.md). Unscoped sessions keep
|
|
341
|
+
// `warnings` untouched via the `!allowed` early return above.
|
|
333
342
|
};
|
|
334
343
|
}
|
|
335
344
|
if (result && typeof result === "object" && "id" in result && "tags" in result) {
|
|
@@ -342,9 +351,41 @@ function applyTagScopeWrappers(
|
|
|
342
351
|
|
|
343
352
|
wrapReadTool(tools, "list-tags", async (orig, params) => {
|
|
344
353
|
const allowed = await getAllowed();
|
|
354
|
+
if (!allowed) return await orig(params);
|
|
355
|
+
// Single-tag detail (`{tag}` param) — the non-array shape the old
|
|
356
|
+
// wrapper silently passed through, which leaked BOTH the full record of
|
|
357
|
+
// an existing out-of-scope tag (vault#560) AND, post-#550, a vault-wide
|
|
358
|
+
// `did_you_mean` suggestion for a nonexistent one. This wrapper is the
|
|
359
|
+
// enforcement layer for tag scope on this tool; core's list-tags stays
|
|
360
|
+
// scope-unaware by architecture (same division as every other wrapper
|
|
361
|
+
// in this file).
|
|
362
|
+
const singleTag = typeof (params as any).tag === "string" ? ((params as any).tag as string) : null;
|
|
363
|
+
if (singleTag !== null && !allowed.has(singleTag)) {
|
|
364
|
+
// Out-of-scope name — whether the tag exists or not. Same
|
|
365
|
+
// "tag_not_found, no leak" shape as the REST handlers' early-return,
|
|
366
|
+
// and deliberately NO did_you_mean (any suggestion would be computed
|
|
367
|
+
// vault-wide). Short-circuits BEFORE core runs, so the full record /
|
|
368
|
+
// suggestion is never even computed.
|
|
369
|
+
return { error: "Tag not found", error_type: "tag_not_found", tag: singleTag };
|
|
370
|
+
}
|
|
345
371
|
const result = await orig(params);
|
|
346
|
-
if (
|
|
347
|
-
|
|
372
|
+
if (Array.isArray(result)) {
|
|
373
|
+
return result.filter((t: any) => allowed.has(t.name));
|
|
374
|
+
}
|
|
375
|
+
// In-scope single-tag miss (nonexistent but allowlisted name): core's
|
|
376
|
+
// tag_not_found may carry a vault-wide `did_you_mean` — keep it only
|
|
377
|
+
// when the suggestion itself is inside the allowlist.
|
|
378
|
+
if (
|
|
379
|
+
result &&
|
|
380
|
+
typeof result === "object" &&
|
|
381
|
+
(result as any).error_type === "tag_not_found" &&
|
|
382
|
+
typeof (result as any).did_you_mean === "string" &&
|
|
383
|
+
!allowed.has((result as any).did_you_mean)
|
|
384
|
+
) {
|
|
385
|
+
const { did_you_mean: _dropped, ...scrubbed } = result as Record<string, unknown>;
|
|
386
|
+
return scrubbed;
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
348
389
|
});
|
|
349
390
|
|
|
350
391
|
wrapReadTool(tools, "find-path", async (orig, params) => {
|
package/src/routes.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
|
-
import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
15
|
+
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
|
+
import { collectUnknownTagWarnings, type QueryWarning } from "../core/src/query-warnings.ts";
|
|
16
17
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
17
18
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
18
19
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
@@ -128,6 +129,56 @@ function json(data: unknown, status = 200): Response {
|
|
|
128
129
|
return Response.json(data, { status });
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* `json(...)` plus the `X-Parachute-Warnings` response header (vault#550) —
|
|
134
|
+
* `encodeURIComponent(JSON.stringify(warnings))`, set only when `warnings`
|
|
135
|
+
* is non-empty. Percent-encoded because HTTP header VALUES are
|
|
136
|
+
* Latin1/ASCII-only (the Fetch spec's ByteString requirement) while warning
|
|
137
|
+
* `message` text uses this codebase's normal house style (em dashes,
|
|
138
|
+
* curly-free but occasionally non-ASCII punctuation) — a raw
|
|
139
|
+
* `JSON.stringify` throws `TypeError: invalid header value` the first time
|
|
140
|
+
* a message isn't 7-bit-clean. Decode with `decodeURIComponent` then
|
|
141
|
+
* `JSON.parse`. Exists so bare-array responses (`GET /notes` without a
|
|
142
|
+
* cursor) can carry warnings WITHOUT changing their wire shape — a code
|
|
143
|
+
* consumer doing `for (const note of await res.json())` keeps working
|
|
144
|
+
* unmodified, while a consumer that cares can read the header. Envelope
|
|
145
|
+
* shapes (`{notes, next_cursor}`, `{nodes, edges}`) get the header too for
|
|
146
|
+
* uniform discoverability; callers that want warnings INLINE in an
|
|
147
|
+
* envelope should also spread them into the response body before calling
|
|
148
|
+
* this (see the cursor-envelope call sites in `handleNotesInner`).
|
|
149
|
+
*/
|
|
150
|
+
function jsonWithWarnings(data: unknown, warnings: QueryWarning[], status = 200): Response {
|
|
151
|
+
const res = json(data, status);
|
|
152
|
+
if (warnings.length > 0) {
|
|
153
|
+
res.headers.set("X-Parachute-Warnings", encodeURIComponent(JSON.stringify(warnings)));
|
|
154
|
+
}
|
|
155
|
+
return res;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* REST-only `removed_param` warning (vault#550) — the flat `date_field` /
|
|
160
|
+
* `date_from` / `date_to` query-string params were removed at 0.6.4
|
|
161
|
+
* (vault#288) and have been silently ignored ever since (routes.ts:633-ish
|
|
162
|
+
* comment). A request that passes any of them now gets a warning naming
|
|
163
|
+
* the ignored param and the bracket-style replacement, instead of quietly
|
|
164
|
+
* coming back unfiltered. One warning per param present (a caller passing
|
|
165
|
+
* all three gets three entries — precise, not lossy-summarized).
|
|
166
|
+
*/
|
|
167
|
+
function collectRemovedParamWarnings(url: URL): QueryWarning[] {
|
|
168
|
+
const REMOVED_DATE_PARAMS = ["date_field", "date_from", "date_to"] as const;
|
|
169
|
+
const warnings: QueryWarning[] = [];
|
|
170
|
+
for (const param of REMOVED_DATE_PARAMS) {
|
|
171
|
+
if (url.searchParams.has(param)) {
|
|
172
|
+
warnings.push({
|
|
173
|
+
code: "removed_param",
|
|
174
|
+
message: `\`${param}\` was removed in 0.6.4 (vault#288) and is silently ignored — use bracket-style date filters instead: \`meta[created_at][gte]=…\` / \`meta[created_at][lt]=…\` (or \`meta[updated_at][...]\`).`,
|
|
175
|
+
param,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return warnings;
|
|
180
|
+
}
|
|
181
|
+
|
|
131
182
|
function parseBool(val: string | null, defaultVal: boolean): boolean {
|
|
132
183
|
if (val === null) return defaultVal;
|
|
133
184
|
return val === "true" || val === "1";
|
|
@@ -221,6 +272,59 @@ function parseInt10(val: string | null): number | undefined {
|
|
|
221
272
|
return isNaN(n) ? undefined : n;
|
|
222
273
|
}
|
|
223
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Parse + validate `?limit=` / `?offset=` for the structured-query path
|
|
277
|
+
* (vault#550). Before this, `parseInt10(...) ?? 50` silently swallowed a
|
|
278
|
+
* non-numeric value into the default (a caller's typo `?limit=fifty` just
|
|
279
|
+
* became `limit=50` with no signal), and any negative value that DID parse
|
|
280
|
+
* flowed straight through to SQLite, whose `LIMIT -1` means "no limit" —
|
|
281
|
+
* silently returning EVERYTHING instead of erroring. Returns `{value}`
|
|
282
|
+
* (undefined when the param is absent — the caller applies its own
|
|
283
|
+
* default) or `{error}` (400 `invalid_query`) when present-but-malformed.
|
|
284
|
+
* `got` echoes the ORIGINAL raw string so the error is legible even though
|
|
285
|
+
* the coerced value (e.g. `NaN`) wouldn't be.
|
|
286
|
+
*/
|
|
287
|
+
function parseNonNegativeIntParam(
|
|
288
|
+
url: URL,
|
|
289
|
+
key: "limit" | "offset",
|
|
290
|
+
): { value?: number; error?: Response } {
|
|
291
|
+
const raw = parseQuery(url, key);
|
|
292
|
+
if (raw === null || raw === "") return {};
|
|
293
|
+
const trimmed = raw.trim();
|
|
294
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
295
|
+
return {
|
|
296
|
+
error: json(
|
|
297
|
+
{
|
|
298
|
+
error: `\`${key}\` must be an integer, got ${JSON.stringify(raw)}`,
|
|
299
|
+
error_type: "invalid_query",
|
|
300
|
+
code: "INVALID_QUERY",
|
|
301
|
+
field: key,
|
|
302
|
+
got: raw,
|
|
303
|
+
hint: `pass a non-negative integer (e.g. ?${key}=${key === "limit" ? "50" : "0"}), or omit for the default`,
|
|
304
|
+
},
|
|
305
|
+
400,
|
|
306
|
+
),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const n = parseInt(trimmed, 10);
|
|
310
|
+
if (n < 0) {
|
|
311
|
+
return {
|
|
312
|
+
error: json(
|
|
313
|
+
{
|
|
314
|
+
error: `\`${key}\` must be zero or greater, got ${n}${key === "limit" ? " — a negative limit used to silently mean \"unlimited\" (SQLite semantics leaking through)" : ""}`,
|
|
315
|
+
error_type: "invalid_query",
|
|
316
|
+
code: "INVALID_QUERY",
|
|
317
|
+
field: key,
|
|
318
|
+
got: raw,
|
|
319
|
+
hint: "pass a non-negative integer, or omit for the default",
|
|
320
|
+
},
|
|
321
|
+
400,
|
|
322
|
+
),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return { value: n };
|
|
326
|
+
}
|
|
327
|
+
|
|
224
328
|
/**
|
|
225
329
|
* Parse bracket-style metadata filters (vault#285 friction point 1.3).
|
|
226
330
|
*
|
|
@@ -612,6 +716,12 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
612
716
|
if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
|
|
613
717
|
const expand = expandParsed.expand;
|
|
614
718
|
|
|
719
|
+
// limit/offset validation (vault#550) — see parseNonNegativeIntParam.
|
|
720
|
+
const limitParsed = parseNonNegativeIntParam(url, "limit");
|
|
721
|
+
if (limitParsed.error) return { hasSearch, hasNear, hasCursor, error: limitParsed.error };
|
|
722
|
+
const offsetParsed = parseNonNegativeIntParam(url, "offset");
|
|
723
|
+
if (offsetParsed.error) return { hasSearch, hasNear, hasCursor, error: offsetParsed.error };
|
|
724
|
+
|
|
615
725
|
const queryOpts: QueryOpts = {
|
|
616
726
|
tags,
|
|
617
727
|
tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
@@ -639,8 +749,8 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
639
749
|
...(bracket.dateFilter ? { dateFilter: bracket.dateFilter } : {}),
|
|
640
750
|
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
641
751
|
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
642
|
-
limit:
|
|
643
|
-
offset:
|
|
752
|
+
limit: limitParsed.value ?? 50,
|
|
753
|
+
offset: offsetParsed.value,
|
|
644
754
|
cursor: cursorParam ?? undefined,
|
|
645
755
|
};
|
|
646
756
|
|
|
@@ -839,8 +949,11 @@ async function handleNotesInner(
|
|
|
839
949
|
// FTS owns its own ordering (relevance, not updated_at), so a cursor
|
|
840
950
|
// would skip rows. MCP rejects this combo at `core/src/mcp.ts`; REST
|
|
841
951
|
// would otherwise route into the `if (search)` branch below and
|
|
842
|
-
// silently drop the cursor. Reject here for surface parity.
|
|
843
|
-
|
|
952
|
+
// silently drop the cursor. Reject here for surface parity. Presence
|
|
953
|
+
// check (`!== null`), not truthiness (vault#550) — a bootstrap
|
|
954
|
+
// `?cursor=` attempt against a search query is still cursor intent
|
|
955
|
+
// and must still be rejected, not silently treated as "no cursor."
|
|
956
|
+
if (search && parseQuery(url, "cursor") !== null) {
|
|
844
957
|
return json(
|
|
845
958
|
{
|
|
846
959
|
error: "cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.",
|
|
@@ -934,11 +1047,22 @@ async function handleNotesInner(
|
|
|
934
1047
|
if (parsed.error) return parsed.error;
|
|
935
1048
|
const queryOpts = parsed.queryOpts!;
|
|
936
1049
|
const cursorParam = parseQuery(url, "cursor");
|
|
1050
|
+
// Cursor mode is keyed on PRESENCE (`!== null`), not truthiness
|
|
1051
|
+
// (vault#550 bootstrap fix). `?cursor=` (present, empty) is the
|
|
1052
|
+
// bootstrap call — "I want to paginate, no watermark yet" — and must
|
|
1053
|
+
// still engage the `{notes, next_cursor}` envelope. Before this fix
|
|
1054
|
+
// `if (cursorParam)` treated an empty string exactly like an absent
|
|
1055
|
+
// param, so the FIRST call could never obtain a cursor even though
|
|
1056
|
+
// `core/src/mcp.ts` documented that flow — the cursor bootstrap gap
|
|
1057
|
+
// this wave closes. Omitting `cursor` entirely (`cursorParam ===
|
|
1058
|
+
// null`) is the only way to opt OUT — that keeps today's bare-array
|
|
1059
|
+
// compat shape.
|
|
1060
|
+
const cursorMode = cursorParam !== null;
|
|
937
1061
|
// Opaque cursor for "since last checked" agent loops (vault#313) is
|
|
938
1062
|
// mutually exclusive with the `near` graph-neighborhood scope (rebuilding
|
|
939
1063
|
// the neighborhood per page isn't stable).
|
|
940
1064
|
const nearNoteIdEarly = parseQuery(url, "near[note_id]");
|
|
941
|
-
if (
|
|
1065
|
+
if (cursorMode && nearNoteIdEarly) {
|
|
942
1066
|
return json(
|
|
943
1067
|
{
|
|
944
1068
|
error: "cursor is incompatible with near (graph neighborhood). Resolve the neighborhood first, then iterate with cursor over the resulting note set.",
|
|
@@ -947,10 +1071,25 @@ async function handleNotesInner(
|
|
|
947
1071
|
400,
|
|
948
1072
|
);
|
|
949
1073
|
}
|
|
1074
|
+
// Warnings channel (vault#550) — structured-query only for this wave
|
|
1075
|
+
// (search= is out of scope, see #551). Skipped entirely for a
|
|
1076
|
+
// tag-scoped session: `collectUnknownTagWarnings` resolves
|
|
1077
|
+
// `did_you_mean` against the FULL vault-wide tag catalog, which would
|
|
1078
|
+
// leak an out-of-scope tag's existence/name across the scope boundary
|
|
1079
|
+
// — the same "no leak" stance this codebase takes everywhere else
|
|
1080
|
+
// (docs/contracts/tag-scoped-tokens.md). `removed_param` warnings
|
|
1081
|
+
// (below, at the response-shaping stage) carry no tag information and
|
|
1082
|
+
// fire regardless of scope.
|
|
1083
|
+
const queryWarnings: QueryWarning[] = [
|
|
1084
|
+
...collectRemovedParamWarnings(url),
|
|
1085
|
+
...(tagScope.allowed === null
|
|
1086
|
+
? collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy())
|
|
1087
|
+
: []),
|
|
1088
|
+
];
|
|
950
1089
|
let results: Note[];
|
|
951
1090
|
let nextCursor: string | null = null;
|
|
952
1091
|
try {
|
|
953
|
-
if (
|
|
1092
|
+
if (cursorMode) {
|
|
954
1093
|
const page = await store.queryNotesPaged(queryOpts);
|
|
955
1094
|
results = page.notes;
|
|
956
1095
|
nextCursor = page.next_cursor;
|
|
@@ -960,9 +1099,23 @@ async function handleNotesInner(
|
|
|
960
1099
|
} catch (e: any) {
|
|
961
1100
|
// QueryError (non-indexed order_by, unknown operator, ...) surfaces
|
|
962
1101
|
// here. Duck-type on `name` + `code` — core is a separate module, so
|
|
963
|
-
// `instanceof` is fragile across bundling boundaries.
|
|
1102
|
+
// `instanceof` is fragile across bundling boundaries. The newer
|
|
1103
|
+
// vault#550 call sites (limit/offset/date validation in
|
|
1104
|
+
// core/src/notes.ts) additionally set `error_type`/`field`/`got`/
|
|
1105
|
+
// `hint` — merged in when present; long-standing QueryError throws
|
|
1106
|
+
// that don't set them keep their existing `{error, code}` shape.
|
|
964
1107
|
if (e && e.name === "QueryError") {
|
|
965
|
-
return json(
|
|
1108
|
+
return json(
|
|
1109
|
+
{
|
|
1110
|
+
error: e.message,
|
|
1111
|
+
code: e.code ?? "INVALID_QUERY",
|
|
1112
|
+
...(e.error_type !== undefined ? { error_type: e.error_type } : {}),
|
|
1113
|
+
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1114
|
+
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1115
|
+
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
1116
|
+
},
|
|
1117
|
+
400,
|
|
1118
|
+
);
|
|
966
1119
|
}
|
|
967
1120
|
// CursorError carries a structured code (cursor_invalid /
|
|
968
1121
|
// cursor_query_mismatch) so the agent loop can distinguish a
|
|
@@ -1072,7 +1225,12 @@ async function handleNotesInner(
|
|
|
1072
1225
|
}
|
|
1073
1226
|
}
|
|
1074
1227
|
}
|
|
1075
|
-
|
|
1228
|
+
// Envelope shape → warnings inline (same policy as the cursor
|
|
1229
|
+
// envelope below), plus the header via jsonWithWarnings.
|
|
1230
|
+
return jsonWithWarnings(
|
|
1231
|
+
{ nodes, edges, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
|
|
1232
|
+
queryWarnings,
|
|
1233
|
+
);
|
|
1076
1234
|
}
|
|
1077
1235
|
|
|
1078
1236
|
if (includeLinks || includeAttachments) {
|
|
@@ -1099,12 +1257,24 @@ async function handleNotesInner(
|
|
|
1099
1257
|
// Cursor mode wraps the list in {notes, next_cursor} so an agent
|
|
1100
1258
|
// loop can chain calls without tracking a watermark client-side.
|
|
1101
1259
|
// Legacy callers (no `cursor` param) still get the flat array.
|
|
1102
|
-
|
|
1103
|
-
|
|
1260
|
+
// Warnings (vault#550): inline on the envelope, header either way
|
|
1261
|
+
// (see jsonWithWarnings).
|
|
1262
|
+
if (cursorMode) {
|
|
1263
|
+
return jsonWithWarnings(
|
|
1264
|
+
{ notes: enrichedOut, next_cursor: nextCursor, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
|
|
1265
|
+
queryWarnings,
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
return jsonWithWarnings(enrichedOut, queryWarnings);
|
|
1104
1269
|
}
|
|
1105
1270
|
|
|
1106
|
-
if (
|
|
1107
|
-
|
|
1271
|
+
if (cursorMode) {
|
|
1272
|
+
return jsonWithWarnings(
|
|
1273
|
+
{ notes: output, next_cursor: nextCursor, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
|
|
1274
|
+
queryWarnings,
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
return jsonWithWarnings(output, queryWarnings);
|
|
1108
1278
|
}
|
|
1109
1279
|
|
|
1110
1280
|
// POST /notes — create (single or batch)
|
|
@@ -1874,16 +2044,42 @@ export async function handleTags(
|
|
|
1874
2044
|
if (singleTag) {
|
|
1875
2045
|
// Tag-scope: a tag-scoped token can only see tags reachable from its
|
|
1876
2046
|
// allowlist (root + descendants per the parent_names hierarchy).
|
|
1877
|
-
// Anything else 404s — same "no leak" stance as note reads.
|
|
2047
|
+
// Anything else 404s — same "no leak" stance as note reads. This
|
|
2048
|
+
// early-return means the vault#550 unknown-tag 404 below never even
|
|
2049
|
+
// runs for a scope-excluded name — `did_you_mean` (which searches
|
|
2050
|
+
// vault-wide) can't fire and leak an out-of-scope tag's existence.
|
|
1878
2051
|
if (tagScope.allowed && !tagScope.allowed.has(singleTag)) {
|
|
1879
|
-
return json({ error: "Tag not found", tag: singleTag }, 404);
|
|
2052
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: singleTag }, 404);
|
|
1880
2053
|
}
|
|
1881
2054
|
const allTags = await store.listTags();
|
|
1882
2055
|
const found = allTags.find((t) => t.name === singleTag);
|
|
1883
2056
|
const record = await store.getTagRecord(singleTag);
|
|
2057
|
+
// vault#550 — a tag with no identity row AND no notes carrying it
|
|
2058
|
+
// isn't a legitimate (if empty) tag; it's a typo or a tag from a
|
|
2059
|
+
// different vault. Return a structured 404 instead of synthesizing
|
|
2060
|
+
// an all-null 200. `did_you_mean` candidates are restricted to the
|
|
2061
|
+
// caller's allowlist when scoped (defense in depth — this branch is
|
|
2062
|
+
// normally unreachable for a scoped session per the early-return
|
|
2063
|
+
// above, but stays scope-safe if that ever changes).
|
|
2064
|
+
if (!found && !record) {
|
|
2065
|
+
const candidates = tagScope.allowed
|
|
2066
|
+
? allTags.filter((t) => tagScope.allowed!.has(t.name)).map((t) => t.name)
|
|
2067
|
+
: allTags.map((t) => t.name);
|
|
2068
|
+
const suggestion = suggestSimilarTag(candidates, singleTag);
|
|
2069
|
+
return json(
|
|
2070
|
+
{
|
|
2071
|
+
error: "Tag not found",
|
|
2072
|
+
error_type: "tag_not_found",
|
|
2073
|
+
tag: singleTag,
|
|
2074
|
+
...(suggestion ? { did_you_mean: suggestion } : {}),
|
|
2075
|
+
},
|
|
2076
|
+
404,
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
1884
2079
|
return json({
|
|
1885
2080
|
name: singleTag,
|
|
1886
2081
|
count: found?.count ?? 0,
|
|
2082
|
+
expanded_count: found?.expanded_count ?? 0,
|
|
1887
2083
|
description: record?.description ?? null,
|
|
1888
2084
|
fields: record?.fields ?? null,
|
|
1889
2085
|
relationships: record?.relationships ?? null,
|
|
@@ -2069,15 +2265,36 @@ export async function handleTags(
|
|
|
2069
2265
|
|
|
2070
2266
|
// GET /tags/:name — single tag detail (full record)
|
|
2071
2267
|
if (req.method === "GET") {
|
|
2268
|
+
// Same "no leak" early-return as the `?tag=` form above — a
|
|
2269
|
+
// scope-excluded name 404s before `did_you_mean` (vault-wide) can run.
|
|
2072
2270
|
if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
|
|
2073
|
-
return json({ error: "Tag not found", tag: tagName }, 404);
|
|
2271
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: tagName }, 404);
|
|
2074
2272
|
}
|
|
2075
2273
|
const allTags = await store.listTags();
|
|
2076
2274
|
const found = allTags.find((t) => t.name === tagName);
|
|
2077
2275
|
const record = await store.getTagRecord(tagName);
|
|
2276
|
+
// vault#550 — see the `?tag=` form above for the rationale (no
|
|
2277
|
+
// identity row + no memberships = not a real tag, not a legitimate
|
|
2278
|
+
// empty one).
|
|
2279
|
+
if (!found && !record) {
|
|
2280
|
+
const candidates = tagScope.allowed
|
|
2281
|
+
? allTags.filter((t) => tagScope.allowed!.has(t.name)).map((t) => t.name)
|
|
2282
|
+
: allTags.map((t) => t.name);
|
|
2283
|
+
const suggestion = suggestSimilarTag(candidates, tagName);
|
|
2284
|
+
return json(
|
|
2285
|
+
{
|
|
2286
|
+
error: "Tag not found",
|
|
2287
|
+
error_type: "tag_not_found",
|
|
2288
|
+
tag: tagName,
|
|
2289
|
+
...(suggestion ? { did_you_mean: suggestion } : {}),
|
|
2290
|
+
},
|
|
2291
|
+
404,
|
|
2292
|
+
);
|
|
2293
|
+
}
|
|
2078
2294
|
return json({
|
|
2079
2295
|
name: tagName,
|
|
2080
2296
|
count: found?.count ?? 0,
|
|
2297
|
+
expanded_count: found?.expanded_count ?? 0,
|
|
2081
2298
|
description: record?.description ?? null,
|
|
2082
2299
|
fields: record?.fields ?? null,
|
|
2083
2300
|
relationships: record?.relationships ?? null,
|