@openparachute/vault 0.7.1 → 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.
- package/core/src/aggregate.test.ts +23 -0
- package/core/src/contract-taxonomy.test.ts +22 -0
- package/core/src/core.test.ts +70 -4
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/hooks.ts +9 -0
- package/core/src/mcp.ts +12 -2
- package/core/src/notes.ts +175 -50
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/query-operators.ts +56 -0
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +39 -13
- package/core/src/store.ts +20 -2
- package/core/src/tag-hierarchy.ts +13 -0
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- package/core/src/wikilinks.test.ts +175 -0
- package/core/src/wikilinks.ts +151 -10
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +170 -7
- package/src/vault.test.ts +227 -1
package/core/src/wikilinks.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
657
|
-
//
|
|
658
|
-
//
|
|
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
|
|
661
|
-
).run(row.source_id,
|
|
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
package/src/cli.ts
CHANGED
|
@@ -2983,6 +2983,12 @@ async function cmdImport(args: string[]) {
|
|
|
2983
2983
|
if (stats.skipped_attachments.length > 0) {
|
|
2984
2984
|
console.log(`Skipped ${stats.skipped_attachments.length} attachment(s) — see warnings above.`);
|
|
2985
2985
|
}
|
|
2986
|
+
if (stats.skipped_duplicate_ids.length > 0) {
|
|
2987
|
+
console.log(
|
|
2988
|
+
`Skipped ${stats.skipped_duplicate_ids.length} file(s) with duplicate/blank note id(s) — ` +
|
|
2989
|
+
`kept the first of each collision; see warnings above.`,
|
|
2990
|
+
);
|
|
2991
|
+
}
|
|
2986
2992
|
return;
|
|
2987
2993
|
}
|
|
2988
2994
|
|
package/src/mirror-import.ts
CHANGED
|
@@ -523,6 +523,11 @@ function importResultFromStats(
|
|
|
523
523
|
`Dropped parent_names on tag "${sp.tag}": ${sp.reason}`,
|
|
524
524
|
);
|
|
525
525
|
}
|
|
526
|
+
for (const sd of stats.skipped_duplicate_ids) {
|
|
527
|
+
warnings.push(
|
|
528
|
+
`Skipped file (id "${sd.id}", path=${sd.path ?? "—"}): ${sd.reason}`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
526
531
|
const result: ImportResult = {
|
|
527
532
|
notes_imported: stats.notes_created + stats.notes_updated,
|
|
528
533
|
tags_imported: stats.schemas_restored,
|
package/src/routes.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
getContentWikilinkWarnings,
|
|
32
32
|
} from "../core/src/wikilinks.ts";
|
|
33
33
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
34
|
-
import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, getVaultMap } from "../core/src/notes.ts";
|
|
34
|
+
import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, validatePath, PathValidationError, getVaultMap } from "../core/src/notes.ts";
|
|
35
35
|
import { normalizePath } from "../core/src/paths.ts";
|
|
36
36
|
import {
|
|
37
37
|
parseContentRange,
|
|
@@ -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
|
|
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
|
|
@@ -1811,6 +1941,9 @@ async function handleNotesInner(
|
|
|
1811
1941
|
const extension = item.extension !== undefined
|
|
1812
1942
|
? validateExtension(item.extension)
|
|
1813
1943
|
: undefined;
|
|
1944
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
1945
|
+
// Same batch-transaction throw semantics as extension validation.
|
|
1946
|
+
validatePath(item.path);
|
|
1814
1947
|
const effectiveExtension = extension ?? "md";
|
|
1815
1948
|
const ifExists: string = item.if_exists ?? "error";
|
|
1816
1949
|
const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
|
|
@@ -1958,6 +2091,13 @@ async function handleNotesInner(
|
|
|
1958
2091
|
400,
|
|
1959
2092
|
);
|
|
1960
2093
|
}
|
|
2094
|
+
// Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
|
|
2095
|
+
if (e && e.code === "INVALID_PATH") {
|
|
2096
|
+
return json(
|
|
2097
|
+
{ error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
|
|
2098
|
+
400,
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
1961
2101
|
throw e;
|
|
1962
2102
|
}
|
|
1963
2103
|
|
|
@@ -2042,7 +2182,9 @@ async function handleNotesInner(
|
|
|
2042
2182
|
if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
|
|
2043
2183
|
return json({ error: "Not found", error_type: "not_found" }, 404);
|
|
2044
2184
|
}
|
|
2045
|
-
const
|
|
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 };
|
|
2046
2188
|
if (!body.path || !body.mimeType) {
|
|
2047
2189
|
return json(
|
|
2048
2190
|
{ error: "path and mimeType are required", error_type: "missing_required_field", hint: "pass both `path` and `mimeType`" },
|
|
@@ -2225,7 +2367,9 @@ async function handleNotesInner(
|
|
|
2225
2367
|
// Body is parsed up front so the `if_missing: "create"` branch
|
|
2226
2368
|
// (vault#309) can fire when the note doesn't exist. Pre-#309
|
|
2227
2369
|
// shape parsed the body only after the not-found check.
|
|
2228
|
-
const
|
|
2370
|
+
const parsedBody = await parseJsonBody(req);
|
|
2371
|
+
if (!parsedBody.ok) return parsedBody.response;
|
|
2372
|
+
const body = parsedBody.body as any;
|
|
2229
2373
|
const note = await resolveNote(store, idOrPath);
|
|
2230
2374
|
if (!note) {
|
|
2231
2375
|
// vault#309 — `if_missing: "create"` turns this PATCH into a
|
|
@@ -2253,6 +2397,9 @@ async function handleNotesInner(
|
|
|
2253
2397
|
const createExt = body.extension !== undefined
|
|
2254
2398
|
? validateExtension(body.extension)
|
|
2255
2399
|
: undefined;
|
|
2400
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
2401
|
+
// Covers both the explicit `path` and the idOrPath-as-path shape.
|
|
2402
|
+
validatePath(explicitPath ?? (idLooksLikePath ? idOrPathStr : undefined));
|
|
2256
2403
|
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
2257
2404
|
...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
2258
2405
|
...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
|
|
@@ -2499,7 +2646,12 @@ async function handleNotesInner(
|
|
|
2499
2646
|
if (body.append !== undefined) updates.append = body.append;
|
|
2500
2647
|
if (body.prepend !== undefined) updates.prepend = body.prepend;
|
|
2501
2648
|
}
|
|
2502
|
-
if (body.path !== undefined)
|
|
2649
|
+
if (body.path !== undefined) {
|
|
2650
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
2651
|
+
// Throws PathValidationError → 400 in the outer catch.
|
|
2652
|
+
validatePath(body.path);
|
|
2653
|
+
updates.path = body.path;
|
|
2654
|
+
}
|
|
2503
2655
|
if (body.extension !== undefined) {
|
|
2504
2656
|
// Validate up front (vault#328). Throws ExtensionValidationError
|
|
2505
2657
|
// which the outer catch converts to a 400.
|
|
@@ -2756,6 +2908,13 @@ async function handleNotesInner(
|
|
|
2756
2908
|
400,
|
|
2757
2909
|
);
|
|
2758
2910
|
}
|
|
2911
|
+
// Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
|
|
2912
|
+
if (e && e.code === "INVALID_PATH") {
|
|
2913
|
+
return json(
|
|
2914
|
+
{ error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
|
|
2915
|
+
400,
|
|
2916
|
+
);
|
|
2917
|
+
}
|
|
2759
2918
|
throw e;
|
|
2760
2919
|
}
|
|
2761
2920
|
}
|
|
@@ -3075,7 +3234,9 @@ export async function handleTags(
|
|
|
3075
3234
|
if (tagScope.allowed && !tagScope.allowed.has(putTagName)) {
|
|
3076
3235
|
return tagScopeForbidden(tagScope.raw ?? []);
|
|
3077
3236
|
}
|
|
3078
|
-
const
|
|
3237
|
+
const parsedBody = await parseJsonBody(req);
|
|
3238
|
+
if (!parsedBody.ok) return parsedBody.response;
|
|
3239
|
+
const body = parsedBody.body as {
|
|
3079
3240
|
description?: string | null;
|
|
3080
3241
|
fields?: Record<string, unknown> | null;
|
|
3081
3242
|
relationships?: Record<string, unknown> | null;
|
|
@@ -3489,7 +3650,9 @@ export async function handleVault(
|
|
|
3489
3650
|
}
|
|
3490
3651
|
|
|
3491
3652
|
if (req.method === "PATCH") {
|
|
3492
|
-
const
|
|
3653
|
+
const parsedBody = await parseJsonBody(req);
|
|
3654
|
+
if (!parsedBody.ok) return parsedBody.response;
|
|
3655
|
+
const body = parsedBody.body as {
|
|
3493
3656
|
description?: string;
|
|
3494
3657
|
config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
|
|
3495
3658
|
};
|