@openparachute/vault 0.5.3-rc.1 → 0.5.3-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.
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, getNoteTags, 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 === "") {
@@ -1013,19 +1013,33 @@ async function handleNotesInner(
1013
1013
  throw e;
1014
1014
  }
1015
1015
 
1016
- // 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>();
1017
1023
  for (const note of created) {
1018
1024
  if (note.tags?.length) {
1019
- 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
+ }
1020
1028
  }
1021
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
+ })();
1022
1037
 
1023
1038
  // Attach `validation_status` so HTTP create-note matches the MCP
1024
- // surface (vault#287). Mirrors the MCP create-note attach site at
1025
- // `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
1039
+ // surface (vault#287). `attachValidationStatus` returns the note
1026
1040
  // unchanged when no tag declares fields, so vaults without any tag
1027
1041
  // schemas see no behavior change.
1028
- const final = created.map((n) => attachValidationStatus(store, db, n));
1042
+ const final = refreshed.map((n) => attachValidationStatus(store, db, n));
1029
1043
  return json(body.notes ? final : final[0], 201);
1030
1044
  }
1031
1045
 
@@ -1636,7 +1650,7 @@ export async function handleTags(
1636
1650
  // that token's allowlist. Aggregate matches across sources for a single
1637
1651
  // 409 envelope.
1638
1652
  const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
1639
- const db = (store as any).db;
1653
+ const db = store.db;
1640
1654
  for (const src of sources) {
1641
1655
  const tokens = findTokensReferencingTag(db, src as string);
1642
1656
  if (tokens.length > 0) referenced.push({ source: src as string, tokens });
@@ -1800,7 +1814,7 @@ export async function handleTags(
1800
1814
  // tag would silently orphan the token's allowlist. Fail closed (409)
1801
1815
  // and name the offending tokens so the operator can revoke or re-mint
1802
1816
  // before retrying. patterns/tag-scoped-tokens.md §Dependencies.
1803
- const referenced_by = findTokensReferencingTag((store as any).db, tagName);
1817
+ const referenced_by = findTokensReferencingTag(store.db, tagName);
1804
1818
  if (referenced_by.length > 0) {
1805
1819
  return json(
1806
1820
  {
@@ -1835,7 +1849,7 @@ export async function handleFindPath(
1835
1849
  const target = parseQuery(url, "target");
1836
1850
  if (!source || !target) return json({ error: "source and target parameters are required" }, 400);
1837
1851
 
1838
- const db = (store as any).db;
1852
+ const db = store.db;
1839
1853
  try {
1840
1854
  const sourceNote = await resolveNote(store, source);
1841
1855
  if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
@@ -1957,7 +1971,7 @@ export function handleUnresolvedWikilinks(
1957
1971
  const url = new URL(req.url);
1958
1972
  const limitStr = url.searchParams.get("limit");
1959
1973
  const limit = limitStr ? parseInt(limitStr, 10) : 50;
1960
- const db = (store as any).db;
1974
+ const db = store.db;
1961
1975
  const result = listUnresolvedWikilinks(db, limit);
1962
1976
 
1963
1977
  // Unscoped token → return as-is (unchanged behavior).
@@ -2619,9 +2633,12 @@ export async function handleStorage(
2619
2633
  // Tag schema defaults — same logic as core/src/mcp.ts applySchemaDefaults
2620
2634
  // ---------------------------------------------------------------------------
2621
2635
 
2622
- 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[]> {
2623
2640
  const schemas = tagSchemaOps.getTagSchemaMap(db);
2624
- if (Object.keys(schemas).length === 0) return;
2641
+ if (Object.keys(schemas).length === 0) return [];
2625
2642
 
2626
2643
  const defaults: Record<string, unknown> = {};
2627
2644
  for (const tag of tags) {
@@ -2633,8 +2650,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
2633
2650
  }
2634
2651
  }
2635
2652
  }
2636
- if (Object.keys(defaults).length === 0) return;
2653
+ if (Object.keys(defaults).length === 0) return [];
2637
2654
 
2655
+ const mutated: string[] = [];
2638
2656
  for (const noteId of noteIds) {
2639
2657
  const note = await store.getNote(noteId);
2640
2658
  if (!note) continue;
@@ -2648,7 +2666,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
2648
2666
  metadata: { ...existing, ...missing },
2649
2667
  skipUpdatedAt: true,
2650
2668
  });
2669
+ mutated.push(noteId);
2651
2670
  }
2671
+ return mutated;
2652
2672
  }
2653
2673
 
2654
2674
  function defaultForField(field: { type: string; enum?: string[] }): unknown {
@@ -149,7 +149,12 @@ function reset(): void {
149
149
  // Default every test to the fixture hub origin so the hub-JWT mint path
150
150
  // resolves JWKS + validates `iss`. Describes that assert the loopback
151
151
  // default (OAuth discovery metadata) override this in their own beforeEach.
152
- if (hubFixtureOrigin) process.env.PARACHUTE_HUB_ORIGIN = hubFixtureOrigin;
152
+ if (hubFixtureOrigin) {
153
+ process.env.PARACHUTE_HUB_ORIGIN = hubFixtureOrigin;
154
+ // Post-vault#464 the JWKS fetch origin resolves separately (loopback by
155
+ // default); point it at the fixture so the mint path's JWKS fetch resolves.
156
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = hubFixtureOrigin;
157
+ }
153
158
  resetJwksCache();
154
159
  resetRevocationCache();
155
160
  }
@@ -183,6 +188,7 @@ afterAll(() => {
183
188
  clearVaultStoreCache();
184
189
  hubServer?.stop(true);
185
190
  delete process.env.PARACHUTE_HUB_ORIGIN;
191
+ delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
186
192
  if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
187
193
  });
188
194
 
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 () => {