@openparachute/vault 0.3.3 → 0.4.0

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.
Files changed (79) hide show
  1. package/.parachute/module.json +15 -0
  2. package/core/src/core.test.ts +2252 -7
  3. package/core/src/links.ts +1 -1
  4. package/core/src/mcp.ts +801 -67
  5. package/core/src/note-schemas.ts +232 -0
  6. package/core/src/notes.ts +313 -35
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +287 -0
  11. package/core/src/schema.ts +393 -9
  12. package/core/src/store.ts +248 -6
  13. package/core/src/tag-hierarchy.ts +137 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +100 -6
  16. package/core/src/wikilinks.ts +3 -3
  17. package/package.json +13 -3
  18. package/src/admin-spa.test.ts +161 -0
  19. package/src/admin-spa.ts +161 -0
  20. package/src/auth-hub-jwt.test.ts +231 -0
  21. package/src/auth-status.ts +84 -0
  22. package/src/auth.test.ts +135 -23
  23. package/src/auth.ts +144 -15
  24. package/src/backup.ts +4 -7
  25. package/src/cli.ts +322 -57
  26. package/src/config.test.ts +44 -0
  27. package/src/config.ts +68 -40
  28. package/src/hub-jwt.test.ts +296 -0
  29. package/src/hub-jwt.ts +79 -0
  30. package/src/init.test.ts +216 -0
  31. package/src/mcp-http.ts +30 -28
  32. package/src/mcp-install.ts +1 -1
  33. package/src/mcp-tools.ts +294 -6
  34. package/src/module-config.ts +1 -1
  35. package/src/oauth.test.ts +345 -0
  36. package/src/oauth.ts +85 -14
  37. package/src/owner-auth.ts +57 -1
  38. package/src/prompt.ts +6 -5
  39. package/src/routes.ts +686 -58
  40. package/src/routing.test.ts +466 -1
  41. package/src/routing.ts +108 -24
  42. package/src/scopes.test.ts +66 -8
  43. package/src/scopes.ts +163 -37
  44. package/src/server.ts +24 -2
  45. package/src/services-manifest.test.ts +20 -0
  46. package/src/services-manifest.ts +9 -2
  47. package/src/stop-signal.test.ts +85 -0
  48. package/src/storage.test.ts +92 -0
  49. package/src/tag-scope.ts +118 -0
  50. package/src/token-store.test.ts +47 -0
  51. package/src/token-store.ts +128 -13
  52. package/src/tokens-routes.test.ts +720 -0
  53. package/src/tokens-routes.ts +392 -0
  54. package/src/transcription-worker.test.ts +5 -0
  55. package/src/triggers.ts +1 -1
  56. package/src/two-factor.ts +2 -2
  57. package/src/vault-create.test.ts +193 -0
  58. package/src/vault-name.test.ts +123 -0
  59. package/src/vault-name.ts +80 -0
  60. package/src/vault.test.ts +868 -3
  61. package/tsconfig.json +8 -1
  62. package/.claude/settings.local.json +0 -8
  63. package/.dockerignore +0 -8
  64. package/.env.example +0 -9
  65. package/CHANGELOG.md +0 -175
  66. package/CLAUDE.md +0 -125
  67. package/Caddyfile +0 -3
  68. package/Dockerfile +0 -22
  69. package/bun.lock +0 -219
  70. package/bunfig.toml +0 -2
  71. package/deploy/parachute-vault.service +0 -20
  72. package/docker-compose.yml +0 -50
  73. package/docs/HTTP_API.md +0 -434
  74. package/docs/auth-model.md +0 -340
  75. package/fly.toml +0 -24
  76. package/package/package.json +0 -32
  77. package/railway.json +0 -14
  78. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  79. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/routes.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * REST API route handlers for the multi-vault server.
3
3
  *
4
- * Mirrors the 9 MCP tools:
4
+ * Mirrors the MCP tools:
5
5
  * /api/notes — query-notes, create-note, update-note, delete-note
6
6
  * /api/tags — list-tags, update-tag, delete-tag
7
+ * /api/note-schemas — list/upsert/delete note_schemas + nested mappings
7
8
  * /api/find-path — find-path
8
9
  * /api/vault — vault-info
10
+ * (synthesize-notes is MCP-only; agents call it through the MCP transport.)
9
11
  *
10
12
  * Each handler receives a Store instance (already resolved for the vault)
11
13
  * and the Request, and returns a Response.
@@ -13,9 +15,28 @@
13
15
 
14
16
  import type { Store, Note } from "../core/src/types.ts";
15
17
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
- import { toNoteIndex, filterMetadata } from "../core/src/notes.ts";
18
+ import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE } from "../core/src/notes.ts";
17
19
  import * as linkOps from "../core/src/links.ts";
