@openparachute/vault 0.7.0-rc.3 → 0.7.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/contract-taxonomy.test.ts +193 -17
- package/core/src/contract-typed-index.test.ts +92 -3
- package/core/src/core.test.ts +29 -6
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +330 -0
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +190 -47
- package/core/src/notes.ts +107 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/store.ts +17 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +231 -4
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/mcp-http.ts +122 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +137 -8
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +350 -79
- package/src/routing.ts +29 -0
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +109 -0
- package/src/vault.test.ts +25 -8
package/src/routes.ts
CHANGED
|
@@ -41,9 +41,14 @@ import {
|
|
|
41
41
|
filterHydratedLinksByTagScope,
|
|
42
42
|
filterNotesByTagScope,
|
|
43
43
|
noteWithinTagScope,
|
|
44
|
+
scrubIndexedFieldConflictError,
|
|
45
|
+
scrubParentCycleError,
|
|
46
|
+
scrubReferencingTagsByScope,
|
|
47
|
+
scrubTagFieldViolationsByScope,
|
|
44
48
|
tagScopeForbidden,
|
|
45
49
|
tagsWithinScope,
|
|
46
50
|
} from "./tag-scope.ts";
|
|
51
|
+
import { ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
47
52
|
import { findTokensReferencingTag } from "./token-store.ts";
|
|
48
53
|
|
|
49
54
|
/**
|
|
@@ -242,7 +247,13 @@ function parseContentRangeQuery(
|
|
|
242
247
|
// is fragile across bundling boundaries (same note as the QueryError
|
|
243
248
|
// handling in the structured-query path below).
|
|
244
249
|
if (e && e.name === "QueryError") {
|
|
245
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
range: null,
|
|
252
|
+
error: json(
|
|
253
|
+
{ error: e.message, code: e.code ?? "INVALID_QUERY", error_type: e.error_type ?? "invalid_query" },
|
|
254
|
+
400,
|
|
255
|
+
),
|
|
256
|
+
};
|
|
246
257
|
}
|
|
247
258
|
throw e;
|
|
248
259
|
}
|
|
@@ -402,6 +413,9 @@ function parseMetaBrackets(url: URL): {
|
|
|
402
413
|
{
|
|
403
414
|
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.`,
|
|
404
415
|
code: "INVALID_QUERY",
|
|
416
|
+
error_type: "invalid_query",
|
|
417
|
+
field,
|
|
418
|
+
hint: `pick either \`meta[${field}]=value\` or \`meta[${field}][<op>]=value\`, not both`,
|
|
405
419
|
},
|
|
406
420
|
400,
|
|
407
421
|
);
|
|
@@ -431,6 +445,9 @@ function parseMetaBrackets(url: URL): {
|
|
|
431
445
|
{
|
|
432
446
|
error: `bracket-date filter on \`${field}\` requires an operator: meta[${field}][gte]=… (lower bound) or meta[${field}][lt]=… (upper bound, exclusive).`,
|
|
433
447
|
code: "INVALID_QUERY",
|
|
448
|
+
error_type: "invalid_query",
|
|
449
|
+
field,
|
|
450
|
+
hint: `pass meta[${field}][gte]=… and/or meta[${field}][lt]=…`,
|
|
434
451
|
},
|
|
435
452
|
400,
|
|
436
453
|
),
|
|
@@ -442,6 +459,10 @@ function parseMetaBrackets(url: URL): {
|
|
|
442
459
|
{
|
|
443
460
|
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.`,
|
|
444
461
|
code: "INVALID_QUERY",
|
|
462
|
+
error_type: "invalid_query",
|
|
463
|
+
field,
|
|
464
|
+
got: op,
|
|
465
|
+
hint: `use \`gte\` or \`lt\` — meta[${field}][gte]=… / meta[${field}][lt]=…`,
|
|
445
466
|
},
|
|
446
467
|
400,
|
|
447
468
|
),
|
|
@@ -456,6 +477,9 @@ function parseMetaBrackets(url: URL): {
|
|
|
456
477
|
{
|
|
457
478
|
error: `bracket-date filter cannot span both \`created_at\` and \`updated_at\` in one request — issue two queries or use one column per request.`,
|
|
458
479
|
code: "INVALID_QUERY",
|
|
480
|
+
error_type: "invalid_query",
|
|
481
|
+
field,
|
|
482
|
+
hint: "issue two queries, or filter one date column per request",
|
|
459
483
|
},
|
|
460
484
|
400,
|
|
461
485
|
),
|
|
@@ -496,6 +520,10 @@ function parseMetaBrackets(url: URL): {
|
|
|
496
520
|
{
|
|
497
521
|
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.`,
|
|
498
522
|
code: "INVALID_OPERATOR_VALUE",
|
|
523
|
+
error_type: "invalid_query",
|
|
524
|
+
field,
|
|
525
|
+
got: op,
|
|
526
|
+
hint: `use \`meta[${field}][${op}]=value\` (no \`[]\`) — array form is only for \`in\`/\`not_in\``,
|
|
499
527
|
},
|
|
500
528
|
400,
|
|
501
529
|
),
|
|
@@ -536,6 +564,10 @@ function parseMetaBrackets(url: URL): {
|
|
|
536
564
|
{
|
|
537
565
|
error: `bracket-meta filter: \`exists\` on \`${field}\` requires "true" or "false", got "${value}"`,
|
|
538
566
|
code: "INVALID_OPERATOR_VALUE",
|
|
567
|
+
error_type: "invalid_query",
|
|
568
|
+
field,
|
|
569
|
+
got: value,
|
|
570
|
+
hint: `pass meta[${field}][exists]=true or meta[${field}][exists]=false`,
|
|
539
571
|
},
|
|
540
572
|
400,
|
|
541
573
|
),
|
|
@@ -614,6 +646,9 @@ function parseMetadataJsonAlias(url: URL): {
|
|
|
614
646
|
{
|
|
615
647
|
error: `metadata query param must be a JSON object of the form {"field":{"op":value}} — ${detail}`,
|
|
616
648
|
code: "INVALID_QUERY",
|
|
649
|
+
error_type: "invalid_query",
|
|
650
|
+
field: "metadata",
|
|
651
|
+
hint: 'pass a JSON object, e.g. ?metadata={"status":{"eq":"active"}}',
|
|
617
652
|
},
|
|
618
653
|
400,
|
|
619
654
|
);
|
|
@@ -653,6 +688,10 @@ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Re
|
|
|
653
688
|
{
|
|
654
689
|
error: `invalid \`expand\` value "${expandParam}" — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes": parent_names descendants).`,
|
|
655
690
|
code: "INVALID_QUERY",
|
|
691
|
+
error_type: "invalid_query",
|
|
692
|
+
field: "expand",
|
|
693
|
+
got: expandParam,
|
|
694
|
+
hint: `pass one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}, or omit for the default`,
|
|
656
695
|
},
|
|
657
696
|
400,
|
|
658
697
|
),
|
|
@@ -736,6 +775,8 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
736
775
|
{
|
|
737
776
|
error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
|
|
738
777
|
code: "INVALID_QUERY",
|
|
778
|
+
error_type: "invalid_query",
|
|
779
|
+
hint: "pick one metadata-filter form: `metadata=` or `meta[field][op]=`, not both",
|
|
739
780
|
},
|
|
740
781
|
400,
|
|
741
782
|
),
|
|
@@ -937,12 +978,12 @@ async function handleNotesInner(
|
|
|
937
978
|
// Single note by id/path
|
|
938
979
|
if (id) {
|
|
939
980
|
const note = await resolveNote(store, id);
|
|
940
|
-
if (!note) return json({ error: "Note not found", id }, 404);
|
|
981
|
+
if (!note) return json({ error: "Note not found", error_type: "not_found", id }, 404);
|
|
941
982
|
// Tag-scope: a token can't see what its allowlist excludes. Surface
|
|
942
983
|
// as 404 (not 403) — the existence of the note is itself information
|
|
943
984
|
// we shouldn't leak across the scope boundary.
|
|
944
985
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
945
|
-
return json({ error: "Note not found", id }, 404);
|
|
986
|
+
return json({ error: "Note not found", error_type: "not_found", id }, 404);
|
|
946
987
|
}
|
|
947
988
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
948
989
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
@@ -992,6 +1033,9 @@ async function handleNotesInner(
|
|
|
992
1033
|
{
|
|
993
1034
|
error: "cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.",
|
|
994
1035
|
code: "INVALID_QUERY",
|
|
1036
|
+
error_type: "invalid_query",
|
|
1037
|
+
field: "cursor",
|
|
1038
|
+
hint: "drop `cursor` when using `search`, or drop `search` and use `meta[updated_at][gte]=…` for since-last-checked polling",
|
|
995
1039
|
},
|
|
996
1040
|
400,
|
|
997
1041
|
);
|
|
@@ -1044,7 +1088,12 @@ async function handleNotesInner(
|
|
|
1044
1088
|
{
|
|
1045
1089
|
error: e.message,
|
|
1046
1090
|
code: e.code ?? "INVALID_QUERY",
|
|
1047
|
-
|
|
1091
|
+
// vault#554: default error_type to "invalid_query" for the
|
|
1092
|
+
// long-standing QueryError call sites that predate the
|
|
1093
|
+
// vault#550/#551 convention (FIELD_NOT_INDEXED,
|
|
1094
|
+
// UNKNOWN_OPERATOR, ...) — every QueryError now carries a
|
|
1095
|
+
// stable error_type, not just the newer ones that set it.
|
|
1096
|
+
error_type: e.error_type ?? "invalid_query",
|
|
1048
1097
|
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1049
1098
|
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1050
1099
|
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
@@ -1144,6 +1193,9 @@ async function handleNotesInner(
|
|
|
1144
1193
|
{
|
|
1145
1194
|
error: "cursor is incompatible with near (graph neighborhood). Resolve the neighborhood first, then iterate with cursor over the resulting note set.",
|
|
1146
1195
|
code: "INVALID_QUERY",
|
|
1196
|
+
error_type: "invalid_query",
|
|
1197
|
+
field: "cursor",
|
|
1198
|
+
hint: "resolve the `near` neighborhood first, then paginate the resulting note set with `cursor`",
|
|
1147
1199
|
},
|
|
1148
1200
|
400,
|
|
1149
1201
|
);
|
|
@@ -1188,15 +1240,16 @@ async function handleNotesInner(
|
|
|
1188
1240
|
// here. Duck-type on `name` + `code` — core is a separate module, so
|
|
1189
1241
|
// `instanceof` is fragile across bundling boundaries. The newer
|
|
1190
1242
|
// vault#550 call sites (limit/offset/date validation in
|
|
1191
|
-
// core/src/notes.ts) additionally set `
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1243
|
+
// core/src/notes.ts) additionally set `field`/`got`/`hint` — merged
|
|
1244
|
+
// in when present. vault#554: every QueryError now carries a stable
|
|
1245
|
+
// `error_type` (defaults to "invalid_query" for the long-standing
|
|
1246
|
+
// call sites that predate that convention), not just the newer ones.
|
|
1194
1247
|
if (e && e.name === "QueryError") {
|
|
1195
1248
|
return json(
|
|
1196
1249
|
{
|
|
1197
1250
|
error: e.message,
|
|
1198
1251
|
code: e.code ?? "INVALID_QUERY",
|
|
1199
|
-
|
|
1252
|
+
error_type: e.error_type ?? "invalid_query",
|
|
1200
1253
|
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1201
1254
|
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1202
1255
|
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
@@ -1208,9 +1261,11 @@ async function handleNotesInner(
|
|
|
1208
1261
|
// cursor_query_mismatch) so the agent loop can distinguish a
|
|
1209
1262
|
// malformed cursor from a hash-mismatch and react appropriately
|
|
1210
1263
|
// (the latter typically means the agent changed its filter and
|
|
1211
|
-
// should drop the cursor + restart from scratch).
|
|
1264
|
+
// should drop the cursor + restart from scratch). vault#554: `code`
|
|
1265
|
+
// IS the `error_type` vocabulary here — surfaced explicitly so a
|
|
1266
|
+
// caller can branch on `error_type` alone like every other error.
|
|
1212
1267
|
if (e && e.name === "CursorError") {
|
|
1213
|
-
return json({ error: e.message, code: e.code ?? "cursor_invalid" }, 400);
|
|
1268
|
+
return json({ error: e.message, code: e.code ?? "cursor_invalid", error_type: e.code ?? "cursor_invalid" }, 400);
|
|
1214
1269
|
}
|
|
1215
1270
|
throw e;
|
|
1216
1271
|
}
|
|
@@ -1219,10 +1274,10 @@ async function handleNotesInner(
|
|
|
1219
1274
|
const nearNoteId = parseQuery(url, "near[note_id]");
|
|
1220
1275
|
if (nearNoteId) {
|
|
1221
1276
|
const anchor = await resolveNote(store, nearNoteId);
|
|
1222
|
-
if (!anchor) return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
|
|
1277
|
+
if (!anchor) return json({ error: "Anchor note not found", error_type: "not_found", note_id: nearNoteId }, 404);
|
|
1223
1278
|
// Tag-scope: anchor must itself be visible to this token.
|
|
1224
1279
|
if (!noteWithinTagScope(anchor, tagScope.allowed, tagScope.raw)) {
|
|
1225
|
-
return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
|
|
1280
|
+
return json({ error: "Anchor note not found", error_type: "not_found", note_id: nearNoteId }, 404);
|
|
1226
1281
|
}
|
|
1227
1282
|
const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
|
|
1228
1283
|
const relationship = parseQuery(url, "near[relationship]") ?? undefined;
|
|
@@ -1449,14 +1504,26 @@ async function handleNotesInner(
|
|
|
1449
1504
|
// Duck-type for module-boundary robustness (matches the PATCH branch).
|
|
1450
1505
|
if (e && e.code === "PATH_CONFLICT") {
|
|
1451
1506
|
return json(
|
|
1452
|
-
{
|
|
1507
|
+
{
|
|
1508
|
+
error_type: "path_conflict",
|
|
1509
|
+
error: "path_conflict",
|
|
1510
|
+
path: e.path,
|
|
1511
|
+
message: e.message,
|
|
1512
|
+
hint: "pass a different `path`, or omit it to let the vault assign one",
|
|
1513
|
+
},
|
|
1453
1514
|
409,
|
|
1454
1515
|
);
|
|
1455
1516
|
}
|
|
1456
1517
|
// Strict-schema rejection (vault#299 Part A) on create — 422.
|
|
1457
1518
|
if (e && e.code === "SCHEMA_VALIDATION") {
|
|
1458
1519
|
return json(
|
|
1459
|
-
{
|
|
1520
|
+
{
|
|
1521
|
+
error_type: "schema_validation",
|
|
1522
|
+
error: "schema_validation",
|
|
1523
|
+
violations: e.violations ?? [],
|
|
1524
|
+
message: e.message,
|
|
1525
|
+
hint: "fix every field listed in `violations` and retry — none of this write was applied",
|
|
1526
|
+
},
|
|
1460
1527
|
422,
|
|
1461
1528
|
);
|
|
1462
1529
|
}
|
|
@@ -1499,12 +1566,12 @@ async function handleNotesInner(
|
|
|
1499
1566
|
return json(body.notes ? final : final[0], 201);
|
|
1500
1567
|
}
|
|
1501
1568
|
|
|
1502
|
-
return json({ error: "Method not allowed" }, 405);
|
|
1569
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1503
1570
|
}
|
|
1504
1571
|
|
|
1505
1572
|
// ---- Note-level routes (/notes/:idOrPath[/attachments]) ----
|
|
1506
1573
|
const idMatch = subpath.match(/^\/([^/]+)(\/.*)?$/);
|
|
1507
|
-
if (!idMatch) return json({ error: "Not found" }, 404);
|
|
1574
|
+
if (!idMatch) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1508
1575
|
|
|
1509
1576
|
const idOrPath = decodeURIComponent(idMatch[1]!);
|
|
1510
1577
|
const sub = idMatch[2] ?? "";
|
|
@@ -1513,12 +1580,17 @@ async function handleNotesInner(
|
|
|
1513
1580
|
if (sub === "/attachments") {
|
|
1514
1581
|
if (method === "POST") {
|
|
1515
1582
|
const note = await resolveNote(store, idOrPath);
|
|
1516
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1583
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1517
1584
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1518
|
-
return json({ error: "Not found" }, 404);
|
|
1585
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1519
1586
|
}
|
|
1520
1587
|
const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
|
|
1521
|
-
if (!body.path || !body.mimeType)
|
|
1588
|
+
if (!body.path || !body.mimeType) {
|
|
1589
|
+
return json(
|
|
1590
|
+
{ error: "path and mimeType are required", error_type: "missing_required_field", hint: "pass both `path` and `mimeType`" },
|
|
1591
|
+
400,
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1522
1594
|
|
|
1523
1595
|
// Decide whether to enqueue this attachment for transcription. Two paths:
|
|
1524
1596
|
//
|
|
@@ -1567,13 +1639,13 @@ async function handleNotesInner(
|
|
|
1567
1639
|
}
|
|
1568
1640
|
if (method === "GET") {
|
|
1569
1641
|
const note = await resolveNote(store, idOrPath);
|
|
1570
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1642
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1571
1643
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1572
|
-
return json({ error: "Not found" }, 404);
|
|
1644
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1573
1645
|
}
|
|
1574
1646
|
return json(await store.getAttachments(note.id));
|
|
1575
1647
|
}
|
|
1576
|
-
return json({ error: "Method not allowed" }, 405);
|
|
1648
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1577
1649
|
}
|
|
1578
1650
|
|
|
1579
1651
|
const attMatch = sub.match(/^\/attachments\/([^/]+)$/);
|
|
@@ -1581,12 +1653,12 @@ async function handleNotesInner(
|
|
|
1581
1653
|
const attId = decodeURIComponent(attMatch[1]!);
|
|
1582
1654
|
if (method === "DELETE") {
|
|
1583
1655
|
const note = await resolveNote(store, idOrPath);
|
|
1584
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1656
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1585
1657
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1586
|
-
return json({ error: "Not found" }, 404);
|
|
1658
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1587
1659
|
}
|
|
1588
1660
|
const result = await store.deleteAttachment(note.id, attId);
|
|
1589
|
-
if (!result.deleted) return json({ error: "Not found" }, 404);
|
|
1661
|
+
if (!result.deleted) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1590
1662
|
// Unlink the storage file only if no other attachment still references
|
|
1591
1663
|
// the same path. Best-effort: the row is already gone, so a missing
|
|
1592
1664
|
// file or unlink error should not flip the DELETE to an error.
|
|
@@ -1599,7 +1671,7 @@ async function handleNotesInner(
|
|
|
1599
1671
|
}
|
|
1600
1672
|
return new Response(null, { status: 204 });
|
|
1601
1673
|
}
|
|
1602
|
-
return json({ error: "Method not allowed" }, 405);
|
|
1674
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1603
1675
|
}
|
|
1604
1676
|
|
|
1605
1677
|
// POST /notes/:idOrPath/retry-transcription — vault#353 design Q5 + finding F.
|
|
@@ -1623,24 +1695,24 @@ async function handleNotesInner(
|
|
|
1623
1695
|
// 404 attachment_missing (auto-flow: transcript_attachment_id row deleted)
|
|
1624
1696
|
// 404 audio_missing (audio file unlinked from disk)
|
|
1625
1697
|
if (sub === "/retry-transcription") {
|
|
1626
|
-
if (method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
1627
|
-
if (!vault) return json({ error: "Vault context required" }, 400);
|
|
1698
|
+
if (method !== "POST") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1699
|
+
if (!vault) return json({ error: "Vault context required", error_type: "invalid_request" }, 400);
|
|
1628
1700
|
const note = await resolveNote(store, idOrPath);
|
|
1629
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1701
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1630
1702
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1631
|
-
return json({ error: "Not found" }, 404);
|
|
1703
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1632
1704
|
}
|
|
1633
1705
|
return handleRetryTranscription(store, note, vault);
|
|
1634
1706
|
}
|
|
1635
1707
|
|
|
1636
|
-
if (sub !== "") return json({ error: "Not found" }, 404);
|
|
1708
|
+
if (sub !== "") return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1637
1709
|
|
|
1638
1710
|
// GET /notes/:idOrPath — single note
|
|
1639
1711
|
if (method === "GET") {
|
|
1640
1712
|
const note = await resolveNote(store, idOrPath);
|
|
1641
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1713
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1642
1714
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1643
|
-
return json({ error: "Not found" }, 404);
|
|
1715
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1644
1716
|
}
|
|
1645
1717
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
1646
1718
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
@@ -1748,7 +1820,7 @@ async function handleNotesInner(
|
|
|
1748
1820
|
}
|
|
1749
1821
|
}
|
|
1750
1822
|
const final = await store.getNote(created.id);
|
|
1751
|
-
if (!final) return json({ error: "Note disappeared" }, 500);
|
|
1823
|
+
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
1752
1824
|
const validated = attachValidationStatus(store, db, final);
|
|
1753
1825
|
const includeContentResp = body.include_content !== false;
|
|
1754
1826
|
if (includeContentResp) return json({ ...validated, created: true });
|
|
@@ -1788,7 +1860,9 @@ async function handleNotesInner(
|
|
|
1788
1860
|
return json(
|
|
1789
1861
|
{
|
|
1790
1862
|
error: "mutually_exclusive",
|
|
1863
|
+
error_type: "mutually_exclusive",
|
|
1791
1864
|
message: "`content`, `append`/`prepend`, and `content_edit` are mutually exclusive — pick one mode of content update.",
|
|
1865
|
+
hint: "pass exactly one of `content`, `append`/`prepend`, or `content_edit`",
|
|
1792
1866
|
},
|
|
1793
1867
|
400,
|
|
1794
1868
|
);
|
|
@@ -1833,6 +1907,7 @@ async function handleNotesInner(
|
|
|
1833
1907
|
"update requires `if_updated_at` (the note's last-seen updated_at) or `force: true`.",
|
|
1834
1908
|
note_id: note.id,
|
|
1835
1909
|
path: note.path ?? null,
|
|
1910
|
+
hint: "re-read the note, pass its `updated_at` as `if_updated_at`, or pass `force: true` to skip the check",
|
|
1836
1911
|
},
|
|
1837
1912
|
428,
|
|
1838
1913
|
);
|
|
@@ -1844,7 +1919,13 @@ async function handleNotesInner(
|
|
|
1844
1919
|
const ce = body.content_edit as { old_text?: unknown; new_text?: unknown };
|
|
1845
1920
|
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
1846
1921
|
return json(
|
|
1847
|
-
{
|
|
1922
|
+
{
|
|
1923
|
+
error: "bad_request",
|
|
1924
|
+
error_type: "invalid_content_edit",
|
|
1925
|
+
field: "content_edit",
|
|
1926
|
+
message: "`content_edit` requires { old_text: string, new_text: string }.",
|
|
1927
|
+
hint: "pass { old_text: string, new_text: string }",
|
|
1928
|
+
},
|
|
1848
1929
|
400,
|
|
1849
1930
|
);
|
|
1850
1931
|
}
|
|
@@ -1855,14 +1936,26 @@ async function handleNotesInner(
|
|
|
1855
1936
|
// current content. Returning 404 implied "note doesn't exist" and
|
|
1856
1937
|
// confused operators chasing a missing record (#202).
|
|
1857
1938
|
return json(
|
|
1858
|
-
{
|
|
1939
|
+
{
|
|
1940
|
+
error: "unprocessable_content",
|
|
1941
|
+
error_type: "content_edit_not_found",
|
|
1942
|
+
field: "content_edit.old_text",
|
|
1943
|
+
message: `content_edit: \`old_text\` not found in note "${note.id}". Re-read and retry.`,
|
|
1944
|
+
hint: "re-read the note's current content and retry with an old_text that occurs exactly once",
|
|
1945
|
+
},
|
|
1859
1946
|
422,
|
|
1860
1947
|
);
|
|
1861
1948
|
}
|
|
1862
1949
|
const second = note.content.indexOf(ce.old_text, idx + 1);
|
|
1863
1950
|
if (second >= 0) {
|
|
1864
1951
|
return json(
|
|
1865
|
-
{
|
|
1952
|
+
{
|
|
1953
|
+
error: "ambiguous",
|
|
1954
|
+
error_type: "content_edit_ambiguous",
|
|
1955
|
+
field: "content_edit.old_text",
|
|
1956
|
+
message: `content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context.`,
|
|
1957
|
+
hint: "add surrounding context to old_text so it matches exactly once",
|
|
1958
|
+
},
|
|
1866
1959
|
409,
|
|
1867
1960
|
);
|
|
1868
1961
|
}
|
|
@@ -1925,7 +2018,13 @@ async function handleNotesInner(
|
|
|
1925
2018
|
if (stBody !== undefined) {
|
|
1926
2019
|
if (typeof stBody.field !== "string" || stBody.field.length === 0) {
|
|
1927
2020
|
return json(
|
|
1928
|
-
{
|
|
2021
|
+
{
|
|
2022
|
+
error: "bad_request",
|
|
2023
|
+
error_type: "invalid_state_transition",
|
|
2024
|
+
field: "state_transition.field",
|
|
2025
|
+
message: "`state_transition.field` must be a non-empty string.",
|
|
2026
|
+
hint: "pass a non-empty string naming the metadata field to transition",
|
|
2027
|
+
},
|
|
1929
2028
|
400,
|
|
1930
2029
|
);
|
|
1931
2030
|
}
|
|
@@ -1986,7 +2085,7 @@ async function handleNotesInner(
|
|
|
1986
2085
|
// Note first, then carry the field across the lean conversion (since
|
|
1987
2086
|
// `toNoteIndex` drops unknown fields).
|
|
1988
2087
|
const updatedNote = await store.getNote(note.id);
|
|
1989
|
-
if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
|
|
2088
|
+
if (updatedNote === null) return json({ error: "Note disappeared", error_type: "not_found" }, 404);
|
|
1990
2089
|
const validated: any = attachValidationStatus(store, db, updatedNote);
|
|
1991
2090
|
// Echo hydrated links when a link mutation was part of this request,
|
|
1992
2091
|
// OR the caller explicitly asked for them via `?include_links=true`
|
|
@@ -2023,7 +2122,7 @@ async function handleNotesInner(
|
|
|
2023
2122
|
lean.created = false;
|
|
2024
2123
|
return json(lean);
|
|
2025
2124
|
} catch (e: any) {
|
|
2026
|
-
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
2125
|
+
if (e instanceof NotFoundError) return json({ error: e.message, error_type: "not_found" }, 404);
|
|
2027
2126
|
// Duck-type on `code` rather than `instanceof ConflictError`: this
|
|
2028
2127
|
// error originates in the core package and survives any future
|
|
2029
2128
|
// bundling / module-boundary split more robustly than a prototype check.
|
|
@@ -2037,6 +2136,7 @@ async function handleNotesInner(
|
|
|
2037
2136
|
path: e.note_path ?? null,
|
|
2038
2137
|
note_id: e.note_id,
|
|
2039
2138
|
message: e.message,
|
|
2139
|
+
hint: "re-read the note (GET) and re-apply your change against its current `updated_at`, or pass `force: true` to overwrite",
|
|
2040
2140
|
// Legacy fields — kept for the lens VaultConflictError shim and
|
|
2041
2141
|
// any other pre-launch callers. Safe to drop post-launch.
|
|
2042
2142
|
error: "conflict",
|
|
@@ -2060,6 +2160,7 @@ async function handleNotesInner(
|
|
|
2060
2160
|
to: e.to,
|
|
2061
2161
|
current: e.current ?? null,
|
|
2062
2162
|
message: e.message,
|
|
2163
|
+
hint: "re-read the note's current value for this field and retry the transition from its actual current state",
|
|
2063
2164
|
},
|
|
2064
2165
|
409,
|
|
2065
2166
|
);
|
|
@@ -2074,6 +2175,7 @@ async function handleNotesInner(
|
|
|
2074
2175
|
error: "schema_validation",
|
|
2075
2176
|
violations: e.violations ?? [],
|
|
2076
2177
|
message: e.message,
|
|
2178
|
+
hint: "fix every field listed in `violations` and retry — none of this write was applied",
|
|
2077
2179
|
},
|
|
2078
2180
|
422,
|
|
2079
2181
|
);
|
|
@@ -2081,7 +2183,13 @@ async function handleNotesInner(
|
|
|
2081
2183
|
// Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
|
|
2082
2184
|
if (e && e.code === "PATH_CONFLICT") {
|
|
2083
2185
|
return json(
|
|
2084
|
-
{
|
|
2186
|
+
{
|
|
2187
|
+
error_type: "path_conflict",
|
|
2188
|
+
error: "path_conflict",
|
|
2189
|
+
path: e.path,
|
|
2190
|
+
message: e.message,
|
|
2191
|
+
hint: "pass a different `path`, or omit it to leave the existing path unchanged",
|
|
2192
|
+
},
|
|
2085
2193
|
409,
|
|
2086
2194
|
);
|
|
2087
2195
|
}
|
|
@@ -2098,17 +2206,17 @@ async function handleNotesInner(
|
|
|
2098
2206
|
// DELETE /notes/:idOrPath — vault:write (no admin gate; consistent with verbForMethod)
|
|
2099
2207
|
if (method === "DELETE") {
|
|
2100
2208
|
const note = await resolveNote(store, idOrPath);
|
|
2101
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
2209
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2102
2210
|
// Tag-scope: can't delete what you can't read. 404 (not 403) for the
|
|
2103
2211
|
// same no-leak reason as the read paths.
|
|
2104
2212
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
2105
|
-
return json({ error: "Not found" }, 404);
|
|
2213
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2106
2214
|
}
|
|
2107
2215
|
await store.deleteNote(note.id);
|
|
2108
2216
|
return json({ deleted: true, id: note.id });
|
|
2109
2217
|
}
|
|
2110
2218
|
|
|
2111
|
-
return json({ error: "Method not allowed" }, 405);
|
|
2219
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2112
2220
|
}
|
|
2113
2221
|
|
|
2114
2222
|
// ---------------------------------------------------------------------------
|
|
@@ -2203,18 +2311,18 @@ export async function handleTags(
|
|
|
2203
2311
|
// POST /tags/merge — atomic multi-source merge into a target tag.
|
|
2204
2312
|
// Must come before the /:name matcher so "merge" isn't read as a tag name.
|
|
2205
2313
|
if (subpath === "/merge") {
|
|
2206
|
-
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
2314
|
+
if (req.method !== "POST") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2207
2315
|
const body = (await req.json().catch(() => null)) as
|
|
2208
2316
|
| { sources?: unknown; target?: unknown }
|
|
2209
2317
|
| null;
|
|
2210
|
-
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2318
|
+
if (!body) return json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400);
|
|
2211
2319
|
const sources = body.sources;
|
|
2212
2320
|
const target = body.target;
|
|
2213
2321
|
if (!Array.isArray(sources) || !sources.every((s) => typeof s === "string" && s.length > 0)) {
|
|
2214
|
-
return json({ error: "sources must be a non-empty array of strings" }, 400);
|
|
2322
|
+
return json({ error: "sources must be a non-empty array of strings", error_type: "invalid_request", field: "sources" }, 400);
|
|
2215
2323
|
}
|
|
2216
2324
|
if (typeof target !== "string" || target.length === 0) {
|
|
2217
|
-
return json({ error: "target must be a non-empty string" }, 400);
|
|
2325
|
+
return json({ error: "target must be a non-empty string", error_type: "invalid_request", field: "target" }, 400);
|
|
2218
2326
|
}
|
|
2219
2327
|
// Tag-scope: every source AND the target must be inside the allowlist.
|
|
2220
2328
|
// A merge that pulls notes out of a token's scope (or pushes notes into
|
|
@@ -2254,13 +2362,13 @@ export async function handleTags(
|
|
|
2254
2362
|
// POST /tags/:name/rename — atomic rename across tags + note_tags + schema
|
|
2255
2363
|
const renameMatch = subpath.match(/^\/([^/]+)\/rename$/);
|
|
2256
2364
|
if (renameMatch) {
|
|
2257
|
-
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
2365
|
+
if (req.method !== "POST") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2258
2366
|
const oldName = decodeURIComponent(renameMatch[1]!);
|
|
2259
2367
|
const body = (await req.json().catch(() => null)) as { new_name?: unknown } | null;
|
|
2260
|
-
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2368
|
+
if (!body) return json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400);
|
|
2261
2369
|
const newName = body.new_name;
|
|
2262
2370
|
if (typeof newName !== "string" || newName.length === 0) {
|
|
2263
|
-
return json({ error: "new_name must be a non-empty string" }, 400);
|
|
2371
|
+
return json({ error: "new_name must be a non-empty string", error_type: "invalid_request", field: "new_name" }, 400);
|
|
2264
2372
|
}
|
|
2265
2373
|
if (tagScope.allowed && (!tagScope.allowed.has(oldName) || !tagScope.allowed.has(newName))) {
|
|
2266
2374
|
return tagScopeForbidden(tagScope.raw ?? []);
|
|
@@ -2271,14 +2379,18 @@ export async function handleTags(
|
|
|
2271
2379
|
// notes.ts:renameTag for the surfaces touched.
|
|
2272
2380
|
const result = await store.renameTag(oldName, newName);
|
|
2273
2381
|
if ("error" in result) {
|
|
2274
|
-
if (result.error === "not_found")
|
|
2382
|
+
if (result.error === "not_found") {
|
|
2383
|
+
return json({ error: "not_found", error_type: "tag_not_found", tag: oldName }, 404);
|
|
2384
|
+
}
|
|
2275
2385
|
if (result.error === "target_exists") {
|
|
2276
2386
|
return json(
|
|
2277
2387
|
{
|
|
2278
2388
|
error: "target_exists",
|
|
2389
|
+
error_type: "target_exists",
|
|
2279
2390
|
target: newName,
|
|
2280
2391
|
conflicting: result.conflicting,
|
|
2281
2392
|
message: "Target tag (or one of its sub-tags) already exists; use POST /api/tags/merge to combine them.",
|
|
2393
|
+
hint: "use POST /api/tags/merge to combine the tags instead",
|
|
2282
2394
|
},
|
|
2283
2395
|
409,
|
|
2284
2396
|
);
|
|
@@ -2293,15 +2405,15 @@ export async function handleTags(
|
|
|
2293
2405
|
// /:name matcher so "conformance" isn't read as a tag name.
|
|
2294
2406
|
const conformanceMatch = subpath.match(/^\/([^/]+)\/conformance$/);
|
|
2295
2407
|
if (conformanceMatch) {
|
|
2296
|
-
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
|
2408
|
+
if (req.method !== "POST") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2297
2409
|
const cTag = decodeURIComponent(conformanceMatch[1]!);
|
|
2298
2410
|
if (tagScope.allowed && !tagScope.allowed.has(cTag)) {
|
|
2299
|
-
return json({ error: "Tag not found", tag: cTag }, 404);
|
|
2411
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: cTag }, 404);
|
|
2300
2412
|
}
|
|
2301
2413
|
const body = (await req.json().catch(() => null)) as
|
|
2302
2414
|
| { fields?: Record<string, unknown> | null }
|
|
2303
2415
|
| null;
|
|
2304
|
-
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2416
|
+
if (!body) return json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400);
|
|
2305
2417
|
// The proposed fields the operator intends to save. Sanitized through the
|
|
2306
2418
|
// same parse the resolver uses (drop non-object specs). Empty/absent →
|
|
2307
2419
|
// nothing to enforce → zero violations.
|
|
@@ -2323,10 +2435,10 @@ export async function handleTags(
|
|
|
2323
2435
|
// Schema editor (vault#283). Must precede the /:name matcher.
|
|
2324
2436
|
const effectiveMatch = subpath.match(/^\/([^/]+)\/effective$/);
|
|
2325
2437
|
if (effectiveMatch) {
|
|
2326
|
-
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
2438
|
+
if (req.method !== "GET") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2327
2439
|
const eTag = decodeURIComponent(effectiveMatch[1]!);
|
|
2328
2440
|
if (tagScope.allowed && !tagScope.allowed.has(eTag)) {
|
|
2329
|
-
return json({ error: "Tag not found", tag: eTag }, 404);
|
|
2441
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: eTag }, 404);
|
|
2330
2442
|
}
|
|
2331
2443
|
const projection = buildVaultProjection(store.db);
|
|
2332
2444
|
const record = await store.getTagRecord(eTag);
|
|
@@ -2347,7 +2459,7 @@ export async function handleTags(
|
|
|
2347
2459
|
|
|
2348
2460
|
// Routes with tag name
|
|
2349
2461
|
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
2350
|
-
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
2462
|
+
if (!nameMatch) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2351
2463
|
const tagName = decodeURIComponent(nameMatch[1]!);
|
|
2352
2464
|
|
|
2353
2465
|
// GET /tags/:name — single tag detail (full record)
|
|
@@ -2450,7 +2562,15 @@ export async function handleTags(
|
|
|
2450
2562
|
parentNamesPatch = null;
|
|
2451
2563
|
} else if (body.parent_names !== undefined) {
|
|
2452
2564
|
if (!Array.isArray(body.parent_names)) {
|
|
2453
|
-
return json(
|
|
2565
|
+
return json(
|
|
2566
|
+
{
|
|
2567
|
+
error: "parent_names must be an array of tag names",
|
|
2568
|
+
error_type: "invalid_parent_names",
|
|
2569
|
+
field: "parent_names",
|
|
2570
|
+
hint: "pass an array of tag name strings, or null to clear",
|
|
2571
|
+
},
|
|
2572
|
+
400,
|
|
2573
|
+
);
|
|
2454
2574
|
}
|
|
2455
2575
|
const cleaned = (body.parent_names as unknown[]).filter(
|
|
2456
2576
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
@@ -2483,6 +2603,46 @@ export async function handleTags(
|
|
|
2483
2603
|
}
|
|
2484
2604
|
}
|
|
2485
2605
|
|
|
2606
|
+
// Cross-tag field validation (vault#553/#554) — validate the fields THIS
|
|
2607
|
+
// call is declaring (`body.fields`, not the merged `fieldsPatch`) against
|
|
2608
|
+
// every other tag's schema BEFORE any write. Mirrors the MCP update-tag
|
|
2609
|
+
// tool's cross-tag checks so REST and MCP report identically for this
|
|
2610
|
+
// class: EVERY conflicting field in one response (not just the first),
|
|
2611
|
+
// and the message states explicitly that no changes were applied —
|
|
2612
|
+
// nothing is persisted before this check runs. Two case families are
|
|
2613
|
+
// deliberately EXCLUDED and keep their pre-existing `IndexedFieldError`
|
|
2614
|
+
// → 400 `invalid_indexed_field` path below (unchanged status/error_type):
|
|
2615
|
+
// the own-field checks (unsupported type, invalid name — vault#478) AND
|
|
2616
|
+
// both-indexed cross-tag type conflicts (already 400 on main via
|
|
2617
|
+
// `declareField`'s cross-declarer sqlite-type check; the wire-contract
|
|
2618
|
+
// floor forbids flipping that to 422). What this pre-check newly rejects
|
|
2619
|
+
// — silent 200s on main — is non-indexed type conflicts and indexed-flag
|
|
2620
|
+
// conflicts. See `collectCrossTagFieldViolations`'s doc comment.
|
|
2621
|
+
if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
|
|
2622
|
+
const fieldViolations = tagSchemaOps.collectCrossTagFieldViolations(
|
|
2623
|
+
store.db,
|
|
2624
|
+
putTagName,
|
|
2625
|
+
body.fields as Record<string, tagSchemaOps.TagFieldSchema>,
|
|
2626
|
+
);
|
|
2627
|
+
if (fieldViolations.length > 0) {
|
|
2628
|
+
// Tag-scope scrub (vault#554 auth-and-scope fold): the write is
|
|
2629
|
+
// still rejected, but a violation whose conflicting declarer is
|
|
2630
|
+
// outside a scoped caller's allowlist must not name that tag or
|
|
2631
|
+
// reveal its schema. No-op for unscoped callers (allowed === null).
|
|
2632
|
+
const violations = scrubTagFieldViolationsByScope(fieldViolations, tagScope.allowed);
|
|
2633
|
+
return json(
|
|
2634
|
+
{
|
|
2635
|
+
error: "tag_field_conflict",
|
|
2636
|
+
error_type: "tag_field_conflict",
|
|
2637
|
+
tag: putTagName,
|
|
2638
|
+
violations,
|
|
2639
|
+
message: `${violations.length} field violation(s) for tag "${putTagName}" — no changes were applied.`,
|
|
2640
|
+
},
|
|
2641
|
+
422,
|
|
2642
|
+
);
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2486
2646
|
// A bad indexed-field name (or an unindexable type, or a cross-tag type
|
|
2487
2647
|
// mismatch) is a CLIENT error — return 400, not the catch-all 500. The
|
|
2488
2648
|
// store pre-validates and is transactional, so the schema is left
|
|
@@ -2497,11 +2657,40 @@ export async function handleTags(
|
|
|
2497
2657
|
});
|
|
2498
2658
|
} catch (err) {
|
|
2499
2659
|
if (err instanceof IndexedFieldError) {
|
|
2660
|
+
// Tag-scope scrub (vault#554 fold): declareField's cross-declarer
|
|
2661
|
+
// type-conflict message names the other declarer tag(s) + their
|
|
2662
|
+
// sqlite type — generalize when any declarer is outside a scoped
|
|
2663
|
+
// caller's allowlist. No-op for unscoped callers and for the solo
|
|
2664
|
+
// own-field errors (no declarer_tags). Status/error_type unchanged.
|
|
2665
|
+
const scrubbed = scrubIndexedFieldConflictError(err, tagScope.allowed);
|
|
2500
2666
|
return json(
|
|
2501
|
-
{ error:
|
|
2667
|
+
{ error: scrubbed.message, error_type: "invalid_indexed_field" },
|
|
2502
2668
|
400,
|
|
2503
2669
|
);
|
|
2504
2670
|
}
|
|
2671
|
+
if (err instanceof ParentCycleError) {
|
|
2672
|
+
// vault#552: parent_names would close a cycle. Nothing was
|
|
2673
|
+
// persisted. Scope-scrub the cycle path for a tag-scoped caller —
|
|
2674
|
+
// the write stays rejected either way. `error` is a SHORT code and
|
|
2675
|
+
// `message` the human sentence — the same split every sibling 409 in
|
|
2676
|
+
// this file uses (target_exists, tag_in_use_by_tokens,
|
|
2677
|
+
// tag_referenced_as_parent). parachute-surface's shared VaultClient
|
|
2678
|
+
// 409 handler reads `body.message` (falling back to a hardcoded
|
|
2679
|
+
// "Note was edited elsewhere"), so the message key is load-bearing:
|
|
2680
|
+
// without it the Tags parent_names editor would show the wrong
|
|
2681
|
+
// conflict text.
|
|
2682
|
+
const scrubbed = scrubParentCycleError(err, tagScope.allowed);
|
|
2683
|
+
return json(
|
|
2684
|
+
{
|
|
2685
|
+
error: "ParentCycle",
|
|
2686
|
+
error_type: "parent_cycle",
|
|
2687
|
+
tag: scrubbed.tag,
|
|
2688
|
+
cycle: scrubbed.cycle,
|
|
2689
|
+
message: scrubbed.message,
|
|
2690
|
+
},
|
|
2691
|
+
409,
|
|
2692
|
+
);
|
|
2693
|
+
}
|
|
2505
2694
|
throw err;
|
|
2506
2695
|
}
|
|
2507
2696
|
return json(result);
|
|
@@ -2529,10 +2718,36 @@ export async function handleTags(
|
|
|
2529
2718
|
409,
|
|
2530
2719
|
);
|
|
2531
2720
|
}
|
|
2532
|
-
|
|
2721
|
+
// `?cascade=true` / `?detach=true` (synonyms — see DeleteTagOpts):
|
|
2722
|
+
// required to delete a tag another tag's parent_names still references
|
|
2723
|
+
// (vault#552). Query params, not a DELETE body, so the flag is visible
|
|
2724
|
+
// in access logs and works with any HTTP client without body support.
|
|
2725
|
+
const cascade = parseBool(parseQuery(url, "cascade"), false);
|
|
2726
|
+
const detach = parseBool(parseQuery(url, "detach"), false);
|
|
2727
|
+
const result = await store.deleteTag(tagName, { cascade, detach });
|
|
2728
|
+
if ("error" in result) {
|
|
2729
|
+
// Referential-integrity refusal (vault#552). Scope-scrub the
|
|
2730
|
+
// referencing tag names for a tag-scoped caller — the delete is
|
|
2731
|
+
// still refused (integrity is scope-independent), but an
|
|
2732
|
+
// out-of-scope referrer's name must not leak. Same posture as
|
|
2733
|
+
// `tag_field_conflict`'s scrub.
|
|
2734
|
+
const referencing_tags = scrubReferencingTagsByScope(result.referencing_tags, tagScope.allowed);
|
|
2735
|
+
return json(
|
|
2736
|
+
{
|
|
2737
|
+
error: "TagReferencedAsParent",
|
|
2738
|
+
error_type: "tag_referenced_as_parent",
|
|
2739
|
+
tag: tagName,
|
|
2740
|
+
referencing_tags,
|
|
2741
|
+
message: `Tag "${tagName}" is referenced by ${referencing_tags.length} other tag(s)' parent_names; pass ?cascade=true or ?detach=true to also remove the reference, or update the referencing tag(s) first.`,
|
|
2742
|
+
hint: "pass ?cascade=true or ?detach=true, or fix the referencing tag(s)' parent_names first",
|
|
2743
|
+
},
|
|
2744
|
+
409,
|
|
2745
|
+
);
|
|
2746
|
+
}
|
|
2747
|
+
return json(result);
|
|
2533
2748
|
}
|
|
2534
2749
|
|
|
2535
|
-
return json({ error: "Method not allowed" }, 405);
|
|
2750
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2536
2751
|
}
|
|
2537
2752
|
|
|
2538
2753
|
// ---------------------------------------------------------------------------
|
|
@@ -2544,24 +2759,29 @@ export async function handleFindPath(
|
|
|
2544
2759
|
store: Store,
|
|
2545
2760
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
2546
2761
|
): Promise<Response> {
|
|
2547
|
-
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
2762
|
+
if (req.method !== "GET") return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2548
2763
|
|
|
2549
2764
|
const url = new URL(req.url);
|
|
2550
2765
|
const source = parseQuery(url, "source");
|
|
2551
2766
|
const target = parseQuery(url, "target");
|
|
2552
|
-
if (!source || !target)
|
|
2767
|
+
if (!source || !target) {
|
|
2768
|
+
return json(
|
|
2769
|
+
{ error: "source and target parameters are required", error_type: "invalid_request", hint: "pass both ?source= and ?target=" },
|
|
2770
|
+
400,
|
|
2771
|
+
);
|
|
2772
|
+
}
|
|
2553
2773
|
|
|
2554
2774
|
const db = store.db;
|
|
2555
2775
|
try {
|
|
2556
2776
|
const sourceNote = await resolveNote(store, source);
|
|
2557
|
-
if (!sourceNote) return json({ error: `Note not found: "${source}"
|
|
2777
|
+
if (!sourceNote) return json({ error: `Note not found: "${source}"`, error_type: "not_found", note_id: source }, 404);
|
|
2558
2778
|
if (!noteWithinTagScope(sourceNote, tagScope.allowed, tagScope.raw)) {
|
|
2559
|
-
return json({ error: `Note not found: "${source}"
|
|
2779
|
+
return json({ error: `Note not found: "${source}"`, error_type: "not_found", note_id: source }, 404);
|
|
2560
2780
|
}
|
|
2561
2781
|
const targetNote = await resolveNote(store, target);
|
|
2562
|
-
if (!targetNote) return json({ error: `Note not found: "${target}"
|
|
2782
|
+
if (!targetNote) return json({ error: `Note not found: "${target}"`, error_type: "not_found", note_id: target }, 404);
|
|
2563
2783
|
if (!noteWithinTagScope(targetNote, tagScope.allowed, tagScope.raw)) {
|
|
2564
|
-
return json({ error: `Note not found: "${target}"
|
|
2784
|
+
return json({ error: `Note not found: "${target}"`, error_type: "not_found", note_id: target }, 404);
|
|
2565
2785
|
}
|
|
2566
2786
|
const maxDepth = Math.min(parseInt10(parseQuery(url, "max_depth")) ?? 5, 10);
|
|
2567
2787
|
|
|
@@ -2579,7 +2799,7 @@ export async function handleFindPath(
|
|
|
2579
2799
|
}
|
|
2580
2800
|
return json(result);
|
|
2581
2801
|
} catch (e: any) {
|
|
2582
|
-
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
2802
|
+
if (e instanceof NotFoundError) return json({ error: e.message, error_type: "not_found" }, 404);
|
|
2583
2803
|
// vault#331 N1 — surface AmbiguousPathError from resolveNote as 409
|
|
2584
2804
|
// mirroring the handleNotes path. Without this, an ambiguous source/
|
|
2585
2805
|
// target path on /api/find-path bubbled to a server-level 500.
|
|
@@ -2678,7 +2898,11 @@ export async function handleVault(
|
|
|
2678
2898
|
return json(
|
|
2679
2899
|
{
|
|
2680
2900
|
error: "invalid_audio_retention",
|
|
2901
|
+
error_type: "invalid_audio_retention",
|
|
2902
|
+
field: "config.audio_retention",
|
|
2903
|
+
got: v,
|
|
2681
2904
|
message: `audio_retention must be one of: ${VALID_AUDIO_RETENTION.join(", ")}`,
|
|
2905
|
+
hint: `pass one of: ${VALID_AUDIO_RETENTION.join(", ")}`,
|
|
2682
2906
|
},
|
|
2683
2907
|
400,
|
|
2684
2908
|
);
|
|
@@ -2700,7 +2924,11 @@ export async function handleVault(
|
|
|
2700
2924
|
return json(
|
|
2701
2925
|
{
|
|
2702
2926
|
error: "invalid_auto_transcribe",
|
|
2927
|
+
error_type: "invalid_auto_transcribe",
|
|
2928
|
+
field: "config.auto_transcribe.enabled",
|
|
2929
|
+
got: enabled,
|
|
2703
2930
|
message: "auto_transcribe.enabled must be a boolean",
|
|
2931
|
+
hint: "pass true or false",
|
|
2704
2932
|
},
|
|
2705
2933
|
400,
|
|
2706
2934
|
);
|
|
@@ -2713,7 +2941,7 @@ export async function handleVault(
|
|
|
2713
2941
|
return json(vaultResponse(vaultConfig));
|
|
2714
2942
|
}
|
|
2715
2943
|
|
|
2716
|
-
return json({ error: "Method not allowed" }, 405);
|
|
2944
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2717
2945
|
}
|
|
2718
2946
|
|
|
2719
2947
|
// ---------------------------------------------------------------------------
|
|
@@ -2748,6 +2976,27 @@ export function handleUnresolvedWikilinks(
|
|
|
2748
2976
|
return Response.json({ unresolved: filtered, count: filtered.length });
|
|
2749
2977
|
}
|
|
2750
2978
|
|
|
2979
|
+
// ---------------------------------------------------------------------------
|
|
2980
|
+
// Doctor — GET /api/doctor (vault#552, admin-gated in routing.ts)
|
|
2981
|
+
// ---------------------------------------------------------------------------
|
|
2982
|
+
|
|
2983
|
+
/**
|
|
2984
|
+
* `GET /vault/{name}/api/doctor` — read-only taxonomy/metadata integrity
|
|
2985
|
+
* scan. Method + `vault:admin` scope are already enforced by the caller
|
|
2986
|
+
* (`src/routing.ts`, dispatched before the generic read/write gate — same
|
|
2987
|
+
* shape as `/api/triggers`); this handler just runs the scan with the
|
|
2988
|
+
* caller's tag-scope allowlist. See `core/src/doctor.ts` for the finding
|
|
2989
|
+
* catalog.
|
|
2990
|
+
*/
|
|
2991
|
+
export async function handleDoctor(
|
|
2992
|
+
_req: Request,
|
|
2993
|
+
store: Store,
|
|
2994
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
2995
|
+
): Promise<Response> {
|
|
2996
|
+
const report = await store.doctor({ allowedTags: tagScope.allowed });
|
|
2997
|
+
return json(report);
|
|
2998
|
+
}
|
|
2999
|
+
|
|
2751
3000
|
// ---------------------------------------------------------------------------
|
|
2752
3001
|
// Published notes — public, no-auth HTML rendering
|
|
2753
3002
|
// ---------------------------------------------------------------------------
|
|
@@ -2981,6 +3230,7 @@ async function handleRetryTranscription(
|
|
|
2981
3230
|
return json(
|
|
2982
3231
|
{
|
|
2983
3232
|
error: "not_failed",
|
|
3233
|
+
error_type: "not_failed",
|
|
2984
3234
|
message: `Transcript note status is "${meta.transcript_status}" — only failed transcripts can be retried.`,
|
|
2985
3235
|
transcript_status: meta.transcript_status,
|
|
2986
3236
|
},
|
|
@@ -2994,6 +3244,7 @@ async function handleRetryTranscription(
|
|
|
2994
3244
|
return json(
|
|
2995
3245
|
{
|
|
2996
3246
|
error: "missing_attachment_id",
|
|
3247
|
+
error_type: "missing_attachment_id",
|
|
2997
3248
|
message: "Transcript note has no `transcript_attachment_id` — can't locate the original audio.",
|
|
2998
3249
|
},
|
|
2999
3250
|
400,
|
|
@@ -3004,6 +3255,7 @@ async function handleRetryTranscription(
|
|
|
3004
3255
|
return json(
|
|
3005
3256
|
{
|
|
3006
3257
|
error: "attachment_missing",
|
|
3258
|
+
error_type: "attachment_missing",
|
|
3007
3259
|
message: `Original audio attachment ${attachmentId} no longer exists in the vault.`,
|
|
3008
3260
|
},
|
|
3009
3261
|
404,
|
|
@@ -3017,6 +3269,7 @@ async function handleRetryTranscription(
|
|
|
3017
3269
|
return json(
|
|
3018
3270
|
{
|
|
3019
3271
|
error: "audio_missing",
|
|
3272
|
+
error_type: "audio_missing",
|
|
3020
3273
|
message: `Original audio file at "${attachment.path}" no longer exists on disk.`,
|
|
3021
3274
|
},
|
|
3022
3275
|
404,
|
|
@@ -3097,6 +3350,7 @@ async function handleRetryLegacyInBody(
|
|
|
3097
3350
|
return json(
|
|
3098
3351
|
{
|
|
3099
3352
|
error: "no_failed_attachment",
|
|
3353
|
+
error_type: "no_failed_attachment",
|
|
3100
3354
|
message:
|
|
3101
3355
|
"Target note is not a transcript note and has no audio attachment with a failed transcription to retry.",
|
|
3102
3356
|
},
|
|
@@ -3112,6 +3366,7 @@ async function handleRetryLegacyInBody(
|
|
|
3112
3366
|
return json(
|
|
3113
3367
|
{
|
|
3114
3368
|
error: "audio_missing",
|
|
3369
|
+
error_type: "audio_missing",
|
|
3115
3370
|
message: `Original audio file at "${failed.path}" no longer exists on disk.`,
|
|
3116
3371
|
},
|
|
3117
3372
|
404,
|
|
@@ -3164,7 +3419,7 @@ async function handleRetryLegacyInBody(
|
|
|
3164
3419
|
const fresh = await store.getNote(note.id);
|
|
3165
3420
|
if (!fresh) {
|
|
3166
3421
|
return json(
|
|
3167
|
-
{ error: "note_missing", message: "Target note disappeared during retry." },
|
|
3422
|
+
{ error: "note_missing", error_type: "not_found", message: "Target note disappeared during retry." },
|
|
3168
3423
|
404,
|
|
3169
3424
|
);
|
|
3170
3425
|
}
|
|
@@ -3194,6 +3449,7 @@ async function handleRetryLegacyInBody(
|
|
|
3194
3449
|
expected_updated_at: err.expected_updated_at,
|
|
3195
3450
|
message:
|
|
3196
3451
|
"Note was modified concurrently while arming the retry; re-fetch and try again.",
|
|
3452
|
+
hint: "re-fetch the note and retry the retry-transcription call",
|
|
3197
3453
|
},
|
|
3198
3454
|
409,
|
|
3199
3455
|
);
|
|
@@ -3335,10 +3591,18 @@ export async function handleStorage(
|
|
|
3335
3591
|
const form = await req.formData();
|
|
3336
3592
|
const file = form.get("file");
|
|
3337
3593
|
if (!(file instanceof File)) {
|
|
3338
|
-
return json({ error: "file is required" }, 400);
|
|
3594
|
+
return json({ error: "file is required", error_type: "missing_required_field", field: "file" }, 400);
|
|
3339
3595
|
}
|
|
3340
3596
|
if (file.size > MAX_UPLOAD_BYTES) {
|
|
3341
|
-
return json(
|
|
3597
|
+
return json(
|
|
3598
|
+
{
|
|
3599
|
+
error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Max: 100MB`,
|
|
3600
|
+
error_type: "file_too_large",
|
|
3601
|
+
limit: MAX_UPLOAD_BYTES,
|
|
3602
|
+
got: file.size,
|
|
3603
|
+
},
|
|
3604
|
+
413,
|
|
3605
|
+
);
|
|
3342
3606
|
}
|
|
3343
3607
|
// Strip trailing dots/whitespace before extracting the extension so a
|
|
3344
3608
|
// `evil.html.` / `evil.svg ` can't slip past the blocklist
|
|
@@ -3350,7 +3614,14 @@ export async function handleStorage(
|
|
|
3350
3614
|
// served same-origin from /storage/ (see BLOCKED_EXTENSIONS). Everything
|
|
3351
3615
|
// else (incl. unknown/arbitrary files) is accepted and served as a
|
|
3352
3616
|
// download (octet-stream + nosniff).
|
|
3353
|
-
return json(
|
|
3617
|
+
return json(
|
|
3618
|
+
{
|
|
3619
|
+
error: `File type ${ext} not allowed (active/executable content)`,
|
|
3620
|
+
error_type: "blocked_upload_extension",
|
|
3621
|
+
extension: ext,
|
|
3622
|
+
},
|
|
3623
|
+
400,
|
|
3624
|
+
);
|
|
3354
3625
|
}
|
|
3355
3626
|
|
|
3356
3627
|
const date = new Date().toISOString().split("T")[0]!;
|
|
@@ -3396,7 +3667,7 @@ export async function handleStorage(
|
|
|
3396
3667
|
try {
|
|
3397
3668
|
decodedPath = decodeURIComponent(path);
|
|
3398
3669
|
} catch {
|
|
3399
|
-
return json({ error: "Not found" }, 404);
|
|
3670
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3400
3671
|
}
|
|
3401
3672
|
|
|
3402
3673
|
const fileMatch = decodedPath.match(/^\/([^/]+)\/(.+)$/);
|
|
@@ -3405,10 +3676,10 @@ export async function handleStorage(
|
|
|
3405
3676
|
const filePath = normalize(join(assets, reqPath));
|
|
3406
3677
|
|
|
3407
3678
|
if (!filePath.startsWith(normalize(assets))) {
|
|
3408
|
-
return json({ error: "Invalid path" }, 403);
|
|
3679
|
+
return json({ error: "Invalid path", error_type: "invalid_path" }, 403);
|
|
3409
3680
|
}
|
|
3410
3681
|
if (!existsSync(filePath)) {
|
|
3411
|
-
return json({ error: "Not found" }, 404);
|
|
3682
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3412
3683
|
}
|
|
3413
3684
|
|
|
3414
3685
|
// Tag-scope gate (C0 adversarial-audit finding). The note-keyed
|
|
@@ -3438,7 +3709,7 @@ export async function handleStorage(
|
|
|
3438
3709
|
}
|
|
3439
3710
|
}
|
|
3440
3711
|
if (!allowed) {
|
|
3441
|
-
return json({ error: "Not found" }, 404);
|
|
3712
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3442
3713
|
}
|
|
3443
3714
|
}
|
|
3444
3715
|
|
|
@@ -3461,7 +3732,7 @@ export async function handleStorage(
|
|
|
3461
3732
|
});
|
|
3462
3733
|
}
|
|
3463
3734
|
|
|
3464
|
-
return json({ error: "Not found" }, 404);
|
|
3735
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3465
3736
|
}
|
|
3466
3737
|
|
|
3467
3738
|
// ---------------------------------------------------------------------------
|