@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/core/src/aggregate.test.ts +260 -0
  2. package/core/src/contract-taxonomy.test.ts +26 -2
  3. package/core/src/contract-typed-index.test.ts +4 -2
  4. package/core/src/core.test.ts +737 -2
  5. package/core/src/cursor-keyset-ms.test.ts +537 -0
  6. package/core/src/cursor.ts +76 -6
  7. package/core/src/doctor.ts +23 -1
  8. package/core/src/hooks.ts +9 -0
  9. package/core/src/indexed-fields.test.ts +4 -1
  10. package/core/src/indexed-fields.ts +6 -1
  11. package/core/src/links.ts +22 -0
  12. package/core/src/mcp.ts +322 -53
  13. package/core/src/notes.ts +518 -67
  14. package/core/src/paths.ts +4 -0
  15. package/core/src/portable-md.test.ts +161 -0
  16. package/core/src/portable-md.ts +140 -9
  17. package/core/src/query-warnings.ts +60 -12
  18. package/core/src/schema-defaults.ts +7 -1
  19. package/core/src/schema.ts +128 -2
  20. package/core/src/seed-packs.ts +55 -15
  21. package/core/src/store.ts +141 -7
  22. package/core/src/tag-schemas.ts +20 -7
  23. package/core/src/txn.test.ts +100 -4
  24. package/core/src/txn.ts +119 -24
  25. package/core/src/types.ts +89 -0
  26. package/core/src/ulid.test.ts +56 -0
  27. package/core/src/ulid.ts +116 -0
  28. package/core/src/vault-projection.ts +19 -1
  29. package/core/src/wikilinks.test.ts +205 -1
  30. package/core/src/wikilinks.ts +200 -35
  31. package/package.json +1 -1
  32. package/src/aggregate-routes.test.ts +230 -0
  33. package/src/cli.ts +6 -0
  34. package/src/contract-errors.test.ts +2 -1
  35. package/src/contract-search.test.ts +17 -0
  36. package/src/mcp-link-warnings-scope.test.ts +144 -0
  37. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  38. package/src/mcp-tools.ts +76 -17
  39. package/src/mirror-import.ts +5 -0
  40. package/src/routes.ts +298 -24
  41. package/src/routing.test.ts +167 -0
  42. package/src/routing.ts +47 -11
  43. package/src/tag-integrity-mcp.test.ts +45 -6
  44. package/src/tag-scope.ts +10 -1
  45. package/src/vault.test.ts +557 -21
  46. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  47. package/web/ui/dist/index.html +1 -1
package/src/routes.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * and the Request, and returns a Response.
12
12
  */
13
13
 
14
- import type { Store, Note, QueryOpts } from "../core/src/types.ts";
14
+ import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
15
15
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
16
16
  import {
17
17
  collectUnknownTagWarnings,
@@ -28,9 +28,10 @@ import {
28
28
  resolveStructuredLinkNote,
29
29
  getUnresolvedLinksForNote,
30
30
  getUnresolvedLinksForNotes,
31
+ getContentWikilinkWarnings,
31
32
  } from "../core/src/wikilinks.ts";
32
33
  import { transactionAsync } from "../core/src/txn.ts";
33
- import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError } from "../core/src/notes.ts";
34
+ import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, validatePath, PathValidationError, getVaultMap } from "../core/src/notes.ts";
34
35
  import { normalizePath } from "../core/src/paths.ts";
