@openparachute/vault 0.4.4-rc.12 → 0.4.5

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 { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
16
+ import { 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";
@@ -75,6 +75,25 @@ function parseQueryList(url: URL, key: string): string[] | undefined {
75
75
  return val ? val.split(",") : undefined;
76
76
  }
77
77
 
78
+ /**
79
+ * Parse the extension query parameter (vault#328). Two accepted shapes:
80
+ * - `?extension=csv` (single value → string)
81
+ * - `?extension=csv&extension=yaml` OR `?extension=csv,yaml`
82
+ * (repeated or comma-list → array)
83
+ * Returns undefined when absent so the queryNotes filter is skipped.
84
+ * Validation lives at the engine layer — bad strings result in zero
85
+ * matches rather than 400, mirroring how `path` works.
86
+ */
87
+ function parseExtensionFilter(url: URL): string | string[] | undefined {
88
+ const all = url.searchParams.getAll("extension");
89
+ if (all.length === 0) return undefined;
90
+ // Flatten comma-lists inside each param.
91
+ const flat = all.flatMap((v) => v.split(",")).map((s) => s.trim()).filter((s) => s.length > 0);
92
+ if (flat.length === 0) return undefined;
93
+ if (flat.length === 1) return flat[0]!;
94
+ return flat;
95
+ }
96
+
78
97
  function parseInt10(val: string | null): number | undefined {
79
98
  if (!val) return undefined;
80
99
  const n = parseInt(val, 10);
@@ -373,11 +392,21 @@ function parseExpandParams(
373
392
 
374
393
 
375
394
  /**
376
- * Resolve a note by ID or path. Tries ID first, then case-insensitive path.
395
+ * Resolve a note by ID or path. Tries ID first, then case-insensitive
396
+ * path. A trailing `.<ext>` matching the extension pattern is parsed
397
+ * as `(path, extension)` to disambiguate notes sharing a path
398
+ * differing only by extension (vault#330 S1). When the path is
399
+ * ambiguous and no extension hint is supplied, `getNoteByPath` throws
400
+ * `AmbiguousPathError` — REST handlers catch it and return 409.
377
401
  */
378
402
  async function resolveNote(store: Store, idOrPath: string): Promise<Note | null> {
379
403
  const byId = await store.getNote(idOrPath);
380
404
  if (byId) return byId;
405
+ const extMatch = idOrPath.match(/^(.*)\.([a-z0-9]{1,16})$/i);
406
+ if (extMatch) {
407
+ const explicit = await store.getNoteByPath(extMatch[1]!, extMatch[2]!);
408
+ if (explicit) return explicit;
409
+ }
381
410
  return await store.getNoteByPath(idOrPath);
382
411
  }
383
412
 
@@ -398,12 +427,49 @@ class NotFoundError extends Error {
398
427
  // Notes — GET/POST/PATCH/DELETE /api/notes[/:idOrPath]
399
428
  // ---------------------------------------------------------------------------
400
429
 
430
+ /**
431
+ * Convert a thrown `AmbiguousPathError` (vault#330 S1) into a structured
432
+ * 409 JSON response. Shared by every handler that calls `resolveNote`
433
+ * with a user-supplied path — handleNotes, handleFindPath,
434
+ * handleViewNote. Returns null when the error isn't an
435
+ * AmbiguousPathError so the caller can re-throw / fall through.
436
+ */
437
+ function ambiguousPathResponse(e: any): Response | null {
438
+ if (!e || e.code !== "AMBIGUOUS_PATH") return null;
439
+ return json(
440
+ {
441
+ error_type: "ambiguous_path",
442
+ error: "ambiguous_path",
443
+ path: e.path,
444
+ candidates: e.candidates,
445
+ message: e.message,
446
+ },
447
+ 409,
448
+ );
449
+ }
450
+
401
451
  export async function handleNotes(
402
452
  req: Request,
403
453
  store: Store,
404
454
  subpath: string,
405
455
  vault?: string,
406
456
  tagScope: TagScopeCtx = NO_TAG_SCOPE,
457
+ ): Promise<Response> {
458
+ try {
459
+ return await handleNotesInner(req, store, subpath, vault, tagScope);
460
+ } catch (e: any) {
461
+ const ambig = ambiguousPathResponse(e);
462
+ if (ambig) return ambig;
463
+ throw e;
464
+ }
465
+ }
466
+
467
+ async function handleNotesInner(
468
+ req: Request,
469
+ store: Store,
470
+ subpath: string,
471
+ vault?: string,
472
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
407
473
  ): Promise<Response> {
408
474
  const url = new URL(req.url);
409
475
  const method = req.method;
@@ -508,6 +574,12 @@ export async function handleNotes(
508
574
  hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
509
575
  path: parseQuery(url, "path") ?? undefined,
510
576
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
577
+ // Extension filter (vault#328). Accepts repeated `extension=`
578
+ // params for the array form: `?extension=csv&extension=yaml`.
579
+ // `parseQueryList` already returns undefined when no params
580
+ // are present, so the filter is silently skipped on a plain
581
+ // GET without the extension query.
582
+ extension: parseExtensionFilter(url),
511
583
  metadata: bracket.metadata,
512
584
  // Date-range precedence chain (highest to lowest):
513
585
  // 1. Bracket-style `meta[created_at][gte]=…` (canonical).
@@ -636,35 +708,10 @@ export async function handleNotes(
636
708
  );
637
709
  }
638
710
 
639
- // Empty-note pre-validation (#213): walk the batch first and reject the
640
- // whole request if any item would be content+path empty. This makes
641
- // mixed batches atomic for the empty-note case — no caller gets a
642
- // half-applied batch where the prefix landed and the empty entry
643
- // surfaced the 400. Mirrors the Store-level invariant exactly.
644
- for (let i = 0; i < items.length; i++) {
645
- const item = items[i];
646
- const content = (item?.content ?? "").toString();
647
- const rawPath = item?.path;
648
- const pathEmpty = rawPath === undefined || rawPath === null
649
- || (typeof rawPath === "string" && rawPath.trim() === "");
650
- if (!content.trim() && pathEmpty) {
651
- return json(
652
- {
653
- error_type: "empty_note",
654
- error: "EmptyNoteError",
655
- message: `empty_note: a note must have either content or a path (item index ${i})`,
656
- item_index: i,
657
- },
658
- 400,
659
- );
660
- }
661
- }
662
-
663
711
  // Tag-scope pre-validation: every new note in the batch must carry at
664
- // least one tag inside the token's allowlist. Same atomic-batch
665
- // discipline as the empty-note check reject the whole request before
666
- // any DB write so a tag-scoped token can't accidentally land a partial
667
- // batch with an in-scope prefix.
712
+ // least one tag inside the token's allowlist. Reject the whole request
713
+ // before any DB write so a tag-scoped token can't accidentally land a
714
+ // partial batch with an in-scope prefix.
668
715
  if (tagScope.allowed) {
669
716
  for (let i = 0; i < items.length; i++) {
670
717
  if (!tagsWithinScope(items[i]?.tags, tagScope.allowed, tagScope.raw)) {
@@ -685,12 +732,19 @@ export async function handleNotes(
685
732
  if (batched) db.exec("BEGIN");
686
733
  try {
687
734
  for (const item of items) {
735
+ // Validate extension before reaching the Store (vault#328).
736
+ // Thrown inside the BEGIN block — outer catch rolls the batch
737
+ // back, same shape as the path-conflict path.
738
+ const extension = item.extension !== undefined
739
+ ? validateExtension(item.extension)
740
+ : undefined;
688
741
  const note = await store.createNote(item.content ?? "", {
689
742
  id: item.id,
690
743
  path: item.path,
691
744
  tags: item.tags,
692
745
  metadata: item.metadata,
693
746
  created_at: item.createdAt ?? item.created_at,
747
+ ...(extension !== undefined ? { extension } : {}),
694
748
  });
695
749
 
696
750
  // Create explicit links
@@ -713,14 +767,9 @@ export async function handleNotes(
713
767
  409,
714
768
  );
715
769
  }
716
- if (e && e.code === "EMPTY_NOTE") {
770
+ if (e && e.code === "INVALID_EXTENSION") {
717
771
  return json(
718
- {
719
- error_type: "empty_note",
720
- error: "EmptyNoteError",
721
- message: e.message,
722
- item_index: e.item_index ?? null,
723
- },
772
+ { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
724
773
  400,
725
774
  );
726
775
  }
@@ -881,18 +930,43 @@ export async function handleNotes(
881
930
  }
882
931
  const idLooksLikePath = idOrPathStr.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPathStr);
883
932
  const explicitPath = typeof body.path === "string" ? body.path as string : undefined;
933
+ // Validate extension before reaching the Store (vault#328).
934
+ const createExt = body.extension !== undefined
935
+ ? validateExtension(body.extension)
936
+ : undefined;
884
937
  const createOpts: Parameters<Store["createNote"]>[1] = {
885
938
  ...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
886
939
  ...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
887
940
  ...(body.metadata !== undefined ? { metadata: body.metadata as Record<string, unknown> } : {}),
888
941
  ...(body.created_at !== undefined ? { created_at: body.created_at as string } : {}),
889
942
  ...(body.createdAt !== undefined ? { created_at: body.createdAt as string } : {}),
943
+ ...(createExt !== undefined ? { extension: createExt } : {}),
890
944
  };
891
945
  const content = (body.content as string | undefined) ?? "";
892
946
  const created = await store.createNote(content, createOpts);
893
947
  if (tagsArr.length > 0) {
894
948
  await applySchemaDefaults(store, db, [created.id], tagsArr);
895
949
  }
950
+ // vault#321 F2 — apply `links.add` on the create branch.
951
+ // MCP's create-on-missing branch already did this
952
+ // (`core/src/mcp.ts` if_missing=create block); the REST side
953
+ // was missing it, producing a cross-surface inconsistency
954
+ // operators (Gitcoin's drift sync) would trip on. Mirror the
955
+ // MCP recipe exactly:
956
+ // - `links.add` IS applied — drift sync can declare typed
957
+ // links at upsert time and have them materialize.
958
+ // - `links.remove` is ignored (nothing to remove on a
959
+ // fresh note).
960
+ // - Missing target notes skip silently (mirrors MCP).
961
+ const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
962
+ if (linksAdd) {
963
+ for (const link of linksAdd) {
964
+ const target = await resolveNote(store, link.target);
965
+ if (target) {
966
+ await store.createLink(created.id, target.id, link.relationship, link.metadata);
967
+ }
968
+ }
969
+ }
896
970
  const final = await store.getNote(created.id);
897
971
  if (!final) return json({ error: "Note disappeared" }, 500);
898
972
  const validated = attachValidationStatus(store, db, final);
@@ -1035,6 +1109,11 @@ export async function handleNotes(
1035
1109
  if (body.prepend !== undefined) updates.prepend = body.prepend;
1036
1110
  }
1037
1111
  if (body.path !== undefined) updates.path = body.path;
1112
+ if (body.extension !== undefined) {
1113
+ // Validate up front (vault#328). Throws ExtensionValidationError
1114
+ // which the outer catch converts to a 400.
1115
+ updates.extension = validateExtension(body.extension);
1116
+ }
1038
1117
  if (body.metadata !== undefined) {
1039
1118
  const existing = (note.metadata as Record<string, unknown>) ?? {};
1040
1119
  updates.metadata = { ...existing, ...body.metadata };
@@ -1124,17 +1203,9 @@ export async function handleNotes(
1124
1203
  409,
1125
1204
  );
1126
1205
  }
1127
- // Empty-note guard from the Store boundary (#213) — the proposed update
1128
- // would clear both content AND path. Surface as 400 so callers can fix
1129
- // the request without retrying.
1130
- if (e && e.code === "EMPTY_NOTE") {
1206
+ if (e && e.code === "INVALID_EXTENSION") {
1131
1207
  return json(
1132
- {
1133
- error_type: "empty_note",
1134
- error: "EmptyNoteError",
1135
- message: e.message,
1136
- note_id: e.note_id ?? null,
1137
- },
1208
+ { error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
1138
1209
  400,
1139
1210
  );
1140
1211
  }
@@ -1478,6 +1549,11 @@ export async function handleFindPath(
1478
1549
  return json(result);
1479
1550
  } catch (e: any) {
1480
1551
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
1552
+ // vault#331 N1 — surface AmbiguousPathError from resolveNote as 409
1553
+ // mirroring the handleNotes path. Without this, an ambiguous source/
1554
+ // target path on /api/find-path bubbled to a server-level 500.
1555
+ const ambig = ambiguousPathResponse(e);
1556
+ if (ambig) return ambig;
1481
1557
  throw e;
1482
1558
  }
1483
1559
  }
@@ -1670,7 +1746,19 @@ export async function handleViewNote(
1670
1746
  options: { authenticated?: boolean; publishedTag?: string } = {},
1671
1747
  ): Promise<Response> {
1672
1748
  const { authenticated = false, publishedTag = "publish" } = options;
1673
- const note = await resolveNote(store, idOrPath);
1749
+ let note: Note | null;
1750
+ try {
1751
+ note = await resolveNote(store, idOrPath);
1752
+ } catch (e: any) {
1753
+ // vault#331 N1 — surface AmbiguousPathError as 409. The HTML view
1754
+ // route doesn't otherwise return JSON, but the structured body is
1755
+ // the right shape for the API contract; a human reader hitting
1756
+ // this URL gets the JSON inline (rare — the bare path form is
1757
+ // mostly an API consumer's mistake).
1758
+ const ambig = ambiguousPathResponse(e);
1759
+ if (ambig) return ambig;
1760
+ throw e;
1761
+ }
1674
1762
  if (!note) {
1675
1763
  return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" } });
1676
1764
  }