@openparachute/vault 0.7.0-rc.3 → 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 +66 -42
- package/core/src/notes.ts +9 -0
- package/core/src/tag-schemas.ts +186 -4
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/mcp-http.ts +109 -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 +276 -78
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-scope.ts +66 -0
package/src/routes.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
filterHydratedLinksByTagScope,
|
|
42
42
|
filterNotesByTagScope,
|
|
43
43
|
noteWithinTagScope,
|
|
44
|
+
scrubIndexedFieldConflictError,
|
|
45
|
+
scrubTagFieldViolationsByScope,
|
|
44
46
|
tagScopeForbidden,
|
|
45
47
|
tagsWithinScope,
|
|
46
48
|
} from "./tag-scope.ts";
|
|
@@ -242,7 +244,13 @@ function parseContentRangeQuery(
|
|
|
242
244
|
// is fragile across bundling boundaries (same note as the QueryError
|
|
243
245
|
// handling in the structured-query path below).
|
|
244
246
|
if (e && e.name === "QueryError") {
|
|
245
|
-
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
|
+
};
|
|
246
254
|
}
|
|
247
255
|
throw e;
|
|
248
256
|
}
|
|
@@ -402,6 +410,9 @@ function parseMetaBrackets(url: URL): {
|
|
|
402
410
|
{
|
|
403
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.`,
|
|
404
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`,
|
|
405
416
|
},
|
|
406
417
|
400,
|
|
407
418
|
);
|
|
@@ -431,6 +442,9 @@ function parseMetaBrackets(url: URL): {
|
|
|
431
442
|
{
|
|
432
443
|
error: `bracket-date filter on \`${field}\` requires an operator: meta[${field}][gte]=… (lower bound) or meta[${field}][lt]=… (upper bound, exclusive).`,
|
|
433
444
|
code: "INVALID_QUERY",
|
|
445
|
+
error_type: "invalid_query",
|
|
446
|
+
field,
|
|
447
|
+
hint: `pass meta[${field}][gte]=… and/or meta[${field}][lt]=…`,
|
|
434
448
|
},
|
|
435
449
|
400,
|
|
436
450
|
),
|
|
@@ -442,6 +456,10 @@ function parseMetaBrackets(url: URL): {
|
|
|
442
456
|
{
|
|
443
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.`,
|
|
444
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]=…`,
|
|
445
463
|
},
|
|
446
464
|
400,
|
|
447
465
|
),
|
|
@@ -456,6 +474,9 @@ function parseMetaBrackets(url: URL): {
|
|
|
456
474
|
{
|
|
457
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.`,
|
|
458
476
|
code: "INVALID_QUERY",
|
|
477
|
+
error_type: "invalid_query",
|
|
478
|
+
field,
|
|
479
|
+
hint: "issue two queries, or filter one date column per request",
|
|
459
480
|
},
|
|
460
481
|
400,
|
|
461
482
|
),
|
|
@@ -496,6 +517,10 @@ function parseMetaBrackets(url: URL): {
|
|
|
496
517
|
{
|
|
497
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.`,
|
|
498
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\``,
|
|
499
524
|
},
|
|
500
525
|
400,
|
|
501
526
|
),
|
|
@@ -536,6 +561,10 @@ function parseMetaBrackets(url: URL): {
|
|
|
536
561
|
{
|
|
537
562
|
error: `bracket-meta filter: \`exists\` on \`${field}\` requires "true" or "false", got "${value}"`,
|
|
538
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`,
|
|
539
568
|
},
|
|
540
569
|
400,
|
|
541
570
|
),
|
|
@@ -614,6 +643,9 @@ function parseMetadataJsonAlias(url: URL): {
|
|
|
614
643
|
{
|
|
615
644
|
error: `metadata query param must be a JSON object of the form {"field":{"op":value}} — ${detail}`,
|
|
616
645
|
code: "INVALID_QUERY",
|
|
646
|
+
error_type: "invalid_query",
|
|
647
|
+
field: "metadata",
|
|
648
|
+
hint: 'pass a JSON object, e.g. ?metadata={"status":{"eq":"active"}}',
|
|
617
649
|
},
|
|
618
650
|
400,
|
|
619
651
|
);
|
|
@@ -653,6 +685,10 @@ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Re
|
|
|
653
685
|
{
|
|
654
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).`,
|
|
655
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`,
|
|
656
692
|
},
|
|
657
693
|
400,
|
|
658
694
|
),
|
|
@@ -736,6 +772,8 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
736
772
|
{
|
|
737
773
|
error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
|
|
738
774
|
code: "INVALID_QUERY",
|
|
775
|
+
error_type: "invalid_query",
|
|
776
|
+
hint: "pick one metadata-filter form: `metadata=` or `meta[field][op]=`, not both",
|
|
739
777
|
},
|
|
740
778
|
400,
|
|
741
779
|
),
|
|
@@ -937,12 +975,12 @@ async function handleNotesInner(
|
|
|
937
975
|
// Single note by id/path
|
|
938
976
|
if (id) {
|
|
939
977
|
const note = await resolveNote(store, id);
|
|
940
|
-
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);
|
|
941
979
|
// Tag-scope: a token can't see what its allowlist excludes. Surface
|
|
942
980
|
// as 404 (not 403) — the existence of the note is itself information
|
|
943
981
|
// we shouldn't leak across the scope boundary.
|
|
944
982
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
945
|
-
return json({ error: "Note not found", id }, 404);
|
|
983
|
+
return json({ error: "Note not found", error_type: "not_found", id }, 404);
|
|
946
984
|
}
|
|
947
985
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
948
986
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
@@ -992,6 +1030,9 @@ async function handleNotesInner(
|
|
|
992
1030
|
{
|
|
993
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.",
|
|
994
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",
|
|
995
1036
|
},
|
|
996
1037
|
400,
|
|
997
1038
|
);
|
|
@@ -1044,7 +1085,12 @@ async function handleNotesInner(
|
|
|
1044
1085
|
{
|
|
1045
1086
|
error: e.message,
|
|
1046
1087
|
code: e.code ?? "INVALID_QUERY",
|
|
1047
|
-
|
|
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",
|
|
1048
1094
|
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1049
1095
|
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1050
1096
|
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
@@ -1144,6 +1190,9 @@ async function handleNotesInner(
|
|
|
1144
1190
|
{
|
|
1145
1191
|
error: "cursor is incompatible with near (graph neighborhood). Resolve the neighborhood first, then iterate with cursor over the resulting note set.",
|
|
1146
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`",
|
|
1147
1196
|
},
|
|
1148
1197
|
400,
|
|
1149
1198
|
);
|
|
@@ -1188,15 +1237,16 @@ async function handleNotesInner(
|
|
|
1188
1237
|
// here. Duck-type on `name` + `code` — core is a separate module, so
|
|
1189
1238
|
// `instanceof` is fragile across bundling boundaries. The newer
|
|
1190
1239
|
// vault#550 call sites (limit/offset/date validation in
|
|
1191
|
-
// core/src/notes.ts) additionally set `
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
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.
|
|
1194
1244
|
if (e && e.name === "QueryError") {
|
|
1195
1245
|
return json(
|
|
1196
1246
|
{
|
|
1197
1247
|
error: e.message,
|
|
1198
1248
|
code: e.code ?? "INVALID_QUERY",
|
|
1199
|
-
|
|
1249
|
+
error_type: e.error_type ?? "invalid_query",
|
|
1200
1250
|
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1201
1251
|
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1202
1252
|
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
@@ -1208,9 +1258,11 @@ async function handleNotesInner(
|
|
|
1208
1258
|
// cursor_query_mismatch) so the agent loop can distinguish a
|
|
1209
1259
|
// malformed cursor from a hash-mismatch and react appropriately
|
|
1210
1260
|
// (the latter typically means the agent changed its filter and
|
|
1211
|
-
// 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.
|
|
1212
1264
|
if (e && e.name === "CursorError") {
|
|
1213
|
-
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);
|
|
1214
1266
|
}
|
|
1215
1267
|
throw e;
|
|
1216
1268
|
}
|
|
@@ -1219,10 +1271,10 @@ async function handleNotesInner(
|
|
|
1219
1271
|
const nearNoteId = parseQuery(url, "near[note_id]");
|
|
1220
1272
|
if (nearNoteId) {
|
|
1221
1273
|
const anchor = await resolveNote(store, nearNoteId);
|
|
1222
|
-
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);
|
|
1223
1275
|
// Tag-scope: anchor must itself be visible to this token.
|
|
1224
1276
|
if (!noteWithinTagScope(anchor, tagScope.allowed, tagScope.raw)) {
|
|
1225
|
-
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);
|
|
1226
1278
|
}
|
|
1227
1279
|
const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
|
|
1228
1280
|
const relationship = parseQuery(url, "near[relationship]") ?? undefined;
|
|
@@ -1449,14 +1501,26 @@ async function handleNotesInner(
|
|
|
1449
1501
|
// Duck-type for module-boundary robustness (matches the PATCH branch).
|
|
1450
1502
|
if (e && e.code === "PATH_CONFLICT") {
|
|
1451
1503
|
return json(
|
|
1452
|
-
{
|
|
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
|
+
},
|
|
1453
1511
|
409,
|
|
1454
1512
|
);
|
|
1455
1513
|
}
|
|
1456
1514
|
// Strict-schema rejection (vault#299 Part A) on create — 422.
|
|
1457
1515
|
if (e && e.code === "SCHEMA_VALIDATION") {
|
|
1458
1516
|
return json(
|
|
1459
|
-
{
|
|
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
|
+
},
|
|
1460
1524
|
422,
|
|
1461
1525
|
);
|
|
1462
1526
|
}
|
|
@@ -1499,12 +1563,12 @@ async function handleNotesInner(
|
|
|
1499
1563
|
return json(body.notes ? final : final[0], 201);
|
|
1500
1564
|
}
|
|
1501
1565
|
|
|
1502
|
-
return json({ error: "Method not allowed" }, 405);
|
|
1566
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1503
1567
|
}
|
|
1504
1568
|
|
|
1505
1569
|
// ---- Note-level routes (/notes/:idOrPath[/attachments]) ----
|
|
1506
1570
|
const idMatch = subpath.match(/^\/([^/]+)(\/.*)?$/);
|
|
1507
|
-
if (!idMatch) return json({ error: "Not found" }, 404);
|
|
1571
|
+
if (!idMatch) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1508
1572
|
|
|
1509
1573
|
const idOrPath = decodeURIComponent(idMatch[1]!);
|
|
1510
1574
|
const sub = idMatch[2] ?? "";
|
|
@@ -1513,12 +1577,17 @@ async function handleNotesInner(
|
|
|
1513
1577
|
if (sub === "/attachments") {
|
|
1514
1578
|
if (method === "POST") {
|
|
1515
1579
|
const note = await resolveNote(store, idOrPath);
|
|
1516
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1580
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1517
1581
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1518
|
-
return json({ error: "Not found" }, 404);
|
|
1582
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1519
1583
|
}
|
|
1520
1584
|
const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
|
|
1521
|
-
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
|
+
}
|
|
1522
1591
|
|
|
1523
1592
|
// Decide whether to enqueue this attachment for transcription. Two paths:
|
|
1524
1593
|
//
|
|
@@ -1567,13 +1636,13 @@ async function handleNotesInner(
|
|
|
1567
1636
|
}
|
|
1568
1637
|
if (method === "GET") {
|
|
1569
1638
|
const note = await resolveNote(store, idOrPath);
|
|
1570
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1639
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1571
1640
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1572
|
-
return json({ error: "Not found" }, 404);
|
|
1641
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1573
1642
|
}
|
|
1574
1643
|
return json(await store.getAttachments(note.id));
|
|
1575
1644
|
}
|
|
1576
|
-
return json({ error: "Method not allowed" }, 405);
|
|
1645
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1577
1646
|
}
|
|
1578
1647
|
|
|
1579
1648
|
const attMatch = sub.match(/^\/attachments\/([^/]+)$/);
|
|
@@ -1581,12 +1650,12 @@ async function handleNotesInner(
|
|
|
1581
1650
|
const attId = decodeURIComponent(attMatch[1]!);
|
|
1582
1651
|
if (method === "DELETE") {
|
|
1583
1652
|
const note = await resolveNote(store, idOrPath);
|
|
1584
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1653
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1585
1654
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1586
|
-
return json({ error: "Not found" }, 404);
|
|
1655
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1587
1656
|
}
|
|
1588
1657
|
const result = await store.deleteAttachment(note.id, attId);
|
|
1589
|
-
if (!result.deleted) return json({ error: "Not found" }, 404);
|
|
1658
|
+
if (!result.deleted) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1590
1659
|
// Unlink the storage file only if no other attachment still references
|
|
1591
1660
|
// the same path. Best-effort: the row is already gone, so a missing
|
|
1592
1661
|
// file or unlink error should not flip the DELETE to an error.
|
|
@@ -1599,7 +1668,7 @@ async function handleNotesInner(
|
|
|
1599
1668
|
}
|
|
1600
1669
|
return new Response(null, { status: 204 });
|
|
1601
1670
|
}
|
|
1602
|
-
return json({ error: "Method not allowed" }, 405);
|
|
1671
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
1603
1672
|
}
|
|
1604
1673
|
|
|
1605
1674
|
// POST /notes/:idOrPath/retry-transcription — vault#353 design Q5 + finding F.
|
|
@@ -1623,24 +1692,24 @@ async function handleNotesInner(
|
|
|
1623
1692
|
// 404 attachment_missing (auto-flow: transcript_attachment_id row deleted)
|
|
1624
1693
|
// 404 audio_missing (audio file unlinked from disk)
|
|
1625
1694
|
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);
|
|
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);
|
|
1628
1697
|
const note = await resolveNote(store, idOrPath);
|
|
1629
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1698
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1630
1699
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1631
|
-
return json({ error: "Not found" }, 404);
|
|
1700
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1632
1701
|
}
|
|
1633
1702
|
return handleRetryTranscription(store, note, vault);
|
|
1634
1703
|
}
|
|
1635
1704
|
|
|
1636
|
-
if (sub !== "") return json({ error: "Not found" }, 404);
|
|
1705
|
+
if (sub !== "") return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1637
1706
|
|
|
1638
1707
|
// GET /notes/:idOrPath — single note
|
|
1639
1708
|
if (method === "GET") {
|
|
1640
1709
|
const note = await resolveNote(store, idOrPath);
|
|
1641
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
1710
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1642
1711
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
1643
|
-
return json({ error: "Not found" }, 404);
|
|
1712
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
1644
1713
|
}
|
|
1645
1714
|
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
1646
1715
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
@@ -1748,7 +1817,7 @@ async function handleNotesInner(
|
|
|
1748
1817
|
}
|
|
1749
1818
|
}
|
|
1750
1819
|
const final = await store.getNote(created.id);
|
|
1751
|
-
if (!final) return json({ error: "Note disappeared" }, 500);
|
|
1820
|
+
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
1752
1821
|
const validated = attachValidationStatus(store, db, final);
|
|
1753
1822
|
const includeContentResp = body.include_content !== false;
|
|
1754
1823
|
if (includeContentResp) return json({ ...validated, created: true });
|
|
@@ -1788,7 +1857,9 @@ async function handleNotesInner(
|
|
|
1788
1857
|
return json(
|
|
1789
1858
|
{
|
|
1790
1859
|
error: "mutually_exclusive",
|
|
1860
|
+
error_type: "mutually_exclusive",
|
|
1791
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`",
|
|
1792
1863
|
},
|
|
1793
1864
|
400,
|
|
1794
1865
|
);
|
|
@@ -1833,6 +1904,7 @@ async function handleNotesInner(
|
|
|
1833
1904
|
"update requires `if_updated_at` (the note's last-seen updated_at) or `force: true`.",
|
|
1834
1905
|
note_id: note.id,
|
|
1835
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",
|
|
1836
1908
|
},
|
|
1837
1909
|
428,
|
|
1838
1910
|
);
|
|
@@ -1844,7 +1916,13 @@ async function handleNotesInner(
|
|
|
1844
1916
|
const ce = body.content_edit as { old_text?: unknown; new_text?: unknown };
|
|
1845
1917
|
if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
|
|
1846
1918
|
return json(
|
|
1847
|
-
{
|
|
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
|
+
},
|
|
1848
1926
|
400,
|
|
1849
1927
|
);
|
|
1850
1928
|
}
|
|
@@ -1855,14 +1933,26 @@ async function handleNotesInner(
|
|
|
1855
1933
|
// current content. Returning 404 implied "note doesn't exist" and
|
|
1856
1934
|
// confused operators chasing a missing record (#202).
|
|
1857
1935
|
return json(
|
|
1858
|
-
{
|
|
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
|
+
},
|
|
1859
1943
|
422,
|
|
1860
1944
|
);
|
|
1861
1945
|
}
|
|
1862
1946
|
const second = note.content.indexOf(ce.old_text, idx + 1);
|
|
1863
1947
|
if (second >= 0) {
|
|
1864
1948
|
return json(
|
|
1865
|
-
{
|
|
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
|
+
},
|
|
1866
1956
|
409,
|
|
1867
1957
|
);
|
|
1868
1958
|
}
|
|
@@ -1925,7 +2015,13 @@ async function handleNotesInner(
|
|
|
1925
2015
|
if (stBody !== undefined) {
|
|
1926
2016
|
if (typeof stBody.field !== "string" || stBody.field.length === 0) {
|
|
1927
2017
|
return json(
|
|
1928
|
-
{
|
|
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
|
+
},
|
|
1929
2025
|
400,
|
|
1930
2026
|
);
|
|
1931
2027
|
}
|
|
@@ -1986,7 +2082,7 @@ async function handleNotesInner(
|
|
|
1986
2082
|
// Note first, then carry the field across the lean conversion (since
|
|
1987
2083
|
// `toNoteIndex` drops unknown fields).
|
|
1988
2084
|
const updatedNote = await store.getNote(note.id);
|
|
1989
|
-
if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
|
|
2085
|
+
if (updatedNote === null) return json({ error: "Note disappeared", error_type: "not_found" }, 404);
|
|
1990
2086
|
const validated: any = attachValidationStatus(store, db, updatedNote);
|
|
1991
2087
|
// Echo hydrated links when a link mutation was part of this request,
|
|
1992
2088
|
// OR the caller explicitly asked for them via `?include_links=true`
|
|
@@ -2023,7 +2119,7 @@ async function handleNotesInner(
|
|
|
2023
2119
|
lean.created = false;
|
|
2024
2120
|
return json(lean);
|
|
2025
2121
|
} catch (e: any) {
|
|
2026
|
-
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);
|
|
2027
2123
|
// Duck-type on `code` rather than `instanceof ConflictError`: this
|
|
2028
2124
|
// error originates in the core package and survives any future
|
|
2029
2125
|
// bundling / module-boundary split more robustly than a prototype check.
|
|
@@ -2037,6 +2133,7 @@ async function handleNotesInner(
|
|
|
2037
2133
|
path: e.note_path ?? null,
|
|
2038
2134
|
note_id: e.note_id,
|
|
2039
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",
|
|
2040
2137
|
// Legacy fields — kept for the lens VaultConflictError shim and
|
|
2041
2138
|
// any other pre-launch callers. Safe to drop post-launch.
|
|
2042
2139
|
error: "conflict",
|
|
@@ -2060,6 +2157,7 @@ async function handleNotesInner(
|
|
|
2060
2157
|
to: e.to,
|
|
2061
2158
|
current: e.current ?? null,
|
|
2062
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",
|
|
2063
2161
|
},
|
|
2064
2162
|
409,
|
|
2065
2163
|
);
|
|
@@ -2074,6 +2172,7 @@ async function handleNotesInner(
|
|
|
2074
2172
|
error: "schema_validation",
|
|
2075
2173
|
violations: e.violations ?? [],
|
|
2076
2174
|
message: e.message,
|
|
2175
|
+
hint: "fix every field listed in `violations` and retry — none of this write was applied",
|
|
2077
2176
|
},
|
|
2078
2177
|
422,
|
|
2079
2178
|
);
|
|
@@ -2081,7 +2180,13 @@ async function handleNotesInner(
|
|
|
2081
2180
|
// Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
|
|
2082
2181
|
if (e && e.code === "PATH_CONFLICT") {
|
|
2083
2182
|
return json(
|
|
2084
|
-
{
|
|
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
|
+
},
|
|
2085
2190
|
409,
|
|
2086
2191
|
);
|
|
2087
2192
|
}
|
|
@@ -2098,17 +2203,17 @@ async function handleNotesInner(
|
|
|
2098
2203
|
// DELETE /notes/:idOrPath — vault:write (no admin gate; consistent with verbForMethod)
|
|
2099
2204
|
if (method === "DELETE") {
|
|
2100
2205
|
const note = await resolveNote(store, idOrPath);
|
|
2101
|
-
if (!note) return json({ error: "Not found" }, 404);
|
|
2206
|
+
if (!note) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2102
2207
|
// Tag-scope: can't delete what you can't read. 404 (not 403) for the
|
|
2103
2208
|
// same no-leak reason as the read paths.
|
|
2104
2209
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
2105
|
-
return json({ error: "Not found" }, 404);
|
|
2210
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2106
2211
|
}
|
|
2107
2212
|
await store.deleteNote(note.id);
|
|
2108
2213
|
return json({ deleted: true, id: note.id });
|
|
2109
2214
|
}
|
|
2110
2215
|
|
|
2111
|
-
return json({ error: "Method not allowed" }, 405);
|
|
2216
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2112
2217
|
}
|
|
2113
2218
|
|
|
2114
2219
|
// ---------------------------------------------------------------------------
|
|
@@ -2203,18 +2308,18 @@ export async function handleTags(
|
|
|
2203
2308
|
// POST /tags/merge — atomic multi-source merge into a target tag.
|
|
2204
2309
|
// Must come before the /:name matcher so "merge" isn't read as a tag name.
|
|
2205
2310
|
if (subpath === "/merge") {
|
|
2206
|
-
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);
|
|
2207
2312
|
const body = (await req.json().catch(() => null)) as
|
|
2208
2313
|
| { sources?: unknown; target?: unknown }
|
|
2209
2314
|
| null;
|
|
2210
|
-
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2315
|
+
if (!body) return json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400);
|
|
2211
2316
|
const sources = body.sources;
|
|
2212
2317
|
const target = body.target;
|
|
2213
2318
|
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);
|
|
2319
|
+
return json({ error: "sources must be a non-empty array of strings", error_type: "invalid_request", field: "sources" }, 400);
|
|
2215
2320
|
}
|
|
2216
2321
|
if (typeof target !== "string" || target.length === 0) {
|
|
2217
|
-
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);
|
|
2218
2323
|
}
|
|
2219
2324
|
// Tag-scope: every source AND the target must be inside the allowlist.
|
|
2220
2325
|
// A merge that pulls notes out of a token's scope (or pushes notes into
|
|
@@ -2254,13 +2359,13 @@ export async function handleTags(
|
|
|
2254
2359
|
// POST /tags/:name/rename — atomic rename across tags + note_tags + schema
|
|
2255
2360
|
const renameMatch = subpath.match(/^\/([^/]+)\/rename$/);
|
|
2256
2361
|
if (renameMatch) {
|
|
2257
|
-
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);
|
|
2258
2363
|
const oldName = decodeURIComponent(renameMatch[1]!);
|
|
2259
2364
|
const body = (await req.json().catch(() => null)) as { new_name?: unknown } | null;
|
|
2260
|
-
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2365
|
+
if (!body) return json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400);
|
|
2261
2366
|
const newName = body.new_name;
|
|
2262
2367
|
if (typeof newName !== "string" || newName.length === 0) {
|
|
2263
|
-
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);
|
|
2264
2369
|
}
|
|
2265
2370
|
if (tagScope.allowed && (!tagScope.allowed.has(oldName) || !tagScope.allowed.has(newName))) {
|
|
2266
2371
|
return tagScopeForbidden(tagScope.raw ?? []);
|
|
@@ -2271,14 +2376,18 @@ export async function handleTags(
|
|
|
2271
2376
|
// notes.ts:renameTag for the surfaces touched.
|
|
2272
2377
|
const result = await store.renameTag(oldName, newName);
|
|
2273
2378
|
if ("error" in result) {
|
|
2274
|
-
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
|
+
}
|
|
2275
2382
|
if (result.error === "target_exists") {
|
|
2276
2383
|
return json(
|
|
2277
2384
|
{
|
|
2278
2385
|
error: "target_exists",
|
|
2386
|
+
error_type: "target_exists",
|
|
2279
2387
|
target: newName,
|
|
2280
2388
|
conflicting: result.conflicting,
|
|
2281
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",
|
|
2282
2391
|
},
|
|
2283
2392
|
409,
|
|
2284
2393
|
);
|
|
@@ -2293,15 +2402,15 @@ export async function handleTags(
|
|
|
2293
2402
|
// /:name matcher so "conformance" isn't read as a tag name.
|
|
2294
2403
|
const conformanceMatch = subpath.match(/^\/([^/]+)\/conformance$/);
|
|
2295
2404
|
if (conformanceMatch) {
|
|
2296
|
-
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);
|
|
2297
2406
|
const cTag = decodeURIComponent(conformanceMatch[1]!);
|
|
2298
2407
|
if (tagScope.allowed && !tagScope.allowed.has(cTag)) {
|
|
2299
|
-
return json({ error: "Tag not found", tag: cTag }, 404);
|
|
2408
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: cTag }, 404);
|
|
2300
2409
|
}
|
|
2301
2410
|
const body = (await req.json().catch(() => null)) as
|
|
2302
2411
|
| { fields?: Record<string, unknown> | null }
|
|
2303
2412
|
| null;
|
|
2304
|
-
if (!body) return json({ error: "Invalid JSON body" }, 400);
|
|
2413
|
+
if (!body) return json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400);
|
|
2305
2414
|
// The proposed fields the operator intends to save. Sanitized through the
|
|
2306
2415
|
// same parse the resolver uses (drop non-object specs). Empty/absent →
|
|
2307
2416
|
// nothing to enforce → zero violations.
|
|
@@ -2323,10 +2432,10 @@ export async function handleTags(
|
|
|
2323
2432
|
// Schema editor (vault#283). Must precede the /:name matcher.
|
|
2324
2433
|
const effectiveMatch = subpath.match(/^\/([^/]+)\/effective$/);
|
|
2325
2434
|
if (effectiveMatch) {
|
|
2326
|
-
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);
|
|
2327
2436
|
const eTag = decodeURIComponent(effectiveMatch[1]!);
|
|
2328
2437
|
if (tagScope.allowed && !tagScope.allowed.has(eTag)) {
|
|
2329
|
-
return json({ error: "Tag not found", tag: eTag }, 404);
|
|
2438
|
+
return json({ error: "Tag not found", error_type: "tag_not_found", tag: eTag }, 404);
|
|
2330
2439
|
}
|
|
2331
2440
|
const projection = buildVaultProjection(store.db);
|
|
2332
2441
|
const record = await store.getTagRecord(eTag);
|
|
@@ -2347,7 +2456,7 @@ export async function handleTags(
|
|
|
2347
2456
|
|
|
2348
2457
|
// Routes with tag name
|
|
2349
2458
|
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
2350
|
-
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
2459
|
+
if (!nameMatch) return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2351
2460
|
const tagName = decodeURIComponent(nameMatch[1]!);
|
|
2352
2461
|
|
|
2353
2462
|
// GET /tags/:name — single tag detail (full record)
|
|
@@ -2450,7 +2559,15 @@ export async function handleTags(
|
|
|
2450
2559
|
parentNamesPatch = null;
|
|
2451
2560
|
} else if (body.parent_names !== undefined) {
|
|
2452
2561
|
if (!Array.isArray(body.parent_names)) {
|
|
2453
|
-
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
|
+
);
|
|
2454
2571
|
}
|
|
2455
2572
|
const cleaned = (body.parent_names as unknown[]).filter(
|
|
2456
2573
|
(p): p is string => typeof p === "string" && p.length > 0,
|
|
@@ -2483,6 +2600,46 @@ export async function handleTags(
|
|
|
2483
2600
|
}
|
|
2484
2601
|
}
|
|
2485
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
|
+
|
|
2486
2643
|
// A bad indexed-field name (or an unindexable type, or a cross-tag type
|
|
2487
2644
|
// mismatch) is a CLIENT error — return 400, not the catch-all 500. The
|
|
2488
2645
|
// store pre-validates and is transactional, so the schema is left
|
|
@@ -2497,8 +2654,14 @@ export async function handleTags(
|
|
|
2497
2654
|
});
|
|
2498
2655
|
} catch (err) {
|
|
2499
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);
|
|
2500
2663
|
return json(
|
|
2501
|
-
{ error:
|
|
2664
|
+
{ error: scrubbed.message, error_type: "invalid_indexed_field" },
|
|
2502
2665
|
400,
|
|
2503
2666
|
);
|
|
2504
2667
|
}
|
|
@@ -2532,7 +2695,7 @@ export async function handleTags(
|
|
|
2532
2695
|
return json(await store.deleteTag(tagName));
|
|
2533
2696
|
}
|
|
2534
2697
|
|
|
2535
|
-
return json({ error: "Method not allowed" }, 405);
|
|
2698
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2536
2699
|
}
|
|
2537
2700
|
|
|
2538
2701
|
// ---------------------------------------------------------------------------
|
|
@@ -2544,24 +2707,29 @@ export async function handleFindPath(
|
|
|
2544
2707
|
store: Store,
|
|
2545
2708
|
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
2546
2709
|
): Promise<Response> {
|
|
2547
|
-
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);
|
|
2548
2711
|
|
|
2549
2712
|
const url = new URL(req.url);
|
|
2550
2713
|
const source = parseQuery(url, "source");
|
|
2551
2714
|
const target = parseQuery(url, "target");
|
|
2552
|
-
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
|
+
}
|
|
2553
2721
|
|
|
2554
2722
|
const db = store.db;
|
|
2555
2723
|
try {
|
|
2556
2724
|
const sourceNote = await resolveNote(store, source);
|
|
2557
|
-
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);
|
|
2558
2726
|
if (!noteWithinTagScope(sourceNote, tagScope.allowed, tagScope.raw)) {
|
|
2559
|
-
return json({ error: `Note not found: "${source}"
|
|
2727
|
+
return json({ error: `Note not found: "${source}"`, error_type: "not_found", note_id: source }, 404);
|
|
2560
2728
|
}
|
|
2561
2729
|
const targetNote = await resolveNote(store, target);
|
|
2562
|
-
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);
|
|
2563
2731
|
if (!noteWithinTagScope(targetNote, tagScope.allowed, tagScope.raw)) {
|
|
2564
|
-
return json({ error: `Note not found: "${target}"
|
|
2732
|
+
return json({ error: `Note not found: "${target}"`, error_type: "not_found", note_id: target }, 404);
|
|
2565
2733
|
}
|
|
2566
2734
|
const maxDepth = Math.min(parseInt10(parseQuery(url, "max_depth")) ?? 5, 10);
|
|
2567
2735
|
|
|
@@ -2579,7 +2747,7 @@ export async function handleFindPath(
|
|
|
2579
2747
|
}
|
|
2580
2748
|
return json(result);
|
|
2581
2749
|
} catch (e: any) {
|
|
2582
|
-
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);
|
|
2583
2751
|
// vault#331 N1 — surface AmbiguousPathError from resolveNote as 409
|
|
2584
2752
|
// mirroring the handleNotes path. Without this, an ambiguous source/
|
|
2585
2753
|
// target path on /api/find-path bubbled to a server-level 500.
|
|
@@ -2678,7 +2846,11 @@ export async function handleVault(
|
|
|
2678
2846
|
return json(
|
|
2679
2847
|
{
|
|
2680
2848
|
error: "invalid_audio_retention",
|
|
2849
|
+
error_type: "invalid_audio_retention",
|
|
2850
|
+
field: "config.audio_retention",
|
|
2851
|
+
got: v,
|
|
2681
2852
|
message: `audio_retention must be one of: ${VALID_AUDIO_RETENTION.join(", ")}`,
|
|
2853
|
+
hint: `pass one of: ${VALID_AUDIO_RETENTION.join(", ")}`,
|
|
2682
2854
|
},
|
|
2683
2855
|
400,
|
|
2684
2856
|
);
|
|
@@ -2700,7 +2872,11 @@ export async function handleVault(
|
|
|
2700
2872
|
return json(
|
|
2701
2873
|
{
|
|
2702
2874
|
error: "invalid_auto_transcribe",
|
|
2875
|
+
error_type: "invalid_auto_transcribe",
|
|
2876
|
+
field: "config.auto_transcribe.enabled",
|
|
2877
|
+
got: enabled,
|
|
2703
2878
|
message: "auto_transcribe.enabled must be a boolean",
|
|
2879
|
+
hint: "pass true or false",
|
|
2704
2880
|
},
|
|
2705
2881
|
400,
|
|
2706
2882
|
);
|
|
@@ -2713,7 +2889,7 @@ export async function handleVault(
|
|
|
2713
2889
|
return json(vaultResponse(vaultConfig));
|
|
2714
2890
|
}
|
|
2715
2891
|
|
|
2716
|
-
return json({ error: "Method not allowed" }, 405);
|
|
2892
|
+
return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
|
|
2717
2893
|
}
|
|
2718
2894
|
|
|
2719
2895
|
// ---------------------------------------------------------------------------
|
|
@@ -2981,6 +3157,7 @@ async function handleRetryTranscription(
|
|
|
2981
3157
|
return json(
|
|
2982
3158
|
{
|
|
2983
3159
|
error: "not_failed",
|
|
3160
|
+
error_type: "not_failed",
|
|
2984
3161
|
message: `Transcript note status is "${meta.transcript_status}" — only failed transcripts can be retried.`,
|
|
2985
3162
|
transcript_status: meta.transcript_status,
|
|
2986
3163
|
},
|
|
@@ -2994,6 +3171,7 @@ async function handleRetryTranscription(
|
|
|
2994
3171
|
return json(
|
|
2995
3172
|
{
|
|
2996
3173
|
error: "missing_attachment_id",
|
|
3174
|
+
error_type: "missing_attachment_id",
|
|
2997
3175
|
message: "Transcript note has no `transcript_attachment_id` — can't locate the original audio.",
|
|
2998
3176
|
},
|
|
2999
3177
|
400,
|
|
@@ -3004,6 +3182,7 @@ async function handleRetryTranscription(
|
|
|
3004
3182
|
return json(
|
|
3005
3183
|
{
|
|
3006
3184
|
error: "attachment_missing",
|
|
3185
|
+
error_type: "attachment_missing",
|
|
3007
3186
|
message: `Original audio attachment ${attachmentId} no longer exists in the vault.`,
|
|
3008
3187
|
},
|
|
3009
3188
|
404,
|
|
@@ -3017,6 +3196,7 @@ async function handleRetryTranscription(
|
|
|
3017
3196
|
return json(
|
|
3018
3197
|
{
|
|
3019
3198
|
error: "audio_missing",
|
|
3199
|
+
error_type: "audio_missing",
|
|
3020
3200
|
message: `Original audio file at "${attachment.path}" no longer exists on disk.`,
|
|
3021
3201
|
},
|
|
3022
3202
|
404,
|
|
@@ -3097,6 +3277,7 @@ async function handleRetryLegacyInBody(
|
|
|
3097
3277
|
return json(
|
|
3098
3278
|
{
|
|
3099
3279
|
error: "no_failed_attachment",
|
|
3280
|
+
error_type: "no_failed_attachment",
|
|
3100
3281
|
message:
|
|
3101
3282
|
"Target note is not a transcript note and has no audio attachment with a failed transcription to retry.",
|
|
3102
3283
|
},
|
|
@@ -3112,6 +3293,7 @@ async function handleRetryLegacyInBody(
|
|
|
3112
3293
|
return json(
|
|
3113
3294
|
{
|
|
3114
3295
|
error: "audio_missing",
|
|
3296
|
+
error_type: "audio_missing",
|
|
3115
3297
|
message: `Original audio file at "${failed.path}" no longer exists on disk.`,
|
|
3116
3298
|
},
|
|
3117
3299
|
404,
|
|
@@ -3164,7 +3346,7 @@ async function handleRetryLegacyInBody(
|
|
|
3164
3346
|
const fresh = await store.getNote(note.id);
|
|
3165
3347
|
if (!fresh) {
|
|
3166
3348
|
return json(
|
|
3167
|
-
{ error: "note_missing", message: "Target note disappeared during retry." },
|
|
3349
|
+
{ error: "note_missing", error_type: "not_found", message: "Target note disappeared during retry." },
|
|
3168
3350
|
404,
|
|
3169
3351
|
);
|
|
3170
3352
|
}
|
|
@@ -3194,6 +3376,7 @@ async function handleRetryLegacyInBody(
|
|
|
3194
3376
|
expected_updated_at: err.expected_updated_at,
|
|
3195
3377
|
message:
|
|
3196
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",
|
|
3197
3380
|
},
|
|
3198
3381
|
409,
|
|
3199
3382
|
);
|
|
@@ -3335,10 +3518,18 @@ export async function handleStorage(
|
|
|
3335
3518
|
const form = await req.formData();
|
|
3336
3519
|
const file = form.get("file");
|
|
3337
3520
|
if (!(file instanceof File)) {
|
|
3338
|
-
return json({ error: "file is required" }, 400);
|
|
3521
|
+
return json({ error: "file is required", error_type: "missing_required_field", field: "file" }, 400);
|
|
3339
3522
|
}
|
|
3340
3523
|
if (file.size > MAX_UPLOAD_BYTES) {
|
|
3341
|
-
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
|
+
);
|
|
3342
3533
|
}
|
|
3343
3534
|
// Strip trailing dots/whitespace before extracting the extension so a
|
|
3344
3535
|
// `evil.html.` / `evil.svg ` can't slip past the blocklist
|
|
@@ -3350,7 +3541,14 @@ export async function handleStorage(
|
|
|
3350
3541
|
// served same-origin from /storage/ (see BLOCKED_EXTENSIONS). Everything
|
|
3351
3542
|
// else (incl. unknown/arbitrary files) is accepted and served as a
|
|
3352
3543
|
// download (octet-stream + nosniff).
|
|
3353
|
-
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
|
+
);
|
|
3354
3552
|
}
|
|
3355
3553
|
|
|
3356
3554
|
const date = new Date().toISOString().split("T")[0]!;
|
|
@@ -3396,7 +3594,7 @@ export async function handleStorage(
|
|
|
3396
3594
|
try {
|
|
3397
3595
|
decodedPath = decodeURIComponent(path);
|
|
3398
3596
|
} catch {
|
|
3399
|
-
return json({ error: "Not found" }, 404);
|
|
3597
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3400
3598
|
}
|
|
3401
3599
|
|
|
3402
3600
|
const fileMatch = decodedPath.match(/^\/([^/]+)\/(.+)$/);
|
|
@@ -3405,10 +3603,10 @@ export async function handleStorage(
|
|
|
3405
3603
|
const filePath = normalize(join(assets, reqPath));
|
|
3406
3604
|
|
|
3407
3605
|
if (!filePath.startsWith(normalize(assets))) {
|
|
3408
|
-
return json({ error: "Invalid path" }, 403);
|
|
3606
|
+
return json({ error: "Invalid path", error_type: "invalid_path" }, 403);
|
|
3409
3607
|
}
|
|
3410
3608
|
if (!existsSync(filePath)) {
|
|
3411
|
-
return json({ error: "Not found" }, 404);
|
|
3609
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3412
3610
|
}
|
|
3413
3611
|
|
|
3414
3612
|
// Tag-scope gate (C0 adversarial-audit finding). The note-keyed
|
|
@@ -3438,7 +3636,7 @@ export async function handleStorage(
|
|
|
3438
3636
|
}
|
|
3439
3637
|
}
|
|
3440
3638
|
if (!allowed) {
|
|
3441
|
-
return json({ error: "Not found" }, 404);
|
|
3639
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3442
3640
|
}
|
|
3443
3641
|
}
|
|
3444
3642
|
|
|
@@ -3461,7 +3659,7 @@ export async function handleStorage(
|
|
|
3461
3659
|
});
|
|
3462
3660
|
}
|
|
3463
3661
|
|
|
3464
|
-
return json({ error: "Not found" }, 404);
|
|
3662
|
+
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
3465
3663
|
}
|
|
3466
3664
|
|
|
3467
3665
|
// ---------------------------------------------------------------------------
|