@openparachute/vault 0.7.0-rc.1 → 0.7.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/cursor.ts +27 -9
- package/core/src/links.ts +63 -3
- package/core/src/mcp.ts +155 -23
- package/core/src/notes.ts +226 -37
- package/core/src/query-operators.ts +18 -1
- package/core/src/query-warnings.ts +219 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +11 -3
- package/core/src/tag-hierarchy.ts +113 -0
- package/core/src/types.ts +32 -5
- package/package.json +1 -1
- package/src/contract-honest-queries.test.ts +350 -38
- package/src/contract-search.test.ts +263 -31
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +4 -1
- package/src/mcp-http.ts +32 -0
- package/src/mcp-list-tags-scope.test.ts +154 -0
- package/src/mcp-tools.ts +49 -8
- package/src/routes.ts +327 -23
- package/src/ws-subscribe.test.ts +14 -0
- package/web/ui/dist/assets/{index-TJ-XN4OZ.js → index-B8pGeQam.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/src/routes.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
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, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "../core/src/query-warnings.ts";
|
|
17
|
+
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
|
|
16
18
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
17
19
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
18
20
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
@@ -128,6 +130,56 @@ function json(data: unknown, status = 200): Response {
|
|
|
128
130
|
return Response.json(data, { status });
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/**
|
|
134
|
+
* `json(...)` plus the `X-Parachute-Warnings` response header (vault#550) —
|
|
135
|
+
* `encodeURIComponent(JSON.stringify(warnings))`, set only when `warnings`
|
|
136
|
+
* is non-empty. Percent-encoded because HTTP header VALUES are
|
|
137
|
+
* Latin1/ASCII-only (the Fetch spec's ByteString requirement) while warning
|
|
138
|
+
* `message` text uses this codebase's normal house style (em dashes,
|
|
139
|
+
* curly-free but occasionally non-ASCII punctuation) — a raw
|
|
140
|
+
* `JSON.stringify` throws `TypeError: invalid header value` the first time
|
|
141
|
+
* a message isn't 7-bit-clean. Decode with `decodeURIComponent` then
|
|
142
|
+
* `JSON.parse`. Exists so bare-array responses (`GET /notes` without a
|
|
143
|
+
* cursor) can carry warnings WITHOUT changing their wire shape — a code
|
|
144
|
+
* consumer doing `for (const note of await res.json())` keeps working
|
|
145
|
+
* unmodified, while a consumer that cares can read the header. Envelope
|
|
146
|
+
* shapes (`{notes, next_cursor}`, `{nodes, edges}`) get the header too for
|
|
147
|
+
* uniform discoverability; callers that want warnings INLINE in an
|
|
148
|
+
* envelope should also spread them into the response body before calling
|
|
149
|
+
* this (see the cursor-envelope call sites in `handleNotesInner`).
|
|
150
|
+
*/
|
|
151
|
+
function jsonWithWarnings(data: unknown, warnings: QueryWarning[], status = 200): Response {
|
|
152
|
+
const res = json(data, status);
|
|
153
|
+
if (warnings.length > 0) {
|
|
154
|
+
res.headers.set("X-Parachute-Warnings", encodeURIComponent(JSON.stringify(warnings)));
|
|
155
|
+
}
|
|
156
|
+
return res;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* REST-only `removed_param` warning (vault#550) — the flat `date_field` /
|
|
161
|
+
* `date_from` / `date_to` query-string params were removed at 0.6.4
|
|
162
|
+
* (vault#288) and have been silently ignored ever since (routes.ts:633-ish
|
|
163
|
+
* comment). A request that passes any of them now gets a warning naming
|
|
164
|
+
* the ignored param and the bracket-style replacement, instead of quietly
|
|
165
|
+
* coming back unfiltered. One warning per param present (a caller passing
|
|
166
|
+
* all three gets three entries — precise, not lossy-summarized).
|
|
167
|
+
*/
|
|
168
|
+
function collectRemovedParamWarnings(url: URL): QueryWarning[] {
|
|
169
|
+
const REMOVED_DATE_PARAMS = ["date_field", "date_from", "date_to"] as const;
|
|
170
|
+
const warnings: QueryWarning[] = [];
|
|
171
|
+
for (const param of REMOVED_DATE_PARAMS) {
|
|
172
|
+
if (url.searchParams.has(param)) {
|
|
173
|
+
warnings.push({
|
|
174
|
+
code: "removed_param",
|
|
175
|
+
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][...]\`).`,
|
|
176
|
+
param,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return warnings;
|
|
181
|
+
}
|
|
182
|
+
|
|
131
183
|
function parseBool(val: string | null, defaultVal: boolean): boolean {
|
|
132
184
|
if (val === null) return defaultVal;
|
|
133
185
|
return val === "true" || val === "1";
|
|
@@ -221,6 +273,59 @@ function parseInt10(val: string | null): number | undefined {
|
|
|
221
273
|
return isNaN(n) ? undefined : n;
|
|
222
274
|
}
|
|
223
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Parse + validate `?limit=` / `?offset=` for the structured-query path
|
|
278
|
+
* (vault#550). Before this, `parseInt10(...) ?? 50` silently swallowed a
|
|
279
|
+
* non-numeric value into the default (a caller's typo `?limit=fifty` just
|
|
280
|
+
* became `limit=50` with no signal), and any negative value that DID parse
|
|
281
|
+
* flowed straight through to SQLite, whose `LIMIT -1` means "no limit" —
|
|
282
|
+
* silently returning EVERYTHING instead of erroring. Returns `{value}`
|
|
283
|
+
* (undefined when the param is absent — the caller applies its own
|
|
284
|
+
* default) or `{error}` (400 `invalid_query`) when present-but-malformed.
|
|
285
|
+
* `got` echoes the ORIGINAL raw string so the error is legible even though
|
|
286
|
+
* the coerced value (e.g. `NaN`) wouldn't be.
|
|
287
|
+
*/
|
|
288
|
+
function parseNonNegativeIntParam(
|
|
289
|
+
url: URL,
|
|
290
|
+
key: "limit" | "offset",
|
|
291
|
+
): { value?: number; error?: Response } {
|
|
292
|
+
const raw = parseQuery(url, key);
|
|
293
|
+
if (raw === null || raw === "") return {};
|
|
294
|
+
const trimmed = raw.trim();
|
|
295
|
+
if (!/^-?\d+$/.test(trimmed)) {
|
|
296
|
+
return {
|
|
297
|
+
error: json(
|
|
298
|
+
{
|
|
299
|
+
error: `\`${key}\` must be an integer, got ${JSON.stringify(raw)}`,
|
|
300
|
+
error_type: "invalid_query",
|
|
301
|
+
code: "INVALID_QUERY",
|
|
302
|
+
field: key,
|
|
303
|
+
got: raw,
|
|
304
|
+
hint: `pass a non-negative integer (e.g. ?${key}=${key === "limit" ? "50" : "0"}), or omit for the default`,
|
|
305
|
+
},
|
|
306
|
+
400,
|
|
307
|
+
),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const n = parseInt(trimmed, 10);
|
|
311
|
+
if (n < 0) {
|
|
312
|
+
return {
|
|
313
|
+
error: json(
|
|
314
|
+
{
|
|
315
|
+
error: `\`${key}\` must be zero or greater, got ${n}${key === "limit" ? " — a negative limit used to silently mean \"unlimited\" (SQLite semantics leaking through)" : ""}`,
|
|
316
|
+
error_type: "invalid_query",
|
|
317
|
+
code: "INVALID_QUERY",
|
|
318
|
+
field: key,
|
|
319
|
+
got: raw,
|
|
320
|
+
hint: "pass a non-negative integer, or omit for the default",
|
|
321
|
+
},
|
|
322
|
+
400,
|
|
323
|
+
),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return { value: n };
|
|
327
|
+
}
|
|
328
|
+
|
|
224
329
|
/**
|
|
225
330
|
* Parse bracket-style metadata filters (vault#285 friction point 1.3).
|
|
226
331
|
*
|
|
@@ -556,6 +661,39 @@ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Re
|
|
|
556
661
|
return { expand: expandParam as TagExpandMode };
|
|
557
662
|
}
|
|
558
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Parse + validate `?search_mode=` (vault#551) — how `search` text is
|
|
666
|
+
* turned into an FTS5 query. "literal" (default, omit or pass explicitly):
|
|
667
|
+
* escape + phrase-quote so punctuation is literal content, not FTS5
|
|
668
|
+
* syntax. "advanced": raw FTS5 passthrough (pre-#551 behavior) for
|
|
669
|
+
* boolean/phrase/prefix operators. Same validation shape as
|
|
670
|
+
* `parseExpandParam` — loud 400 on an unknown value rather than a silent
|
|
671
|
+
* fallback to the default.
|
|
672
|
+
*
|
|
673
|
+
* Returns `{ mode }` (undefined when absent/empty → search branch defaults
|
|
674
|
+
* to "literal") or `{ error }` (a 400 Response) on an unknown value.
|
|
675
|
+
*/
|
|
676
|
+
function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response } {
|
|
677
|
+
const raw = parseQuery(url, "search_mode");
|
|
678
|
+
if (raw === null || raw === "") return {};
|
|
679
|
+
if (!isValidSearchMode(raw)) {
|
|
680
|
+
return {
|
|
681
|
+
error: json(
|
|
682
|
+
{
|
|
683
|
+
error: `invalid \`search_mode\` value "${raw}" — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
|
|
684
|
+
code: "INVALID_QUERY",
|
|
685
|
+
error_type: "invalid_query",
|
|
686
|
+
field: "search_mode",
|
|
687
|
+
got: raw,
|
|
688
|
+
hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
|
|
689
|
+
},
|
|
690
|
+
400,
|
|
691
|
+
),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return { mode: raw };
|
|
695
|
+
}
|
|
696
|
+
|
|
559
697
|
/**
|
|
560
698
|
* Parse the shared notes-query parameters (tags / excludeTags / path /
|
|
561
699
|
* pathPrefix / extension / metadata filters / date filters / sort / paging /
|
|
@@ -612,6 +750,12 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
612
750
|
if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
|
|
613
751
|
const expand = expandParsed.expand;
|
|
614
752
|
|
|
753
|
+
// limit/offset validation (vault#550) — see parseNonNegativeIntParam.
|
|
754
|
+
const limitParsed = parseNonNegativeIntParam(url, "limit");
|
|
755
|
+
if (limitParsed.error) return { hasSearch, hasNear, hasCursor, error: limitParsed.error };
|
|
756
|
+
const offsetParsed = parseNonNegativeIntParam(url, "offset");
|
|
757
|
+
if (offsetParsed.error) return { hasSearch, hasNear, hasCursor, error: offsetParsed.error };
|
|
758
|
+
|
|
615
759
|
const queryOpts: QueryOpts = {
|
|
616
760
|
tags,
|
|
617
761
|
tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
@@ -639,8 +783,8 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
639
783
|
...(bracket.dateFilter ? { dateFilter: bracket.dateFilter } : {}),
|
|
640
784
|
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
641
785
|
orderBy: parseQuery(url, "order_by") ?? undefined,
|
|
642
|
-
limit:
|
|
643
|
-
offset:
|
|
786
|
+
limit: limitParsed.value ?? 50,
|
|
787
|
+
offset: offsetParsed.value,
|
|
644
788
|
cursor: cursorParam ?? undefined,
|
|
645
789
|
};
|
|
646
790
|
|
|
@@ -839,8 +983,11 @@ async function handleNotesInner(
|
|
|
839
983
|
// FTS owns its own ordering (relevance, not updated_at), so a cursor
|
|
840
984
|
// would skip rows. MCP rejects this combo at `core/src/mcp.ts`; REST
|
|
841
985
|
// would otherwise route into the `if (search)` branch below and
|
|
842
|
-
// silently drop the cursor. Reject here for surface parity.
|
|
843
|
-
|
|
986
|
+
// silently drop the cursor. Reject here for surface parity. Presence
|
|
987
|
+
// check (`!== null`), not truthiness (vault#550) — a bootstrap
|
|
988
|
+
// `?cursor=` attempt against a search query is still cursor intent
|
|
989
|
+
// and must still be rejected, not silently treated as "no cursor."
|
|
990
|
+
if (search && parseQuery(url, "cursor") !== null) {
|
|
844
991
|
return json(
|
|
845
992
|
{
|
|
846
993
|
error: "cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.",
|
|
@@ -860,11 +1007,54 @@ async function handleNotesInner(
|
|
|
860
1007
|
// value. The validated mode is threaded into the search tag-narrowing.
|
|
861
1008
|
const tagExpand = parseExpandParam(url);
|
|
862
1009
|
if (tagExpand.error) return tagExpand.error;
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1010
|
+
// `search_mode` (vault#551) — same loud-validation policy as
|
|
1011
|
+
// `expand` above; also bypasses `parseNotesQueryOpts` so it needs
|
|
1012
|
+
// its own check here.
|
|
1013
|
+
const searchModeParsed = parseSearchModeParam(url);
|
|
1014
|
+
if (searchModeParsed.error) return searchModeParsed.error;
|
|
1015
|
+
const mode: SearchMode = searchModeParsed.mode ?? "literal";
|
|
1016
|
+
// `sort` under search (vault#551 item 3): omit for FTS5 relevance
|
|
1017
|
+
// (default, unchanged); explicit asc/desc switches to created_at.
|
|
1018
|
+
const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
|
|
1019
|
+
|
|
1020
|
+
const searchWarnings: QueryWarning[] = [];
|
|
1021
|
+
let rawResults: Note[];
|
|
1022
|
+
// "Only whitespace/quotes" (vault#551 edge case) — short-circuit
|
|
1023
|
+
// BEFORE ever calling FTS5 rather than risk a syntax error (or a
|
|
1024
|
+
// meaningless always-empty query) on a degenerate escaped phrase.
|
|
1025
|
+
if (mode === "literal" && buildLiteralSearchQuery(search).isEmpty) {
|
|
1026
|
+
rawResults = [];
|
|
1027
|
+
searchWarnings.push(emptySearchWarning());
|
|
1028
|
+
} else {
|
|
1029
|
+
try {
|
|
1030
|
+
// A malformed `search_mode: "advanced"` query throws here
|
|
1031
|
+
// (structured `invalid_search_syntax`, vault#551) instead of
|
|
1032
|
+
// the pre-#551 silent `[]` — caught below and formatted the
|
|
1033
|
+
// same way the structured-query path formats a `QueryError`.
|
|
1034
|
+
rawResults = await store.searchNotes(search, {
|
|
1035
|
+
tags: searchTags,
|
|
1036
|
+
limit,
|
|
1037
|
+
expand: tagExpand.expand,
|
|
1038
|
+
mode,
|
|
1039
|
+
sort,
|
|
1040
|
+
});
|
|
1041
|
+
} catch (e: any) {
|
|
1042
|
+
if (e && e.name === "QueryError") {
|
|
1043
|
+
return json(
|
|
1044
|
+
{
|
|
1045
|
+
error: e.message,
|
|
1046
|
+
code: e.code ?? "INVALID_QUERY",
|
|
1047
|
+
...(e.error_type !== undefined ? { error_type: e.error_type } : {}),
|
|
1048
|
+
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1049
|
+
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1050
|
+
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
1051
|
+
},
|
|
1052
|
+
400,
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
throw e;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
868
1058
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
869
1059
|
// happens after the store query so an empty post-filter list still
|
|
870
1060
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
@@ -902,7 +1092,7 @@ async function handleNotesInner(
|
|
|
902
1092
|
);
|
|
903
1093
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
904
1094
|
}
|
|
905
|
-
return
|
|
1095
|
+
return jsonWithWarnings(output, searchWarnings);
|
|
906
1096
|
}
|
|
907
1097
|
|
|
908
1098
|
// Structured query
|
|
@@ -934,11 +1124,22 @@ async function handleNotesInner(
|
|
|
934
1124
|
if (parsed.error) return parsed.error;
|
|
935
1125
|
const queryOpts = parsed.queryOpts!;
|
|
936
1126
|
const cursorParam = parseQuery(url, "cursor");
|
|
1127
|
+
// Cursor mode is keyed on PRESENCE (`!== null`), not truthiness
|
|
1128
|
+
// (vault#550 bootstrap fix). `?cursor=` (present, empty) is the
|
|
1129
|
+
// bootstrap call — "I want to paginate, no watermark yet" — and must
|
|
1130
|
+
// still engage the `{notes, next_cursor}` envelope. Before this fix
|
|
1131
|
+
// `if (cursorParam)` treated an empty string exactly like an absent
|
|
1132
|
+
// param, so the FIRST call could never obtain a cursor even though
|
|
1133
|
+
// `core/src/mcp.ts` documented that flow — the cursor bootstrap gap
|
|
1134
|
+
// this wave closes. Omitting `cursor` entirely (`cursorParam ===
|
|
1135
|
+
// null`) is the only way to opt OUT — that keeps today's bare-array
|
|
1136
|
+
// compat shape.
|
|
1137
|
+
const cursorMode = cursorParam !== null;
|
|
937
1138
|
// Opaque cursor for "since last checked" agent loops (vault#313) is
|
|
938
1139
|
// mutually exclusive with the `near` graph-neighborhood scope (rebuilding
|
|
939
1140
|
// the neighborhood per page isn't stable).
|
|
940
1141
|
const nearNoteIdEarly = parseQuery(url, "near[note_id]");
|
|
941
|
-
if (
|
|
1142
|
+
if (cursorMode && nearNoteIdEarly) {
|
|
942
1143
|
return json(
|
|
943
1144
|
{
|
|
944
1145
|
error: "cursor is incompatible with near (graph neighborhood). Resolve the neighborhood first, then iterate with cursor over the resulting note set.",
|
|
@@ -947,10 +1148,35 @@ async function handleNotesInner(
|
|
|
947
1148
|
400,
|
|
948
1149
|
);
|
|
949
1150
|
}
|
|
1151
|
+
// Warnings channel (vault#550). `unknown_tag` stays structured-query
|
|
1152
|
+
// only (skipped entirely for a tag-scoped session:
|
|
1153
|
+
// `collectUnknownTagWarnings` resolves `did_you_mean` against the
|
|
1154
|
+
// FULL vault-wide tag catalog, which would leak an out-of-scope tag's
|
|
1155
|
+
// existence/name across the scope boundary — the same "no leak"
|
|
1156
|
+
// stance this codebase takes everywhere else
|
|
1157
|
+
// (docs/contracts/tag-scoped-tokens.md)). `removed_param` warnings
|
|
1158
|
+
// carry no tag information and fire regardless of scope. `search_mode`
|
|
1159
|
+
// without `search` (vault#551) also carries no tag information — this
|
|
1160
|
+
// IS the structured-query path precisely because `search` was absent
|
|
1161
|
+
// or empty, so a `search_mode` param here is always a no-op.
|
|
1162
|
+
const queryWarnings: QueryWarning[] = [
|
|
1163
|
+
...collectRemovedParamWarnings(url),
|
|
1164
|
+
...(parseQuery(url, "search_mode")
|
|
1165
|
+
? [
|
|
1166
|
+
ignoredParamWarning(
|
|
1167
|
+
"search_mode",
|
|
1168
|
+
"no `search` was provided — search_mode only affects full-text search query parsing",
|
|
1169
|
+
),
|
|
1170
|
+
]
|
|
1171
|
+
: []),
|
|
1172
|
+
...(tagScope.allowed === null
|
|
1173
|
+
? collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy())
|
|
1174
|
+
: []),
|
|
1175
|
+
];
|
|
950
1176
|
let results: Note[];
|
|
951
1177
|
let nextCursor: string | null = null;
|
|
952
1178
|
try {
|
|
953
|
-
if (
|
|
1179
|
+
if (cursorMode) {
|
|
954
1180
|
const page = await store.queryNotesPaged(queryOpts);
|
|
955
1181
|
results = page.notes;
|
|
956
1182
|
nextCursor = page.next_cursor;
|
|
@@ -960,9 +1186,23 @@ async function handleNotesInner(
|
|
|
960
1186
|
} catch (e: any) {
|
|
961
1187
|
// QueryError (non-indexed order_by, unknown operator, ...) surfaces
|
|
962
1188
|
// here. Duck-type on `name` + `code` — core is a separate module, so
|
|
963
|
-
// `instanceof` is fragile across bundling boundaries.
|
|
1189
|
+
// `instanceof` is fragile across bundling boundaries. The newer
|
|
1190
|
+
// vault#550 call sites (limit/offset/date validation in
|
|
1191
|
+
// core/src/notes.ts) additionally set `error_type`/`field`/`got`/
|
|
1192
|
+
// `hint` — merged in when present; long-standing QueryError throws
|
|
1193
|
+
// that don't set them keep their existing `{error, code}` shape.
|
|
964
1194
|
if (e && e.name === "QueryError") {
|
|
965
|
-
return json(
|
|
1195
|
+
return json(
|
|
1196
|
+
{
|
|
1197
|
+
error: e.message,
|
|
1198
|
+
code: e.code ?? "INVALID_QUERY",
|
|
1199
|
+
...(e.error_type !== undefined ? { error_type: e.error_type } : {}),
|
|
1200
|
+
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1201
|
+
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1202
|
+
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
1203
|
+
},
|
|
1204
|
+
400,
|
|
1205
|
+
);
|
|
966
1206
|
}
|
|
967
1207
|
// CursorError carries a structured code (cursor_invalid /
|
|
968
1208
|
// cursor_query_mismatch) so the agent loop can distinguish a
|
|
@@ -1072,7 +1312,12 @@ async function handleNotesInner(
|
|
|
1072
1312
|
}
|
|
1073
1313
|
}
|
|
1074
1314
|
}
|
|
1075
|
-
|
|
1315
|
+
// Envelope shape → warnings inline (same policy as the cursor
|
|
1316
|
+
// envelope below), plus the header via jsonWithWarnings.
|
|
1317
|
+
return jsonWithWarnings(
|
|
1318
|
+
{ nodes, edges, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
|
|
1319
|
+
queryWarnings,
|
|
1320
|
+
);
|
|
1076
1321
|
}
|
|
1077
1322
|
|
|
1078
1323
|
if (includeLinks || includeAttachments) {
|
|
@@ -1099,12 +1344,24 @@ async function handleNotesInner(
|
|
|
1099
1344
|
// Cursor mode wraps the list in {notes, next_cursor} so an agent
|
|
1100
1345
|
// loop can chain calls without tracking a watermark client-side.
|
|
1101
1346
|
// Legacy callers (no `cursor` param) still get the flat array.
|
|
1102
|
-
|
|
1103
|
-
|
|
1347
|
+
// Warnings (vault#550): inline on the envelope, header either way
|
|
1348
|
+
// (see jsonWithWarnings).
|
|
1349
|
+
if (cursorMode) {
|
|
1350
|
+
return jsonWithWarnings(
|
|
1351
|
+
{ notes: enrichedOut, next_cursor: nextCursor, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
|
|
1352
|
+
queryWarnings,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
return jsonWithWarnings(enrichedOut, queryWarnings);
|
|
1104
1356
|
}
|
|
1105
1357
|
|
|
1106
|
-
if (
|
|
1107
|
-
|
|
1358
|
+
if (cursorMode) {
|
|
1359
|
+
return jsonWithWarnings(
|
|
1360
|
+
{ notes: output, next_cursor: nextCursor, ...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}) },
|
|
1361
|
+
queryWarnings,
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
return jsonWithWarnings(output, queryWarnings);
|
|
1108
1365
|
}
|
|
1109
1366
|
|
|
1110
1367
|
// POST /notes — create (single or batch)
|
|
@@ -1874,16 +2131,42 @@ export async function handleTags(
|
|
|
1874
2131
|
if (singleTag) {
|
|
1875
2132
|
// Tag-scope: a tag-scoped token can only see tags reachable from its
|
|
1876
2133
|
// allowlist (root + descendants per the parent_names hierarchy).
|
|
1877
|
-
// Anything else 404s — same "no leak" stance as note reads.
|
|
2134
|
+
// Anything else 404s — same "no leak" stance as note reads. This
|
|
2135
|
+
// early-return means the vault#550 unknown-tag 404 below never even
|
|
2136
|
+
// runs for a scope-excluded name — `did_you_mean` (which searches
|
|
2137
|
+
// vault-wide) can't fire and leak an out-of-scope tag's existence.
|
|
1878
2138
|
if (tagScope.allowed && !tagScope.allowed.has(singleTag)) {
|
|
1879
|
-
return json({ error: "Tag not found", tag: singleTag }, 404);
|
|
2139
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: singleTag }, 404);
|
|
1880
2140
|
}
|
|
1881
2141
|
const allTags = await store.listTags();
|
|
1882
2142
|
const found = allTags.find((t) => t.name === singleTag);
|
|
1883
2143
|
const record = await store.getTagRecord(singleTag);
|
|
2144
|
+
// vault#550 — a tag with no identity row AND no notes carrying it
|
|
2145
|
+
// isn't a legitimate (if empty) tag; it's a typo or a tag from a
|
|
2146
|
+
// different vault. Return a structured 404 instead of synthesizing
|
|
2147
|
+
// an all-null 200. `did_you_mean` candidates are restricted to the
|
|
2148
|
+
// caller's allowlist when scoped (defense in depth — this branch is
|
|
2149
|
+
// normally unreachable for a scoped session per the early-return
|
|
2150
|
+
// above, but stays scope-safe if that ever changes).
|
|
2151
|
+
if (!found && !record) {
|
|
2152
|
+
const candidates = tagScope.allowed
|
|
2153
|
+
? allTags.filter((t) => tagScope.allowed!.has(t.name)).map((t) => t.name)
|
|
2154
|
+
: allTags.map((t) => t.name);
|
|
2155
|
+
const suggestion = suggestSimilarTag(candidates, singleTag);
|
|
2156
|
+
return json(
|
|
2157
|
+
{
|
|
2158
|
+
error: "Tag not found",
|
|
2159
|
+
error_type: "tag_not_found",
|
|
2160
|
+
tag: singleTag,
|
|
2161
|
+
...(suggestion ? { did_you_mean: suggestion } : {}),
|
|
2162
|
+
},
|
|
2163
|
+
404,
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
1884
2166
|
return json({
|
|
1885
2167
|
name: singleTag,
|
|
1886
2168
|
count: found?.count ?? 0,
|
|
2169
|
+
expanded_count: found?.expanded_count ?? 0,
|
|
1887
2170
|
description: record?.description ?? null,
|
|
1888
2171
|
fields: record?.fields ?? null,
|
|
1889
2172
|
relationships: record?.relationships ?? null,
|
|
@@ -2069,15 +2352,36 @@ export async function handleTags(
|
|
|
2069
2352
|
|
|
2070
2353
|
// GET /tags/:name — single tag detail (full record)
|
|
2071
2354
|
if (req.method === "GET") {
|
|
2355
|
+
// Same "no leak" early-return as the `?tag=` form above — a
|
|
2356
|
+
// scope-excluded name 404s before `did_you_mean` (vault-wide) can run.
|
|
2072
2357
|
if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
|
|
2073
|
-
return json({ error: "Tag not found", tag: tagName }, 404);
|
|
2358
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: tagName }, 404);
|
|
2074
2359
|
}
|
|
2075
2360
|
const allTags = await store.listTags();
|
|
2076
2361
|
const found = allTags.find((t) => t.name === tagName);
|
|
2077
2362
|
const record = await store.getTagRecord(tagName);
|
|
2363
|
+
// vault#550 — see the `?tag=` form above for the rationale (no
|
|
2364
|
+
// identity row + no memberships = not a real tag, not a legitimate
|
|
2365
|
+
// empty one).
|
|
2366
|
+
if (!found && !record) {
|
|
2367
|
+
const candidates = tagScope.allowed
|
|
2368
|
+
? allTags.filter((t) => tagScope.allowed!.has(t.name)).map((t) => t.name)
|
|
2369
|
+
: allTags.map((t) => t.name);
|
|
2370
|
+
const suggestion = suggestSimilarTag(candidates, tagName);
|
|
2371
|
+
return json(
|
|
2372
|
+
{
|
|
2373
|
+
error: "Tag not found",
|
|
2374
|
+
error_type: "tag_not_found",
|
|
2375
|
+
tag: tagName,
|
|
2376
|
+
...(suggestion ? { did_you_mean: suggestion } : {}),
|
|
2377
|
+
},
|
|
2378
|
+
404,
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2078
2381
|
return json({
|
|
2079
2382
|
name: tagName,
|
|
2080
2383
|
count: found?.count ?? 0,
|
|
2384
|
+
expanded_count: found?.expanded_count ?? 0,
|
|
2081
2385
|
description: record?.description ?? null,
|
|
2082
2386
|
fields: record?.fields ?? null,
|
|
2083
2387
|
relationships: record?.relationships ?? null,
|
package/src/ws-subscribe.test.ts
CHANGED
|
@@ -440,6 +440,20 @@ describe("WS live-query — bad query + cap", () => {
|
|
|
440
440
|
expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
+
// vault#551 — literal-by-default search + `search_mode` are a
|
|
444
|
+
// snapshot/REST-only concept (escaping happens in `core/src/notes.ts`
|
|
445
|
+
// `searchNotes`, which the live matcher never calls). `search` itself
|
|
446
|
+
// stays categorically unsupported for live subscriptions regardless of
|
|
447
|
+
// mode — confirms `search_mode` doesn't accidentally open a bypass.
|
|
448
|
+
it("rejects an unsupported query with search_mode present too — search_mode doesn't bypass the search rejection", async () => {
|
|
449
|
+
const { server } = makeServer();
|
|
450
|
+
const res = await fetch(`http://localhost:${server.port}/vault/${VAULT}/api/subscribe?search=hi&search_mode=advanced`, {
|
|
451
|
+
headers: { Upgrade: "websocket", Connection: "Upgrade", "Sec-WebSocket-Key": "x", "Sec-WebSocket-Version": "13" },
|
|
452
|
+
});
|
|
453
|
+
expect(res.status).toBe(400);
|
|
454
|
+
expect((await res.json()).code).toBe("UNSUPPORTED_SUBSCRIPTION_QUERY");
|
|
455
|
+
});
|
|
456
|
+
|
|
443
457
|
it("refuses over the per-vault cap (503) and self-heals when a socket closes", async () => {
|
|
444
458
|
const { binding } = makeServer({ maxSubscriptions: 2 });
|
|
445
459
|
const fakeWs = (): { data: SubscribeWsData; close(): void; send(): void } => ({
|