@openparachute/vault 0.7.2-rc.3 → 0.7.2-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.
@@ -214,6 +214,29 @@ describe("aggregateNotes — errors", () => {
214
214
  }
215
215
  });
216
216
 
217
+ // LB — a `type: "number"` (float) field can NEVER be `indexed: true`
218
+ // (only integer/boolean are indexable numeric shapes), so it legitimately
219
+ // never appears in `indexed_fields`. Before the fix, the FIELD_NOT_INDEXED
220
+ // thrown here carried the SAME generic "declare it via update-tag with
221
+ // indexed: true" hint every other unindexed field gets — advice that's
222
+ // impossible to satisfy for `number` specifically, dead-ending an agent
223
+ // that tries it and hits the SEPARATE "unsupported type ... for indexing"
224
+ // error. The hint must instead name the real fix: store as an integer.
225
+ it("FIELD_NOT_INDEXED on a type: \"number\" sum field carries an actionable hint, not the impossible generic \"declare indexed: true\" advice", async () => {
226
+ await store.upsertTagRecord("expense", { fields: { amount: { type: "number" } } });
227
+ await store.createNote("a", { tags: ["expense"], metadata: { amount: 9.99 } });
228
+ try {
229
+ aggregateNotes(db, { aggregate: { group_by: "tag", op: "sum", field: "amount" } });
230
+ throw new Error("expected throw");
231
+ } catch (e: any) {
232
+ expect(e.code).toBe("FIELD_NOT_INDEXED");
233
+ expect(e.message).not.toContain("declare it via update-tag with indexed: true");
234
+ const guidance = `${e.message} ${e.hint ?? ""}`.toLowerCase();
235
+ expect(guidance).toContain("integer");
236
+ expect(guidance).toMatch(/float|number|decimal/);
237
+ }
238
+ });
239
+
217
240
  it("throws INVALID_QUERY when the sum field is indexed but not numeric (TEXT-backed)", async () => {
218
241
  await store.upsertTagRecord("task", {
219
242
  fields: {
@@ -295,6 +295,28 @@ describe("contract: taxonomy — #552 (flipped from test.todo)", () => {
295
295
  expect(cleanReport.summary).toContain("clean");
296
296
  });
297
297
 
298
+ // LB — a DIRECT self-parent (`parent_names: [tag]` on tag itself) is a
299
+ // degenerate one-node cycle: `getTagDescendants` starts its traversal
300
+ // result at `{tag}`, and the moment it walks the tag's own self-edge it
301
+ // finds `tag` already `result.has(child)` and skips re-expanding it — so
302
+ // `descendants` never grows past size 1. `findHierarchyCycles`'s original
303
+ // gate (`descendants.has(tag) && descendants.size > 1`) requires size>1,
304
+ // so this exact case reported clean despite being a real cycle. Only
305
+ // reachable via guard-bypassing data (direct DB write / import) since
306
+ // `upsertTagRecord` rejects a self-parent at write time (see the "bare
307
+ // self-parent: also caught" assertion above, which exercises the WRITE
308
+ // guard — this test exercises the READ-side doctor scan on data that
309
+ // skipped that guard).
310
+ it("doctor flags a bare self-parent cycle (X declares parent_names: [X]), which the size>1 gate alone misses", async () => {
311
+ await store.upsertTagRecord("self-cyc", {});
312
+ db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?").run(JSON.stringify(["self-cyc"]), "self-cyc");
313
+
314
+ const report = await store.doctor();
315
+ const finding = report.findings.find((f) => f.type === "parent_names_cycle" && f.subject === "self-cyc");
316
+ expect(finding).toBeDefined();
317
+ expect(finding?.severity).toBe("error");
318
+ });
319
+
298
320
  it("dead_tag_metadata_reference does not false-positive on a schema-declared enum field whose values coincide with an unrelated live tag (vault#570)", async () => {
299
321
  // "archived" is a real, unrelated live tag — coincidentally sharing a
300
322
  // name with one of "status"'s declared enum values. Pre-fix, this ONE
@@ -101,13 +101,69 @@ function toBinding(field: string, op: string, value: unknown): SQLQueryBindings
101
101
  );
102
102
  }
103
103
 
104
+ /**
105
+ * Best-effort lookup of a metadata field's declared `type` across every tag
106
+ * schema — used ONLY to sharpen the {@link requireIndexedField} error hint
107
+ * below when the field was never indexed. Scans `tags.fields` directly
108
+ * rather than going through `indexed_fields` (which by construction has no
109
+ * row for a type that can never BE indexed, e.g. `"number"`) or a
110
+ * per-note schema resolution (scoped to one note's tags, not "any tag
111
+ * anywhere"). Only reached on the FIELD_NOT_INDEXED error path — a full
112
+ * table scan here is fine since it runs once per rejected call, never on a
113
+ * hot path. Returns the type from the FIRST tag found declaring `field`
114
+ * (a cross-tag type conflict on the same field name is its own separate
115
+ * validation elsewhere, not this function's job); `undefined` when no tag
116
+ * declares this field name, or its `fields` JSON doesn't parse.
117
+ */
118
+ function findDeclaredFieldType(db: Database, field: string): string | undefined {
119
+ const rows = db.prepare(
120
+ `SELECT fields FROM tags WHERE fields IS NOT NULL`,
121
+ ).all() as { fields: string | null }[];
122
+ for (const row of rows) {
123
+ if (!row.fields) continue;
124
+ try {
125
+ const parsed: unknown = JSON.parse(row.fields);
126
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue;
127
+ const spec = (parsed as Record<string, unknown>)[field];
128
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec)) continue;
129
+ const type = (spec as Record<string, unknown>).type;
130
+ if (typeof type === "string") return type;
131
+ } catch {
132
+ // Malformed `fields` JSON on some other tag — not this lookup's job
133
+ // to validate; skip and keep scanning.
134
+ }
135
+ }
136
+ return undefined;
137
+ }
138
+
104
139
  /**
105
140
  * Look up `field` in `indexed_fields` or throw a loud error suggesting the
106
141
  * caller declare it via `update-tag` with `indexed: true`.
142
+ *
143
+ * That generic advice is IMPOSSIBLE to satisfy for a field whose schema
144
+ * declares `type: "number"` (a float) — only `integer`/`boolean` are
145
+ * indexable numeric shapes (see `indexed-fields.ts`'s `TYPE_MAP`), so
146
+ * `update-tag` with `indexed: true` on a `number` field itself throws
147
+ * "unsupported type ... for indexing". An agent that only sees the generic
148
+ * hint has no escape from that loop (LB — round-4 bug hunt). When the
149
+ * declared type is `"number"`, swap in a hint that names the actual fix:
150
+ * store the value as an integer (e.g. cents instead of dollars) and index
151
+ * THAT field instead.
107
152
  */
108
153
  export function requireIndexedField(db: Database, field: string): IndexedField {
109
154
  const row = getIndexedField(db, field);
110
155
  if (!row) {
156
+ if (findDeclaredFieldType(db, field) === "number") {
157
+ throw new QueryError(
158
+ `metadata field "${field}" is not indexed, and can never be: it's declared type: "number" (a float), and only integer/boolean fields are indexable numeric types. "declare indexed: true" will not work on this field.`,
159
+ "FIELD_NOT_INDEXED",
160
+ {
161
+ error_type: "field_not_indexed",
162
+ field,
163
+ hint: `"${field}" is a float ("number") field — float fields can't be server-side indexed, filtered with operators, used in order_by, or summed via aggregate. Store the value as an integer instead (e.g. cents rather than dollars) and declare indexed: true on THAT field to enable those.`,
164
+ },
165
+ );
166
+ }
111
167
  throw new QueryError(
112
168
  `metadata field "${field}" is not indexed. To use operator queries or order_by on this field, declare it via update-tag with indexed: true.`,
113
169
  "FIELD_NOT_INDEXED",
package/core/src/store.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  resolveUnresolvedWikilinks,
16
16
  resolveOrQueueLink,
17
17
  clearQueuedLink,
18
+ requeueInboundWikilinksForDelete,
18
19
  } from "./wikilinks.js";
19
20
  import { pathTitle } from "./paths.js";
20
21
  import { timestampToMs } from "./cursor.js";
@@ -382,6 +383,13 @@ export class BunSqliteStore implements Store {
382
383
  // by design, hooks subscribing to "deleted" receive a DeletedNoteRef,
383
384
  // not a Note.
384
385
  const existing = noteOps.getNote(this.db, id);
386
+ // LB6: re-queue inbound wikilink edges BEFORE the FK cascade drops the
387
+ // `links` rows, so recreating a note at this path/title auto-heals the
388
+ // edge instead of leaving every referencing note's `[[link]]` dead until
389
+ // it's individually re-saved. See requeueInboundWikilinksForDelete's doc
390
+ // comment for why this must run pre-delete and what it deliberately
391
+ // excludes (typed `links`, not just wikilinks).
392
+ requeueInboundWikilinksForDelete(this.db, id);
385
393
  noteOps.deleteNote(this.db, id);
386
394
  if (existing?.path) this.invalidateConfigCachesForPath(existing.path);
387
395
  // Dispatch even when `existing` was null — the caller asked for a
@@ -372,6 +372,19 @@ export function computeExpandedTagCounts(
372
372
  export function findHierarchyCycles(h: TagHierarchy): string[] {
373
373
  const cycles: string[] = [];
374
374
  for (const tag of h.childrenOf.keys()) {
375
+ // Direct self-edge (`parent_names` lists the tag itself) — checked
376
+ // separately from the descendant-set gate below because
377
+ // `getTagDescendants` can't see it: its traversal seeds `result` with
378
+ // `{tag}` and then skips re-expanding any child already present in
379
+ // `result` (the visited-set that makes runtime traversal cycle-safe) —
380
+ // so a bare `X -> X` edge never grows `descendants` past size 1, and
381
+ // `descendants.size > 1` alone silently reports it clean. Only
382
+ // reachable via guard-bypassing data (a direct DB write, or an import
383
+ // that skips `upsertTagRecord`'s write-time cycle guard).
384
+ if (h.childrenOf.get(tag)?.has(tag)) {
385
+ cycles.push(tag);
386
+ continue;
387
+ }
375
388
  const descendants = getTagDescendants(h, tag);
376
389
  if (descendants.has(tag) && descendants.size > 1) {
377
390
  // tag reaches itself through a non-trivial path
@@ -10,7 +10,10 @@ import {
10
10
  listUnresolvedWikilinks,
11
11
  getContentWikilinkWarnings,
12
12
  resolveOrQueueLink,
13
+ requeueInboundWikilinksForDelete,
14
+ getUnresolvedLinksForNote,
13
15
  } from "./wikilinks.js";
16
+ import { findPath } from "./links.js";
14
17
 
15
18
  let store: SqliteStore;
16
19
  let db: Database;
@@ -480,6 +483,178 @@ describe("path-based resolution", async () => {
480
483
  });
481
484
  });
482
485
 
486
+ // ---------------------------------------------------------------------------
487
+ // requeueInboundWikilinksForDelete (LB6) — a deleted note's inbound wikilink
488
+ // edges must re-queue so recreating a note at the same path/title heals
489
+ // them, instead of staying dead until the SOURCE note is individually
490
+ // re-saved.
491
+ // ---------------------------------------------------------------------------
492
+
493
+ describe("delete → recreate re-resolves inbound wikilinks (LB6)", () => {
494
+ it("A [[Foo]] survives Foo's delete-then-recreate without touching A", async () => {
495
+ const a = await store.createNote("Link to [[Foo]]", { path: "A" });
496
+
497
+ // Foo doesn't exist yet — the link is queued, not yet live.
498
+ expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
499
+ const pendingBefore = db.prepare(
500
+ "SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?",
501
+ ).all(a.id) as { target_path: string }[];
502
+ expect(pendingBefore.map((r) => r.target_path)).toEqual(["Foo"]);
503
+
504
+ // Create Foo — the wikilink resolves and the edge + backlink exist.
505
+ const foo1 = await store.createNote("First Foo", { path: "Foo" });
506
+ let links = await store.getLinks(a.id, { direction: "outbound" });
507
+ expect(links).toHaveLength(1);
508
+ expect(links[0]!.targetId).toBe(foo1.id);
509
+ expect(findPath(db, a.id, foo1.id)).not.toBeNull();
510
+ const backlinks = await store.getLinks(foo1.id, { direction: "inbound" });
511
+ expect(backlinks.some((l) => l.sourceId === a.id)).toBe(true);
512
+
513
+ // Delete Foo. Without the LB6 fix, `unresolved_wikilinks` stays empty —
514
+ // nothing pending — even though A's `[[Foo]]` text is untouched.
515
+ await store.deleteNote(foo1.id);
516
+ expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
517
+ const pendingAfterDelete = db.prepare(
518
+ "SELECT target_path, relationship FROM unresolved_wikilinks WHERE source_id = ?",
519
+ ).all(a.id) as { target_path: string; relationship: string }[];
520
+ expect(pendingAfterDelete).toEqual([{ target_path: "Foo", relationship: "wikilink" }]);
521
+
522
+ // Recreate Foo (new note, new id — same path). A was never re-saved.
523
+ const foo2 = await store.createNote("Second Foo", { path: "Foo" });
524
+
525
+ // The A -> Foo edge is back, pointed at the NEW Foo, and find-path sees it.
526
+ links = await store.getLinks(a.id, { direction: "outbound" });
527
+ expect(links).toHaveLength(1);
528
+ expect(links[0]!.targetId).toBe(foo2.id);
529
+ expect(links[0]!.relationship).toBe("wikilink");
530
+ expect(findPath(db, a.id, foo2.id)).not.toBeNull();
531
+
532
+ // A's content was never touched.
533
+ const reread = await store.getNote(a.id);
534
+ expect(reread!.content).toBe("Link to [[Foo]]");
535
+
536
+ // The pending row is consumed on resolution.
537
+ const pendingAfterRecreate = db.prepare(
538
+ "SELECT * FROM unresolved_wikilinks WHERE source_id = ?",
539
+ ).all(a.id);
540
+ expect(pendingAfterRecreate).toHaveLength(0);
541
+ });
542
+
543
+ it("only re-queues wikilink-relationship inbound edges, not explicit typed links", async () => {
544
+ const source = await store.createNote("A", { id: "src", path: "Src" });
545
+ const target = await store.createNote("B", { id: "tgt", path: "Tgt" });
546
+ await store.createLink("src", "tgt", "related-to"); // hand-authored typed link, not a wikilink
547
+
548
+ requeueInboundWikilinksForDelete(db, target.id);
549
+
550
+ // getUnresolvedLinksForNote tolerates the table not existing at all
551
+ // (no wikilink was ever parsed in this test) — a raw SELECT would throw.
552
+ expect(getUnresolvedLinksForNote(db, source.id)).toHaveLength(0);
553
+ });
554
+
555
+ it("re-queues via basename resolution, not just exact path, and recovers on recreate at the same path", async () => {
556
+ // Target lives at a nested path; source links via the bare basename —
557
+ // resolved through resolveWikilink's basename fallback (rule #3), not
558
+ // an exact path match.
559
+ const target = await store.createNote("# Weekly Review\n\nBody.", { path: "Projects/Weekly Review" });
560
+ const source = await store.createNote("See [[Weekly Review]]", { path: "A" });
561
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(1);
562
+
563
+ await store.deleteNote(target.id);
564
+
565
+ const pending = db.prepare(
566
+ "SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?",
567
+ ).all(source.id) as { target_path: string }[];
568
+ // The re-queued key is the RAW bracket text ("Weekly Review"), not the
569
+ // deleted note's full path — that's what the basename-fallback lazy
570
+ // resolver (`resolveUnresolvedWikilinks`'s `? LIKE '%/' || target_path`
571
+ // suffix match) actually matches on.
572
+ expect(pending.map((r) => r.target_path)).toEqual(["Weekly Review"]);
573
+
574
+ // Recreate at the SAME nested path — the suffix match backfills it.
575
+ const target2 = await store.createNote("# Weekly Review v2\n\nBody.", { path: "Projects/Weekly Review" });
576
+ const links = await store.getLinks(source.id, { direction: "outbound" });
577
+ expect(links).toHaveLength(1);
578
+ expect(links[0]!.targetId).toBe(target2.id);
579
+ });
580
+
581
+ // BLOCKER (Fable review) — the delete→recreate re-heal must cover links
582
+ // that resolved via the H1-TITLE fallback and the explicit-EXTENSION form,
583
+ // not just path/basename. The re-queued row's target string is the raw
584
+ // bracket text (e.g. "John Doe" / "budget.csv"), which the OLD sweep
585
+ // matched against the new note by PATH TEXT ONLY — so a title- or
586
+ // extension-resolved link stayed queued (0 links rebuilt) forever. The
587
+ // sweep now runs each candidate through the SAME resolver write-time uses,
588
+ // so deferred resolution finally matches write-time on all four legs.
589
+ it("re-heals a TITLE-fallback link AND an EXTENSION-form link on delete→recreate at the same path+H1", async () => {
590
+ // A resolves to `people/jdoe` only via its H1 title "John Doe".
591
+ // B resolves to `budget` (ext csv) only via the explicit [[budget.csv]]
592
+ // form (the extension leg is exact-path, so `budget` lives at path
593
+ // "budget", disambiguating it from a same-named `.md` note).
594
+ const person = await store.createNote("# John Doe\n\nBio.", { path: "people/jdoe" });
595
+ const budget = await store.createNote("month,total\n2026-01,9000", { path: "budget", extension: "csv" });
596
+ const a = await store.createNote("met [[John Doe]] today", { path: "A" });
597
+ const b = await store.createNote("see [[budget.csv]]", { path: "B" });
598
+
599
+ // Both links live at write time (title fallback + extension form).
600
+ expect((await store.getLinks(a.id, { direction: "outbound" }))[0]?.targetId).toBe(person.id);
601
+ expect((await store.getLinks(b.id, { direction: "outbound" }))[0]?.targetId).toBe(budget.id);
602
+
603
+ await store.deleteNote(person.id);
604
+ await store.deleteNote(budget.id);
605
+
606
+ // Both re-queued with their raw bracket text — NOT the deleted notes' paths.
607
+ const pendingA = db.prepare("SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?").all(a.id) as { target_path: string }[];
608
+ const pendingB = db.prepare("SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?").all(b.id) as { target_path: string }[];
609
+ expect(pendingA.map((r) => r.target_path)).toEqual(["John Doe"]);
610
+ expect(pendingB.map((r) => r.target_path)).toEqual(["budget.csv"]);
611
+ expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
612
+ expect(await store.getLinks(b.id, { direction: "outbound" })).toHaveLength(0);
613
+
614
+ // Recreate each at the SAME path + H1 (new note, new id). Neither A nor B
615
+ // is re-saved — only the deferred sweep can rebuild these edges.
616
+ const person2 = await store.createNote("# John Doe\n\nBio v2.", { path: "people/jdoe" });
617
+ const budget2 = await store.createNote("month,total\n2026-02,9500", { path: "budget", extension: "csv" });
618
+
619
+ // BOTH re-heal (RED before the sweep completion — title/ext legs missing).
620
+ const aLinks = await store.getLinks(a.id, { direction: "outbound" });
621
+ const bLinks = await store.getLinks(b.id, { direction: "outbound" });
622
+ expect(aLinks).toHaveLength(1);
623
+ expect(aLinks[0]!.targetId).toBe(person2.id);
624
+ expect(findPath(db, a.id, person2.id)).not.toBeNull();
625
+ expect(bLinks).toHaveLength(1);
626
+ expect(bLinks[0]!.targetId).toBe(budget2.id);
627
+
628
+ // Pending rows consumed on resolution.
629
+ expect(getUnresolvedLinksForNote(db, a.id)).toHaveLength(0);
630
+ expect(getUnresolvedLinksForNote(db, b.id)).toHaveLength(0);
631
+ });
632
+
633
+ // The completed sweep must NOT mis-resolve an AMBIGUOUS target — matching
634
+ // write-time's "don't guess" contract. A pending [[John Doe]] is swept when
635
+ // a same-titled note is created; if TWO notes already share that H1 by the
636
+ // time the sweep verifies, the resolver returns ambiguous and the row stays
637
+ // queued rather than the old path-only sweep's "link to whoever showed up."
638
+ // (Reached via a pathless first note — the sweep is path-gated, so it never
639
+ // fired to consume the row when only ONE John Doe existed.)
640
+ it("does not re-heal a title link when the target is ambiguous (two notes share the H1)", async () => {
641
+ const a = await store.createNote("met [[John Doe]]", { path: "A" });
642
+ expect(getUnresolvedLinksForNote(db, a.id).map((l) => l.target)).toEqual(["John Doe"]); // queued, no John Doe yet
643
+
644
+ // A PATHLESS note carrying the H1 — createNote's sweep is path-gated, so
645
+ // it does NOT fire here; A's pending row survives with one John Doe extant.
646
+ await store.createNote("# John Doe\n\nfirst.");
647
+ expect(getUnresolvedLinksForNote(db, a.id).map((l) => l.target)).toEqual(["John Doe"]); // still queued
648
+
649
+ // A SECOND note with the same H1, WITH a path → the sweep fires. Now two
650
+ // notes carry "John Doe" → resolveWikilinkDetailed is ambiguous → the row
651
+ // is left queued, nothing linked.
652
+ await store.createNote("# John Doe\n\nsecond.", { path: "people/jd" });
653
+ expect(await store.getLinks(a.id, { direction: "outbound" })).toHaveLength(0);
654
+ expect(getUnresolvedLinksForNote(db, a.id).map((l) => l.target)).toEqual(["John Doe"]);
655
+ });
656
+ });
657
+
483
658
  // ---------------------------------------------------------------------------
484
659
  // unresolved_wikilinks relationship-column migration — atomicity (vault#555
485
660
  // wire+generalist must-fix; W7's migrateToV25-interruption lesson applied).
@@ -1,7 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Note } from "./types.js";
3
3
  import * as linkOps from "./links.js";
4
- import { getNote, findNotesByTitle } from "./notes.js";
4
+ import { getNote, findNotesByTitle, extractH1Title } from "./notes.js";
5
5
  import { chunkForInClause } from "./sql-in.js";
6
6
  import { transaction } from "./txn.js";
7
7
  import type { QueryWarning } from "./query-warnings.js";
@@ -619,11 +619,32 @@ function syncUnresolvedWikilinks(
619
619
 
620
620
  /**
621
621
  * Try to resolve pending wikilinks AND pending structured-link forward-refs
622
- * (vault#555) that point to a given path. Called when a note is created or
622
+ * (vault#555) that point to a given note. Called when a note is created or
623
623
  * its path changes. Each pending row materializes with ITS OWN relationship
624
624
  * (a structured link queued via {@link queueUnresolvedLink} backfills with
625
625
  * the caller's original relationship, not "wikilink").
626
626
  *
627
+ * Deferred resolution now covers all four legs of
628
+ * {@link resolveWikilinkDetailed}: exact path, basename, H1 title, and the
629
+ * explicit `path.ext` form. (Caveat: the candidate pre-filter below matches the
630
+ * title/ext legs via SQL `COLLATE NOCASE`, which case-folds ASCII only — a
631
+ * non-ASCII title differing from the target only in letter case is missed at
632
+ * the candidate stage and re-heals on the source's next save instead. Rare;
633
+ * tracked as a follow-up.) Before this, the sweep matched a pending row to
634
+ * the new note by PATH TEXT ONLY (`target_path = path OR path LIKE
635
+ * '%/'||target_path`) — so a `[[John Doe]]` that resolved at write time via
636
+ * the H1-title fallback (its note's displayed title differs from its path,
637
+ * e.g. `people/jdoe`), or a `[[Foo.csv]]` that resolved via the extension
638
+ * leg, silently never re-healed on a delete→recreate (the exact LB6 gap) and
639
+ * more broadly never backfilled when the target note was created AFTER the
640
+ * referencing note. Each candidate pending row is now VERIFIED through
641
+ * `resolveWikilinkDetailed` against the current DB — a row is healed only
642
+ * when its target string actually resolves to THIS note. An AMBIGUOUS target
643
+ * (≥2 notes now share the path/title) resolves to neither and stays queued,
644
+ * identical to write-time's "don't guess" contract — this also closes the
645
+ * pre-existing asymmetry where the path-only sweep would link an ambiguous
646
+ * `[[Foo]]` to whichever colliding note happened to be created.
647
+ *
627
648
  * Returns the number of links resolved.
628
649
  */
629
650
  export function resolveUnresolvedWikilinks(
@@ -632,13 +653,32 @@ export function resolveUnresolvedWikilinks(
632
653
  noteId: string,
633
654
  ): number {
634
655
  ensureRelationshipColumn(db);
635
- let rows: { source_id: string; relationship: string }[];
656
+
657
+ // The newly-created / newly-repathed note supplies the two resolution keys
658
+ // the pending row's `target_path` (a bare path/basename string) can't carry
659
+ // on its own: the note's H1 title (title-fallback leg) and its extension
660
+ // (explicit `path.ext` leg). `getNote` is one indexed PK lookup; the title
661
+ // is parsed from content in JS (no separate query).
662
+ const note = getNote(db, noteId);
663
+ const h1Title = note?.content ? extractH1Title(note.content) : null;
664
+ const pathDotExt = note?.extension ? `${notePath}.${note.extension}` : null;
665
+
666
+ let rows: { source_id: string; target_path: string; relationship: string }[];
636
667
  try {
668
+ // Candidate pre-filter: every pending row whose `target_path` COULD
669
+ // resolve to this note under any resolveWikilinkDetailed leg — exact
670
+ // path, basename (target is the last path segment), H1 title, or the
671
+ // `path.ext` form. A `null` bind (no H1 heading / no extension) makes its
672
+ // clause never match (`target_path = NULL` is NULL, i.e. falsy in SQL).
673
+ // The verify step below is what enforces correctness; this clause only
674
+ // BOUNDS how many rows reach the (title-fallback-scanning) resolver.
637
675
  rows = db.prepare(`
638
- SELECT source_id, relationship FROM unresolved_wikilinks
676
+ SELECT source_id, target_path, relationship FROM unresolved_wikilinks
639
677
  WHERE target_path = ? COLLATE NOCASE
640
678
  OR ? LIKE '%/' || target_path
641
- `).all(notePath, notePath) as { source_id: string; relationship: string }[];
679
+ OR target_path = ? COLLATE NOCASE
680
+ OR target_path = ? COLLATE NOCASE
681
+ `).all(notePath, notePath, h1Title, pathDotExt) as typeof rows;
642
682
  } catch {
643
683
  return 0; // Table doesn't exist
644
684
  }
@@ -649,16 +689,24 @@ export function resolveUnresolvedWikilinks(
649
689
  for (const row of rows) {
650
690
  if (row.source_id === noteId) continue; // Skip self-links
651
691
 
692
+ // Verify against the SAME resolver write-time uses, so deferred
693
+ // resolution can't diverge from it: heal a pending row ONLY when its
694
+ // target string actually resolves to THIS note now. A miss or an
695
+ // ambiguous result leaves the row queued (surfaced as a visible broken
696
+ // link, and re-tried on the next matching note create).
697
+ const detail = resolveWikilinkDetailed(db, row.target_path);
698
+ if (!detail.resolved || detail.note_id !== noteId) continue;
699
+
652
700
  const relationship = row.relationship || WIKILINK_REL;
653
701
  linkOps.createLink(db, row.source_id, noteId, relationship);
654
702
  resolved++;
655
703
 
656
- // Remove the unresolved entry (this exact relationship only a
657
- // source may have BOTH a wikilink and a structured-link forward-ref
658
- // pending against the same target_path).
704
+ // Remove exactly this pending row a source may have BOTH a wikilink and
705
+ // a structured-link forward-ref pending against the same target_path
706
+ // (distinct relationships), so scope the delete to all three PK columns.
659
707
  db.prepare(
660
- "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ? AND (target_path = ? COLLATE NOCASE OR ? LIKE '%/' || target_path)",
661
- ).run(row.source_id, relationship, notePath, notePath);
708
+ "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND target_path = ? AND relationship = ?",
709
+ ).run(row.source_id, row.target_path, relationship);
662
710
  }
663
711
 
664
712
  return resolved;
@@ -840,3 +888,96 @@ export function getContentWikilinkWarnings(
840
888
 
841
889
  return warnings;
842
890
  }
891
+
892
+ // ---------------------------------------------------------------------------
893
+ // Delete-time re-queue (LB6) — a deleted note's INBOUND wikilink edges must
894
+ // come back to life if a note is later recreated at the same path/title.
895
+ // ---------------------------------------------------------------------------
896
+
897
+ /**
898
+ * Before a note is deleted, re-queue every INBOUND `wikilink`-relationship
899
+ * edge pointing at it into `unresolved_wikilinks`, so that recreating a note
900
+ * matching the original `[[target]]` text (same path, basename, H1 title, or
901
+ * `path.ext` form — anything {@link resolveWikilinkDetailed} would have
902
+ * matched) auto-heals the edge via {@link resolveUnresolvedWikilinks} — which
903
+ * now verifies each pending row through that same resolver — exactly as if
904
+ * the link had never resolved in the first place.
905
+ *
906
+ * Without this, `deleteNote`'s `DELETE FROM notes` cascades the `links` row
907
+ * away (FK `ON DELETE CASCADE`) but leaves `unresolved_wikilinks` untouched —
908
+ * a note recreated at the deleted note's path never gets linked back to,
909
+ * because nothing is pending resolution for it. Only a re-save of the
910
+ * SOURCE note (which reparses its own content from scratch) would recover
911
+ * it; a fresh `[[Foo]]` reference elsewhere would work, but the ORIGINAL
912
+ * source note's edge stayed dead despite its unchanged `[[Foo]]` text.
913
+ *
914
+ * MUST be called BEFORE the note row is deleted — it needs both the `links`
915
+ * row (about to cascade away) and, per source, the CURRENT content (to
916
+ * recover the raw `[[target]]` text the resolver actually matched on: a
917
+ * resolved link's row retains the target NOTE id, not the original bracket
918
+ * text, and title-fallback / basename resolution mean that text can differ
919
+ * from the deleted note's own path).
920
+ *
921
+ * Scope: only `relationship = "wikilink"` inbound edges are re-queued.
922
+ * Explicit typed `links` (structured `links: [{target, relationship}]`
923
+ * entries, vault#555) are left alone — those are hand-authored associations
924
+ * the caller owns, not content-derived, so silently resurrecting them as a
925
+ * forward-ref on an unrelated future note would be surprising. A source
926
+ * that links to itself is never queued (wikilinks never create self-links,
927
+ * so no such inbound row can exist) and is skipped defensively anyway.
928
+ */
929
+ export function requeueInboundWikilinksForDelete(db: Database, noteId: string): void {
930
+ let inbound: { source_id: string }[];
931
+ try {
932
+ inbound = db.prepare(
933
+ "SELECT DISTINCT source_id FROM links WHERE target_id = ? AND relationship = ?",
934
+ ).all(noteId, WIKILINK_REL) as { source_id: string }[];
935
+ } catch {
936
+ return; // links table missing (shouldn't happen post-schema-init, but never block a delete on this)
937
+ }
938
+ if (inbound.length === 0) return;
939
+
940
+ // Cheap string pre-filter (perf): a wikilink can only have resolved to the
941
+ // note being deleted if its raw target text equals one of THIS note's own
942
+ // resolution keys — its path, basename, H1 title, or `path.ext` form (the
943
+ // four legs {@link resolveWikilinkDetailed} matches on; every leg produces a
944
+ // target string equal to one of these, so the key set is a complete
945
+ // necessary-condition superset). Computed ONCE, then each source wikilink's
946
+ // target is gated on set membership BEFORE the expensive resolver call
947
+ // (whose title-fallback leg scans every note's content). Without this, a hub
948
+ // note with hundreds of inbound sources would fire hundreds of full-vault
949
+ // scans in one delete. The resolver still CONFIRMS each survivor — the
950
+ // pre-filter narrows, it doesn't decide.
951
+ const deleted = getNote(db, noteId);
952
+ const keys = new Set<string>();
953
+ const addKey = (s: string | null | undefined): void => {
954
+ const k = s?.trim().toLowerCase();
955
+ if (k) keys.add(k);
956
+ };
957
+ if (deleted?.path) {
958
+ addKey(deleted.path);
959
+ const slash = deleted.path.lastIndexOf("/");
960
+ addKey(slash >= 0 ? deleted.path.slice(slash + 1) : deleted.path); // basename
961
+ if (deleted.extension) addKey(`${deleted.path}.${deleted.extension}`);
962
+ }
963
+ if (deleted?.content) addKey(extractH1Title(deleted.content));
964
+ if (keys.size === 0) return; // no key any wikilink could have matched on
965
+
966
+ for (const { source_id } of inbound) {
967
+ if (source_id === noteId) continue; // defensive — wikilinks never self-link
968
+
969
+ const source = getNote(db, source_id);
970
+ if (!source || !source.content) continue;
971
+
972
+ const targetMap = dedupeWikilinkTargets(parseWikilinks(source.content));
973
+ for (const [, wl] of targetMap) {
974
+ // Skip the resolver unless this target text could match the deleted
975
+ // note by one of its resolution keys (necessary-condition pre-filter).
976
+ if (!keys.has(wl.target.trim().toLowerCase())) continue;
977
+ const detail = resolveWikilinkDetailed(db, wl.target);
978
+ if (detail.resolved && detail.note_id === noteId) {
979
+ queueUnresolvedLink(db, source_id, wl.target, WIKILINK_REL);
980
+ }
981
+ }
982
+ }
983
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.2-rc.3",
3
+ "version": "0.7.2-rc.4",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/routes.ts CHANGED
@@ -178,6 +178,105 @@ function jsonWithWarnings(data: unknown, warnings: QueryWarning[], status = 200)
178
178
  return res;
179
179
  }
180
180
 
181
+ /**
182
+ * Sane upper bound on a mutating-route JSON request body (LB7c). Without
183
+ * this, a giant field (most commonly a note's `content`) sails straight
184
+ * through `req.json()` and into wikilink parsing + the store write with no
185
+ * gate at all — reproduced at 50MB `content` -> 201 Created, RSS 92MB ->
186
+ * 663MB (memory-DoS via a single request). 10MB is generous for
187
+ * hand-authored or transcribed note content (multiple hours of dense
188
+ * transcript) while bounding the failure mode. Mirrors `/upload`'s
189
+ * `MAX_UPLOAD_BYTES` file-size gate for the JSON-body transport.
190
+ */
191
+ export const MAX_JSON_BODY_BYTES = 10 * 1024 * 1024;
192
+
193
+ function payloadTooLargeResponse(bytes: number): Response {
194
+ return json(
195
+ {
196
+ error: `Request body too large (${(bytes / 1024 / 1024).toFixed(1)}MB). Max: ${Math.round(MAX_JSON_BODY_BYTES / 1024 / 1024)}MB`,
197
+ error_type: "payload_too_large",
198
+ limit: MAX_JSON_BODY_BYTES,
199
+ got: bytes,
200
+ },
201
+ 413,
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Single choke point for every mutating route that reads a JSON request
207
+ * body (LB7 — door asymmetry fix). Three failure modes, all returning a
208
+ * clean 400/413 instead of falling through to server.ts's generic
209
+ * `{ error: "Internal server error" }` 500-with-no-`error_type` (LB7a), or
210
+ * silently succeeding on a wrong-shape body (LB7b — `null` used to throw a
211
+ * TypeError deep in the handler; `42`/`[]` used to sail through property
212
+ * access as `undefined` and create a blank note/no-op update):
213
+ *
214
+ * 1. **Oversize** — `Content-Length` over {@link MAX_JSON_BODY_BYTES}
215
+ * rejects BEFORE `req.json()` even buffers/parses the body (the cheap
216
+ * path). When the header is absent or understates the true size
217
+ * (chunked transfer, a lying client), the parsed body's own
218
+ * JSON-serialized size is checked as a backstop — one extra parse, but
219
+ * still cheaper than letting the oversized value reach the store and
220
+ * get re-processed (wikilink scan, indexing, etc.).
221
+ * 2. **Malformed JSON** — `req.json()` rejects; caught here rather than
222
+ * propagating to the top-level handler.
223
+ * 3. **Wrong shape** — valid JSON, but not a plain object (`null`, an
224
+ * array, or a bare primitive). Gated by `requireObject` (default
225
+ * `true`); every current caller wants this — the parameter exists so a
226
+ * future non-object-bodied route doesn't have to bypass the helper
227
+ * entirely to opt out.
228
+ *
229
+ * Callers: check `.ok` and return `.response` on failure; `.body` is the
230
+ * parsed JSON value (still `any` — this validates SHAPE, not the specific
231
+ * fields a given route requires, which callers keep checking themselves).
232
+ */
233
+ async function parseJsonBody(
234
+ req: Request,
235
+ opts?: { requireObject?: boolean },
236
+ ): Promise<{ ok: true; body: any } | { ok: false; response: Response }> {
237
+ const contentLengthHeader = req.headers.get("content-length");
238
+ if (contentLengthHeader) {
239
+ const declaredBytes = Number(contentLengthHeader);
240
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_JSON_BODY_BYTES) {
241
+ return { ok: false, response: payloadTooLargeResponse(declaredBytes) };
242
+ }
243
+ }
244
+
245
+ const INVALID_JSON = Symbol("invalid-json");
246
+ const parsed = await req.json().catch(() => INVALID_JSON);
247
+ if (parsed === INVALID_JSON) {
248
+ return { ok: false, response: json({ error: "Invalid JSON body", error_type: "invalid_json" }, 400) };
249
+ }
250
+
251
+ if (!contentLengthHeader) {
252
+ const approxBytes = Buffer.byteLength(JSON.stringify(parsed) ?? "", "utf8");
253
+ if (approxBytes > MAX_JSON_BODY_BYTES) {
254
+ return { ok: false, response: payloadTooLargeResponse(approxBytes) };
255
+ }
256
+ }
257
+
258
+ const requireObject = opts?.requireObject ?? true;
259
+ if (requireObject && (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))) {
260
+ // The body PARSED as JSON but is the wrong SHAPE (a primitive/array/null,
261
+ // not an object) — a `invalid_request` per the taxonomy (parallel to
262
+ // `/tags/merge`'s `sources`/`target` shape errors). `invalid_json` is
263
+ // reserved for a genuine parse failure (the branch above).
264
+ return {
265
+ ok: false,
266
+ response: json(
267
+ {
268
+ error: "Request body must be a JSON object",
269
+ error_type: "invalid_request",
270
+ hint: `expected a JSON object body — got ${parsed === null ? "null" : Array.isArray(parsed) ? "an array" : typeof parsed}`,
271
+ },
272
+ 400,
273
+ ),
274
+ };
275
+ }
276
+
277
+ return { ok: true, body: parsed };
278
+ }
279
+
181
280
  /**
182
281
  * REST-only `removed_param` warning (vault#550) — the flat `date_field` /
183
282
  * `date_from` / `date_to` query-string params were removed at 0.6.4
@@ -1647,8 +1746,39 @@ async function handleNotesInner(
1647
1746
 
1648
1747
  // POST /notes — create (single or batch)
1649
1748
  if (method === "POST") {
1650
- const body = await req.json() as any;
1749
+ const parsedBody = await parseJsonBody(req);
1750
+ if (!parsedBody.ok) return parsedBody.response;
1751
+ const body = parsedBody.body as any;
1752
+ // `notes` (when present) must actually be an array — otherwise
1753
+ // `items` below iterates whatever `body.notes` happens to be (e.g. a
1754
+ // STRING iterates as one-character items), silently creating a batch
1755
+ // of blank notes (LB7b).
1756
+ if (body.notes !== undefined && !Array.isArray(body.notes)) {
1757
+ return json(
1758
+ {
1759
+ error: "notes must be an array",
1760
+ error_type: "invalid_request",
1761
+ field: "notes",
1762
+ hint: 'pass { "notes": [...] } for a batch, or a single note object with no `notes` key',
1763
+ },
1764
+ 400,
1765
+ );
1766
+ }
1651
1767
  const items: any[] = body.notes ?? [body];
1768
+ // Every batch item must itself be a plain object — a primitive/array
1769
+ // entry would otherwise read as an all-`undefined` note (LB7b).
1770
+ for (const item of items) {
1771
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
1772
+ return json(
1773
+ {
1774
+ error: "each note must be a JSON object",
1775
+ error_type: "invalid_request",
1776
+ hint: 'each entry in "notes" (or the top-level body, for a single-note POST) must be an object, e.g. {"content": "..."}',
1777
+ },
1778
+ 400,
1779
+ );
1780
+ }
1781
+ }
1652
1782
 
1653
1783
  // Batch cap (#213): refuse oversized batches before doing any work. 500
1654
1784
  // is the cap (Benjamin's number) — tighter blast radius than 1000 for
@@ -2052,7 +2182,9 @@ async function handleNotesInner(
2052
2182
  if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
2053
2183
  return json({ error: "Not found", error_type: "not_found" }, 404);
2054
2184
  }
2055
- const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
2185
+ const parsedBody = await parseJsonBody(req);
2186
+ if (!parsedBody.ok) return parsedBody.response;
2187
+ const body = parsedBody.body as { path: string; mimeType: string; transcribe?: boolean };
2056
2188
  if (!body.path || !body.mimeType) {
2057
2189
  return json(
2058
2190
  { error: "path and mimeType are required", error_type: "missing_required_field", hint: "pass both `path` and `mimeType`" },
@@ -2235,7 +2367,9 @@ async function handleNotesInner(
2235
2367
  // Body is parsed up front so the `if_missing: "create"` branch
2236
2368
  // (vault#309) can fire when the note doesn't exist. Pre-#309
2237
2369
  // shape parsed the body only after the not-found check.
2238
- const body = await req.json() as any;
2370
+ const parsedBody = await parseJsonBody(req);
2371
+ if (!parsedBody.ok) return parsedBody.response;
2372
+ const body = parsedBody.body as any;
2239
2373
  const note = await resolveNote(store, idOrPath);
2240
2374
  if (!note) {
2241
2375
  // vault#309 — `if_missing: "create"` turns this PATCH into a
@@ -3100,7 +3234,9 @@ export async function handleTags(
3100
3234
  if (tagScope.allowed && !tagScope.allowed.has(putTagName)) {
3101
3235
  return tagScopeForbidden(tagScope.raw ?? []);
3102
3236
  }
3103
- const body = (await req.json()) as {
3237
+ const parsedBody = await parseJsonBody(req);
3238
+ if (!parsedBody.ok) return parsedBody.response;
3239
+ const body = parsedBody.body as {
3104
3240
  description?: string | null;
3105
3241
  fields?: Record<string, unknown> | null;
3106
3242
  relationships?: Record<string, unknown> | null;
@@ -3514,7 +3650,9 @@ export async function handleVault(
3514
3650
  }
3515
3651
 
3516
3652
  if (req.method === "PATCH") {
3517
- const body = await req.json() as {
3653
+ const parsedBody = await parseJsonBody(req);
3654
+ if (!parsedBody.ok) return parsedBody.response;
3655
+ const body = parsedBody.body as {
3518
3656
  description?: string;
3519
3657
  config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
3520
3658
  };
package/src/vault.test.ts CHANGED
@@ -11,7 +11,7 @@ import { BunStore } from "./vault-store.ts";
11
11
  import { generateMcpTools } from "../core/src/mcp.ts";
12
12
  import { getLinksHydrated } from "../core/src/links.ts";
13
13
  import { buildVaultProjection } from "../core/src/vault-projection.ts";
14
- import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks } from "./routes.ts";
14
+ import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks, MAX_JSON_BODY_BYTES } from "./routes.ts";
15
15
  import { expandTokenTagScope } from "./tag-scope.ts";
16
16
  import type { TagScopeCtx } from "./routes.ts";
17
17
  import { extractApiKey } from "./auth.ts";
@@ -4198,6 +4198,144 @@ describe("HTTP /notes", async () => {
4198
4198
  });
4199
4199
  });
4200
4200
 
4201
+ // ---------------------------------------------------------------------------
4202
+ // REST transport hardening — malformed/wrong-shape/oversize JSON bodies
4203
+ // (LB7). Before the fix: malformed JSON on POST /notes, POST
4204
+ // /notes/:id/attachments, and PUT /tags/:name threw past the handler into
4205
+ // server.ts's generic top-level catch — a 500 with no `error_type` (LB7a),
4206
+ // unlike the already-hardened /tags/merge and /tags/:name/rename routes,
4207
+ // which returned a clean 400 `invalid_json`. A syntactically-valid but
4208
+ // wrong-shape body (`null`, `42`, `[]`) either threw a TypeError deep in the
4209
+ // handler or sailed through property access as `undefined`, silently
4210
+ // creating a blank note / no-op update (LB7b). And nothing capped request
4211
+ // body size, so an oversized `content` field reached the store unbounded
4212
+ // (LB7c). Every assertion here MUST fail without the fix (parseJsonBody).
4213
+ // ---------------------------------------------------------------------------
4214
+ describe("REST transport hardening — malformed/wrong-shape/oversize JSON bodies (LB7)", async () => {
4215
+ /** Raw (non-JSON.stringify'd) request body — for malformed-JSON cases mkReq can't express. */
4216
+ function mkRawReq(method: string, path: string, rawBody: string): Request {
4217
+ return new Request(`${BASE}${path}`, {
4218
+ method,
4219
+ body: rawBody,
4220
+ headers: { "Content-Type": "application/json" },
4221
+ });
4222
+ }
4223
+
4224
+ describe("malformed JSON -> clean 400 invalid_json, not a 500 (LB7a)", () => {
4225
+ test("POST /notes", async () => {
4226
+ const res = await handleNotes(mkRawReq("POST", "/notes", "{not valid json"), store, "");
4227
+ expect(res.status).toBe(400);
4228
+ const body = await res.json() as any;
4229
+ expect(body.error_type).toBe("invalid_json");
4230
+ });
4231
+
4232
+ test("POST /notes/:id/attachments", async () => {
4233
+ await store.createNote("x", { id: "att-malformed" });
4234
+ const res = await handleNotes(
4235
+ mkRawReq("POST", "/notes/att-malformed/attachments", "{not valid json"),
4236
+ store,
4237
+ "/att-malformed/attachments",
4238
+ );
4239
+ expect(res.status).toBe(400);
4240
+ const body = await res.json() as any;
4241
+ expect(body.error_type).toBe("invalid_json");
4242
+ });
4243
+
4244
+ test("PATCH /notes/:idOrPath", async () => {
4245
+ await store.createNote("x", { id: "patch-malformed" });
4246
+ const res = await handleNotes(
4247
+ mkRawReq("PATCH", "/notes/patch-malformed", "{not valid json"),
4248
+ store,
4249
+ "/patch-malformed",
4250
+ );
4251
+ expect(res.status).toBe(400);
4252
+ const body = await res.json() as any;
4253
+ expect(body.error_type).toBe("invalid_json");
4254
+ });
4255
+
4256
+ test("PUT /tags/:name — parity with the already-hardened /tags/merge shape", async () => {
4257
+ const res = await handleTags(mkRawReq("PUT", "/tags/malformed", "{not valid json"), store, "/malformed");
4258
+ expect(res.status).toBe(400);
4259
+ const body = await res.json() as any;
4260
+ expect(body.error_type).toBe("invalid_json");
4261
+ });
4262
+
4263
+ test("PATCH /vault-info", async () => {
4264
+ const cfg = { name: "default" } as { name: string };
4265
+ const res = await handleVault(mkRawReq("PATCH", "/vault", "{not valid json"), store, cfg as any);
4266
+ expect(res.status).toBe(400);
4267
+ const body = await res.json() as any;
4268
+ expect(body.error_type).toBe("invalid_json");
4269
+ });
4270
+ });
4271
+
4272
+ // Wrong-SHAPE bodies parse fine as JSON but aren't the object a route
4273
+ // expects — the taxonomy calls that `invalid_request` (parallel to
4274
+ // /tags/merge's sources/target shape errors), NOT `invalid_json` (which is
4275
+ // reserved for a genuine parse failure, asserted in the LB7a block above).
4276
+ describe("wrong-shape (but syntactically valid) JSON body -> 400 invalid_request, not 500 or a silent blank write (LB7b)", () => {
4277
+ test("POST /notes with a `null` body -> 400 invalid_request, not a 500 TypeError", async () => {
4278
+ const res = await handleNotes(mkReq("POST", "/notes", null), store, "");
4279
+ expect(res.status).toBe(400);
4280
+ const body = await res.json() as any;
4281
+ expect(body.error_type).toBe("invalid_request");
4282
+ });
4283
+
4284
+ test("POST /notes with a bare number body -> 400 invalid_request, not a silently-created blank note", async () => {
4285
+ const res = await handleNotes(mkReq("POST", "/notes", 42), store, "");
4286
+ expect(res.status).toBe(400);
4287
+ const body = await res.json() as any;
4288
+ expect(body.error_type).toBe("invalid_request");
4289
+ const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
4290
+ expect(after).toHaveLength(0); // no blank note landed
4291
+ });
4292
+
4293
+ test("POST /notes with a bare array body -> 400 invalid_request, not a silently-created blank note", async () => {
4294
+ const res = await handleNotes(mkReq("POST", "/notes", []), store, "");
4295
+ expect(res.status).toBe(400);
4296
+ const body = await res.json() as any;
4297
+ expect(body.error_type).toBe("invalid_request");
4298
+ const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
4299
+ expect(after).toHaveLength(0);
4300
+ });
4301
+
4302
+ test("POST /notes with notes: \"not-an-array\" -> 400 invalid_request, not per-character blank notes", async () => {
4303
+ const res = await handleNotes(mkReq("POST", "/notes", { notes: "oops" }), store, "");
4304
+ expect(res.status).toBe(400);
4305
+ const body = await res.json() as any;
4306
+ expect(body.error_type).toBe("invalid_request");
4307
+ expect(body.field).toBe("notes");
4308
+ const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
4309
+ expect(after).toHaveLength(0);
4310
+ });
4311
+
4312
+ test("POST /notes with a non-object item inside notes[] -> 400 invalid_request", async () => {
4313
+ const res = await handleNotes(
4314
+ mkReq("POST", "/notes", { notes: [{ content: "ok", path: "ok-one" }, 42] }),
4315
+ store,
4316
+ "",
4317
+ );
4318
+ expect(res.status).toBe(400);
4319
+ const body = await res.json() as any;
4320
+ expect(body.error_type).toBe("invalid_request");
4321
+ const after = await (await handleNotes(mkReq("GET", "/notes"), store, "")).json() as any[];
4322
+ expect(after).toHaveLength(0); // the whole batch is rejected, not a partial write
4323
+ });
4324
+ });
4325
+
4326
+ describe("oversize request body -> 413, not an unbounded write (LB7c)", () => {
4327
+ test("POST /notes with an oversized content field is rejected", async () => {
4328
+ const huge = "x".repeat(MAX_JSON_BODY_BYTES + 1024);
4329
+ const res = await handleNotes(mkReq("POST", "/notes", { content: huge, path: "huge-note" }), store, "");
4330
+ expect(res.status).toBe(413);
4331
+ const body = await res.json() as any;
4332
+ expect(body.error_type).toBe("payload_too_large");
4333
+ const stored = await store.getNoteByPath("huge-note");
4334
+ expect(stored).toBeNull();
4335
+ });
4336
+ });
4337
+ });
4338
+
4201
4339
  // ---------------------------------------------------------------------------
4202
4340
  // REST tag-scope confidentiality (security review). expand_links must not
4203
4341
  // inline out-of-scope wikilinked content; include_links must not hydrate