@openparachute/vault 0.5.2 → 0.5.3-rc.2

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/src/routes.ts CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { Store, Note } from "../core/src/types.ts";
15
15
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
- import { getNote, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
16
+ import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
17
17
  import { attachValidationStatus } from "../core/src/mcp.ts";
18
18
  import * as linkOps from "../core/src/links.ts";
19
19
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
@@ -567,7 +567,7 @@ async function handleNotesInner(
567
567
  ): Promise<Response> {
568
568
  const url = new URL(req.url);
569
569
  const method = req.method;
570
- const db = (store as any).db;
570
+ const db = store.db;
571
571
 
572
572
  // ---- Collection routes (no ID in path) ----
573
573
  if (subpath === "") {
@@ -817,16 +817,24 @@ async function handleNotesInner(
817
817
  }
818
818
  const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
819
819
  const relationship = parseQuery(url, "near[relationship]") ?? undefined;
820
- // Tag-scope policy for `near[]` (output-filter, not hop-guard): the
821
- // BFS walks the FULL graph from the anchor, including out-of-scope
822
- // intermediate hops, then the RESULT set is tag-scope-filtered below
823
- // (`filterNotesByTagScope`). No out-of-scope content or ids leak
824
- // out-of-scope notes never survive into the response. This is
825
- // ASYMMETRIC with `find-path`, which guards every hop (it returns the
826
- // path itself, so an out-of-scope intermediary would be a leak there).
827
- // The asymmetry is deliberate; tracked at vault#439 should we ever want
828
- // `near[]` to also constrain traversal hops.
829
- const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship });
820
+ // Tag-scope policy for `near[]` (vault#439 hop-guard, symmetric with
821
+ // find-path): for a tag-scoped token the BFS refuses to traverse
822
+ // THROUGH out-of-scope notes — scope is a wall, not a sieve. So a token
823
+ // scoped to ["work"] can't reach an in-scope note at depth 2 via a
824
+ // #personal intermediary at depth 1; that note is simply unreachable.
825
+ // The `filterNotesByTagScope` pass below still runs (defense in depth),
826
+ // but the wall makes it redundant for the `near[]` result set.
827
+ // Unscoped tokens (`tagScope.raw === null`) install no predicate the
828
+ // FULL graph is walked exactly as before, behavior unchanged.
829
+ const isTraversable = tagScope.raw
830
+ ? (id: string) =>
831
+ noteWithinTagScope(
832
+ { id, tags: getNoteTags(db, id) } as Note,
833
+ tagScope.allowed,
834
+ tagScope.raw,
835
+ )
836
+ : undefined;
837
+ const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship, isTraversable });
830
838
  const nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
831
839
  results = results.filter((n) => nearScope.has(n.id));
832
840
  }
@@ -1005,19 +1013,33 @@ async function handleNotesInner(
1005
1013
  throw e;
1006
1014
  }
1007
1015
 
1008
- // Apply tag schema defaults
1016
+ // Apply tag schema defaults, then re-read the notes whose metadata was
1017
+ // actually default-filled so the response reflects the final on-disk
1018
+ // state (the `created` entries were read before `applySchemaDefaults`
1019
+ // ran). Mirrors the MCP create-note path (vault#316). Batched re-read
1020
+ // (`getNotes` = one `WHERE id IN (...)`), skipped when no defaults
1021
+ // applied so the common no-defaults path adds zero extra reads.
1022
+ const mutatedIds = new Set<string>();
1009
1023
  for (const note of created) {
1010
1024
  if (note.tags?.length) {
1011
- await applySchemaDefaults(store, db, [note.id], note.tags);
1025
+ for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
1026
+ mutatedIds.add(id);
1027
+ }
1012
1028
  }
1013
1029
  }
1030
+ const refreshed =
1031
+ mutatedIds.size === 0
1032
+ ? created
1033
+ : (() => {
1034
+ const byId = new Map(getNotes(db, [...mutatedIds]).map((n) => [n.id, n]));
1035
+ return created.map((n) => byId.get(n.id) ?? n);
1036
+ })();
1014
1037
 
1015
1038
  // Attach `validation_status` so HTTP create-note matches the MCP
1016
- // surface (vault#287). Mirrors the MCP create-note attach site at
1017
- // `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
1039
+ // surface (vault#287). `attachValidationStatus` returns the note
1018
1040
  // unchanged when no tag declares fields, so vaults without any tag