18
20
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
21
+ import { MAPPING_KINDS, type SchemaMappingKind, type NoteSchemaField } from "../core/src/note-schemas.ts";
22
+ import {
23
+ filterNotesByTagScope,
24
+ noteWithinTagScope,
25
+ tagScopeForbidden,
26
+ tagsWithinScope,
27
+ } from "./tag-scope.ts";
28
+ import { findTokensReferencingTag } from "./token-store.ts";
29
+
30
+ /**
31
+ * Tag-scope context threaded through handlers. `allowed` is the
32
+ * pre-expanded set of permitted tags (root + descendants), `raw` is the
33
+ * original allowlist for error messages. Both null when the token is
34
+ * unscoped — handlers fast-path on `allowed === null` and behave
35
+ * identically to the pre-tag-scope code path.
36
+ */
37
+ export type TagScopeCtx = { allowed: Set<string> | null; raw: string[] | null };
38
+
39
+ const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
19
40
  import {
20
41
  expandContent,
21
42
  DEFAULT_EXPAND_DEPTH,
@@ -132,6 +153,7 @@ export async function handleNotes(
132
153
  store: Store,
133
154
  subpath: string,
134
155
  vault?: string,
156
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
135
157
  ): Promise<Response> {
136
158
  const url = new URL(req.url);
137
159
  const method = req.method;
@@ -148,6 +170,12 @@ export async function handleNotes(
148
170
  if (id) {
149
171
  const note = await resolveNote(store, id);
150
172
  if (!note) return json({ error: "Note not found", id }, 404);
173
+ // Tag-scope: a token can't see what its allowlist excludes. Surface
174
+ // as 404 (not 403) — the existence of the note is itself information
175
+ // we shouldn't leak across the scope boundary.
176
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
177
+ return json({ error: "Note not found", id }, 404);
178
+ }
151
179
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
152
180
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
153
181
  const expand = parseExpandParams(url, db);
@@ -169,7 +197,11 @@ export async function handleNotes(
169
197
  if (search) {
170
198
  const searchTags = parseQueryList(url, "tag");
171
199
  const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
172
- const results = await store.searchNotes(search, { tags: searchTags, limit });
200
+ const rawResults = await store.searchNotes(search, { tags: searchTags, limit });
201
+ // Tag-scope: drop any result the token isn't permitted to see. Filter
202
+ // happens after the store query so an empty post-filter list still
203
+ // returns 200 [] (consistent with "no matches"), not 403.
204
+ const results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
173
205
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
174
206
  const inclMeta = parseIncludeMetadata(url);
175
207
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
@@ -189,6 +221,13 @@ export async function handleNotes(
189
221
  }
190
222
 
191
223
  // Structured query
224
+ //
225
+ // Surface asymmetry: REST uses three flat query params
226
+ // (`date_field`, `date_from`, `date_to`) while MCP takes a nested
227
+ // `date_filter: { field, from, to }` object. Both lower to the same
228
+ // store-level `dateFilter` shape — the difference is just that query
229
+ // strings are flat by nature. This mirrors the broader REST/MCP
230
+ // pattern across the API and is intentional, not a fix-it-up TODO.
192
231
  const tags = parseQueryList(url, "tag");
193
232
  let results: Note[];
194
233
  try {
@@ -201,8 +240,22 @@ export async function handleNotes(
201
240
  path: parseQuery(url, "path") ?? undefined,
202
241
  pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
203
242
  metadata: undefined, // metadata filter not practical in query params
204
- dateFrom: parseQuery(url, "date_from") ?? undefined,
205
- dateTo: parseQuery(url, "date_to") ?? undefined,
243
+ // `date_field=<name>&date_from=...&date_to=...` activates the
244
+ // generalized filter (filters on the named indexed field). Without
245
+ // `date_field`, `date_from`/`date_to` keep their legacy meaning of
246
+ // filtering on `created_at` (vault ingestion time).
247
+ ...(parseQuery(url, "date_field")
248
+ ? {
249
+ dateFilter: {
250
+ field: parseQuery(url, "date_field")!,
251
+ from: parseQuery(url, "date_from") ?? undefined,
252
+ to: parseQuery(url, "date_to") ?? undefined,
253
+ },
254
+ }
255
+ : {
256
+ dateFrom: parseQuery(url, "date_from") ?? undefined,
257
+ dateTo: parseQuery(url, "date_to") ?? undefined,
258
+ }),
206
259
  sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
207
260
  orderBy: parseQuery(url, "order_by") ?? undefined,
208
261
  limit: parseInt10(parseQuery(url, "limit")) ?? 50,
@@ -223,6 +276,10 @@ export async function handleNotes(
223
276
  if (nearNoteId) {
224
277
  const anchor = await resolveNote(store, nearNoteId);
225
278
  if (!anchor) return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
279
+ // Tag-scope: anchor must itself be visible to this token.
280
+ if (!noteWithinTagScope(anchor, tagScope.allowed, tagScope.raw)) {
281
+ return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
282
+ }
226
283
  const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
227
284
  const relationship = parseQuery(url, "near[relationship]") ?? undefined;
228
285
  const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship });
@@ -230,6 +287,11 @@ export async function handleNotes(
230
287
  results = results.filter((n) => nearScope.has(n.id));
231
288
  }
232
289
 
290
+ // Tag-scope: drop any result outside the allowlist before shaping
291
+ // output. Same semantics as the search path — empty result is 200 [],
292
+ // not 403.
293
+ results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
294
+
233
295
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
234
296
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
235
297
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
@@ -285,25 +347,110 @@ export async function handleNotes(
285
347
  const body = await req.json() as any;
286
348
  const items: any[] = body.notes ?? [body];
287
349
 
288
- const created: Note[] = [];
289
- for (const item of items) {
290
- const note = await store.createNote(item.content ?? "", {
291
- id: item.id,
292
- path: item.path,
293
- tags: item.tags,
294
- metadata: item.metadata,
295
- created_at: item.createdAt ?? item.created_at,
296
- });
350
+ // Batch cap (#213): refuse oversized batches before doing any work. 500
351
+ // is the cap (Benjamin's number) — tighter blast radius than 1000 for
352
+ // the runaway-client case that flooded a deployment with 7,453 notes.
353
+ if (items.length > MAX_BATCH_SIZE) {
354
+ return json(
355
+ {
356
+ error_type: "batch_too_large",
357
+ error: "BatchTooLarge",
358
+ message: `max ${MAX_BATCH_SIZE} notes per request, got ${items.length}`,
359
+ limit: MAX_BATCH_SIZE,
360
+ },
361
+ 413,
362
+ );
363
+ }
297
364
 
298
- // Create explicit links
299
- if (item.links) {
300
- for (const link of item.links as { target: string; relationship: string }[]) {
301
- const target = await resolveNote(store, link.target);
302
- if (target) await store.createLink(note.id, target.id, link.relationship);
365
+ // Empty-note pre-validation (#213): walk the batch first and reject the
366
+ // whole request if any item would be content+path empty. This makes
367
+ // mixed batches atomic for the empty-note case no caller gets a
368
+ // half-applied batch where the prefix landed and the empty entry
369
+ // surfaced the 400. Mirrors the Store-level invariant exactly.
370
+ for (let i = 0; i < items.length; i++) {
371
+ const item = items[i];
372
+ const content = (item?.content ?? "").toString();
373
+ const rawPath = item?.path;
374
+ const pathEmpty = rawPath === undefined || rawPath === null
375
+ || (typeof rawPath === "string" && rawPath.trim() === "");
376
+ if (!content.trim() && pathEmpty) {
377
+ return json(
378
+ {
379
+ error_type: "empty_note",
380
+ error: "EmptyNoteError",
381
+ message: `empty_note: a note must have either content or a path (item index ${i})`,
382
+ item_index: i,
383
+ },
384
+ 400,
385
+ );
386
+ }
387
+ }
388
+
389
+ // Tag-scope pre-validation: every new note in the batch must carry at
390
+ // least one tag inside the token's allowlist. Same atomic-batch
391
+ // discipline as the empty-note check — reject the whole request before
392
+ // any DB write so a tag-scoped token can't accidentally land a partial
393
+ // batch with an in-scope prefix.
394
+ if (tagScope.allowed) {
395
+ for (let i = 0; i < items.length; i++) {
396
+ if (!tagsWithinScope(items[i]?.tags, tagScope.allowed, tagScope.raw)) {
397
+ return tagScopeForbidden(tagScope.raw ?? []);
303
398
  }
304
399
  }
400
+ }
305
401
 
306
- created.push((await store.getNote(note.id)) ?? note);
402
+ const created: Note[] = [];
403
+ // Wrap multi-item batches in a SQLite transaction so a mid-batch
404
+ // failure (path conflict, etc.) rolls back every prior insert. Without
405
+ // this, callers got half-applied batches where the prefix landed and
406
+ // the offending entry surfaced the 409 — see #236. Single-item posts
407
+ // are already atomic at the store layer and skip the wrap so they
408
+ // don't collide with concurrent single-item callers on the shared
409
+ // bun:sqlite connection.
410
+ const batched = items.length > 1;
411
+ if (batched) db.exec("BEGIN");
412
+ try {
413
+ for (const item of items) {
414
+ const note = await store.createNote(item.content ?? "", {
415
+ id: item.id,
416
+ path: item.path,
417
+ tags: item.tags,
418
+ metadata: item.metadata,
419
+ created_at: item.createdAt ?? item.created_at,
420
+ });
421
+
422
+ // Create explicit links
423
+ if (item.links) {
424
+ for (const link of item.links as { target: string; relationship: string }[]) {
425
+ const target = await resolveNote(store, link.target);
426
+ if (target) await store.createLink(note.id, target.id, link.relationship);
427
+ }
428
+ }
429
+
430
+ created.push((await store.getNote(note.id)) ?? note);
431
+ }
432
+ if (batched) db.exec("COMMIT");
433
+ } catch (e: any) {
434
+ if (batched) db.exec("ROLLBACK");
435
+ // Duck-type for module-boundary robustness (matches the PATCH branch).
436
+ if (e && e.code === "PATH_CONFLICT") {
437
+ return json(
438
+ { error_type: "path_conflict", error: "path_conflict", path: e.path, message: e.message },
439
+ 409,
440
+ );
441
+ }
442
+ if (e && e.code === "EMPTY_NOTE") {
443
+ return json(
444
+ {
445
+ error_type: "empty_note",
446
+ error: "EmptyNoteError",
447
+ message: e.message,
448
+ item_index: e.item_index ?? null,
449
+ },
450
+ 400,
451
+ );
452
+ }
453
+ throw e;
307
454
  }
308
455
 
309
456
  // Apply tag schema defaults
@@ -323,7 +470,7 @@ export async function handleNotes(
323
470
  const idMatch = subpath.match(/^\/([^/]+)(\/.*)?$/);
324
471
  if (!idMatch) return json({ error: "Not found" }, 404);
325
472
 
326
- const idOrPath = decodeURIComponent(idMatch[1]);
473
+ const idOrPath = decodeURIComponent(idMatch[1]!);
327
474
  const sub = idMatch[2] ?? "";
328
475
 
329
476
  // Attachments sub-routes (keep as-is — Daily needs them)
@@ -331,6 +478,9 @@ export async function handleNotes(
331
478
  if (method === "POST") {
332
479
  const note = await resolveNote(store, idOrPath);
333
480
  if (!note) return json({ error: "Not found" }, 404);
481
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
482
+ return json({ error: "Not found" }, 404);
483
+ }
334
484
  const body = await req.json() as { path: string; mimeType: string; transcribe?: boolean };
335
485
  if (!body.path || !body.mimeType) return json({ error: "path and mimeType are required" }, 400);
336
486
 
@@ -361,6 +511,9 @@ export async function handleNotes(
361
511
  if (method === "GET") {
362
512
  const note = await resolveNote(store, idOrPath);
363
513
  if (!note) return json({ error: "Not found" }, 404);
514
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
515
+ return json({ error: "Not found" }, 404);
516
+ }
364
517
  return json(await store.getAttachments(note.id));
365
518
  }
366
519
  return json({ error: "Method not allowed" }, 405);
@@ -372,6 +525,9 @@ export async function handleNotes(
372
525
  if (method === "DELETE") {
373
526
  const note = await resolveNote(store, idOrPath);
374
527
  if (!note) return json({ error: "Not found" }, 404);
528
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
529
+ return json({ error: "Not found" }, 404);
530
+ }
375
531
  const result = await store.deleteAttachment(note.id, attId);
376
532
  if (!result.deleted) return json({ error: "Not found" }, 404);
377
533
  // Unlink the storage file only if no other attachment still references
@@ -395,6 +551,9 @@ export async function handleNotes(
395
551
  if (method === "GET") {
396
552
  const note = await resolveNote(store, idOrPath);
397
553
  if (!note) return json({ error: "Not found" }, 404);
554
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
555
+ return json({ error: "Not found" }, 404);
556
+ }
398
557
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
399
558
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
400
559
  const expand = parseExpandParams(url, db);
@@ -417,13 +576,60 @@ export async function handleNotes(
417
576
  try {
418
577
  const note = await resolveNote(store, idOrPath);
419
578
  if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
579
+ // Tag-scope: existing note must be in scope. Mirror the read-side
580
+ // 404-not-403 stance — a token can't see (and therefore can't
581
+ // discover-then-modify) notes outside its allowlist.
582
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
583
+ throw new NotFoundError(`Note not found: "${idOrPath}"`);
584
+ }
420
585
  const body = await req.json() as any;
586
+ // Tag-scope: post-update tag set must still satisfy scope. Compute
587
+ // the prospective tag set (existing − removed + added) and reject
588
+ // before any write if it would drift outside the allowlist. This
589
+ // covers the bot-untags-its-only-allowlisted-tag escape route.
590
+ if (tagScope.allowed) {
591
+ const removed = new Set<string>((body.tags?.remove as string[] | undefined) ?? []);
592
+ const projected = new Set<string>((note.tags ?? []).filter((t) => !removed.has(t)));
593
+ for (const t of (body.tags?.add as string[] | undefined) ?? []) projected.add(t);
594
+ if (!tagsWithinScope([...projected], tagScope.allowed, tagScope.raw)) {
595
+ return tagScopeForbidden(tagScope.raw ?? []);
596
+ }
597
+ }
598
+
599
+ // --- Validate mutual exclusion of content modes ---
600
+ const hasContent = body.content !== undefined;
601
+ const hasAppendPrepend = body.append !== undefined || body.prepend !== undefined;
602
+ const hasContentEdit = body.content_edit !== undefined;
603
+ const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
604
+ if (contentModes > 1) {
605
+ return json(
606
+ {
607
+ error: "mutually_exclusive",
608
+ message: "`content`, `append`/`prepend`, and `content_edit` are mutually exclusive — pick one mode of content update.",
609
+ },
610
+ 400,
611
+ );
612
+ }
421
613
 
422
614
  // --- Safety-by-default: refuse mutations without a precondition ---
423
615
  // Mirror the MCP tool: require `if_updated_at` unless the caller
424
616
  // explicitly sets `force: true`. 428 Precondition Required is the
425
617
  // RFC 6585 status for exactly this case.
426
- if (body.if_updated_at === undefined && body.force !== true) {
618
+ //
619
+ // Append/prepend-only updates are exempt — SQL-atomic concatenation
620
+ // is no-conflict-by-design. Tag/link mutations are *not* exempt
621
+ // (#201): they're idempotent set-ops, but still represent a
622
+ // non-content change the caller should observe before re-asserting.
623
+ const isAppendOnly = hasAppendPrepend
624
+ && !hasContent
625
+ && !hasContentEdit
626
+ && body.path === undefined
627
+ && body.metadata === undefined
628
+ && body.created_at === undefined
629
+ && body.createdAt === undefined
630
+ && body.tags === undefined
631
+ && body.links === undefined;
632
+ if (!isAppendOnly && body.if_updated_at === undefined && body.force !== true) {
427
633
  return json(
428
634
  {
429
635
  error_type: "precondition_required",
@@ -437,10 +643,40 @@ export async function handleNotes(
437
643
  );
438
644
  }
439
645
 
646
+ // --- Resolve content_edit into a full content string ---
647
+ let contentOverride = body.content as string | undefined;
648
+ if (hasContentEdit) {
649
+ const ce = body.content_edit as { old_text?: unknown; new_text?: unknown };
650
+ if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
651
+ return json(
652
+ { error: "bad_request", message: "`content_edit` requires { old_text: string, new_text: string }." },
653
+ 400,
654
+ );
655
+ }
656
+ const idx = note.content.indexOf(ce.old_text);
657
+ if (idx < 0) {
658
+ // 422 Unprocessable Entity, not 404: the note exists, the request is
659
+ // syntactically valid, but the search string can't be applied to the
660
+ // current content. Returning 404 implied "note doesn't exist" and
661
+ // confused operators chasing a missing record (#202).
662
+ return json(
663
+ { error: "unprocessable_content", message: `content_edit: \`old_text\` not found in note "${note.id}". Re-read and retry.` },
664
+ 422,
665
+ );
666
+ }
667
+ const second = note.content.indexOf(ce.old_text, idx + 1);
668
+ if (second >= 0) {
669
+ return json(
670
+ { error: "ambiguous", message: `content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context.` },
671
+ 409,
672
+ );
673
+ }
674
+ contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
675
+ }
676
+
440
677
  // --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
441
678
  // The actual link deletions happen only after the core UPDATE succeeds,
442
679
  // so a conflict leaves the note untouched.
443
- let contentOverride = body.content as string | undefined;
444
680
  const linksRemove = body.links?.remove as { target: string; relationship: string }[] | undefined;
445
681
  const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
446
682
  if (linksRemove) {
@@ -449,7 +685,12 @@ export async function handleNotes(
449
685
  if (!target) continue;
450
686
  resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
451
687
  if (link.relationship === "wikilink" && target.path) {
452
- const current = contentOverride ?? note.content;
688
+ // Materialize the prospective content for append/prepend callers
689
+ // so we don't fight the SQL-atomic path with a JS-level rewrite.
690
+ const current = contentOverride
691
+ ?? (hasAppendPrepend
692
+ ? (body.prepend as string ?? "") + note.content + (body.append as string ?? "")
693
+ : note.content);
453
694
  const cleaned = removeWikilinkBrackets(current, target.path);
454
695
  if (cleaned !== current) contentOverride = cleaned;
455
696
  }
@@ -458,7 +699,12 @@ export async function handleNotes(
458
699
 
459
700
  // --- Core update (runs the if_updated_at check atomically) ---
460
701
  const updates: any = {};
461
- if (contentOverride !== undefined) updates.content = contentOverride;
702
+ if (contentOverride !== undefined) {
703
+ updates.content = contentOverride;
704
+ } else if (hasAppendPrepend) {
705
+ if (body.append !== undefined) updates.append = body.append;
706
+ if (body.prepend !== undefined) updates.prepend = body.prepend;
707
+ }
462
708
  if (body.path !== undefined) updates.path = body.path;
463
709
  if (body.metadata !== undefined) {
464
710
  const existing = (note.metadata as Record<string, unknown>) ?? {};
@@ -521,6 +767,27 @@ export async function handleNotes(
521
767
  409,
522
768
  );
523
769
  }
770
+ // Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
771
+ if (e && e.code === "PATH_CONFLICT") {
772
+ return json(
773
+ { error_type: "path_conflict", error: "path_conflict", path: e.path, message: e.message },
774
+ 409,
775
+ );
776
+ }
777
+ // Empty-note guard from the Store boundary (#213) — the proposed update
778
+ // would clear both content AND path. Surface as 400 so callers can fix
779
+ // the request without retrying.
780
+ if (e && e.code === "EMPTY_NOTE") {
781
+ return json(
782
+ {
783
+ error_type: "empty_note",
784
+ error: "EmptyNoteError",
785
+ message: e.message,
786
+ note_id: e.note_id ?? null,
787
+ },
788
+ 400,
789
+ );
790
+ }
524
791
  throw e;
525
792
  }
526
793
  }
@@ -529,6 +796,11 @@ export async function handleNotes(
529
796
  if (method === "DELETE") {
530
797
  const note = await resolveNote(store, idOrPath);
531
798
  if (!note) return json({ error: "Not found" }, 404);
799
+ // Tag-scope: can't delete what you can't read. 404 (not 403) for the
800
+ // same no-leak reason as the read paths.
801
+ if (!noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
802
+ return json({ error: "Not found" }, 404);
803
+ }
532
804
  await store.deleteNote(note.id);
533
805
  return json({ deleted: true, id: note.id });
534
806
  }
@@ -541,7 +813,12 @@ export async function handleNotes(
541
813
  // POST /api/tags/:name/rename
542
814
  // ---------------------------------------------------------------------------
543
815
 
544
- export async function handleTags(req: Request, store: Store, subpath = ""): Promise<Response> {
816
+ export async function handleTags(
817
+ req: Request,
818
+ store: Store,
819
+ subpath = "",
820
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
821
+ ): Promise<Response> {
545
822
  const url = new URL(req.url);
546
823
 
547
824
  // GET /tags — list all, or get single tag detail
@@ -549,27 +826,49 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
549
826
  const singleTag = parseQuery(url, "tag");
550
827
 
551
828
  if (singleTag) {
829
+ // Tag-scope: a tag-scoped token can only see tags reachable from its
830
+ // allowlist (root + descendants per the parent_names hierarchy).
831
+ // Anything else 404s — same "no leak" stance as note reads.
832
+ if (tagScope.allowed && !tagScope.allowed.has(singleTag)) {
833
+ return json({ error: "Tag not found", tag: singleTag }, 404);
834
+ }
552
835
  const allTags = await store.listTags();
553
836
  const found = allTags.find((t) => t.name === singleTag);
554
- const schema = await store.getTagSchema(singleTag);
837
+ const record = await store.getTagRecord(singleTag);
555
838
  return json({
556
839
  name: singleTag,
557
840
  count: found?.count ?? 0,
558
- description: schema?.description ?? null,
559
- fields: schema?.fields ?? null,
841
+ description: record?.description ?? null,
842
+ fields: record?.fields ?? null,
843
+ relationships: record?.relationships ?? null,
844
+ parent_names: record?.parent_names ?? null,
845
+ created_at: record?.created_at ?? null,
846
+ updated_at: record?.updated_at ?? null,
560
847
  });
561
848
  }
562
849
 
563
850
  const tags = await store.listTags();
851
+ const filtered = tagScope.allowed
852
+ ? tags.filter((t) => tagScope.allowed!.has(t.name))
853
+ : tags;
564
854
  if (parseBool(parseQuery(url, "include_schema"), false)) {
565
- const schemas = await store.getTagSchemaMap();
566
- return json(tags.map((t) => ({
567
- ...t,
568
- description: schemas[t.name]?.description ?? null,
569
- fields: schemas[t.name]?.fields ?? null,
570
- })));
855
+ const records = new Map(
856
+ (await store.listTagRecords()).map((r) => [r.tag, r] as const),
857
+ );
858
+ return json(filtered.map((t) => {
859
+ const r = records.get(t.name);
860
+ return {
861
+ ...t,
862
+ description: r?.description ?? null,
863
+ fields: r?.fields ?? null,
864
+ relationships: r?.relationships ?? null,
865
+ parent_names: r?.parent_names ?? null,
866
+ created_at: r?.created_at ?? null,
867
+ updated_at: r?.updated_at ?? null,
868
+ };
869
+ }));
571
870
  }
572
- return json(tags);
871
+ return json(filtered);
573
872
  }
574
873
 
575
874
  // POST /tags/merge — atomic multi-source merge into a target tag.
@@ -588,6 +887,37 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
588
887
  if (typeof target !== "string" || target.length === 0) {
589
888
  return json({ error: "target must be a non-empty string" }, 400);
590
889
  }
890
+ // Tag-scope: every source AND the target must be inside the allowlist.
891
+ // A merge that pulls notes out of a token's scope (or pushes notes into
892
+ // it) is a privilege escalation; refuse the whole op.
893
+ if (tagScope.allowed) {
894
+ for (const t of [...sources, target]) {
895
+ if (!tagScope.allowed.has(t)) {
896
+ return tagScopeForbidden(tagScope.raw ?? []);
897
+ }
898
+ }
899
+ }
900
+ // Same dependency check as DELETE /tags/:name — merging consumes every
901
+ // source tag, so a source referenced by a tag-scoped token would orphan
902
+ // that token's allowlist. Aggregate matches across sources for a single
903
+ // 409 envelope.
904
+ const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
905
+ const db = (store as any).db;
906
+ for (const src of sources) {
907
+ const tokens = findTokensReferencingTag(db, src as string);
908
+ if (tokens.length > 0) referenced.push({ source: src as string, tokens });
909
+ }
910
+ if (referenced.length > 0) {
911
+ return json(
912
+ {
913
+ error: "TagInUseByTokens",
914
+ error_type: "tag_in_use_by_tokens",
915
+ message: `Cannot merge: ${referenced.length} source tag(s) referenced by tag-scoped token(s); revoke or re-mint them first.`,
916
+ referenced_by: referenced,
917
+ },
918
+ 409,
919
+ );
920
+ }
591
921
  const result = await store.mergeTags(sources, target);
592
922
  return json(result);
593
923
  }
@@ -596,13 +926,34 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
596
926
  const renameMatch = subpath.match(/^\/([^/]+)\/rename$/);
597
927
  if (renameMatch) {
598
928
  if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
599
- const oldName = decodeURIComponent(renameMatch[1]);
929
+ const oldName = decodeURIComponent(renameMatch[1]!);
600
930
  const body = (await req.json().catch(() => null)) as { new_name?: unknown } | null;
601
931
  if (!body) return json({ error: "Invalid JSON body" }, 400);
602
932
  const newName = body.new_name;
603
933
  if (typeof newName !== "string" || newName.length === 0) {
604
934
  return json({ error: "new_name must be a non-empty string" }, 400);
605
935
  }
936
+ if (tagScope.allowed && (!tagScope.allowed.has(oldName) || !tagScope.allowed.has(newName))) {
937
+ return tagScopeForbidden(tagScope.raw ?? []);
938
+ }
939
+ // Same dependency check as DELETE / merge — until vault#240 ships the
940
+ // rename→token cascade, fail-closed on referenced names so the rename
941
+ // can't silently orphan a token's allowlist (the JSON-stored old name
942
+ // would no longer match anything). When the cascade lands this becomes
943
+ // an unconditional cascade + audit log entry per patterns#26 §Lifecycle.
944
+ const referenced_by = findTokensReferencingTag((store as any).db, oldName);
945
+ if (referenced_by.length > 0) {
946
+ return json(
947
+ {
948
+ error: "TagInUseByTokens",
949
+ error_type: "tag_in_use_by_tokens",
950
+ message: `Tag "${oldName}" is referenced by ${referenced_by.length} tag-scoped token(s); revoke or re-mint them before renaming. (vault#240 will replace this with an automatic cascade.)`,
951
+ tag: oldName,
952
+ referenced_by,
953
+ },
954
+ 409,
955
+ );
956
+ }
606
957
  const result = await store.renameTag(oldName, newName);
607
958
  if ("error" in result) {
608
959
  if (result.error === "not_found") return json({ error: "not_found", tag: oldName }, 404);
@@ -623,47 +974,297 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
623
974
  // Routes with tag name
624
975
  const nameMatch = subpath.match(/^\/([^/]+)$/);
625
976
  if (!nameMatch) return json({ error: "Not found" }, 404);
626
- const tagName = decodeURIComponent(nameMatch[1]);
977
+ const tagName = decodeURIComponent(nameMatch[1]!);
627
978
 
628
- // GET /tags/:name — single tag detail
979
+ // GET /tags/:name — single tag detail (full record)
629
980
  if (req.method === "GET") {
981
+ if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
982
+ return json({ error: "Tag not found", tag: tagName }, 404);
983
+ }
630
984
  const allTags = await store.listTags();
631
985
  const found = allTags.find((t) => t.name === tagName);
632
- const schema = await store.getTagSchema(tagName);
986
+ const record = await store.getTagRecord(tagName);
633
987
  return json({
634
988
  name: tagName,
635
989
  count: found?.count ?? 0,
636
- description: schema?.description ?? null,
637
- fields: schema?.fields ?? null,
990
+ description: record?.description ?? null,
991
+ fields: record?.fields ?? null,
992
+ relationships: record?.relationships ?? null,
993
+ parent_names: record?.parent_names ?? null,
994
+ created_at: record?.created_at ?? null,
995
+ updated_at: record?.updated_at ?? null,
638
996
  });
639
997
  }
640
998
 
641
- // PUT /tags/:name — upsert tag schema (description + fields)
999
+ // PUT /tags/:name — upsert tag identity row. Body accepts any combination
1000
+ // of { description, fields, relationships, parent_names }; omitted keys
1001
+ // are preserved, explicit null clears. See patterns/tag-data-model.md.
642
1002
  if (req.method === "PUT") {
643
- const body = await req.json() as { description?: string; fields?: Record<string, unknown> };
644
- const existing = await store.getTagSchema(tagName);
645
- const mergedFields = { ...existing?.fields, ...(body.fields as any) };
646
- const schema = await store.upsertTagSchema(tagName, {
647
- description: body.description ?? existing?.description,
648
- fields: Object.keys(mergedFields).length > 0 ? mergedFields : undefined,
1003
+ if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
1004
+ return tagScopeForbidden(tagScope.raw ?? []);
1005
+ }
1006
+ const body = (await req.json()) as {
1007
+ description?: string | null;
1008
+ fields?: Record<string, unknown> | null;
1009
+ relationships?: Record<string, unknown> | null;
1010
+ parent_names?: unknown;
1011
+ };
1012
+
1013
+ // Validate relationships shape + cardinality vocabulary up front so
1014
+ // a bad payload returns 400, not a thrown 500.
1015
+ let relationshipsPatch:
1016
+ | Record<string, tagSchemaOps.TagRelationship>
1017
+ | null
1018
+ | undefined;
1019
+ if (body.relationships === null) {
1020
+ relationshipsPatch = null;
1021
+ } else if (body.relationships !== undefined) {
1022
+ try {
1023
+ relationshipsPatch = tagSchemaOps.validateRelationships(body.relationships);
1024
+ } catch (err) {
1025
+ return json(
1026
+ { error: (err as Error).message, error_type: "invalid_relationships" },
1027
+ 400,
1028
+ );
1029
+ }
1030
+ }
1031
+
1032
+ let parentNamesPatch: string[] | null | undefined;
1033
+ if (body.parent_names === null) {
1034
+ parentNamesPatch = null;
1035
+ } else if (body.parent_names !== undefined) {
1036
+ if (!Array.isArray(body.parent_names)) {
1037
+ return json({ error: "parent_names must be an array of tag names" }, 400);
1038
+ }
1039
+ const cleaned = (body.parent_names as unknown[]).filter(
1040
+ (p): p is string => typeof p === "string" && p.length > 0,
1041
+ );
1042
+ parentNamesPatch = cleaned.length > 0 ? cleaned : null;
1043
+ }
1044
+
1045
+ // Field merge mirrors MCP update-tag — preserves prior keys when the
1046
+ // payload only declares new ones.
1047
+ let fieldsPatch:
1048
+ | Record<string, tagSchemaOps.TagFieldSchema>
1049
+ | null
1050
+ | undefined;
1051
+ if (body.fields === null) {
1052
+ fieldsPatch = null;
1053
+ } else if (body.fields !== undefined) {
1054
+ const existing = await store.getTagSchema(tagName);
1055
+ const merged: Record<string, tagSchemaOps.TagFieldSchema> = {
1056
+ ...(existing?.fields ?? {}),
1057
+ ...(body.fields as Record<string, tagSchemaOps.TagFieldSchema>),
1058
+ };
1059
+ fieldsPatch = Object.keys(merged).length > 0 ? merged : null;
1060
+ }
1061
+
1062
+ const result = await store.upsertTagRecord(tagName, {
1063
+ ...(body.description !== undefined ? { description: body.description } : {}),
1064
+ ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
1065
+ ...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
1066
+ ...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
649
1067
  });
650
- return json(schema);
1068
+ return json(result);
651
1069
  }
652
1070
 
653
- // DELETE /tags/:name — delete tag + schema from all notes
1071
+ // DELETE /tags/:name — delete tag + identity row + remove from all notes
654
1072
  if (req.method === "DELETE") {
655
- await store.deleteTagSchema(tagName);
1073
+ if (tagScope.allowed && !tagScope.allowed.has(tagName)) {
1074
+ return tagScopeForbidden(tagScope.raw ?? []);
1075
+ }
1076
+ // Tag-scoped tokens reference root tags by name; deleting a referenced
1077
+ // tag would silently orphan the token's allowlist. Fail closed (409)
1078
+ // and name the offending tokens so the operator can revoke or re-mint
1079
+ // before retrying. patterns/tag-scoped-tokens.md §Dependencies.
1080
+ const referenced_by = findTokensReferencingTag((store as any).db, tagName);
1081
+ if (referenced_by.length > 0) {
1082
+ return json(
1083
+ {
1084
+ error: "TagInUseByTokens",
1085
+ error_type: "tag_in_use_by_tokens",
1086
+ message: `Tag "${tagName}" is referenced by ${referenced_by.length} tag-scoped token(s); revoke or re-mint them before deleting.`,
1087
+ tag: tagName,
1088
+ referenced_by,
1089
+ },
1090
+ 409,
1091
+ );
1092
+ }
656
1093
  return json(await store.deleteTag(tagName));
657
1094
  }
658
1095
 
659
1096
  return json({ error: "Method not allowed" }, 405);
660
1097
  }
661
1098
 
1099
+ // ---------------------------------------------------------------------------
1100
+ // Note schemas — GET/PUT/DELETE /api/note-schemas[/:name],
1101
+ // GET/POST/DELETE /api/note-schemas/:name/mappings
1102
+ // ---------------------------------------------------------------------------
1103
+
1104
+ export async function handleNoteSchemas(
1105
+ req: Request,
1106
+ store: Store,
1107
+ subpath = "",
1108
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
1109
+ ): Promise<Response> {
1110
+ const url = new URL(req.url);
1111
+
1112
+ // Tag-scope filter for `tag`-kind mappings. `path_prefix` mappings carry no
1113
+ // tag-axis information so they're always visible/writable. The single-tag
1114
+ // check delegates to `tagsWithinScope` so the string-form fallback in
1115
+ // patterns/tag-scoped-tokens.md §Storage details is honored end-to-end.
1116
+ const mappingInScope = (m: { match_kind: SchemaMappingKind; match_value: string }): boolean => {
1117
+ if (m.match_kind !== "tag") return true;
1118
+ return tagsWithinScope([m.match_value], tagScope.allowed, tagScope.raw);
1119
+ };
1120
+
1121
+ // GET /note-schemas — list all
1122
+ if (req.method === "GET" && subpath === "") {
1123
+ const schemas = await store.listNoteSchemas();
1124
+ if (parseBool(parseQuery(url, "include_mappings"), false)) {
1125
+ const allMappings = (await store.listSchemaMappings()).filter(mappingInScope);
1126
+ const byName = new Map<string, typeof allMappings>();
1127
+ for (const m of allMappings) {
1128
+ const list = byName.get(m.schema_name) ?? [];
1129
+ list.push(m);
1130
+ byName.set(m.schema_name, list);
1131
+ }
1132
+ return json(schemas.map((s) => ({ ...s, mappings: byName.get(s.name) ?? [] })));
1133
+ }
1134
+ return json(schemas);
1135
+ }
1136
+
1137
+ // /note-schemas/:name(/mappings)
1138
+ const mappingsMatch = subpath.match(/^\/([^/]+)\/mappings$/);
1139
+ if (mappingsMatch) {
1140
+ const schemaName = decodeURIComponent(mappingsMatch[1]!);
1141
+
1142
+ // GET /note-schemas/:name/mappings
1143
+ if (req.method === "GET") {
1144
+ const mappings = (await store.listSchemaMappings({ schema_name: schemaName })).filter(mappingInScope);
1145
+ return json(mappings);
1146
+ }
1147
+
1148
+ // POST /note-schemas/:name/mappings — body: { match_kind, match_value }
1149
+ if (req.method === "POST") {
1150
+ const body = (await req.json().catch(() => null)) as
1151
+ | { match_kind?: unknown; match_value?: unknown }
1152
+ | null;
1153
+ if (!body) return json({ error: "Invalid JSON body" }, 400);
1154
+ const match_kind = body.match_kind;
1155
+ const match_value = body.match_value;
1156
+ if (typeof match_kind !== "string" || !MAPPING_KINDS.includes(match_kind as SchemaMappingKind)) {
1157
+ return json(
1158
+ { error: `match_kind must be one of: ${MAPPING_KINDS.join(", ")}`, error_type: "invalid_match_kind" },
1159
+ 400,
1160
+ );
1161
+ }
1162
+ if (typeof match_value !== "string" || match_value.length === 0) {
1163
+ return json({ error: "match_value must be a non-empty string" }, 400);
1164
+ }
1165
+ // Tag-scope write gate: a tag mapping for an out-of-scope tag would let
1166
+ // a tag-scoped token bind a schema to a tag it can't see. Mirrors the
1167
+ // vault#241 write-gate shape (403 + tag_scope_violation envelope).
1168
+ if (!mappingInScope({ match_kind: match_kind as SchemaMappingKind, match_value })) {
1169
+ return tagScopeForbidden(tagScope.raw ?? []);
1170
+ }
1171
+ // Validate FK explicitly so a bad schema_name surfaces as 404 (not a
1172
+ // raw SQLITE_CONSTRAINT 500).
1173
+ if (!(await store.getNoteSchema(schemaName))) {
1174
+ return json({ error: "Schema not found", schema_name: schemaName }, 404);
1175
+ }
1176
+ await store.setSchemaMapping(schemaName, match_kind as SchemaMappingKind, match_value);
1177
+ return json({ ok: true, schema_name: schemaName, match_kind, match_value }, 201);
1178
+ }
1179
+
1180
+ // DELETE /note-schemas/:name/mappings?match_kind=...&match_value=...
1181
+ // Query params (not URL segments) because match_value can contain slashes
1182
+ // for path-prefix mappings.
1183
+ if (req.method === "DELETE") {
1184
+ const match_kind = parseQuery(url, "match_kind");
1185
+ const match_value = parseQuery(url, "match_value");
1186
+ if (!match_kind || !MAPPING_KINDS.includes(match_kind as SchemaMappingKind)) {
1187
+ return json(
1188
+ { error: `match_kind must be one of: ${MAPPING_KINDS.join(", ")}`, error_type: "invalid_match_kind" },
1189
+ 400,
1190
+ );
1191
+ }
1192
+ if (!match_value) {
1193
+ return json({ error: "match_value query parameter is required" }, 400);
1194
+ }
1195
+ if (!mappingInScope({ match_kind: match_kind as SchemaMappingKind, match_value })) {
1196
+ return tagScopeForbidden(tagScope.raw ?? []);
1197
+ }
1198
+ const deleted = await store.deleteSchemaMapping(
1199
+ schemaName,
1200
+ match_kind as SchemaMappingKind,
1201
+ match_value,
1202
+ );
1203
+ return json({ deleted, schema_name: schemaName, match_kind, match_value });
1204
+ }
1205
+
1206
+ return json({ error: "Method not allowed" }, 405);
1207
+ }
1208
+
1209
+ // /note-schemas/:name (no nested segment)
1210
+ const nameMatch = subpath.match(/^\/([^/]+)$/);
1211
+ if (!nameMatch) return json({ error: "Not found" }, 404);
1212
+ const name = decodeURIComponent(nameMatch[1]!);
1213
+
1214
+ // GET /note-schemas/:name — single (with mappings)
1215
+ if (req.method === "GET") {
1216
+ const schema = await store.getNoteSchema(name);
1217
+ if (!schema) return json({ error: "Schema not found", name }, 404);
1218
+ const mappings = (await store.listSchemaMappings({ schema_name: name })).filter(mappingInScope);
1219
+ return json({ ...schema, mappings });
1220
+ }
1221
+
1222
+ // PUT /note-schemas/:name — partial-upsert (mirrors update-tag shape)
1223
+ if (req.method === "PUT") {
1224
+ const body = (await req.json().catch(() => null)) as {
1225
+ description?: string | null;
1226
+ fields?: Record<string, unknown> | null;
1227
+ required?: unknown;
1228
+ } | null;
1229
+ if (!body) return json({ error: "Invalid JSON body" }, 400);
1230
+
1231
+ const patch: { description?: string | null; fields?: Record<string, NoteSchemaField> | null; required?: string[] | null } = {};
1232
+ if (body.description === null) patch.description = null;
1233
+ else if (body.description !== undefined) patch.description = body.description;
1234
+ if (body.fields === null) patch.fields = null;
1235
+ else if (body.fields !== undefined) patch.fields = body.fields as Record<string, NoteSchemaField>;
1236
+ if (body.required === null) patch.required = null;
1237
+ else if (body.required !== undefined) {
1238
+ if (!Array.isArray(body.required)) {
1239
+ return json({ error: "required must be an array of field names" }, 400);
1240
+ }
1241
+ patch.required = (body.required as unknown[]).filter(
1242
+ (x): x is string => typeof x === "string",
1243
+ );
1244
+ }
1245
+
1246
+ const result = await store.upsertNoteSchema(name, patch);
1247
+ return json(result);
1248
+ }
1249
+
1250
+ // DELETE /note-schemas/:name — drop schema; FK CASCADE drops its mappings.
1251
+ if (req.method === "DELETE") {
1252
+ const deleted = await store.deleteNoteSchema(name);
1253
+ return json({ deleted, name });
1254
+ }
1255
+
1256
+ return json({ error: "Method not allowed" }, 405);
1257
+ }
1258
+
662
1259
  // ---------------------------------------------------------------------------
663
1260
  // Find-path — GET /api/find-path?source=...&target=...
664
1261
  // ---------------------------------------------------------------------------
665
1262
 
666
- export async function handleFindPath(req: Request, store: Store): Promise<Response> {
1263
+ export async function handleFindPath(
1264
+ req: Request,
1265
+ store: Store,
1266
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
1267
+ ): Promise<Response> {
667
1268
  if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
668
1269
 
669
1270
  const url = new URL(req.url);
@@ -675,11 +1276,28 @@ export async function handleFindPath(req: Request, store: Store): Promise<Respon
675
1276
  try {
676
1277
  const sourceNote = await resolveNote(store, source);
677
1278
  if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
1279
+ if (!noteWithinTagScope(sourceNote, tagScope.allowed, tagScope.raw)) {
1280
+ return json({ error: `Note not found: "${source}"` }, 404);
1281
+ }
678
1282
  const targetNote = await resolveNote(store, target);
679
1283
  if (!targetNote) return json({ error: `Note not found: "${target}"` }, 404);
1284
+ if (!noteWithinTagScope(targetNote, tagScope.allowed, tagScope.raw)) {
1285
+ return json({ error: `Note not found: "${target}"` }, 404);
1286
+ }
680
1287
  const maxDepth = Math.min(parseInt10(parseQuery(url, "max_depth")) ?? 5, 10);
681
1288
 
1289
+ // Tag-scope on the *path* itself: every intermediate hop must also be
1290
+ // within scope. A reachable target via an out-of-scope hop is not a
1291
+ // permitted answer — surface as "no path" (null).
682
1292
  const result = linkOps.findPath(db, sourceNote.id, targetNote.id, { max_depth: maxDepth });
1293
+ if (result && tagScope.allowed) {
1294
+ for (const id of result.path) {
1295
+ const hop = await store.getNote(id);
1296
+ if (!hop || !noteWithinTagScope(hop, tagScope.allowed, tagScope.raw)) {
1297
+ return json(null);
1298
+ }
1299
+ }
1300
+ }
683
1301
  return json(result);
684
1302
  } catch (e: any) {
685
1303
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
@@ -789,7 +1407,7 @@ function renderMarkdown(md: string): string {
789
1407
  let inCodeBlock = false;
790
1408
 
791
1409
  for (let i = 0; i < lines.length; i++) {
792
- const line = lines[i];
1410
+ const line = lines[i]!;
793
1411
 
794
1412
  if (line.trimStart().startsWith("```")) {
795
1413
  if (inCodeBlock) {
@@ -815,15 +1433,15 @@ function renderMarkdown(md: string): string {
815
1433
 
816
1434
  const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)/);
817
1435
  if (headerMatch) {
818
- const level = headerMatch[1].length;
819
- out.push(`<h${level}>${inlineMarkdown(escapeHtml(headerMatch[2]))}</h${level}>`);
1436
+ const level = headerMatch[1]!.length;
1437
+ out.push(`<h${level}>${inlineMarkdown(escapeHtml(headerMatch[2]!))}</h${level}>`);
820
1438
  continue;
821
1439
  }
822
1440
 
823
1441
  if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
824
1442
  const items: string[] = [trimmed.slice(2)];
825
1443
  while (i + 1 < lines.length) {
826
- const next = lines[i + 1].trim();
1444
+ const next = lines[i + 1]!.trim();
827
1445
  if (next.startsWith("- ") || next.startsWith("* ")) {
828
1446
  items.push(next.slice(2));
829
1447
  i++;
@@ -949,9 +1567,17 @@ export function assetsDir(vault: string): string {
949
1567
  }
950
1568
  const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
951
1569
 
1570
+ // Storage allowlist policy:
1571
+ // - audio + image + .pdf (knowledge-vault content: papers, scans, receipts)
1572
+ // + .mp4 (mobile capture default; iOS records mp4, not webm).
1573
+ // - .svg and .html are deliberately excluded — both can embed `<script>`
1574
+ // tags, which would turn an upload into a same-origin XSS vector when
1575
+ // the asset is served back from /storage/. If a future use case needs
1576
+ // SVG, sanitize on read (strip <script>/<foreignObject>) and revisit.
952
1577
  const ALLOWED_EXTENSIONS = new Set([
953
1578
  ".wav", ".mp3", ".m4a", ".ogg", ".webm",
954
1579
  ".png", ".jpg", ".jpeg", ".gif", ".webp",
1580
+ ".pdf", ".mp4",
955
1581
  ]);
956
1582
 
957
1583
  const MIME_TYPES: Record<string, string> = {
@@ -965,6 +1591,8 @@ const MIME_TYPES: Record<string, string> = {
965
1591
  ".jpeg": "image/jpeg",
966
1592
  ".gif": "image/gif",
967
1593
  ".webp": "image/webp",
1594
+ ".pdf": "application/pdf",
1595
+ ".mp4": "video/mp4",
968
1596
  };
969
1597
 
970
1598
  export async function handleStorage(req: Request, path: string, vault: string): Promise<Response> {
@@ -984,7 +1612,7 @@ export async function handleStorage(req: Request, path: string, vault: string):
984
1612
  return json({ error: `File type ${ext} not allowed` }, 400);
985
1613
  }
986
1614
 
987
- const date = new Date().toISOString().split("T")[0];
1615
+ const date = new Date().toISOString().split("T")[0]!;
988
1616
  const dir = join(assets, date);
989
1617
  mkdirSync(dir, { recursive: true });
990
1618