35
36
  import {
36
37
  parseContentRange,
@@ -62,6 +63,7 @@ import {
62
63
  scrubValidationStatusByScope,
63
64
  tagScopeForbidden,
64
65
  tagsWithinScope,
66
+ tagVisibleInScope,
65
67
  } from "./tag-scope.ts";
66
68
  import { ParentCycleError, InvalidFieldDefaultError, InvalidFieldTypeError } from "../core/src/tag-schemas.ts";
67
69
  import { findTokensReferencingTag } from "./token-store.ts";
@@ -850,6 +852,58 @@ export function parseNotesQueryOpts(url: URL): {
850
852
  return { queryOpts, hasSearch, hasNear, hasCursor };
851
853
  }
852
854
 
855
+ /**
856
+ * Parse the `?aggregate[group_by]=…&aggregate[op]=…&aggregate[field]=…`
857
+ * aggregation params (bracket-style, consistent with `meta[field][op]=`
858
+ * above) into an `AggregateSpec`. Absent entirely (none of the three keys
859
+ * present) → `{}` — no aggregate intent, the caller falls through to a
860
+ * normal query. `group_by` + `op` are required together when ANY of the
861
+ * three is present; `field` is optional at the parser level (its
862
+ * requiredness depends on `op`, enforced by `aggregateNotes` itself). Value
863
+ * validity beyond shape (indexed field, numeric type, sum-requires-field)
864
+ * is ALSO enforced by `aggregateNotes` — same FIELD_NOT_INDEXED /
865
+ * INVALID_QUERY contract every other query surface uses.
866
+ *
867
+ * Returns `{ aggregate? }` or `{ error }` (a 400 Response) on a malformed
868
+ * shape (missing `group_by`/`op`, or an unrecognized `op`).
869
+ */
870
+ function parseAggregateParam(url: URL): { aggregate?: AggregateSpec; error?: Response } {
871
+ const groupBy = parseQuery(url, "aggregate[group_by]");
872
+ const op = parseQuery(url, "aggregate[op]");
873
+ const field = parseQuery(url, "aggregate[field]");
874
+ if (groupBy === null && op === null && field === null) return {};
875
+ if (groupBy === null || op === null) {
876
+ return {
877
+ error: json(
878
+ {
879
+ error: `aggregate requires both aggregate[group_by] and aggregate[op] — group_by is an indexed metadata field or "tag"; op is "count" or "sum".`,
880
+ code: "INVALID_QUERY",
881
+ error_type: "invalid_query",
882
+ field: "aggregate",
883
+ hint: `pass ?aggregate[group_by]=<field|tag>&aggregate[op]=<count|sum>[&aggregate[field]=<numeric field>]`,
884
+ },
885
+ 400,
886
+ ),
887
+ };
888
+ }
889
+ if (op !== "count" && op !== "sum") {
890
+ return {
891
+ error: json(
892
+ {
893
+ error: `invalid aggregate[op]: "${op}" — must be "count" or "sum"`,
894
+ code: "INVALID_QUERY",
895
+ error_type: "invalid_query",
896
+ field: "aggregate.op",
897
+ got: op,
898
+ hint: `pass "count" or "sum"`,
899
+ },
900
+ 400,
901
+ ),
902
+ };
903
+ }
904
+ return { aggregate: { group_by: groupBy, op, field: field ?? undefined } };
905
+ }
906
+
853
907
  /**
854
908
  * Parse include_metadata query param.
855
909
  * - absent/null → undefined (all metadata, default)
@@ -907,6 +961,18 @@ function parseExpandParams(
907
961
  * differing only by extension (vault#330 S1). When the path is
908
962
  * ambiguous and no extension hint is supplied, `getNoteByPath` throws
909
963
  * `AmbiguousPathError` — REST handlers catch it and return 409.
964
+ *
965
+ * Title fallback (additive): when id AND path/extension both miss
966
+ * cleanly (no throw), tries an H1-title match via `getNoteByTitle` — the
967
+ * note whose first `# ` content line equals `idOrPath`, only when
968
+ * exactly one note has that title. Mirrors the MCP-layer `resolveNote`
969
+ * in core/src/mcp.ts and `[[wikilink]]` resolution (core/src/wikilinks.ts);
970
+ * exact id/path always wins first. Skipped (falls through to "not found")
971
+ * when `store.db` isn't available — the `Store` interface documents `db`
972
+ * as always present on the concrete class, but a handful of tests pass a
973
+ * minimal duck-typed stub (e.g. `handleViewNote`'s coverage in
974
+ * published.test.ts) that only implements `getNote`/`getNoteByPath`; this
975
+ * fallback must never turn that into a crash.
910
976
  */
911
977
  async function resolveNote(store: Store, idOrPath: string): Promise<Note | null> {
912
978
  const byId = await store.getNote(idOrPath);
@@ -916,7 +982,9 @@ async function resolveNote(store: Store, idOrPath: string): Promise<Note | null>
916
982
  const explicit = await store.getNoteByPath(extMatch[1]!, extMatch[2]!);
917
983
  if (explicit) return explicit;
918
984
  }
919
- return await store.getNoteByPath(idOrPath);
985
+ const byPath = await store.getNoteByPath(idOrPath);
986
+ if (byPath) return byPath;
987
+ return store.db ? getNoteByTitle(store.db, idOrPath) : null;
920
988
  }
921
989
 
922
990
  async function requireNote(store: Store, idOrPath: string): Promise<Note> {
@@ -1241,6 +1309,87 @@ async function handleNotesInner(
1241
1309
  400,
1242
1310
  );
1243
1311
  }
