@openparachute/vault 0.7.0-rc.2 → 0.7.0-rc.4
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-typed-index.test.ts +92 -3
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +161 -61
- package/core/src/notes.ts +93 -9
- package/core/src/query-warnings.ts +35 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +2 -1
- package/core/src/tag-schemas.ts +186 -4
- package/core/src/types.ts +13 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/contract-search.test.ts +263 -31
- package/src/mcp-http.ts +122 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +48 -4
- package/src/routes.ts +378 -93
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-scope.ts +66 -0
- package/src/ws-subscribe.test.ts +14 -0
package/src/routes.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
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
|
+
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";
|
|
17
18
|
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
18
19
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
19
20
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
@@ -40,6 +41,8 @@ import {
|
|
|
40
41
|
filterHydratedLinksByTagScope,
|
|
41
42
|
filterNotesByTagScope,
|
|
42
43
|
noteWithinTagScope,
|
|
44
|
+
scrubIndexedFieldConflictError,
|
|
45
|
+
scrubTagFieldViolationsByScope,
|
|
43
46
|
tagScopeForbidden,
|
|
44
47
|
tagsWithinScope,
|
|
45
48
|
} from "./tag-scope.ts";
|
|
@@ -241,7 +244,13 @@ function parseContentRangeQuery(
|
|
|
241
244
|
// is fragile across bundling boundaries (same note as the QueryError
|
|
242
245
|
// handling in the structured-query path below).
|
|
243
246
|
if (e && e.name === "QueryError") {
|
|
244
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
range: null,
|
|
249
|
+
error: json(
|
|
250
|
+
{ error: e.message, code: e.code ?? "INVALID_QUERY", error_type: e.error_type ?? "invalid_query" },
|
|
251
|
+
400,
|
|
252
|
+
),
|
|
253
|
+
};
|
|
245
254
|
}
|
|
246
255
|
throw e;
|
|
247
256
|
}
|
|
@@ -401,6 +410,9 @@ function parseMetaBrackets(url: URL): {
|
|
|
401
410
|
{
|
|
402
411
|
error: `bracket-meta filter: cannot mix shorthand and operator forms for the same field — \`meta[${field}]=…\` and \`meta[${field}][<op>]=…\` are mutually exclusive in one request. Pick one form.`,
|
|
403
412
|
code: "INVALID_QUERY",
|
|
413
|
+
error_type: "invalid_query",
|
|
414
|
+
field,
|
|
415
|
+
hint: `pick either \`meta[${field}]=value\` or \`meta[${field}][<op>]=value\`, not both`,
|
|
404
416
|
},
|
|
405
417
|
400,
|
|
406
418
|
);
|
|
@@ -430,6 +442,9 @@ function parseMetaBrackets(url: URL): {
|
|
|
430
442
|
{
|
|
431
443
|
error: `bracket-date filter on \`${field}\` requires an operator: meta[${field}][gte]=… (lower bound) or meta[${field}][lt]=… (upper bound, exclusive).`,
|
|
432
444
|
code: "INVALID_QUERY",
|
|
445
|
+
error_type: "invalid_query",
|
|
446
|
+
field,
|
|
447
|
+
hint: `pass meta[${field}][gte]=… and/or meta[${field}][lt]=…`,
|
|
433
448
|
},
|
|
434
449
|
400,
|
|
435
450
|
),
|
|
@@ -441,6 +456,10 @@ function parseMetaBrackets(url: URL): {
|
|
|
441
456
|
{
|
|
442
457
|
error: `bracket-date filter on \`${field}\` supports only \`gte\` (inclusive lower bound) and \`lt\` (exclusive upper bound). Got: \`${op}\`. The dateFilter contract is half-open by design.`,
|
|
443
458
|
code: "INVALID_QUERY",
|
|
459
|
+
error_type: "invalid_query",
|
|
460
|
+
field,
|
|
461
|
+
got: op,
|
|
462
|
+
hint: `use \`gte\` or \`lt\` — meta[${field}][gte]=… / meta[${field}][lt]=…`,
|
|
444
463
|
},
|
|
445
464
|
400,
|
|
446
465
|
),
|
|
@@ -455,6 +474,9 @@ function parseMetaBrackets(url: URL): {
|
|
|
455
474
|
{
|
|
456
475
|
error: `bracket-date filter cannot span both \`created_at\` and \`updated_at\` in one request — issue two queries or use one column per request.`,
|
|
457
476
|
code: "INVALID_QUERY",
|
|
477
|
+
error_type: "invalid_query",
|
|
478
|
+
field,
|
|
479
|
+
hint: "issue two queries, or filter one date column per request",
|
|
458
480
|
},
|
|
459
481
|
400,
|
|
460
482
|
),
|
|
@@ -495,6 +517,10 @@ function parseMetaBrackets(url: URL): {
|
|
|
495
517
|
{
|
|
496
518
|
error: `bracket-meta filter: array form \`meta[${field}][${op}][]=…\` is only valid for \`in\` and \`not_in\`. \`${op}\` takes a single value — use \`meta[${field}][${op}]=value\` instead.`,
|
|
497
519
|
code: "INVALID_OPERATOR_VALUE",
|
|
520
|
+
error_type: "invalid_query",
|
|
521
|
+
field,
|
|
522
|
+
got: op,
|
|
523
|
+
hint: `use \`meta[${field}][${op}]=value\` (no \`[]\`) — array form is only for \`in\`/\`not_in\``,
|
|
498
524
|
},
|
|
499
525
|
400,
|
|
500
526
|
),
|
|
@@ -535,6 +561,10 @@ function parseMetaBrackets(url: URL): {
|
|
|
535
561
|
{
|
|
536
562
|
error: `bracket-meta filter: \`exists\` on \`${field}\` requires "true" or "false", got "${value}"`,
|
|
537
563
|
code: "INVALID_OPERATOR_VALUE",
|
|
564
|
+
error_type: "invalid_query",
|
|
565
|
+
field,
|
|
566
|
+
got: value,
|
|
567
|
+
hint: `pass meta[${field}][exists]=true or meta[${field}][exists]=false`,
|
|
538
568
|
},
|
|
539
569
|
400,
|
|
540
570
|
),
|
|
@@ -613,6 +643,9 @@ function parseMetadataJsonAlias(url: URL): {
|
|
|
613
643
|
{
|
|
614
644
|
error: `metadata query param must be a JSON object of the form {"field":{"op":value}} — ${detail}`,
|
|
615
645
|
code: "INVALID_QUERY",
|
|
646
|
+
error_type: "invalid_query",
|
|
647
|
+
field: "metadata",
|
|
648
|
+
hint: 'pass a JSON object, e.g. ?metadata={"status":{"eq":"active"}}',
|
|
616
649
|
},
|
|
617
650
|
400,
|
|
618
651
|
);
|
|
@@ -652,6 +685,10 @@ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Re
|
|
|
652
685
|
{
|
|
653
686
|
error: `invalid \`expand\` value "${expandParam}" — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes": parent_names descendants).`,
|
|
654
687
|
code: "INVALID_QUERY",
|
|
688
|
+
error_type: "invalid_query",
|
|
689
|
+
field: "expand",
|
|
690
|
+
got: expandParam,
|
|
691
|
+
hint: `pass one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}, or omit for the default`,
|
|
655
692
|
},
|
|
656
693
|
400,
|
|
657
694
|
),
|
|
@@ -660,6 +697,39 @@ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Re
|
|
|
660
697
|
return { expand: expandParam as TagExpandMode };
|
|
661
698
|
}
|
|
662
699
|
|
|
700
|
+
/**
|
|
701
|
+
* Parse + validate `?search_mode=` (vault#551) — how `search` text is
|
|
702
|
+
* turned into an FTS5 query. "literal" (default, omit or pass explicitly):
|
|
703
|
+
* escape + phrase-quote so punctuation is literal content, not FTS5
|
|
704
|
+
* syntax. "advanced": raw FTS5 passthrough (pre-#551 behavior) for
|
|
705
|
+
* boolean/phrase/prefix operators. Same validation shape as
|
|
706
|
+
* `parseExpandParam` — loud 400 on an unknown value rather than a silent
|
|
707
|
+
* fallback to the default.
|
|
708
|
+
*
|
|
709
|
+
* Returns `{ mode }` (undefined when absent/empty → search branch defaults
|
|
710
|
+
* to "literal") or `{ error }` (a 400 Response) on an unknown value.
|
|
711
|
+
*/
|
|
712
|
+
function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response } {
|
|
713
|
+
const raw = parseQuery(url, "search_mode");
|
|
714
|
+
if (raw === null || raw === "") return {};
|
|
715
|
+
if (!isValidSearchMode(raw)) {
|
|
716
|
+
return {
|
|
717
|
+
error: json(
|
|
718
|
+
{
|
|
719
|
+
error: `invalid \`search_mode\` value "${raw}" — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
|
|
720
|
+
code: "INVALID_QUERY",
|
|
721
|
+
error_type: "invalid_query",
|
|
722
|
+
field: "search_mode",
|
|
723
|
+
got: raw,
|
|
724
|
+
hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
|
|
725
|
+
},
|
|
726
|
+
400,
|
|
727
|
+
),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
return { mode: raw };
|
|
731
|
+
}
|
|
732
|
+
|
|
663
733
|
/**
|
|
664
734
|
* Parse the shared notes-query parameters (tags / excludeTags / path /
|
|
665
735
|
* pathPrefix / extension / metadata filters / date filters / sort / paging /
|
|
@@ -702,6 +772,8 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
702
772
|
{
|
|
703
773
|
error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
|
|
704
774
|
code: "INVALID_QUERY",
|
|
775
|
+
error_type: "invalid_query",
|
|
776
|
+
hint: "pick one metadata-filter form: `metadata=` or `meta[field][op]=`, not both",
|
|
705
777
|
},
|
|
706
778
|
400,
|
|
707
779
|
),
|
|
@@ -903,12 +975,12 @@ async function handleNotesInner(
|
|
|
903
975
|
// Single note by id/path
|
|
904
976
|
if (id) {
|
|
905
977
|
const note = await resolveNote(store, id);
|
|
906
|
-
if (!note) return json({ error: "Note not found", id }, 404);
|
|
978
|
+
if (!note) return json({ error: "Note not found", error_type: "not_found", id }, 404);
|
|
907
979
|
// Tag-scope: a token can't see what its allowlist excludes. Surface
|
|
908
980
|
// as 404 (not 403) — the existence of the note is itself information
|
|
909
981
|
// we shouldn't leak across the scope boundary.
|
|
910
982
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
911
|
-
return json({ error: "Note not found", id }, 404);
|
|
983
|
+
return json({ error: "Note not found", error_type: "not_found", id }, 404);
|
|
912
984
|
}
|
|
913
985
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
914
986
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
@@ -958,6 +1030,9 @@ async function handleNotesInner(
|
|
|
958
1030
|
{
|
|
959
1031
|
error: "cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.",
|
|
960
1032
|
code: "INVALID_QUERY",
|
|
1033
|
+
error_type: "invalid_query",
|
|
1034
|
+
field: "cursor",
|
|
1035
|
+
hint: "drop `cursor` when using `search`, or drop `search` and use `meta[updated_at][gte]=…` for since-last-checked polling",
|
|
961
1036
|
},
|
|
962
1037
|
400,
|
|
963
1038
|
);
|
|
@@ -973,11 +1048,59 @@ async function handleNotesInner(
|
|
|
973
1048
|
// value. The validated mode is threaded into the search tag-narrowing.
|
|
974
1049
|
const tagExpand = parseExpandParam(url);
|
|
975
1050
|
if (tagExpand.error) return tagExpand.error;
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1051
|
+
// `search_mode` (vault#551) — same loud-validation policy as
|
|
1052
|
+
// `expand` above; also bypasses `parseNotesQueryOpts` so it needs
|
|
1053
|
+
// its own check here.
|
|
1054
|
+
const searchModeParsed = parseSearchModeParam(url);
|
|
1055
|
+
if (searchModeParsed.error) return searchModeParsed.error;
|
|
1056
|
+
const mode: SearchMode = searchModeParsed.mode ?? "literal";
|
|
1057
|
+
// `sort` under search (vault#551 item 3): omit for FTS5 relevance
|
|
1058
|
+
// (default, unchanged); explicit asc/desc switches to created_at.
|
|
1059
|
+
const sort = (parseQuery(url, "sort") as "asc" | "desc" | null) ?? undefined;
|
|
1060
|
+
|
|
1061
|
+
const searchWarnings: QueryWarning[] = [];
|
|
1062
|
+
let rawResults: Note[];
|
|
1063
|
+
// "Only whitespace/quotes" (vault#551 edge case) — short-circuit
|
|
1064
|
+
// BEFORE ever calling FTS5 rather than risk a syntax error (or a
|
|
1065
|
+
// meaningless always-empty query) on a degenerate escaped phrase.
|
|
1066
|
+
if (mode === "literal" && buildLiteralSearchQuery(search).isEmpty) {
|
|
1067
|
+
rawResults = [];
|
|
1068
|
+
searchWarnings.push(emptySearchWarning());
|
|
1069
|
+
} else {
|
|
1070
|
+
try {
|
|
1071
|
+
// A malformed `search_mode: "advanced"` query throws here
|
|
1072
|
+
// (structured `invalid_search_syntax`, vault#551) instead of
|
|
1073
|
+
// the pre-#551 silent `[]` — caught below and formatted the
|
|
1074
|
+
// same way the structured-query path formats a `QueryError`.
|
|
1075
|
+
rawResults = await store.searchNotes(search, {
|
|
1076
|
+
tags: searchTags,
|
|
1077
|
+
limit,
|
|
1078
|
+
expand: tagExpand.expand,
|
|
1079
|
+
mode,
|
|
1080
|
+
sort,
|
|
1081
|
+
});
|
|
1082
|
+
} catch (e: any) {
|
|
1083
|
+
if (e && e.name === "QueryError") {
|
|
1084
|
+
return json(
|
|
1085
|
+
{
|
|
1086
|
+
error: e.message,
|
|
1087
|
+
code: e.code ?? "INVALID_QUERY",
|
|
1088
|
+
// vault#554: default error_type to "invalid_query" for the
|
|
1089
|
+
// long-standing QueryError call sites that predate the
|
|
1090
|
+
// vault#550/#551 convention (FIELD_NOT_INDEXED,
|
|
1091
|
+
// UNKNOWN_OPERATOR, ...) — every QueryError now carries a
|
|
1092
|
+
// stable error_type, not just the newer ones that set it.
|
|
1093
|
+
error_type: e.error_type ?? "invalid_query",
|
|
1094
|
+
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1095
|
+
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1096
|
+
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
1097
|
+
},
|
|
1098
|
+
400,
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
throw e;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
981
1104
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
982
1105
|
// happens after the store query so an empty post-filter list still
|
|
983
1106
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
@@ -1015,7 +1138,7 @@ async function handleNotesInner(
|
|
|
1015
1138
|
);
|
|
1016
1139
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1017
1140
|
}
|
|
1018
|
-
return
|
|
1141
|
+
return jsonWithWarnings(output, searchWarnings);
|
|
1019
1142
|
}
|
|
1020
1143
|
|
|
1021
1144
|
// Structured query
|
|
@@ -1067,21 +1190,34 @@ async function handleNotesInner(
|
|
|
1067
1190
|
{
|
|
1068
1191
|
error: "cursor is incompatible with near (graph neighborhood). Resolve the neighborhood first, then iterate with cursor over the resulting note set.",
|
|
1069
1192
|
code: "INVALID_QUERY",
|
|
1193
|
+
error_type: "invalid_query",
|
|
1194
|
+
field: "cursor",
|
|
1195
|
+
hint: "resolve the `near` neighborhood first, then paginate the resulting note set with `cursor`",
|
|
1070
1196
|
},
|
|
1071
1197
|
400,
|
|
1072
1198
|
);
|
|
1073
1199
|
}
|
|
1074
|
-
// Warnings channel (vault#550)
|
|
1075
|
-
// (
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
// (docs/contracts/tag-scoped-tokens.md). `removed_param` warnings
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1200
|
+
// Warnings channel (vault#550). `unknown_tag` stays structured-query
|
|
1201
|
+
// only (skipped entirely for a tag-scoped session:
|
|
1202
|
+
// `collectUnknownTagWarnings` resolves `did_you_mean` against the
|
|
1203
|
+
// FULL vault-wide tag catalog, which would leak an out-of-scope tag's
|
|
1204
|
+
// existence/name across the scope boundary — the same "no leak"
|
|
1205
|
+
// stance this codebase takes everywhere else
|
|
1206
|
+
// (docs/contracts/tag-scoped-tokens.md)). `removed_param` warnings
|
|
1207
|
+
// carry no tag information and fire regardless of scope. `search_mode`
|
|
1208
|
+
// without `search` (vault#551) also carries no tag information — this
|
|
1209
|
+
// IS the structured-query path precisely because `search` was absent
|
|
1210
|
+
// or empty, so a `search_mode` param here is always a no-op.
|
|
1083
1211
|
const queryWarnings: QueryWarning[] = [
|
|
1084
1212
|
...collectRemovedParamWarnings(url),
|
|
1213
|
+
...(parseQuery(url, "search_mode")
|
|
1214
|
+
? [
|
|
1215
|
+
ignoredParamWarning(
|
|
1216
|
+
"search_mode",
|
|
1217
|
+
"no `search` was provided — search_mode only affects full-text search query parsing",
|
|
1218
|
+
),
|
|
1219
|
+
]
|
|
1220
|
+
: []),
|
|
1085
1221
|
...(tagScope.allowed === null
|
|
1086
1222
|
? collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy())
|
|
1087
1223
|
: []),
|
|
@@ -1101,15 +1237,16 @@ async function handleNotesInner(
|
|
|
1101
1237
|
// here. Duck-type on `name` + `code` — core is a separate module, so
|
|
1102
1238
|
// `instanceof` is fragile across bundling boundaries. The newer
|
|
1103
1239
|
// vault#550 call sites (limit/offset/date validation in
|
|
1104
|
-
// core/src/notes.ts) additionally set `
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1240
|
+
// core/src/notes.ts) additionally set `field`/`got`/`hint` — merged
|
|
1241
|
+
// in when present. vault#554: every QueryError now carries a stable
|
|
1242
|
+
// `error_type` (defaults to "invalid_query" for the long-standing
|
|
1243
|
+
// call sites that predate that convention), not just the newer ones.
|
|
1107
1244
|
if (e && e.name === "QueryError") {
|
|
1108
1245
|
return json(
|
|
1109
1246
|
{
|
|
1110
1247
|
error: e.message,
|
|
1111
1248
|
code: e.code ?? "INVALID_QUERY",
|
|
1112
|
-
|
|
1249
|
+
error_type: e.error_type ?? "invalid_query",
|
|
1113
1250
|
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1114
1251
|
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1115
1252
|
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
@@ -1121,9 +1258,11 @@ async function handleNotesInner(
|
|
|
1121
1258
|
// cursor_query_mismatch) so the agent loop can distinguish a
|
|
1122
1259
|
// malformed cursor from a hash-mismatch and react appropriately
|
|
1123
1260
|
// (the latter typically means the agent changed its filter and
|
|
1124
|
-
// should drop the cursor + restart from scratch).
|
|
1261
|
+
// should drop the cursor + restart from scratch). vault#554: `code`
|
|
1262
|
+
// IS the `error_type` vocabulary here — surfaced explicitly so a
|
|
1263
|
+
// caller can branch on `error_type` alone like every other error.
|
|
1125
1264
|
if (e && e.name === "CursorError") {
|
|
1126
|
-
return json({ error: e.message, code: e.code ?? "cursor_invalid" }, 400);
|
|
1265
|
+
return json({ error: e.message, code: e.code ?? "cursor_invalid", error_type: e.code ?? "cursor_invalid" }, 400);
|
|
1127
1266
|
}
|
|
1128
1267
|
throw e;
|
|
1129
1268
|
}
|
|
@@ -1132,10 +1271,10 @@ async function handleNotesInner(
|
|
|
1132
1271
|
const nearNoteId = parseQuery(url, "near[note_id]");
|
|
1133
1272
|
if (nearNoteId) {
|
|
1134
1273
|
const anchor = await resolveNote(store, nearNoteId);
|
|
1135
|
-
if (!anchor) return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
|
|
1274
|
+
if (!anchor) return json({ error: "Anchor note not found", error_type: "not_found", note_id: nearNoteId }, 404);
|
|
1136
1275
|
// Tag-scope: anchor must itself be visible to this token.
|
|
1137
1276
|
if (!noteWithinTagScope(anchor, tagScope.allowed, tagScope.raw)) {
|
|
1138
|
-
return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
|
|
1277
|
+
return json({ error: "Anchor note not found", error_type: "not_found", note_id: nearNoteId }, 404);
|
|
1139
1278
|
}
|
|
1140
1279
|
const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
|
|
1141
1280
|
const relationship = parseQuery(url, "near[relationship]") ?? undefined;
|
|
@@ -1362,14 +1501,26 @@ async function handleNotesInner(
|
|
|
1362
1501
|
// Duck-type for module-boundary robustness (matches the PATCH branch).
|
|
1363
1502
|
if (e && e.code === "PATH_CONFLICT") {
|
|
1364
1503
|
return json(
|
|
1365
|
-
{
|
|
1504
|
+
{
|
|
1505
|
+
error_type: "path_conflict",
|
|
1506
|
+
error: "path_conflict",
|
|
1507
|
+
path: e.path,
|
|
1508
|
+
message: e.message,
|
|
1509
|
+
hint: "pass a different `path`, or omit it to let the vault assign one",
|
|
1510
|
+
},
|
|
1366
1511
|
409,
|
|
1367
1512
|
);
|
|
1368
1513
|
}
|
|
1369
1514
|
// Strict-schema rejection (vault#299 Part A) on create — 422.
|
|
1370
1515
|
if (e && e.code === "SCHEMA_VALIDATION") {
|
|
1371
1516
|
return json(
|
|
1372
|
-
{
|
|
1517
|
+
{
|
|
1518
|
+
error_type: "schema_validation",
|
|
1519
|
+
error: "schema_validation",
|
|
1520
|
+
violations: e.violations ?? [],
|
|
1521
|
+
message: e.message,
|
|
1522
|
+
hint: "fix every field listed in `violations` and retry — none of this write was applied",
|
|
1523
|
+
},
|
|
1373
1524
|
422,
|
|
1374
1525
|
);
|
|
1375
1526
|
}
|
|
@@ -1412,12 +1563,12 @@ async function handleNotesInner(
|
|
|
1412
1563
|
return json(body.notes ? final : final[0], 201);
|
|
1413
1564
|
}
|
|
1414
1565
|
|
|
1415
|
-
return json({ error: "Method not allowed" }, 405);
|
|
1566
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1416
1567
|
}
|
|
1417
1568
|
|
|
1418
1569
|
// ---- Note-level routes (/notes/:idOrPath[/attachments]) ----
|
|
1419
1570
|
const idMatch = subpath.match(/^\/([^/]+)(\/.*)?$/);
|
|
1420
|
-
if (!idMatch) return json({ error: "Not found" }, 404);
|
|
1571
|
+
if (!idMatch) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1421
1572
|
|
|
1422
1573
|
const idOrPath = decodeURIComponent(idMatch[1]!);
|
|
1423
1574
|
const sub = idMatch[2] ?? "";
|
|
@@ -1426,12 +1577,17 @@ async function handleNotesInner(
|
|
|
1426
1577
|
if (sub === "/attachments") {
|
|
1427
1578
|
if (method === "POST") {
|
|
1428
1579
|
const note = await resolveNote(store, idOrPath);
|
|
1429
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1580
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1430
1581
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1431
|
-
return json({ error: "Not found" }, 404);
|
|
1582
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1432
1583
|
}
|
|
1433
1584
|
const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
|
|
1434
|
-
if (!body.path || !body.mimeType)
|
|
1585
|
+
if (!body.path || !body.mimeType) {
|
|
1586
|
+
return json(
|
|
1587
|
+
{ error: "path and mimeType are required", error_type: "missing_required_field", hint: "pass both `path` and `mimeType`" },
|
|
1588
|
+
400,
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1435
1591
|
|
|
1436
1592
|
// Decide whether to enqueue this attachment for transcription. Two paths:
|
|
1437
1593
|
//
|
|
@@ -1480,13 +1636,13 @@ async function handleNotesInner(
|
|
|
1480
1636
|
}
|
|
1481
1637
|
if (method === "GET") {
|
|
1482
1638
|
const note = await resolveNote(store, idOrPath);
|
|
1483
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1639
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1484
1640
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1485
|
-
return json({ error: "Not found" }, 404);
|
|
1641
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1486
1642
|
}
|
|
1487
1643
|
return json(await store.getAttachments(note.id));
|
|
1488
1644
|
}
|
|
1489
|
-
return json({ error: "Method not allowed" }, 405);
|
|
1645
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1490
1646
|
}
|
|
1491
1647
|
|
|
1492
1648
|
const attMatch = sub.match(/^\/attachments\/([^/]+)$/);
|
|
@@ -1494,12 +1650,12 @@ async function handleNotesInner(
|
|
|
1494
1650
|
const attId = decodeURIComponent(attMatch[1]!);
|
|
1495
1651
|
if (method === "DELETE") {
|
|
1496
1652
|
const note = await resolveNote(store, idOrPath);
|
|
1497
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1653
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1498
1654
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1499
|
-
return json({ error: "Not found" }, 404);
|
|
1655
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1500
1656
|
}
|
|
1501
1657
|
const result = await store.deleteAttachment(note.id, attId);
|
|
1502
|
-
if (!result.deleted) return json({ error: "Not found" }, 404);
|
|
1658
|
+
if (!result.deleted) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1503
1659
|
// Unlink the storage file only if no other attachment still references
|
|
1504
1660
|
// the same path. Best-effort: the row is already gone, so a missing
|
|
1505
1661
|
// file or unlink error should not flip the DELETE to an error.
|
|
@@ -1512,7 +1668,7 @@ async function handleNotesInner(
|
|
|
1512
1668
|
}
|
|
1513
1669
|
return new Response(null, { status: 204 });
|
|
1514
1670
|
}
|
|
1515
|
-
return json({ error: "Method not allowed" }, 405);
|
|
1671
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1516
1672
|
}
|
|
1517
1673
|
|
|
1518
1674
|
// POST /notes/:idOrPath/retry-transcription — vault#353 design Q5 + finding F.
|
|
@@ -1536,24 +1692,24 @@ async function handleNotesInner(
|
|
|
1536
1692
|
// 404 attachment_missing (auto-flow: transcript_attachment_id row deleted)
|
|
1537
1693
|
// 404 audio_missing (audio file unlinked from disk)
|
|
1538
1694
|
if (sub === "/retry-transcription") {
|
|
1539
|
-
if (method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
1540
|
-
if (!vault) return json({ error: "Vault context required" }, 400);
|
|
1695
|
+
if (method !== "POST") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1696
|
+
if (!vault) return json({ error: "Vault context required", error_type: "invalid_request" }, 400);
|
|
1541
1697
|
const note = await resolveNote(store, idOrPath);
|
|
1542
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1698
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1543
1699
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1544
|
-
return json({ error: "Not found" }, 404);
|
|
1700
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1545
1701
|
}
|
|
1546
1702
|
return handleRetryTranscription(store, note, vault);
|
|
1547
1703
|
}
|
|
1548
1704
|
|
|
1549
|
-
if (sub !== "") return json({ error: "Not found" }, 404);
|
|
1705
|
+
if (sub !== "") return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1550
1706
|
|
|
1551
1707
|
// GET /notes/:idOrPath — single note
|
|
1552
1708
|
if (method === "GET") {
|
|
1553
1709
|
const note = await resolveNote(store, idOrPath);
|
|
1554
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1710
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1555
1711
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1556
|
-
return json({ error: "Not found" }, 404);
|
|
1712
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1557
1713
|
}
|
|
1558
1714
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
1559
1715
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
@@ -1661,7 +1817,7 @@ async function handleNotesInner(
|
|
|
1661
1817
|
}
|
|
1662
1818
|
}
|
|
1663
1819
|
const final = await store.getNote(created.id);
|
|
1664
|
-
if (!final) return json({ error: "Note disappeared" }, 500);
|
|
1820
|
+
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
1665
1821
|
const validated = attachValidationStatus(store, db, final);
|
|
1666
1822
|
const includeContentResp = body.include_content !== false;
|
|
1667
1823
|
if (includeContentResp) return json({ ...validated, created: true });
|
|
@@ -1701,7 +1857,9 @@ async function handleNotesInner(
|
|
|
1701
1857
|
return json(
|
|
1702
1858
|
{
|
|
1703
1859
|
error: "mutually_exclusive",
|
|
1860
|
+
error_type: "mutually_exclusive",
|
|
1704
1861
|
message: "`content`, `append`/`prepend`, and `content_edit` are mutually exclusive — pick one mode of content update.",
|
|
1862
|
+
hint: "pass exactly one of `content`, `append`/`prepend`, or `content_edit`",
|
|
1705
1863
|
},
|
|
1706
1864
|
400,
|
|
1707
1865
|
);
|
|
@@ -1746,6 +1904,7 @@ async function handleNotesInner(
|
|
|
1746
1904
|
"update requires `if_updated_at` (the note's last-seen updated_at) or `force: true`.",
|
|
1747
1905
|
note_id: note.id,
|
|
1748
1906
|
path: note.path ?? null,
|
|
1907
|
+
hint: "re-read the note, pass its `updated_at` as `if_updated_at`, or pass `force: true` to skip the check",
|
|
1749
1908
|
},
|
|
1750
1909
|
428,
|
|
1751
1910
|
);
|
|
@@ -1757,7 +1916,13 @@ async function handleNotesInner(
|
|
|
1757
1916
|
const ce = body.content_edit as { old_text?: unknown; new_text?: unknown };
|
|
1758
1917
|
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
1759
1918
|
return json(
|
|
1760
|
-
{
|
|
1919
|
+
{
|
|
1920
|
+
error: "bad_request",
|
|
1921
|
+
error_type: "invalid_content_edit",
|
|
1922
|
+
field: "content_edit",
|
|
1923
|
+
message: "`content_edit` requires { old_text: string, new_text: string }.",
|
|
1924
|
+
hint: "pass { old_text: string, new_text: string }",
|
|
1925
|
+
},
|
|
1761
1926
|
400,
|
|
1762
1927
|
);
|
|
1763
1928
|
}
|
|
@@ -1768,14 +1933,26 @@ async function handleNotesInner(
|
|
|
1768
1933
|
// current content. Returning 404 implied "note doesn't exist" and
|
|
1769
1934
|
// confused operators chasing a missing record (#202).
|
|
1770
1935
|
return json(
|
|
1771
|
-
{
|
|
1936
|
+
{
|
|
1937
|
+
error: "unprocessable_content",
|
|
1938
|
+
error_type: "content_edit_not_found",
|
|
1939
|
+
field: "content_edit.old_text",
|
|
1940
|
+
message: `content_edit: \`old_text\` not found in note "${note.id}". Re-read and retry.`,
|
|
1941
|
+
hint: "re-read the note's current content and retry with an old_text that occurs exactly once",
|
|
1942
|
+
},
|
|
1772
1943
|
422,
|
|
1773
1944
|
);
|
|
1774
1945
|
}
|
|
1775
1946
|
const second = note.content.indexOf(ce.old_text, idx + 1);
|
|
1776
1947
|
if (second >= 0) {
|
|
1777
1948
|
return json(
|
|
1778
|
-
{
|
|
1949
|
+
{
|
|
1950
|
+
error: "ambiguous",
|
|
1951
|
+
error_type: "content_edit_ambiguous",
|
|
1952
|
+
field: "content_edit.old_text",
|
|
1953
|
+
message: `content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context.`,
|
|
1954
|
+
hint: "add surrounding context to old_text so it matches exactly once",
|
|
1955
|
+
},
|
|
1779
1956
|
409,
|
|
1780
1957
|
);
|
|
1781
1958
|
}
|
|
@@ -1838,7 +2015,13 @@ async function handleNotesInner(
|
|
|
1838
2015
|
if (stBody !== undefined) {
|
|
1839
2016
|
if (typeof stBody.field !== "string" || stBody.field.length === 0) {
|
|
1840
2017
|
return json(
|
|
1841
|
-
{
|
|
2018
|
+
{
|
|
2019
|
+
error: "bad_request",
|
|
2020
|
+
error_type: "invalid_state_transition",
|
|
2021
|
+
field: "state_transition.field",
|
|
2022
|
+
message: "`state_transition.field` must be a non-empty string.",
|
|
2023
|
+
hint: "pass a non-empty string naming the metadata field to transition",
|
|
2024
|
+
},
|
|
1842
2025
|
400,
|
|
1843
2026
|
);
|
|
1844
2027
|
}
|
|
@@ -1899,7 +2082,7 @@ async function handleNotesInner(
|
|
|
1899
2082
|
// Note first, then carry the field across the lean conversion (since
|
|
1900
2083
|
// `toNoteIndex` drops unknown fields).
|
|
1901
2084
|
const updatedNote = await store.getNote(note.id);
|
|
1902
|
-
if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
|
|
2085
|
+
if (updatedNote === null) return json({ error: "Note disappeared", error_type: "not_found" }, 404);
|
|
1903
2086
|
const validated: any = attachValidationStatus(store, db, updatedNote);
|
|
1904
2087
|
// Echo hydrated links when a link mutation was part of this request,
|
|
1905
2088
|
// OR the caller explicitly asked for them via `?include_links=true`
|
|
@@ -1936,7 +2119,7 @@ async function handleNotesInner(
|
|
|
1936
2119
|
lean.created = false;
|
|
1937
2120
|
return json(lean);
|
|
1938
2121
|
} catch (e: any) {
|
|
1939
|
-
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
2122
|
+
if (e instanceof NotFoundError) return json({ error: e.message, error_type: "not_found" }, 404);
|
|
1940
2123
|
// Duck-type on `code` rather than `instanceof ConflictError`: this
|
|
1941
2124
|
// error originates in the core package and survives any future
|
|
1942
2125
|
// bundling / module-boundary split more robustly than a prototype check.
|
|
@@ -1950,6 +2133,7 @@ async function handleNotesInner(
|
|
|
1950
2133
|
path: e.note_path ?? null,
|
|
1951
2134
|
note_id: e.note_id,
|
|
1952
2135
|
message: e.message,
|
|
2136
|
+
hint: "re-read the note (GET) and re-apply your change against its current `updated_at`, or pass `force: true` to overwrite",
|
|
1953
2137
|
// Legacy fields — kept for the lens VaultConflictError shim and
|
|
1954
2138
|
// any other pre-launch callers. Safe to drop post-launch.
|
|
1955
2139
|
error: "conflict",
|
|
@@ -1973,6 +2157,7 @@ async function handleNotesInner(
|
|
|
1973
2157
|
to: e.to,
|
|
1974
2158
|
current: e.current ?? null,
|
|
1975
2159
|
message: e.message,
|
|
2160
|
+
hint: "re-read the note's current value for this field and retry the transition from its actual current state",
|
|
1976
2161
|
},
|
|
1977
2162
|
409,
|
|
1978
2163
|
);
|
|
@@ -1987,6 +2172,7 @@ async function handleNotesInner(
|
|
|
1987
2172
|
error: "schema_validation",
|
|
1988
2173
|
violations: e.violations ?? [],
|
|
1989
2174
|
message: e.message,
|
|
2175
|
+
hint: "fix every field listed in `violations` and retry — none of this write was applied",
|
|
1990
2176
|
},
|
|
1991
2177
|
422,
|
|
1992
2178
|
);
|
|
@@ -1994,7 +2180,13 @@ async function handleNotesInner(
|
|
|
1994
2180
|
// Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
|
|
1995
2181
|
if (e && e.code === "PATH_CONFLICT") {
|
|
1996
2182
|
return json(
|
|
1997
|
-
{
|
|
2183
|
+
{
|
|
2184
|
+
error_type: "path_conflict",
|
|
2185
|
+
error: "path_conflict",
|
|
2186
|
+
path: e.path,
|
|
2187
|
+
message: e.message,
|
|
2188
|
+
hint: "pass a different `path`, or omit it to leave the existing path unchanged",
|
|
2189
|
+
},
|
|
1998
2190
|
409,
|
|
1999
2191
|
);
|
|
2000
2192
|
}
|
|
@@ -2011,17 +2203,17 @@ async function handleNotesInner(
|
|
|
2011
2203
|
// DELETE /notes/:idOrPath — vault:write (no admin gate; consistent with verbForMethod)
|
|
2012
2204
|
if (method === "DELETE") {
|
|
2013
2205
|
const note = await resolveNote(store, idOrPath);
|
|
2014
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
2206
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2015
2207
|
// Tag-scope: can't delete what you can't read. 404 (not 403) for the
|
|
2016
2208
|
// same no-leak reason as the read paths.
|
|
2017
2209
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
2018
|
-
return json({ error: "Not found" }, 404);
|
|
2210
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2019
2211
|
}
|
|
2020
2212
|
await store.deleteNote(note.id);
|
|
2021
2213
|
return json({ deleted: true, id: note.id });
|
|
2022
2214
|
}
|
|
2023
2215
|
|
|
2024
|
-
return json({ error: "Method not allowed" }, 405);
|
|
2216
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2025
2217
|
}
|
|
2026
2218
|
|
|
2027
2219
|
// ---------------------------------------------------------------------------
|
|
@@ -2116,18 +2308,18 @@ export async function handleTags(
|
|
|
2116
2308
|
// POST /tags/merge — atomic multi-source merge into a target tag.
|
|
2117
2309
|
// Must come before the /:name matcher so "merge" isn't read as a tag name.
|
|
2118
2310
|
if (subpath === "/merge") {
|
|
2119
|
-
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
2311
|
+
if (req.method !== "POST") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2120
2312
|
const body = (await req.json().catch(() => null)) as
|
|
2121
2313
|
| { sources?: unknown; target?: unknown }
|
|
2122
2314
|
| null;
|
|
2123
|
-
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2315
|
+
if (!body) return json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400);
|
|
2124
2316
|
const sources = body.sources;
|
|
2125
2317
|
const target = body.target;
|
|
2126
2318
|
if (!Array.isArray(sources) || !sources.every((s) => typeof s === "string" && s.length > 0)) {
|
|
2127
|
-
return json({ error: "sources must be a non-empty array of strings" }, 400);
|
|
2319
|
+
return json({ error: "sources must be a non-empty array of strings", error_type: "invalid_request", field: "sources" }, 400);
|
|
2128
2320
|
}
|
|
2129
2321
|
if (typeof target !== "string" || target.length === 0) {
|
|
2130
|
-
return json({ error: "target must be a non-empty string" }, 400);
|
|
2322
|
+
return json({ error: "target must be a non-empty string", error_type: "invalid_request", field: "target" }, 400);
|
|
2131
2323
|
}
|
|
2132
2324
|
// Tag-scope: every source AND the target must be inside the allowlist.
|
|
2133
2325
|
// A merge that pulls notes out of a token's scope (or pushes notes into
|
|
@@ -2167,13 +2359,13 @@ export async function handleTags(
|
|
|
2167
2359
|
// POST /tags/:name/rename — atomic rename across tags + note_tags + schema
|
|
2168
2360
|
const renameMatch = subpath.match(/^\/([^/]+)\/rename$/);
|
|
2169
2361
|
if (renameMatch) {
|
|
2170
|
-
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
2362
|
+
if (req.method !== "POST") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2171
2363
|
const oldName = decodeURIComponent(renameMatch[1]!);
|
|
2172
2364
|
const body = (await req.json().catch(() => null)) as { new_name?: unknown } | null;
|
|
2173
|
-
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2365
|
+
if (!body) return json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400);
|
|
2174
2366
|
const newName = body.new_name;
|
|
2175
2367
|
if (typeof newName !== "string" || newName.length === 0) {
|
|
2176
|
-
return json({ error: "new_name must be a non-empty string" }, 400);
|
|
2368
|
+
return json({ error: "new_name must be a non-empty string", error_type: "invalid_request", field: "new_name" }, 400);
|
|
2177
2369
|
}
|
|
2178
2370
|
if (tagScope.allowed && (!tagScope.allowed.has(oldName) || !tagScope.allowed.has(newName))) {
|
|
2179
2371
|
return tagScopeForbidden(tagScope.raw ?? []);
|
|
@@ -2184,14 +2376,18 @@ export async function handleTags(
|
|
|
2184
2376
|
// notes.ts:renameTag for the surfaces touched.
|
|
2185
2377
|
const result = await store.renameTag(oldName, newName);
|
|
2186
2378
|
if ("error" in result) {
|
|
2187
|
-
if (result.error === "not_found")
|
|
2379
|
+
if (result.error === "not_found") {
|
|
2380
|
+
return json({ error: "not_found", error_type: "tag_not_found", tag: oldName }, 404);
|
|
2381
|
+
}
|
|
2188
2382
|
if (result.error === "target_exists") {
|
|
2189
2383
|
return json(
|
|
2190
2384
|
{
|
|
2191
2385
|
error: "target_exists",
|
|
2386
|
+
error_type: "target_exists",
|
|
2192
2387
|
target: newName,
|
|
2193
2388
|
conflicting: result.conflicting,
|
|
2194
2389
|
message: "Target tag (or one of its sub-tags) already exists; use POST /api/tags/merge to combine them.",
|
|
2390
|
+
hint: "use POST /api/tags/merge to combine the tags instead",
|
|
2195
2391
|
},
|
|
2196
2392
|
409,
|
|
2197
2393
|
);
|
|
@@ -2206,15 +2402,15 @@ export async function handleTags(
|
|
|
2206
2402
|
// /:name matcher so "conformance" isn't read as a tag name.
|
|
2207
2403
|
const conformanceMatch = subpath.match(/^\/([^/]+)\/conformance$/);
|
|
2208
2404
|
if (conformanceMatch) {
|
|
2209
|
-
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
2405
|
+
if (req.method !== "POST") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2210
2406
|
const cTag = decodeURIComponent(conformanceMatch[1]!);
|
|
2211
2407
|
if (tagScope.allowed && !tagScope.allowed.has(cTag)) {
|
|
2212
|
-
return json({ error: "Tag not found", tag: cTag }, 404);
|
|
2408
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: cTag }, 404);
|
|
2213
2409
|
}
|
|
2214
2410
|
const body = (await req.json().catch(() => null)) as
|
|
2215
2411
|
| { fields?: Record<string, unknown> | null }
|
|
2216
2412
|
| null;
|
|
2217
|
-
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2413
|
+
if (!body) return json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400);
|
|
2218
2414
|
// The proposed fields the operator intends to save. Sanitized through the
|
|
2219
2415
|
// same parse the resolver uses (drop non-object specs). Empty/absent →
|
|
2220
2416
|
// nothing to enforce → zero violations.
|
|
@@ -2236,10 +2432,10 @@ export async function handleTags(
|
|
|
2236
2432
|
// Schema editor (vault#283). Must precede the /:name matcher.
|
|
2237
2433
|
const effectiveMatch = subpath.match(/^\/([^/]+)\/effective$/);
|
|
2238
2434
|
if (effectiveMatch) {
|
|
2239
|
-
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
2435
|
+
if (req.method !== "GET") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2240
2436
|
const eTag = decodeURIComponent(effectiveMatch[1]!);
|
|
2241
2437
|
if (tagScope.allowed && !tagScope.allowed.has(eTag)) {
|
|
2242
|
-
return json({ error: "Tag not found", tag: eTag }, 404);
|
|
2438
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: eTag }, 404);
|
|
2243
2439
|
}
|
|
2244
2440
|
const projection = buildVaultProjection(store.db);
|
|
2245
2441
|
const record = await store.getTagRecord(eTag);
|
|
@@ -2260,7 +2456,7 @@ export async function handleTags(
|
|
|
2260
2456
|
|
|
2261
2457
|
// Routes with tag name
|
|
2262
2458
|
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
2263
|
-
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
2459
|
+
if (!nameMatch) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2264
2460
|
const tagName = decodeURIComponent(nameMatch[1]!);
|
|
2265
2461
|
|
|
2266
2462
|
// GET /tags/:name — single tag detail (full record)
|
|
@@ -2363,7 +2559,15 @@ export async function handleTags(
|
|
|
2363
2559
|
parentNamesPatch = null;
|
|
2364
2560
|
} else if (body.parent_names !== undefined) {
|
|
2365
2561
|
if (!Array.isArray(body.parent_names)) {
|
|
2366
|
-
return json(
|
|
2562
|
+
return json(
|
|
2563
|
+
{
|
|
2564
|
+
error: "parent_names must be an array of tag names",
|
|
2565
|
+
error_type: "invalid_parent_names",
|
|
2566
|
+
field: "parent_names",
|
|
2567
|
+
hint: "pass an array of tag name strings, or null to clear",
|
|
2568
|
+
},
|
|
2569
|
+
400,
|
|
2570
|
+
);
|
|
2367
2571
|
}
|
|
2368
2572
|
const cleaned = (body.parent_names as unknown[]).filter(
|
|
2369
2573
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
@@ -2396,6 +2600,46 @@ export async function handleTags(
|
|
|
2396
2600
|
}
|
|
2397
2601
|
}
|
|
2398
2602
|
|
|
2603
|
+
// Cross-tag field validation (vault#553/#554) — validate the fields THIS
|
|
2604
|
+
// call is declaring (`body.fields`, not the merged `fieldsPatch`) against
|
|
2605
|
+
// every other tag's schema BEFORE any write. Mirrors the MCP update-tag
|
|
2606
|
+
// tool's cross-tag checks so REST and MCP report identically for this
|
|
2607
|
+
// class: EVERY conflicting field in one response (not just the first),
|
|
2608
|
+
// and the message states explicitly that no changes were applied —
|
|
2609
|
+
// nothing is persisted before this check runs. Two case families are
|
|
2610
|
+
// deliberately EXCLUDED and keep their pre-existing `IndexedFieldError`
|
|
2611
|
+
// → 400 `invalid_indexed_field` path below (unchanged status/error_type):
|
|
2612
|
+
// the own-field checks (unsupported type, invalid name — vault#478) AND
|
|
2613
|
+
// both-indexed cross-tag type conflicts (already 400 on main via
|
|
2614
|
+
// `declareField`'s cross-declarer sqlite-type check; the wire-contract
|
|
2615
|
+
// floor forbids flipping that to 422). What this pre-check newly rejects
|
|
2616
|
+
// — silent 200s on main — is non-indexed type conflicts and indexed-flag
|
|
2617
|
+
// conflicts. See `collectCrossTagFieldViolations`'s doc comment.
|
|
2618
|
+
if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
|
|
2619
|
+
const fieldViolations = tagSchemaOps.collectCrossTagFieldViolations(
|
|
2620
|
+
store.db,
|
|
2621
|
+
putTagName,
|
|
2622
|
+
body.fields as Record<string, tagSchemaOps.TagFieldSchema>,
|
|
2623
|
+
);
|
|
2624
|
+
if (fieldViolations.length > 0) {
|
|
2625
|
+
// Tag-scope scrub (vault#554 auth-and-scope fold): the write is
|
|
2626
|
+
// still rejected, but a violation whose conflicting declarer is
|
|
2627
|
+
// outside a scoped caller's allowlist must not name that tag or
|
|
2628
|
+
// reveal its schema. No-op for unscoped callers (allowed === null).
|
|
2629
|
+
const violations = scrubTagFieldViolationsByScope(fieldViolations, tagScope.allowed);
|
|
2630
|
+
return json(
|
|
2631
|
+
{
|
|
2632
|
+
error: "tag_field_conflict",
|
|
2633
|
+
error_type: "tag_field_conflict",
|
|
2634
|
+
tag: putTagName,
|
|
2635
|
+
violations,
|
|
2636
|
+
message: `${violations.length} field violation(s) for tag "${putTagName}" — no changes were applied.`,
|
|
2637
|
+
},
|
|
2638
|
+
422,
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2399
2643
|
// A bad indexed-field name (or an unindexable type, or a cross-tag type
|
|
2400
2644
|
// mismatch) is a CLIENT error — return 400, not the catch-all 500. The
|
|
2401
2645
|
// store pre-validates and is transactional, so the schema is left
|
|
@@ -2410,8 +2654,14 @@ export async function handleTags(
|
|
|
2410
2654
|
});
|
|
2411
2655
|
} catch (err) {
|
|
2412
2656
|
if (err instanceof IndexedFieldError) {
|
|
2657
|
+
// Tag-scope scrub (vault#554 fold): declareField's cross-declarer
|
|
2658
|
+
// type-conflict message names the other declarer tag(s) + their
|
|
2659
|
+
// sqlite type — generalize when any declarer is outside a scoped
|
|
2660
|
+
// caller's allowlist. No-op for unscoped callers and for the solo
|
|
2661
|
+
// own-field errors (no declarer_tags). Status/error_type unchanged.
|
|
2662
|
+
const scrubbed = scrubIndexedFieldConflictError(err, tagScope.allowed);
|
|
2413
2663
|
return json(
|
|
2414
|
-
{ error:
|
|
2664
|
+
{ error: scrubbed.message, error_type: "invalid_indexed_field" },
|
|
2415
2665
|
400,
|
|
2416
2666
|
);
|
|
2417
2667
|
}
|
|
@@ -2445,7 +2695,7 @@ export async function handleTags(
|
|
|
2445
2695
|
return json(await store.deleteTag(tagName));
|
|
2446
2696
|
}
|
|
2447
2697
|
|
|
2448
|
-
return json({ error: "Method not allowed" }, 405);
|
|
2698
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2449
2699
|
}
|
|
2450
2700
|
|
|
2451
2701
|
// ---------------------------------------------------------------------------
|
|
@@ -2457,24 +2707,29 @@ export async function handleFindPath(
|
|
|
2457
2707
|
store: Store,
|
|
2458
2708
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
2459
2709
|
): Promise<Response> {
|
|
2460
|
-
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
2710
|
+
if (req.method !== "GET") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2461
2711
|
|
|
2462
2712
|
const url = new URL(req.url);
|
|
2463
2713
|
const source = parseQuery(url, "source");
|
|
2464
2714
|
const target = parseQuery(url, "target");
|
|
2465
|
-
if (!source || !target)
|
|
2715
|
+
if (!source || !target) {
|
|
2716
|
+
return json(
|
|
2717
|
+
{ error: "source and target parameters are required", error_type: "invalid_request", hint: "pass both ?source= and ?target=" },
|
|
2718
|
+
400,
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2466
2721
|
|
|
2467
2722
|
const db = store.db;
|
|
2468
2723
|
try {
|
|
2469
2724
|
const sourceNote = await resolveNote(store, source);
|
|
2470
|
-
if (!sourceNote) return json({ error: `Note not found: "${source}"
|
|
2725
|
+
if (!sourceNote) return json({ error: `Note not found: "${source}"`, error_type: "not_found", note_id: source }, 404);
|
|
2471
2726
|
if (!noteWithinTagScope(sourceNote, tagScope.allowed, tagScope.raw)) {
|
|
2472
|
-
return json({ error: `Note not found: "${source}"
|
|
2727
|
+
return json({ error: `Note not found: "${source}"`, error_type: "not_found", note_id: source }, 404);
|
|
2473
2728
|
}
|
|
2474
2729
|
const targetNote = await resolveNote(store, target);
|
|
2475
|
-
if (!targetNote) return json({ error: `Note not found: "${target}"
|
|
2730
|
+
if (!targetNote) return json({ error: `Note not found: "${target}"`, error_type: "not_found", note_id: target }, 404);
|
|
2476
2731
|
if (!noteWithinTagScope(targetNote, tagScope.allowed, tagScope.raw)) {
|
|
2477
|
-
return json({ error: `Note not found: "${target}"
|
|
2732
|
+
return json({ error: `Note not found: "${target}"`, error_type: "not_found", note_id: target }, 404);
|
|
2478
2733
|
}
|
|
2479
2734
|
const maxDepth = Math.min(parseInt10(parseQuery(url, "max_depth")) ?? 5, 10);
|
|
2480
2735
|
|
|
@@ -2492,7 +2747,7 @@ export async function handleFindPath(
|
|
|
2492
2747
|
}
|
|
2493
2748
|
return json(result);
|
|
2494
2749
|
} catch (e: any) {
|
|
2495
|
-
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
2750
|
+
if (e instanceof NotFoundError) return json({ error: e.message, error_type: "not_found" }, 404);
|
|
2496
2751
|
// vault#331 N1 — surface AmbiguousPathError from resolveNote as 409
|
|
2497
2752
|
// mirroring the handleNotes path. Without this, an ambiguous source/
|
|
2498
2753
|
// target path on /api/find-path bubbled to a server-level 500.
|
|
@@ -2591,7 +2846,11 @@ export async function handleVault(
|
|
|
2591
2846
|
return json(
|
|
2592
2847
|
{
|
|
2593
2848
|
error: "invalid_audio_retention",
|
|
2849
|
+
error_type: "invalid_audio_retention",
|
|
2850
|
+
field: "config.audio_retention",
|
|
2851
|
+
got: v,
|
|
2594
2852
|
message: `audio_retention must be one of: ${VALID_AUDIO_RETENTION.join(", ")}`,
|
|
2853
|
+
hint: `pass one of: ${VALID_AUDIO_RETENTION.join(", ")}`,
|
|
2595
2854
|
},
|
|
2596
2855
|
400,
|
|
2597
2856
|
);
|
|
@@ -2613,7 +2872,11 @@ export async function handleVault(
|
|
|
2613
2872
|
return json(
|
|
2614
2873
|
{
|
|
2615
2874
|
error: "invalid_auto_transcribe",
|
|
2875
|
+
error_type: "invalid_auto_transcribe",
|
|
2876
|
+
field: "config.auto_transcribe.enabled",
|
|
2877
|
+
got: enabled,
|
|
2616
2878
|
message: "auto_transcribe.enabled must be a boolean",
|
|
2879
|
+
hint: "pass true or false",
|
|
2617
2880
|
},
|
|
2618
2881
|
400,
|
|
2619
2882
|
);
|
|
@@ -2626,7 +2889,7 @@ export async function handleVault(
|
|
|
2626
2889
|
return json(vaultResponse(vaultConfig));
|
|
2627
2890
|
}
|
|
2628
2891
|
|
|
2629
|
-
return json({ error: "Method not allowed" }, 405);
|
|
2892
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2630
2893
|
}
|
|
2631
2894
|
|
|
2632
2895
|
// ---------------------------------------------------------------------------
|
|
@@ -2894,6 +3157,7 @@ async function handleRetryTranscription(
|
|
|
2894
3157
|
return json(
|
|
2895
3158
|
{
|
|
2896
3159
|
error: "not_failed",
|
|
3160
|
+
error_type: "not_failed",
|
|
2897
3161
|
message: `Transcript note status is "${meta.transcript_status}" — only failed transcripts can be retried.`,
|
|
2898
3162
|
transcript_status: meta.transcript_status,
|
|
2899
3163
|
},
|
|
@@ -2907,6 +3171,7 @@ async function handleRetryTranscription(
|
|
|
2907
3171
|
return json(
|
|
2908
3172
|
{
|
|
2909
3173
|
error: "missing_attachment_id",
|
|
3174
|
+
error_type: "missing_attachment_id",
|
|
2910
3175
|
message: "Transcript note has no `transcript_attachment_id` — can't locate the original audio.",
|
|
2911
3176
|
},
|
|
2912
3177
|
400,
|
|
@@ -2917,6 +3182,7 @@ async function handleRetryTranscription(
|
|
|
2917
3182
|
return json(
|
|
2918
3183
|
{
|
|
2919
3184
|
error: "attachment_missing",
|
|
3185
|
+
error_type: "attachment_missing",
|
|
2920
3186
|
message: `Original audio attachment ${attachmentId} no longer exists in the vault.`,
|
|
2921
3187
|
},
|
|
2922
3188
|
404,
|
|
@@ -2930,6 +3196,7 @@ async function handleRetryTranscription(
|
|
|
2930
3196
|
return json(
|
|
2931
3197
|
{
|
|
2932
3198
|
error: "audio_missing",
|
|
3199
|
+
error_type: "audio_missing",
|
|
2933
3200
|
message: `Original audio file at "${attachment.path}" no longer exists on disk.`,
|
|
2934
3201
|
},
|
|
2935
3202
|
404,
|
|
@@ -3010,6 +3277,7 @@ async function handleRetryLegacyInBody(
|
|
|
3010
3277
|
return json(
|
|
3011
3278
|
{
|
|
3012
3279
|
error: "no_failed_attachment",
|
|
3280
|
+
error_type: "no_failed_attachment",
|
|
3013
3281
|
message:
|
|
3014
3282
|
"Target note is not a transcript note and has no audio attachment with a failed transcription to retry.",
|
|
3015
3283
|
},
|
|
@@ -3025,6 +3293,7 @@ async function handleRetryLegacyInBody(
|
|
|
3025
3293
|
return json(
|
|
3026
3294
|
{
|
|
3027
3295
|
error: "audio_missing",
|
|
3296
|
+
error_type: "audio_missing",
|
|
3028
3297
|
message: `Original audio file at "${failed.path}" no longer exists on disk.`,
|
|
3029
3298
|
},
|
|
3030
3299
|
404,
|
|
@@ -3077,7 +3346,7 @@ async function handleRetryLegacyInBody(
|
|
|
3077
3346
|
const fresh = await store.getNote(note.id);
|
|
3078
3347
|
if (!fresh) {
|
|
3079
3348
|
return json(
|
|
3080
|
-
{ error: "note_missing", message: "Target note disappeared during retry." },
|
|
3349
|
+
{ error: "note_missing", error_type: "not_found", message: "Target note disappeared during retry." },
|
|
3081
3350
|
404,
|
|
3082
3351
|
);
|
|
3083
3352
|
}
|
|
@@ -3107,6 +3376,7 @@ async function handleRetryLegacyInBody(
|
|
|
3107
3376
|
expected_updated_at: err.expected_updated_at,
|
|
3108
3377
|
message:
|
|
3109
3378
|
"Note was modified concurrently while arming the retry; re-fetch and try again.",
|
|
3379
|
+
hint: "re-fetch the note and retry the retry-transcription call",
|
|
3110
3380
|
},
|
|
3111
3381
|
409,
|
|
3112
3382
|
);
|
|
@@ -3248,10 +3518,18 @@ export async function handleStorage(
|
|
|
3248
3518
|
const form = await req.formData();
|
|
3249
3519
|
const file = form.get("file");
|
|
3250
3520
|
if (!(file instanceof File)) {
|
|
3251
|
-
return json({ error: "file is required" }, 400);
|
|
3521
|
+
return json({ error: "file is required", error_type: "missing_required_field", field: "file" }, 400);
|
|
3252
3522
|
}
|
|
3253
3523
|
if (file.size > MAX_UPLOAD_BYTES) {
|
|
3254
|
-
return json(
|
|
3524
|
+
return json(
|
|
3525
|
+
{
|
|
3526
|
+
error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Max: 100MB`,
|
|
3527
|
+
error_type: "file_too_large",
|
|
3528
|
+
limit: MAX_UPLOAD_BYTES,
|
|
3529
|
+
got: file.size,
|
|
3530
|
+
},
|
|
3531
|
+
413,
|
|
3532
|
+
);
|
|
3255
3533
|
}
|
|
3256
3534
|
// Strip trailing dots/whitespace before extracting the extension so a
|
|
3257
3535
|
// `evil.html.` / `evil.svg ` can't slip past the blocklist
|
|
@@ -3263,7 +3541,14 @@ export async function handleStorage(
|
|
|
3263
3541
|
// served same-origin from /storage/ (see BLOCKED_EXTENSIONS). Everything
|
|
3264
3542
|
// else (incl. unknown/arbitrary files) is accepted and served as a
|
|
3265
3543
|
// download (octet-stream + nosniff).
|
|
3266
|
-
return json(
|
|
3544
|
+
return json(
|
|
3545
|
+
{
|
|
3546
|
+
error: `File type ${ext} not allowed (active/executable content)`,
|
|
3547
|
+
error_type: "blocked_upload_extension",
|
|
3548
|
+
extension: ext,
|
|
3549
|
+
},
|
|
3550
|
+
400,
|
|
3551
|
+
);
|
|
3267
3552
|
}
|
|
3268
3553
|
|
|
3269
3554
|
const date = new Date().toISOString().split("T")[0]!;
|
|
@@ -3309,7 +3594,7 @@ export async function handleStorage(
|
|
|
3309
3594
|
try {
|
|
3310
3595
|
decodedPath = decodeURIComponent(path);
|
|
3311
3596
|
} catch {
|
|
3312
|
-
return json({ error: "Not found" }, 404);
|
|
3597
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3313
3598
|
}
|
|
3314
3599
|
|
|
3315
3600
|
const fileMatch = decodedPath.match(/^\/([^/]+)\/(.+)$/);
|
|
@@ -3318,10 +3603,10 @@ export async function handleStorage(
|
|
|
3318
3603
|
const filePath = normalize(join(assets, reqPath));
|
|
3319
3604
|
|
|
3320
3605
|
if (!filePath.startsWith(normalize(assets))) {
|
|
3321
|
-
return json({ error: "Invalid path" }, 403);
|
|
3606
|
+
return json({ error: "Invalid path", error_type: "invalid_path" }, 403);
|
|
3322
3607
|
}
|
|
3323
3608
|
if (!existsSync(filePath)) {
|
|
3324
|
-
return json({ error: "Not found" }, 404);
|
|
3609
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3325
3610
|
}
|
|
3326
3611
|
|
|
3327
3612
|
// Tag-scope gate (C0 adversarial-audit finding). The note-keyed
|
|
@@ -3351,7 +3636,7 @@ export async function handleStorage(
|
|
|
3351
3636
|
}
|
|
3352
3637
|
}
|
|
3353
3638
|
if (!allowed) {
|
|
3354
|
-
return json({ error: "Not found" }, 404);
|
|
3639
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3355
3640
|
}
|
|
3356
3641
|
}
|
|
3357
3642
|
|
|
@@ -3374,7 +3659,7 @@ export async function handleStorage(
|
|
|
3374
3659
|
});
|
|
3375
3660
|
}
|
|
3376
3661
|
|
|
3377
|
-
return json({ error: "Not found" }, 404);
|
|
3662
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3378
3663
|
}
|
|
3379
3664
|
|
|
3380
3665
|
// ---------------------------------------------------------------------------
|