1019
1041
  // schemas see no behavior change.
1020
- const final = created.map((n) => attachValidationStatus(store, db, n));
1042
+ const final = refreshed.map((n) => attachValidationStatus(store, db, n));
1021
1043
  return json(body.notes ? final : final[0], 201);
1022
1044
  }
1023
1045
 
@@ -1628,7 +1650,7 @@ export async function handleTags(
1628
1650
  // that token's allowlist. Aggregate matches across sources for a single
1629
1651
  // 409 envelope.
1630
1652
  const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
1631
- const db = (store as any).db;
1653
+ const db = store.db;
1632
1654
  for (const src of sources) {
1633
1655
  const tokens = findTokensReferencingTag(db, src as string);
1634
1656
  if (tokens.length > 0) referenced.push({ source: src as string, tokens });
@@ -1792,7 +1814,7 @@ export async function handleTags(
1792
1814
  // tag would silently orphan the token's allowlist. Fail closed (409)
1793
1815
  // and name the offending tokens so the operator can revoke or re-mint
1794
1816
  // before retrying. patterns/tag-scoped-tokens.md §Dependencies.
1795
- const referenced_by = findTokensReferencingTag((store as any).db, tagName);
1817
+ const referenced_by = findTokensReferencingTag(store.db, tagName);
1796
1818
  if (referenced_by.length > 0) {
1797
1819
  return json(
1798
1820
  {
@@ -1827,7 +1849,7 @@ export async function handleFindPath(
1827
1849
  const target = parseQuery(url, "target");
1828
1850
  if (!source || !target) return json({ error: "source and target parameters are required" }, 400);
1829
1851
 
1830
- const db = (store as any).db;
1852
+ const db = store.db;
1831
1853
  try {
1832
1854
  const sourceNote = await resolveNote(store, source);
1833
1855
  if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
@@ -1949,7 +1971,7 @@ export function handleUnresolvedWikilinks(
1949
1971
  const url = new URL(req.url);
1950
1972
  const limitStr = url.searchParams.get("limit");
1951
1973
  const limit = limitStr ? parseInt(limitStr, 10) : 50;
1952
- const db = (store as any).db;
1974
+ const db = store.db;
1953
1975
  const result = listUnresolvedWikilinks(db, limit);
1954
1976
 
1955
1977
  // Unscoped token → return as-is (unchanged behavior).
@@ -2611,9 +2633,12 @@ export async function handleStorage(
2611
2633
  // Tag schema defaults — same logic as core/src/mcp.ts applySchemaDefaults
2612
2634
  // ---------------------------------------------------------------------------
2613
2635
 
2614
- async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<void> {
2636
+ // Returns the IDs of notes whose metadata was actually default-filled, so
2637
+ // the caller can re-read ONLY the mutated notes (and skip the re-read when
2638
+ // nothing changed). Mirrors the core/src/mcp.ts contract.
2639
+ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
2615
2640
  const schemas = tagSchemaOps.getTagSchemaMap(db);
2616
- if (Object.keys(schemas).length === 0) return;
2641
+ if (Object.keys(schemas).length === 0) return [];
2617
2642
 
2618
2643
  const defaults: Record<string, unknown> = {};
2619
2644
  for (const tag of tags) {
@@ -2625,8 +2650,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
2625
2650
  }
2626
2651
  }
2627
2652
  }
2628
- if (Object.keys(defaults).length === 0) return;
2653
+ if (Object.keys(defaults).length === 0) return [];
2629
2654
 
2655
+ const mutated: string[] = [];
2630
2656
  for (const noteId of noteIds) {
2631
2657
  const note = await store.getNote(noteId);
2632
2658
  if (!note) continue;
@@ -2640,7 +2666,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
2640
2666
  metadata: { ...existing, ...missing },
2641
2667
  skipUpdatedAt: true,
2642
2668
  });
2669
+ mutated.push(noteId);
2643
2670
  }
2671
+ return mutated;
2644
2672
  }
2645
2673
 
2646
2674
  function defaultForField(field: { type: string; enum?: string[] }): unknown {
@@ -30,7 +30,7 @@ const testDir = join(
30
30
  process.env.PARACHUTE_HOME = testDir;
31
31
 
32
32
  // Dynamic import after env override so modules pick up the tmp dir.
33
- const { route } = await import("./routing.ts");
33
+ const { route, filterVaultListForBinding } = await import("./routing.ts");
34
34
  const {
35
35
  readGlobalConfig,
36
36
  writeGlobalConfig,
@@ -318,6 +318,56 @@ describe("GET /vaults/list (public discovery)", () => {
318
318
  });
319
319
  });
320
320
 
321
+ // ---------------------------------------------------------------------------
322
+ // GET /vaults — authenticated metadata listing, filtered by vault binding
323
+ // (vault#259). Operator / admin-channel callers (vault_name === null) see the
324
+ // full list; a vault-bound caller sees only its own vault.
325
+ // ---------------------------------------------------------------------------
326
+
327
+ describe("GET /vaults (binding filter — vault#259)", () => {
328
+ // Pure policy helper — drives the filtering decision independent of the
329
+ // auth path (no current credential yields a non-null vault_name HERE, since
330
+ // authenticateGlobalRequest 401s hub JWTs; the helper pins the correct
331
+ // shape for any future vault-bound credential on this surface).
332
+ test("filterVaultListForBinding: null binding (operator) keeps the full list", () => {
333
+ const names = ["work", "boulder", "default"];
334
+ expect(filterVaultListForBinding(names, null)).toEqual(names);
335
+ });
336
+
337
+ test("filterVaultListForBinding: a vault-bound caller sees only its own vault", () => {
338
+ const names = ["work", "boulder", "default"];
339
+ expect(filterVaultListForBinding(names, "work")).toEqual(["work"]);
340
+ // No cross-vault info-leak: boulder/default are not disclosed.
341
+ expect(filterVaultListForBinding(names, "work")).not.toContain("boulder");
342
+ expect(filterVaultListForBinding(names, "work")).not.toContain("default");
343
+ });
344
+
345
+ test("filterVaultListForBinding: binding to a vault absent from the list yields empty", () => {
346
+ expect(filterVaultListForBinding(["work", "default"], "ghost")).toEqual([]);
347
+ });
348
+
349
+ test("operator token (VAULT_AUTH_TOKEN) gets the UNFILTERED full listing", async () => {
350
+ createVault("work");
351
+ createVault("boulder");
352
+ createVault("default");
353
+ const prev = process.env.VAULT_AUTH_TOKEN;
354
+ process.env.VAULT_AUTH_TOKEN = "op-secret-token-259";
355
+ try {
356
+ const req = new Request("http://localhost:1940/vaults", {
357
+ headers: { authorization: "Bearer op-secret-token-259" },
358
+ });
359
+ const res = await route(req, "/vaults");
360
+ expect(res.status).toBe(200);
361
+ const body = (await res.json()) as { vaults: { name: string }[] };
362
+ const names = body.vaults.map((v) => v.name);
363
+ expect(new Set(names)).toEqual(new Set(["work", "boulder", "default"]));
364
+ } finally {
365
+ if (prev === undefined) delete process.env.VAULT_AUTH_TOKEN;
366
+ else process.env.VAULT_AUTH_TOKEN = prev;
367
+ }
368
+ });
369
+ });
370
+
321
371
  // ---------------------------------------------------------------------------
322
372
  // /vault/<name>/admin/* — admin SPA static-file mount. Detailed tests live
323
373
  // in admin-spa.test.ts (with a tmp dist dir); these pin the dispatch — i.e.
@@ -1640,6 +1690,147 @@ describe("scope enforcement on /api/*", () => {
1640
1690
  expect(res.status).toBe(201);
1641
1691
  });
1642
1692
 
1693
+ // ----- vault#439: near[] BFS is a WALL, not a sieve --------------------
1694
+ // For a tag-scoped token the graph traversal must refuse to walk THROUGH
1695
+ // an out-of-scope note. So an in-scope note reachable ONLY via an
1696
+ // out-of-scope intermediary is unreachable — symmetric with find-path.
1697
+ // Topology: A(#work) --link--> P(#personal) --link--> B(#work).
1698
+ // A token scoped to ["work"] anchored at A, depth 2:
1699
+ // - sieve (old): B survives (reached via P, then output-filtered to keep B)
1700
+ // - wall (new): P is the wall; B is never reached.
1701
+
1702
+ test("vault#439: tag-scoped near[] cannot reach an in-scope note through an out-of-scope hop", async () => {
1703
+ createVault("journal");
1704
+ const store = getVaultStore("journal");
1705
+ const a = await store.createNote("anchor-work", { tags: ["work"] });
1706
+ const p = await store.createNote("bridge-personal", { tags: ["personal"] });
1707
+ const b = await store.createNote("far-work", { tags: ["work"] });
1708
+ await store.createLink(a.id, p.id, "relates");
1709
+ await store.createLink(p.id, b.id, "relates");
1710
+ const token = await mintTagScopedToken("journal", ["vault:read"], ["work"]);
1711
+
1712
+ // `route`'s second arg is the pathname only; the query rides on req.url.
1713
+ const pathname = "/vault/journal/api/notes";
1714
+ const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
1715
+ const res = await route(authed(token, "GET", full), pathname);
1716
+ expect(res.status).toBe(200);
1717
+ const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
1718
+ const list = Array.isArray(body) ? body : (body.notes ?? []);
1719
+ const ids = list.map((n) => n.id);
1720
+ // B is in-scope (#work) but only reachable via the out-of-scope #personal
1721
+ // bridge — the wall makes it unreachable.
1722
+ expect(ids).not.toContain(b.id);
1723
+ // P itself never leaks (it's out of scope).
1724
+ expect(ids).not.toContain(p.id);
1725
+ });
1726
+
1727
+ test("vault#439: tag-scoped near[] still reaches in-scope notes via in-scope hops", async () => {
1728
+ createVault("journal");
1729
+ const store = getVaultStore("journal");
1730
+ const a = await store.createNote("anchor-work", { tags: ["work"] });
1731
+ const mid = await store.createNote("mid-work", { tags: ["work"] });
1732
+ const far = await store.createNote("far-work", { tags: ["work"] });
1733
+ await store.createLink(a.id, mid.id, "relates");
1734
+ await store.createLink(mid.id, far.id, "relates");
1735
+ const token = await mintTagScopedToken("journal", ["vault:read"], ["work"]);
1736
+
1737
+ const pathname = "/vault/journal/api/notes";
1738
+ const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
1739
+ const res = await route(authed(token, "GET", full), pathname);
1740
+ expect(res.status).toBe(200);
1741
+ const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
1742
+ const list = Array.isArray(body) ? body : (body.notes ?? []);
1743
+ const ids = list.map((n) => n.id);
1744
+ // All-in-scope path: both mid (depth 1) and far (depth 2) are reachable.
1745
+ expect(ids).toContain(mid.id);
1746
+ expect(ids).toContain(far.id);
1747
+ });
1748
+
1749
+ test("vault#439: UNSCOPED token near[] still walks the full graph (behavior unchanged)", async () => {
1750
+ createVault("journal");
1751
+ const store = getVaultStore("journal");
1752
+ const a = await store.createNote("anchor-work", { tags: ["work"] });
1753
+ const p = await store.createNote("bridge-personal", { tags: ["personal"] });
1754
+ const b = await store.createNote("far-work", { tags: ["work"] });
1755
+ await store.createLink(a.id, p.id, "relates");
1756
+ await store.createLink(p.id, b.id, "relates");
1757
+ // No scopedTags → unscoped admin token; no wall installed.
1758
+ const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:admin"] });
1759
+
1760
+ const pathname = "/vault/journal/api/notes";
1761
+ const full = `${pathname}?near[note_id]=${a.id}&near[depth]=2`;
1762
+ const res = await route(authed(token, "GET", full), pathname);
1763
+ expect(res.status).toBe(200);
1764
+ const body = (await res.json()) as { notes?: { id: string }[] } | { id: string }[];
1765
+ const list = Array.isArray(body) ? body : (body.notes ?? []);
1766
+ const ids = list.map((n) => n.id);
1767
+ // Unscoped: the full neighborhood is visible, including the #personal
1768
+ // bridge and the note beyond it.
1769
+ expect(ids).toContain(p.id);
1770
+ expect(ids).toContain(b.id);
1771
+ });
1772
+
1773
+ // ----- vault#404: REST-path hub-JWT tag-scoping enforcement (C0) --------
1774
+ // The MCP path's tag-scope enforcement is covered end-to-end; pin that a
1775
+ // tag-scoped HUB JWT (allowlist carried in the `permissions.scoped_tags`
1776
+ // claim, NOT a vestigial DB row) hitting the REST surface enforces the
1777
+ // same allowlist on both read and write. `mintTagScopedToken` mints a real
1778
+ // hub JWT, so these tests exercise the hub-JWT-sourced `scoped_tags` path.
1779
+
1780
+ test("vault#404: hub-JWT tag-scoped READ via REST enforces the allowlist (list + single)", async () => {
1781
+ createVault("journal");
1782
+ const store = getVaultStore("journal");
1783
+ const inScope = await store.createNote("h", { tags: ["health"] });
1784
+ const outOfScope = await store.createNote("w", { tags: ["work"] });
1785
+ const token = await mintTagScopedToken("journal", ["vault:read"], ["health"]);
1786
+
1787
+ // List: only in-scope notes.
1788
+ const listPath = "/vault/journal/api/notes";
1789
+ const listRes = await route(authed(token, "GET", listPath), listPath);
1790
+ expect(listRes.status).toBe(200);
1791
+ const listBody = (await listRes.json()) as { notes?: { id: string }[] } | { id: string }[];
1792
+ const list = Array.isArray(listBody) ? listBody : (listBody.notes ?? []);
1793
+ const ids = list.map((n) => n.id);
1794
+ expect(ids).toContain(inScope.id);
1795
+ expect(ids).not.toContain(outOfScope.id);
1796
+
1797
+ // Single in-scope → 200; single out-of-scope → 404 (no existence leak).
1798
+ const okPath = `/vault/journal/api/notes/${inScope.id}`;
1799
+ expect((await route(authed(token, "GET", okPath), okPath)).status).toBe(200);
1800
+ const denyPath = `/vault/journal/api/notes/${outOfScope.id}`;
1801
+ expect((await route(authed(token, "GET", denyPath), denyPath)).status).toBe(404);
1802
+ });
1803
+
1804
+ test("vault#404: hub-JWT tag-scoped WRITE via REST enforces the allowlist", async () => {
1805
+ createVault("journal");
1806
+ const token = await mintTagScopedToken("journal", ["vault:read", "vault:write"], ["health"]);
1807
+
1808
+ // In-scope write → 201.
1809
+ const path = "/vault/journal/api/notes";
1810
+ const okRes = await route(
1811
+ new Request(`http://localhost:1940${path}`, {
1812
+ method: "POST",
1813
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
1814
+ body: JSON.stringify({ content: "ok", tags: ["health"] }),
1815
+ }),
1816
+ path,
1817
+ );
1818
+ expect(okRes.status).toBe(201);
1819
+
1820
+ // Out-of-scope write → 403 tag_scope_violation.
1821
+ const denyRes = await route(
1822
+ new Request(`http://localhost:1940${path}`, {
1823
+ method: "POST",
1824
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
1825
+ body: JSON.stringify({ content: "denied", tags: ["work"] }),
1826
+ }),
1827
+ path,
1828
+ );
1829
+ expect(denyRes.status).toBe(403);
1830
+ const denyBody = (await denyRes.json()) as { error_type?: string };
1831
+ expect(denyBody.error_type).toBe("tag_scope_violation");
1832
+ });
1833
+
1643
1834
  // ----- Q5: tag-delete dependency check ---------------------------------
1644
1835
  // Deleting a tag referenced by any token's scoped_tags would silently
1645
1836
  // orphan the token's allowlist; fail closed with 409 + referenced_by.
package/src/routing.ts CHANGED
@@ -180,6 +180,23 @@ function handleParachuteIcon(): Response {
180
180
  });
181
181
  }
182
182
 
183
+ /**
184
+ * Filter a server-wide vault-name list for a caller's per-vault binding
185
+ * (vault#259). `vaultName === null` is the operator / admin-channel caller
186
+ * (server-wide VAULT_AUTH_TOKEN or legacy cross-vault config.yaml key) — they
187
+ * keep the FULL list. A non-null binding reduces the list to just that vault
188
+ * (empty if the bound vault isn't in the listing). Pure + exported so the
189
+ * policy is unit-testable independent of the auth path. See the `/vaults`
190
+ * handler for the full rationale.
191
+ */
192
+ export function filterVaultListForBinding(
193
+ names: string[],
194
+ vaultName: string | null,
195
+ ): string[] {
196
+ if (vaultName === null) return names;
197
+ return names.filter((name) => name === vaultName);
198
+ }
199
+
183
200
  export async function route(
184
201
  req: Request,
185
202
  path: string,
@@ -286,10 +303,24 @@ export async function route(
286
303
  }
287
304
 
288
305
  // Authenticated vault metadata list.
306
+ //
307
+ // Vault-binding filter (vault#259, info-leak follow-up): `/vaults` is a
308
+ // cross-vault DISCOVERY endpoint, not a single-vault operational one (unlike
309
+ // /mcp, which legitimately routes a vault-bound token back into its own
310
+ // vault). A caller bound to one vault shouldn't learn that OTHER vaults exist
311
+ // on this server. So when the authenticated caller carries a per-vault
312
+ // binding (`auth.vault_name !== null`), the listing is reduced to just that
313
+ // vault. Operator / admin-channel callers — the server-wide VAULT_AUTH_TOKEN
314
+ // and legacy cross-vault config.yaml keys — have `vault_name === null` and
315
+ // keep the full listing (they're explicitly the cross-vault management
316
+ // channel). `authenticateGlobalRequest` already 401s hub JWTs here, so the
317
+ // only callers that reach this point today are operator-channel
318
+ // (`vault_name === null`); this filter is the security-correct shape for any
319
+ // future vault-bound credential that becomes accepted on this surface.
289
320
  if (path === "/vaults" && req.method === "GET") {
290
321
  const auth = await authenticateGlobalRequest(req);
291
322
  if ("error" in auth) return auth.error;
292
- const names = listVaults();
323
+ const names = filterVaultListForBinding(listVaults(), auth.vault_name);
293
324
  const vaults = names.map((name) => {
294
325
  const config = readVaultConfig(name);
295
326
  return {
package/src/vault.test.ts CHANGED
@@ -2023,6 +2023,46 @@ describe("HTTP /notes", async () => {
2023
2023
  expect(body.createdAt).toBe("2025-01-01T00:00:00.000Z");
2024
2024
  });
2025
2025
 
2026
+ // vault#316 — the HTTP POST path re-reads each note AFTER
2027
+ // `applySchemaDefaults`, so the response metadata carries the just-written
2028
+ // defaults (mirrors the MCP create-note path). Before the fix the response
2029
+ // mapped over the pre-defaults in-memory objects, so default-filled
2030
+ // metadata was missing from `POST /api/notes` responses.
2031
+ test("POST /notes response reflects post-applySchemaDefaults state (vault#316)", async () => {
2032
+ await store.upsertTagSchema("task", {
2033
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
2034
+ });
2035
+
2036
+ // Single: default lands in the returned metadata and agrees with disk.
2037
+ const single = await handleNotes(
2038
+ mkReq("POST", "/notes", { content: "do the thing", path: "Inbox/task-1", tags: ["task"] }),
2039
+ store,
2040
+ "",
2041
+ );
2042
+ expect(single.status).toBe(201);
2043
+ const singleBody = await single.json() as any;
2044
+ expect(singleBody.metadata?.priority).toBe("high"); // first enum value
2045
+ const onDisk = await store.getNoteByPath("Inbox/task-1");
2046
+ expect((onDisk!.metadata as any)?.priority).toBe("high");
2047
+
2048
+ // Batch: each entry is re-read post-defaults too, in input order.
2049
+ const batch = await handleNotes(
2050
+ mkReq("POST", "/notes", {
2051
+ notes: [
2052
+ { content: "a", path: "Inbox/task-2", tags: ["task"] },
2053
+ { content: "b", path: "Inbox/task-3", tags: ["task"] },
2054
+ ],
2055
+ }),
2056
+ store,
2057
+ "",
2058
+ );
2059
+ expect(batch.status).toBe(201);
2060
+ const batchBody = await batch.json() as any[];
2061
+ expect(batchBody.map((n) => n.path)).toEqual(["Inbox/task-2", "Inbox/task-3"]);
2062
+ expect(batchBody[0].metadata?.priority).toBe("high");
2063
+ expect(batchBody[1].metadata?.priority).toBe("high");
2064
+ });
2065
+
2026
2066
  // ---- Extension field (vault#328) ----
2027
2067
 
2028
2068
  test("POST /notes accepts extension and persists it", async () => {