1312
+
1313
+ // Aggregation / rollup mode (top new-feature ask from a UX round).
1314
+ // Mutually exclusive with cursor/near — a rollup returns one row per
1315
+ // group, not a paginated/graph-scoped note list — so reject those
1316
+ // combos loudly before touching the DB, then short-circuit: none of
1317
+ // the normal-query output machinery below (expand, include_links,
1318
+ // metadata filtering, ...) applies to rollup rows.
1319
+ const aggregateParsed = parseAggregateParam(url);
1320
+ if (aggregateParsed.error) return aggregateParsed.error;
1321
+ if (aggregateParsed.aggregate) {
1322
+ if (cursorMode) {
1323
+ return json(
1324
+ {
1325
+ error: "aggregate is incompatible with cursor pagination — a rollup has no watermark to page through.",
1326
+ code: "INVALID_QUERY",
1327
+ error_type: "invalid_query",
1328
+ field: "aggregate",
1329
+ hint: "drop `cursor` when using `aggregate`",
1330
+ },
1331
+ 400,
1332
+ );
1333
+ }
1334
+ if (nearNoteIdEarly) {
1335
+ return json(
1336
+ {
1337
+ error: "aggregate is incompatible with near (graph neighborhood).",
1338
+ code: "INVALID_QUERY",
1339
+ error_type: "invalid_query",
1340
+ field: "aggregate",
1341
+ hint: "drop `near` when using `aggregate`",
1342
+ },
1343
+ 400,
1344
+ );
1345
+ }
1346
+ try {
1347
+ let rows;
1348
+ if (tagScope.raw === null) {
1349
+ rows = await store.aggregateNotes({ ...queryOpts, aggregate: aggregateParsed.aggregate });
1350
+ } else {
1351
+ // Tag-scoped: filter to visible notes FIRST — fetch every note
1352
+ // the OTHER filters match (unpaginated, same `limit: 1000000`
1353
+ // "get everything" convention core's `syncAllWikilinks` uses),
1354
+ // narrow via the SAME `filterNotesByTagScope` every other read
1355
+ // path applies, THEN aggregate over just that visible id set
1356
+ // (reusing the `ids` filter `near` already pushes into SQL).
1357
+ const allMatches = await store.queryNotes({ ...queryOpts, limit: 1000000, offset: 0 });
1358
+ const visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
1359
+ rows = visible.length === 0
1360
+ ? []
1361
+ : await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
1362
+ // That note-level narrowing isn't sufficient on its own under
1363
+ // `group_by: "tag"`: a note can be in scope via one tag while
1364
+ // ALSO carrying an out-of-scope co-tag, and a tag rollup's
1365
+ // `group` values ARE tag names — the co-tag would otherwise
1366
+ // surface directly as a group. Scrub group NAMES too, mirroring
1367
+ // the MCP wrapper's identical fix (src/mcp-tools.ts).
1368
+ if (aggregateParsed.aggregate.group_by === "tag") {
1369
+ rows = rows.filter(
1370
+ (r: any) => typeof r?.group === "string" && tagVisibleInScope(r.group, tagScope.allowed, tagScope.raw),
1371
+ );
1372
+ }
1373
+ }
1374
+ return json(rows);
1375
+ } catch (e: any) {
1376
+ if (e && e.name === "QueryError") {
1377
+ return json(
1378
+ {
1379
+ error: e.message,
1380
+ code: e.code ?? "INVALID_QUERY",
1381
+ error_type: e.error_type ?? "invalid_query",
1382
+ ...(e.field !== undefined ? { field: e.field } : {}),
1383
+ ...(e.got !== undefined ? { got: e.got } : {}),
1384
+ ...(e.hint !== undefined ? { hint: e.hint } : {}),
1385
+ },
1386
+ 400,
1387
+ );
1388
+ }
1389
+ throw e;
1390
+ }
1391
+ }
1392
+
1244
1393
  // Warnings channel (vault#550). `unknown_tag` stays structured-query
1245
1394
  // only (skipped entirely for a tag-scoped session:
1246
1395
  // `collectUnknownTagWarnings` resolves `did_you_mean` against the
@@ -1537,6 +1686,17 @@ async function handleNotesInner(
1537
1686
  // layers share `resolveOrQueueLink` from core/wikilinks.ts).
1538
1687
  const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
1539
1688
  const linkWarningsByNote = new Map<string, QueryWarning[]>();
1689
+ const pushLinkWarning = (noteId: string, warning: QueryWarning): void => {
1690
+ const list = linkWarningsByNote.get(noteId) ?? [];
1691
+ list.push(warning);
1692
+ linkWarningsByNote.set(noteId, list);
1693
+ };
1694
+ // Content wikilinks whose note+content pair needs an `unresolved_link`/
1695
+ // `ambiguous_link` warning check (vault#570) — deferred to the same
1696
+ // second pass as `pendingLinks` for the same forward-ref-within-batch
1697
+ // reason (a content `[[wikilink]]` to a batch sibling created later
1698
+ // resolves via the pending-wikilink backfill by the time this runs).
1699
+ const contentWikilinkNotes: { noteId: string; content: string }[] = [];
1540
1700
  // `if_exists` bookkeeping (vault#555) — mirrors core/src/mcp.ts's
1541
1701
  // create-note tool exactly (same contract, independent REST-layer
1542
1702
  // reimplementation sharing the same core primitives). See that file's
@@ -1610,6 +1770,14 @@ async function handleNotesInner(
1610
1770
  });
1611
1771
  }
1612
1772
 
1773
+ // Content-wikilink warnings (vault#570) — this branch's content
1774
+ // update (if any) already ran `syncWikilinks` inside
1775
+ // `store.updateNote` above; queue for the shared second-pass
1776
+ // classification below.
1777
+ if (updates.content !== undefined) {
1778
+ contentWikilinkNotes.push({ noteId: result.id, content: updates.content });
1779
+ }
1780
+
1613
1781
  if (incomingTags.length > 0) {
1614
1782
  await store.tagNote(result.id, incomingTags);
1615
1783
  // Redundant with the outer batch loop's applySchemaDefaults (this
@@ -1643,6 +1811,9 @@ async function handleNotesInner(
1643
1811
  const extension = item.extension !== undefined
1644
1812
  ? validateExtension(item.extension)
1645
1813
  : undefined;
1814
+ // Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
1815
+ // Same batch-transaction throw semantics as extension validation.
1816
+ validatePath(item.path);
1646
1817
  const effectiveExtension = extension ?? "md";
1647
1818
  const ifExists: string = item.if_exists ?? "error";
1648
1819
  const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
@@ -1705,6 +1876,13 @@ async function handleNotesInner(
1705
1876
  pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
1706
1877
  }
1707
1878
 
1879
+ // Content-wikilink warnings (vault#570) — `store.createNote` above
1880
+ // already ran `syncWikilinks` (gated on `content` truthy, same
1881
+ // condition here); queue for the shared second-pass classification.
1882
+ if (item.content) {
1883
+ contentWikilinkNotes.push({ noteId: note.id, content: item.content });
1884
+ }
1885
+
1708
1886
  created.push((await store.getNote(note.id)) ?? note);
1709
1887
  }
1710
1888
 
@@ -1713,24 +1891,40 @@ async function handleNotesInner(
1713
1891
  // that every sibling note in this batch exists. A target that still
1714
1892
  // doesn't resolve is queued for lazy resolution (backfills
1715
1893
  // automatically when a matching note is created later) and surfaces
1716
- // an `unresolved_link` warning naming the target never silent.
1894
+ // an `unresolved_link` warning naming the target. A target that
1895
+ // matched ≥2 notes (vault#570) is neither linked nor queued —
1896
+ // surfaces a distinct `ambiguous_link` warning instead. Never silent.
1717
1897
  for (const { sourceId, links } of pendingLinks) {
1718
1898
  for (const link of links) {
1719
- const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1720
- if (targetId) {
1721
- await store.createLink(sourceId, targetId, link.relationship);
1899
+ const outcome = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1900
+ if (outcome.status === "resolved") {
1901
+ await store.createLink(sourceId, outcome.note_id, link.relationship);
1902
+ } else if (outcome.status === "ambiguous") {
1903
+ pushLinkWarning(sourceId, {
1904
+ code: "ambiguous_link",
1905
+ message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
1906
+ target: link.target,
1907
+ relationship: link.relationship,
1908
+ candidate_count: outcome.candidates.length,
1909
+ });
1722
1910
  } else {
1723
- const list = linkWarningsByNote.get(sourceId) ?? [];
1724
- list.push({
1911
+ pushLinkWarning(sourceId, {
1725
1912
  code: "unresolved_link",
1726
1913
  message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
1727
1914
  target: link.target,
1728
1915
  relationship: link.relationship,
1729
1916
  });
1730
- linkWarningsByNote.set(sourceId, list);
1731
1917
  }
1732
1918
  }
1733
1919
  }
1920
+
1921
+ // --- Content-wikilink warnings (vault#570) ---
1922
+ // Same forward-ref-aware timing as the structured-links pass above.
1923
+ for (const { noteId, content } of contentWikilinkNotes) {
1924
+ for (const warning of getContentWikilinkWarnings(db, noteId, content)) {
1925
+ pushLinkWarning(noteId, warning);
1926
+ }
1927
+ }
1734
1928
  };
1735
1929
  try {
1736
1930
  await (batched ? transactionAsync(db, runBatch) : runBatch());
@@ -1767,6 +1961,13 @@ async function handleNotesInner(
1767
1961
  400,
1768
1962
  );
1769
1963
  }
1964
+ // Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
1965
+ if (e && e.code === "INVALID_PATH") {
1966
+ return json(
1967
+ { error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
1968
+ 400,
1969
+ );
1970
+ }
1770
1971
  throw e;
1771
1972
  }
1772
1973
 
@@ -2062,6 +2263,9 @@ async function handleNotesInner(
2062
2263
  const createExt = body.extension !== undefined
2063
2264
  ? validateExtension(body.extension)
2064
2265
  : undefined;
2266
+ // Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
2267
+ // Covers both the explicit `path` and the idOrPath-as-path shape.
2268
+ validatePath(explicitPath ?? (idLooksLikePath ? idOrPathStr : undefined));
2065
2269
  const createOpts: Parameters<Store["createNote"]>[1] = {
2066
2270
  ...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
2067
2271
  ...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
@@ -2096,15 +2300,25 @@ async function handleNotesInner(
2096
2300
  // fresh note).
2097
2301
  // - Missing target notes skip silently (mirrors MCP).
2098
2302
  const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
2099
- // `unresolved_link` warnings (vault#555) — a target that doesn't
2100
- // resolve is queued for lazy resolution (backfills automatically
2101
- // when a matching note is created later), never silently dropped.
2303
+ // `unresolved_link` / `ambiguous_link` warnings (vault#555, vault#570)
2304
+ // — a target that doesn't resolve is queued for lazy resolution
2305
+ // (backfills automatically when a matching note is created later),
2306
+ // and a target matching ≥2 notes is neither linked nor queued.
2307
+ // Never silently dropped.
2102
2308
  const createWarnings: QueryWarning[] = [];
2103
2309
  if (linksAdd) {
2104
2310
  for (const link of linksAdd) {
2105
- const targetId = resolveOrQueueLink(db, created.id, link.target, link.relationship);
2106
- if (targetId) {
2107
- await store.createLink(created.id, targetId, link.relationship, link.metadata);
2311
+ const outcome = resolveOrQueueLink(db, created.id, link.target, link.relationship);
2312
+ if (outcome.status === "resolved") {
2313
+ await store.createLink(created.id, outcome.note_id, link.relationship, link.metadata);
2314
+ } else if (outcome.status === "ambiguous") {
2315
+ createWarnings.push({
2316
+ code: "ambiguous_link",
2317
+ message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
2318
+ target: link.target,
2319
+ relationship: link.relationship,
2320
+ candidate_count: outcome.candidates.length,
2321
+ });
2108
2322
  } else {
2109
2323
  createWarnings.push({
2110
2324
  code: "unresolved_link",
@@ -2115,6 +2329,13 @@ async function handleNotesInner(
2115
2329
  }
2116
2330
  }
2117
2331
  }
2332
+ // Content-wikilink warnings (vault#570) — `store.createNote` above
2333
+ // already ran `syncWikilinks`; this is the single-note (non-batch)
2334
+ // create path, so there's no batch-sibling forward-ref to wait for
2335
+ // — compute right away against the committed content.
2336
+ if (content) {
2337
+ createWarnings.push(...getContentWikilinkWarnings(db, created.id, content));
2338
+ }
2118
2339
  const final = await store.getNote(created.id);
2119
2340
  if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
2120
2341
  const validated: any = attachValidationStatus(store, db, final);
@@ -2291,7 +2512,12 @@ async function handleNotesInner(
2291
2512
  if (body.append !== undefined) updates.append = body.append;
2292
2513
  if (body.prepend !== undefined) updates.prepend = body.prepend;
2293
2514
  }
2294
- if (body.path !== undefined) updates.path = body.path;
2515
+ if (body.path !== undefined) {
2516
+ // Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
2517
+ // Throws PathValidationError → 400 in the outer catch.
2518
+ validatePath(body.path);
2519
+ updates.path = body.path;
2520
+ }
2295
2521
  if (body.extension !== undefined) {
2296
2522
  // Validate up front (vault#328). Throws ExtensionValidationError
2297
2523
  // which the outer catch converts to a 400.
@@ -2352,6 +2578,14 @@ async function handleNotesInner(
2352
2578
  // moved despite a real tags/note_tags or links change.
2353
2579
  const hasTagMutation = (body.tags?.add?.length ?? 0) > 0 || (body.tags?.remove?.length ?? 0) > 0;
2354
2580
  const hasLinkMutation = body.links?.add !== undefined || body.links?.remove !== undefined;
2581
+ // Content-wikilink warnings (vault#570) gate on the SAME condition
2582
+ // `store.updateNote`/`syncWikilinks` use to decide whether to re-sync
2583
+ // content wikilinks at all — a tags/links-only PATCH must not
2584
+ // spuriously re-warn about pre-existing broken links this call never
2585
+ // touched.
2586
+ const contentChanged = updates.content !== undefined
2587
+ || updates.append !== undefined
2588
+ || updates.prepend !== undefined;
2355
2589
  if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
2356
2590
  // Write-attribution (vault#298) — REST update. Stamp the most-recent-
2357
2591
  // write columns on the same UPDATE that bumps updated_at. `updates`
@@ -2378,16 +2612,25 @@ async function handleNotesInner(
2378
2612
  await store.untagNote(note.id, body.tags.remove);
2379
2613
  }
2380
2614
 
2381
- // Add links. `unresolved_link` warnings (vault#555) — a target that
2382
- // doesn't resolve is queued for lazy resolution (backfills
2383
- // automatically when a matching note is created later), never
2384
- // silently dropped.
2615
+ // Add links. `unresolved_link` / `ambiguous_link` warnings (vault#555,
2616
+ // vault#570) — a target that doesn't resolve is queued for lazy
2617
+ // resolution (backfills automatically when a matching note is created
2618
+ // later); a target matching ≥2 notes is neither linked nor queued.
2619
+ // Never silently dropped.
2385
2620
  const linkWarnings: QueryWarning[] = [];
2386
2621
  if (body.links?.add) {
2387
2622
  for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
2388
- const targetId = resolveOrQueueLink(db, note.id, link.target, link.relationship);
2389
- if (targetId) {
2390
- await store.createLink(note.id, targetId, link.relationship, link.metadata);
2623
+ const outcome = resolveOrQueueLink(db, note.id, link.target, link.relationship);
2624
+ if (outcome.status === "resolved") {
2625
+ await store.createLink(note.id, outcome.note_id, link.relationship, link.metadata);
2626
+ } else if (outcome.status === "ambiguous") {
2627
+ linkWarnings.push({
2628
+ code: "ambiguous_link",
2629
+ message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
2630
+ target: link.target,
2631
+ relationship: link.relationship,
2632
+ candidate_count: outcome.candidates.length,
2633
+ });
2391
2634
  } else {
2392
2635
  linkWarnings.push({
2393
2636
  code: "unresolved_link",
@@ -2409,6 +2652,13 @@ async function handleNotesInner(
2409
2652
  // `toNoteIndex` drops unknown fields).
2410
2653
  const updatedNote = await store.getNote(note.id);
2411
2654
  if (updatedNote === null) return json({ error: "Note disappeared", error_type: "not_found" }, 404);
2655
+ // Content-wikilink warnings (vault#570) — `store.updateNote` above
2656
+ // already ran `syncWikilinks` when `contentChanged`; this PATCH
2657
+ // handles a single note (no batch-sibling forward-ref to wait for),
2658
+ // so compute right away against the committed content.
2659
+ if (contentChanged) {
2660
+ linkWarnings.push(...getContentWikilinkWarnings(db, note.id, updatedNote.content));
2661
+ }
2412
2662
  const validated: any = attachValidationStatus(store, db, updatedNote);
2413
2663
  // Echo hydrated links when a link mutation was part of this request,
2414
2664
  // OR the caller explicitly asked for them via `?include_links=true`
@@ -2524,6 +2774,13 @@ async function handleNotesInner(
2524
2774
  400,
2525
2775
  );
2526
2776
  }
2777
+ // Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
2778
+ if (e && e.code === "INVALID_PATH") {
2779
+ return json(
2780
+ { error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
2781
+ 400,
2782
+ );
2783
+ }
2527
2784
  throw e;
2528
2785
  }
2529
2786
  }
@@ -3224,6 +3481,13 @@ export async function handleVault(
3224
3481
  * configured AND available), which is what a surface gates its mic on.
3225
3482
  */
3226
3483
  resolveCapability: () => Promise<TranscriptionCapability> = resolveTranscriptionCapability,
3484
+ /**
3485
+ * Tag-scope allowlist (front-door structural map). Threaded from
3486
+ * routing.ts the same way every other read handler gets it. Unscoped
3487
+ * (`NO_TAG_SCOPE`) by default so every pre-existing call site — none of
3488
+ * which pass this — is unaffected.
3489
+ */
3490
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
3227
3491
  ): Promise<Response> {
3228
3492
  const url = new URL(req.url);
3229
3493
 
@@ -3233,6 +3497,16 @@ export async function handleVault(
3233
3497
  // and available. `minutes_remaining` is omitted (cloud/plan concern;
3234
3498
  // self-host is unmetered). This is the field Notes gates the mic on.
3235
3499
  result.transcription = await resolveCapability();
3500
+ // Front-door structural map — ALWAYS included (unlike `stats`, which is
3501
+ // include_stats-gated): three cheap grouped-COUNT queries, so a fresh
3502
+ // reader orients in one call. Scope-aware: a tag-scoped token's map
3503
+ // covers only notes reachable through an in-scope tag (`tagFilter`
3504
+ // restricts the underlying query rather than post-hoc filtering an
3505
+ // unscoped rollup — see `getVaultMap`'s doc comment for why).
3506
+ result.map =
3507
+ tagScope.raw === null
3508
+ ? getVaultMap(store.db)
3509
+ : getVaultMap(store.db, { tagFilter: [...(tagScope.allowed ?? [])] });
3236
3510
  if (parseBool(parseQuery(url, "include_stats"), false)) {
3237
3511
  result.stats = await store.getVaultStats();
3238
3512
  }
@@ -2168,6 +2168,173 @@ describe("scope enforcement on /api/*", () => {
2168
2168
  );
2169
2169
  expect(writeRes.status).toBe(403);
2170
2170
  });
2171
+
2172
+ // ----- REST scope-tier completion (vault#570-adjacent) -------------------
2173
+ //
2174
+ // The MCP door re-tiered update-tag/delete-tag/rename-tag/merge-tags
2175
+ // write → admin, and doctor admin → read (core/src/mcp.ts). The REST door
2176
+ // originally only enforced the generic method→verb default (GET→read,
2177
+ // everything else→write) here, and hardcoded `/api/doctor` to admin
2178
+ // regardless — a gap between doors: a `vault:write` token could mutate
2179
+ // tag schemas over REST while MCP already refused it, and a `vault:read`
2180
+ // token could run the equivalent scan over MCP but not REST. These tests
2181
+ // pin BOTH doors now agreeing, mirroring the MCP suite in
2182
+ // `src/vault.test.ts`'s "MCP write/admin re-tier — scope enforcement at
2183
+ // the tools/call layer" describe block.
2184
+
2185
+ test("vault:write token: POST /api/notes succeeds but PUT/DELETE /api/tags/:name, POST /api/tags/merge, POST /api/tags/:name/rename are DENIED (now admin-tier — REST/MCP parity)", async () => {
2186
+ createVault("journal");
2187
+ const store = getVaultStore("journal");
2188
+ await store.createNote("seed", { tags: ["mine"] });
2189
+ const token = await mintToken("journal", { permission: "full", scopes: ["vault:write"] });
2190
+
2191
+ // Allowed: content authorship.
2192
+ const notesPath = "/vault/journal/api/notes";
2193
+ const createRes = await route(
2194
+ new Request(`http://localhost:1940${notesPath}`, {
2195
+ method: "POST",
2196
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2197
+ body: JSON.stringify({ content: "hello", tags: ["mine"] }),
2198
+ }),
2199
+ notesPath,
2200
+ );
2201
+ expect(createRes.status).toBeLessThan(400);
2202
+
2203
+ // Denied: each now requires vault:admin, not vault:write.
2204
+ const putPath = "/vault/journal/api/tags/mine";
2205
+ const putRes = await route(
2206
+ new Request(`http://localhost:1940${putPath}`, {
2207
+ method: "PUT",
2208
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2209
+ body: JSON.stringify({ description: "hijacked" }),
2210
+ }),
2211
+ putPath,
2212
+ );
2213
+ expect(putRes.status).toBe(403);
2214
+ const putBody = (await putRes.json()) as { error_type?: string; required_scope?: string };
2215
+ expect(putBody.error_type).toBe("insufficient_scope");
2216
+ expect(putBody.required_scope).toBe("vault:admin");
2217
+
2218
+ const deletePath = "/vault/journal/api/tags/mine";
2219
+ const deleteRes = await route(authed(token, "DELETE", deletePath), deletePath);
2220
+ expect(deleteRes.status).toBe(403);
2221
+ expect(((await deleteRes.json()) as { required_scope?: string }).required_scope).toBe("vault:admin");
2222
+
2223
+ const mergePath = "/vault/journal/api/tags/merge";
2224
+ const mergeRes = await route(
2225
+ new Request(`http://localhost:1940${mergePath}`, {
2226
+ method: "POST",
2227
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2228
+ body: JSON.stringify({ sources: ["mine"], target: "mine2" }),
2229
+ }),
2230
+ mergePath,
2231
+ );
2232
+ expect(mergeRes.status).toBe(403);
2233
+ expect(((await mergeRes.json()) as { required_scope?: string }).required_scope).toBe("vault:admin");
2234
+
2235
+ const renamePath = "/vault/journal/api/tags/mine/rename";
2236
+ const renameRes = await route(
2237
+ new Request(`http://localhost:1940${renamePath}`, {
2238
+ method: "POST",
2239
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2240
+ body: JSON.stringify({ new_name: "mine2" }),
2241
+ }),
2242
+ renamePath,
2243
+ );
2244
+ expect(renameRes.status).toBe(403);
2245
+ expect(((await renameRes.json()) as { required_scope?: string }).required_scope).toBe("vault:admin");
2246
+
2247
+ // Untouched — none of the denied calls above ever reached the store.
2248
+ const tags = await store.listTags();
2249
+ expect(tags.some((t) => t.name === "mine")).toBe(true);
2250
+ expect(tags.some((t) => t.name === "mine2")).toBe(false);
2251
+ });
2252
+
2253
+ test("vault:read token: GET /api/doctor succeeds (now read-tier — REST/MCP parity)", async () => {
2254
+ createVault("journal");
2255
+ const token = await mintToken("journal", { permission: "read", scopes: ["vault:read"] });
2256
+ const path = "/vault/journal/api/doctor";
2257
+ const res = await route(authed(token, "GET", path), path);
2258
+ expect(res.status).toBe(200);
2259
+ const body = (await res.json()) as { findings?: unknown[]; summary?: unknown };
2260
+ expect(body.findings).toBeDefined();
2261
+ expect(body.summary).toBeDefined();
2262
+ });
2263
+
2264
+ test("vault:admin token: CAN PUT/DELETE /api/tags/:name, POST /api/tags/merge, POST /api/tags/:name/rename, and GET /api/doctor", async () => {
2265
+ createVault("journal");
2266
+ const store = getVaultStore("journal");
2267
+ await store.upsertTagRecord("a", {});
2268
+ await store.upsertTagRecord("b", {});
2269
+ await store.createNote("seed", { tags: ["a"] });
2270
+ const token = await mintToken("journal", { permission: "full", scopes: ["vault:admin"] });
2271
+
2272
+ const putPath = "/vault/journal/api/tags/a";
2273
+ const putRes = await route(
2274
+ new Request(`http://localhost:1940${putPath}`, {
2275
+ method: "PUT",
2276
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2277
+ body: JSON.stringify({ description: "updated" }),
2278
+ }),
2279
+ putPath,
2280
+ );
2281
+ expect(putRes.status).toBe(200);
2282
+
2283
+ const doctorPath = "/vault/journal/api/doctor";
2284
+ const doctorRes = await route(authed(token, "GET", doctorPath), doctorPath);
2285
+ expect(doctorRes.status).toBe(200);
2286
+
2287
+ const mergePath = "/vault/journal/api/tags/merge";
2288
+ const mergeRes = await route(
2289
+ new Request(`http://localhost:1940${mergePath}`, {
2290
+ method: "POST",
2291
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2292
+ body: JSON.stringify({ sources: ["b"], target: "a" }),
2293
+ }),
2294
+ mergePath,
2295
+ );
2296
+ expect(mergeRes.status).toBe(200);
2297
+
2298
+ const renamePath = "/vault/journal/api/tags/a/rename";
2299
+ const renameRes = await route(
2300
+ new Request(`http://localhost:1940${renamePath}`, {
2301
+ method: "POST",
2302
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2303
+ body: JSON.stringify({ new_name: "a2" }),
2304
+ }),
2305
+ renamePath,
2306
+ );
2307
+ expect(renameRes.status).toBe(200);
2308
+
2309
+ const deletePath = "/vault/journal/api/tags/a2";
2310
+ const deleteRes = await route(authed(token, "DELETE", deletePath), deletePath);
2311
+ expect(deleteRes.status).toBe(200);
2312
+ });
2313
+
2314
+ test("vault:write token: GET /api/doctor still succeeds (write ⊇ read inheritance, unaffected by the re-tier)", async () => {
2315
+ createVault("journal");
2316
+ const token = await mintToken("journal", { permission: "full", scopes: ["vault:write"] });
2317
+ const path = "/vault/journal/api/doctor";
2318
+ const res = await route(authed(token, "GET", path), path);
2319
+ expect(res.status).toBe(200);
2320
+ });
2321
+
2322
+ test("vault:read token: PUT /api/tags/:name is DENIED (read does not imply admin)", async () => {
2323
+ createVault("journal");
2324
+ getVaultStore("journal").upsertTagRecord("mine", {});
2325
+ const token = await mintToken("journal", { permission: "read", scopes: ["vault:read"] });
2326
+ const path = "/vault/journal/api/tags/mine";
2327
+ const res = await route(
2328
+ new Request(`http://localhost:1940${path}`, {
2329
+ method: "PUT",
2330
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
2331
+ body: JSON.stringify({ description: "denied" }),
2332
+ }),
2333
+ path,
2334
+ );
2335
+ expect(res.status).toBe(403);
2336
+ expect(((await res.json()) as { required_scope?: string }).required_scope).toBe("vault:admin");
2337
+ });
2171
2338
  });
2172
2339
 
2173
2340
  // ---------------------------------------------------------------------------