@openparachute/vault 0.6.0-rc.1 → 0.6.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 (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/src/routes.ts CHANGED
@@ -11,13 +11,16 @@
11
11
  * and the Request, and returns a Response.
12
12
  */
13
13
 
14
- import type { Store, Note } from "../core/src/types.ts";
14
+ import type { Store, Note, QueryOpts } from "../core/src/types.ts";
15
+ import { TAG_EXPAND_MODES, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
15
16
  import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
- import { toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
17
+ import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
17
18
  import { attachValidationStatus } from "../core/src/mcp.ts";
18
19
  import * as linkOps from "../core/src/links.ts";
19
20
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
20
21
  import {
22
+ buildExpandVisibility,
23
+ filterHydratedLinksByTagScope,
21
24
  filterNotesByTagScope,
22
25
  noteWithinTagScope,
23
26
  tagScopeForbidden,
@@ -44,8 +47,16 @@ import {
44
47
  } from "../core/src/expand.ts";
45
48
  import { join, extname, normalize } from "path";
46
49
  import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
47
- import { vaultDir } from "./config.ts";
50
+ import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
48
51
  import { shouldAutoTranscribe } from "./auto-transcribe.ts";
52
+ // usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
53
+ // of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
54
+ import { invalidateUsageCache } from "./usage.ts";
55
+
56
+ // Re-export `assetsDir` (now defined in config.ts) so the existing callers
57
+ // that import it from this module — mirror-deps, mirror-routes, server,
58
+ // triggers, cli — keep working unchanged.
59
+ export { assetsDir };
49
60
 
50
61
  // ---------------------------------------------------------------------------
51
62
  // Helpers
@@ -71,6 +82,22 @@ function parseQuery(url: URL, key: string): string | null {
71
82
  return url.searchParams.get(key);
72
83
  }
73
84
 
85
+ /**
86
+ * Parse `link_count_direction` (vault feedback #4). Defaults to "both";
87
+ * anything other than the three known values falls back to "both" so a
88
+ * typo silently degrades to the documented default rather than erroring.
89
+ *
90
+ * Tag-scope note (symmetric with the MCP param description): `linkCount`
91
+ * is a raw degree that MAY include edges to notes a tag-scoped token can't
92
+ * see — the tag-scope filter runs post-query, over the result notes, not
93
+ * their neighbors. Only the number leaks, not the neighbor.
94
+ */
95
+ function parseLinkCountDirection(url: URL): "both" | "outbound" | "inbound" {
96
+ const v = url.searchParams.get("link_count_direction");
97
+ if (v === "outbound" || v === "inbound") return v;
98
+ return "both";
99
+ }
100
+
74
101
  function parseQueryList(url: URL, key: string): string[] | undefined {
75
102
  const val = url.searchParams.get(key);
76
103
  return val ? val.split(",") : undefined;
@@ -353,6 +380,180 @@ function parseMetaBrackets(url: URL): {
353
380
  return result;
354
381
  }
355
382
 
383
+ /**
384
+ * Parse the `?metadata=<json>` alias on GET /api/notes — the JSON-object form
385
+ * of the metadata filter, symmetric with the nested `metadata` object MCP
386
+ * forwards verbatim (`core/src/mcp.ts`). The value is a JSON object of the form
387
+ * `{"field":{"op":value}}` (operator query) or `{"field":value}` (shorthand
388
+ * equality via the engine's json_extract fallback).
389
+ *
390
+ * This exists because the bracket grammar (`?meta[field][op]=value`) couldn't
391
+ * see a `metadata=` param at all — it was silently dropped, and the query
392
+ * returned ALL tag-matching notes (a silent wrong result, not an error).
393
+ *
394
+ * We do NOT validate operators here — the parsed object lowers straight into
395
+ * `queryNotes`, where `validateOperatorObject` raises a loud 400 on unknown
396
+ * operators (caught by the QueryError handler in handleNotes). We only enforce
397
+ * that the param parses and is a non-null, non-array plain object; anything
398
+ * else is a malformed filter the engine can't consume.
399
+ *
400
+ * An empty object (`metadata={}`) carries no filter intent, so it's treated as
401
+ * absent: it sets no metadata filter AND doesn't trip the both-forms guard, so
402
+ * it composes harmlessly with bracket params.
403
+ *
404
+ * Returns `{ metadata?, error? }`. When `error` is set the caller returns it
405
+ * directly (already shaped as a 400 with `error` + `code`).
406
+ */
407
+ function parseMetadataJsonAlias(url: URL): {
408
+ metadata?: Record<string, unknown>;
409
+ error?: Response;
410
+ } {
411
+ const raw = parseQuery(url, "metadata");
412
+ if (raw === null) return {};
413
+
414
+ const malformed = (detail: string): Response =>
415
+ json(
416
+ {
417
+ error: `metadata query param must be a JSON object of the form {"field":{"op":value}} — ${detail}`,
418
+ code: "INVALID_QUERY",
419
+ },
420
+ 400,
421
+ );
422
+
423
+ let parsed: unknown;
424
+ try {
425
+ parsed = JSON.parse(raw);
426
+ } catch (e) {
427
+ return { error: malformed(`failed to parse: ${e instanceof Error ? e.message : String(e)}`) };
428
+ }
429
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
430
+ const got = Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed;
431
+ return { error: malformed(`got ${got}`) };
432
+ }
433
+ // Empty object → no filter intent. Return absent so it neither sets a
434
+ // metadata filter nor trips the both-forms guard in the handler.
435
+ if (Object.keys(parsed).length === 0) return {};
436
+ return { metadata: parsed as Record<string, unknown> };
437
+ }
438
+
439
+ /**
440
+ * Parse + validate the `?expand=` tag-expansion axis (vault tag `expand` axis).
441
+ * Shared by `parseNotesQueryOpts` (structured + subscribe) AND the full-text
442
+ * search branch of `handleNotes` (which bypasses `parseNotesQueryOpts`), so the
443
+ * enum lives in exactly one place and `GET /notes?search=...&expand=bogus` is
444
+ * validated identically to the structured path.
445
+ *
446
+ * Returns `{ expand }` (undefined when absent/empty → store defaults to
447
+ * "subtypes") or `{ error }` (a 400 Response) on an unknown value.
448
+ */
449
+ export function parseExpandParam(url: URL): { expand?: TagExpandMode; error?: Response } {
450
+ const expandParam = parseQuery(url, "expand");
451
+ if (expandParam === null || expandParam === "") return {};
452
+ if (!(TAG_EXPAND_MODES as readonly string[]).includes(expandParam)) {
453
+ return {
454
+ error: json(
455
+ {
456
+ error: `invalid \`expand\` value "${expandParam}" — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes": parent_names descendants).`,
457
+ code: "INVALID_QUERY",
458
+ },
459
+ 400,
460
+ ),
461
+ };
462
+ }
463
+ return { expand: expandParam as TagExpandMode };
464
+ }
465
+
466
+ /**
467
+ * Parse the shared notes-query parameters (tags / excludeTags / path /
468
+ * pathPrefix / extension / metadata filters / date filters / sort / paging /
469
+ * cursor) into a `QueryOpts`, plus flags for the query shapes that the live
470
+ * subscription endpoint must reject (`search`, `near`, `cursor`).
471
+ *
472
+ * Factored out of `handleNotesInner`'s structured-query branch so the
473
+ * `/subscribe` route evaluates the SAME predicate the snapshot query does —
474
+ * predicate parity by construction, not copy-paste. `handleNotesInner` keeps
475
+ * its own inline parsing for the single-note (`id`) and full-text (`search`)
476
+ * branches; this helper covers the structured-query shape both endpoints share.
477
+ *
478
+ * Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
479
+ * as the inline code did. `hasSearch` is surfaced from the raw `search` param
480
+ * (this helper does not itself build a search query — the caller routes that).
481
+ */
482
+ export function parseNotesQueryOpts(url: URL): {
483
+ queryOpts?: QueryOpts;
484
+ hasSearch: boolean;
485
+ hasNear: boolean;
486
+ hasCursor: boolean;
487
+ error?: Response;
488
+ } {
489
+ const hasSearch = parseQuery(url, "search") !== null && parseQuery(url, "search") !== "";
490
+ const hasNear = parseQuery(url, "near[note_id]") !== null;
491
+ const cursorParam = parseQuery(url, "cursor");
492
+ const hasCursor = cursorParam !== null && cursorParam !== "";
493
+
494
+ const tags = parseQueryList(url, "tag");
495
+ const bracket = parseMetaBrackets(url);
496
+ if (bracket.error) return { hasSearch, hasNear, hasCursor, error: bracket.error };
497
+ const metadataAlias = parseMetadataJsonAlias(url);
498
+ if (metadataAlias.error) return { hasSearch, hasNear, hasCursor, error: metadataAlias.error };
499
+ if (metadataAlias.metadata && bracket.metadata) {
500
+ return {
501
+ hasSearch,
502
+ hasNear,
503
+ hasCursor,
504
+ error: json(
505
+ {
506
+ error: "pass metadata filters as either the JSON `metadata=` param or bracket `meta[field][op]=` form, not both.",
507
+ code: "INVALID_QUERY",
508
+ },
509
+ 400,
510
+ ),
511
+ };
512
+ }
513
+
514
+ // Tag-expansion axis (vault tag `expand` axis). Optional; absent →
515
+ // "subtypes" (resolved at the store, kept undefined here so it stays
516
+ // byte-identical to pre-axis behavior). Unknown value → 400 via the shared
517
+ // helper (same shape the search branch uses).
518
+ const expandParsed = parseExpandParam(url);
519
+ if (expandParsed.error) return { hasSearch, hasNear, hasCursor, error: expandParsed.error };
520
+ const expand = expandParsed.expand;
521
+
522
+ const queryOpts: QueryOpts = {
523
+ tags,
524
+ tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
525
+ expand,
526
+ excludeTags: parseQueryList(url, "exclude_tag"),
527
+ hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
528
+ hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
529
+ path: parseQuery(url, "path") ?? undefined,
530
+ pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
531
+ extension: parseExtensionFilter(url),
532
+ metadata: bracket.metadata ?? metadataAlias.metadata,
533
+ ...(bracket.dateFilter
534
+ ? { dateFilter: bracket.dateFilter }
535
+ : parseQuery(url, "date_field")
536
+ ? {
537
+ dateFilter: {
538
+ field: parseQuery(url, "date_field")!,
539
+ from: parseQuery(url, "date_from") ?? undefined,
540
+ to: parseQuery(url, "date_to") ?? undefined,
541
+ },
542
+ }
543
+ : {
544
+ dateFrom: parseQuery(url, "date_from") ?? undefined,
545
+ dateTo: parseQuery(url, "date_to") ?? undefined,
546
+ }),
547
+ sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
548
+ orderBy: parseQuery(url, "order_by") ?? undefined,
549
+ limit: parseInt10(parseQuery(url, "limit")) ?? 50,
550
+ offset: parseInt10(parseQuery(url, "offset")),
551
+ cursor: cursorParam ?? undefined,
552
+ };
553
+
554
+ return { queryOpts, hasSearch, hasNear, hasCursor };
555
+ }
556
+
356
557
  /**
357
558
  * Parse include_metadata query param.
358
559
  * - absent/null → undefined (all metadata, default)
@@ -373,10 +574,20 @@ function parseIncludeMetadata(url: URL): boolean | string[] | undefined {
373
574
  /**
374
575
  * Parse expand_links/expand_depth/expand_mode from query params, returning
375
576
  * an (ExpandContext, depth) pair if expansion is requested, else null.
577
+ *
578
+ * `tagScope` (security review): when the caller is tag-scoped, an `isVisible`
579
+ * predicate is built from the SAME tag-scope allowlist the rest of the
580
+ * handler uses and injected into the expand context. The expander then
581
+ * leaves any `[[wikilink]]` whose target is out of scope UNRESOLVED — so
582
+ * `expand_links=true` can never inline out-of-scope note content (and the
583
+ * out-of-scope case is byte-indistinguishable from a missing target). For
584
+ * an unscoped token the predicate is `undefined` and expansion behaves
585
+ * exactly as before.
376
586
  */
377
587
  function parseExpandParams(
378
588
  url: URL,
379
589
  db: any,
590
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
380
591
  ): { ctx: ExpandContext; depth: number } | null {
381
592
  if (!parseBool(parseQuery(url, "expand_links"), false)) return null;
382
593
  const modeRaw = parseQuery(url, "expand_mode");
@@ -388,7 +599,8 @@ function parseExpandParams(
388
599
  MAX_EXPAND_DEPTH,
389
600
  ),
390
601
  );
391
- return { ctx: { db, mode, expanded: new Set() }, depth };
602
+ const isVisible = buildExpandVisibility(tagScope.allowed, tagScope.raw);
603
+ return { ctx: { db, mode, expanded: new Set(), ...(isVisible ? { isVisible } : {}) }, depth };
392
604
  }
393
605
 
394
606
 
@@ -474,7 +686,7 @@ async function handleNotesInner(
474
686
  ): Promise<Response> {
475
687
  const url = new URL(req.url);
476
688
  const method = req.method;
477
- const db = (store as any).db;
689
+ const db = store.db;
478
690
 
479
691
  // ---- Collection routes (no ID in path) ----
480
692
  if (subpath === "") {
@@ -495,18 +707,30 @@ async function handleNotesInner(
495
707
  }
496
708
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
497
709
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
498
- const expand = parseExpandParams(url, db);
710
+ const expand = parseExpandParams(url, db, tagScope);
499
711
  if (expand && includeContent && typeof result.content === "string") {
500
712
  expand.ctx.expanded.add(note.id);
501
713
  result.content = expandContent(result.content, expand.ctx, expand.depth);
502
714
  }
503
715
  result = filterMetadata(result, parseIncludeMetadata(url));
504
716
  if (parseBool(parseQuery(url, "include_links"), false)) {
505
- result.links = linkOps.getLinksHydrated(db, note.id);
717
+ // Tag-scope: drop links whose neighbor is out of scope so the
718
+ // hydrated sourceNote/targetNote summaries can't leak out-of-scope
719
+ // ids/paths/tags. No-op for unscoped tokens.
720
+ result.links = filterHydratedLinksByTagScope(
721
+ linkOps.getLinksHydrated(db, note.id),
722
+ tagScope.allowed,
723
+ tagScope.raw,
724
+ );
506
725
  }
507
726
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
508
727
  result.attachments = await store.getAttachments(note.id);
509
728
  }
729
+ // linkCount injected after filterMetadata on purpose — same as
730
+ // links/attachments above; filterMetadata only touches `metadata`.
731
+ if (parseBool(parseQuery(url, "include_link_count"), false)) {
732
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
733
+ }
510
734
  return json(result);
511
735
  }
512
736
 
@@ -529,7 +753,17 @@ async function handleNotesInner(
529
753
  if (search) {
530
754
  const searchTags = parseQueryList(url, "tag");
531
755
  const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
532
- const rawResults = await store.searchNotes(search, { tags: searchTags, limit });
756
+ // Tag-expansion axis (vault tag `expand` axis). This branch bypasses
757
+ // `parseNotesQueryOpts`, so validate `?expand=` here too — otherwise
758
+ // `GET /notes?search=x&expand=bogus` would silently ignore the bad
759
+ // value. The validated mode is threaded into the search tag-narrowing.
760
+ const tagExpand = parseExpandParam(url);
761
+ if (tagExpand.error) return tagExpand.error;
762
+ const rawResults = await store.searchNotes(search, {
763
+ tags: searchTags,
764
+ limit,
765
+ expand: tagExpand.expand,
766
+ });
533
767
  // Tag-scope: drop any result the token isn't permitted to see. Filter
534
768
  // happens after the store query so an empty post-filter list still
535
769
  // returns 200 [] (consistent with "no matches"), not 403.
@@ -537,7 +771,7 @@ async function handleNotesInner(
537
771
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
538
772
  const inclMeta = parseIncludeMetadata(url);
539
773
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
540
- const expand = parseExpandParams(url, db);
774
+ const expand = parseExpandParams(url, db, tagScope);
541
775
  if (expand && includeContent) {
542
776
  for (const n of output) expand.ctx.expanded.add(n.id);
543
777
  for (const n of output) {
@@ -549,6 +783,18 @@ async function handleNotesInner(
549
783
  if (inclMeta !== undefined && inclMeta !== true) {
550
784
  output = output.map((n: any) => filterMetadata(n, inclMeta));
551
785
  }
786
+ // Opt-in link degree (vault feedback #4) — one batch count, same as
787
+ // the structured-query path. Runs AFTER filterMetadata on purpose
788
+ // (filterMetadata only touches `metadata`, so linkCount survives —
789
+ // don't casually swap the order).
790
+ if (parseBool(parseQuery(url, "include_link_count"), false)) {
791
+ const counts = linkOps.getLinkCounts(
792
+ db,
793
+ output.map((n: any) => n.id),
794
+ parseLinkCountDirection(url),
795
+ );
796
+ for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
797
+ }
552
798
  return json(output);
553
799
  }
554
800
 
@@ -564,7 +810,7 @@ async function handleNotesInner(
564
810
  //
565
811
  // - **Flat date params** (DEPRECATED): `?date_field=created_at&
566
812
  // date_from=…&date_to=…` and the legacy `?date_from=…&date_to=…`.
567
- // Still functional through 0.5.x; planned removal in 0.6.0
813
+ // Still functional through 0.5.x; planned removal in a later 0.x
568
814
  // (vault#288). New consumers should use bracket-style.
569
815
  //
570
816
  // Precedence on overlap: bracket-style wins. If a caller passes both
@@ -577,15 +823,16 @@ async function handleNotesInner(
577
823
  // Surface asymmetry: REST flattens to a query string; MCP takes a
578
824
  // nested `date_filter: { field, from, to }` object directly. Both
579
825
  // lower to the same store-level `dateFilter` shape.
580
- const tags = parseQueryList(url, "tag");
581
- const bracket = parseMetaBrackets(url);
582
- if (bracket.error) return bracket.error;
583
- // Opaque cursor for "since last checked" agent loops (vault#313).
584
- // When present, switches the response shape to {notes, next_cursor}
585
- // and routes through queryNotesPaged for keyset pagination. Mutually
586
- // exclusive with the `near` graph-neighborhood scope (rebuilding the
587
- // neighborhood per page isn't stable) — rejected below.
826
+ // Structured-query parsing is shared with the live `/subscribe` route
827
+ // (see `parseNotesQueryOpts`) so both endpoints lower an identical query
828
+ // string to the same `QueryOpts` — predicate parity by construction.
829
+ const parsed = parseNotesQueryOpts(url);
830
+ if (parsed.error) return parsed.error;
831
+ const queryOpts = parsed.queryOpts!;
588
832
  const cursorParam = parseQuery(url, "cursor");
833
+ // Opaque cursor for "since last checked" agent loops (vault#313) is
834
+ // mutually exclusive with the `near` graph-neighborhood scope (rebuilding
835
+ // the neighborhood per page isn't stable).
589
836
  const nearNoteIdEarly = parseQuery(url, "near[note_id]");
590
837
  if (cursorParam && nearNoteIdEarly) {
591
838
  return json(
@@ -598,48 +845,6 @@ async function handleNotesInner(
598
845
  }
599
846
  let results: Note[];
600
847
  let nextCursor: string | null = null;
601
- const queryOpts = {
602
- tags,
603
- tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
604
- excludeTags: parseQueryList(url, "exclude_tag"),
605
- hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
606
- hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
607
- path: parseQuery(url, "path") ?? undefined,
608
- pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
609
- // Extension filter (vault#328). Accepts repeated `extension=`
610
- // params for the array form: `?extension=csv&extension=yaml`.
611
- // `parseQueryList` already returns undefined when no params
612
- // are present, so the filter is silently skipped on a plain
613
- // GET without the extension query.
614
- extension: parseExtensionFilter(url),
615
- metadata: bracket.metadata,
616
- // Date-range precedence chain (highest to lowest):
617
- // 1. Bracket-style `meta[created_at][gte]=…` (canonical).
618
- // 2. Flat `date_field=…&date_from=…&date_to=…` (deprecated).
619
- // 3. Legacy `date_from=…&date_to=…` (no date_field, deprecated)
620
- // — filters on `n.created_at` by definition.
621
- // The engine rejects combinations of `dateFilter` with the legacy
622
- // `dateFrom`/`dateTo`, so we never set both shapes simultaneously.
623
- ...(bracket.dateFilter
624
- ? { dateFilter: bracket.dateFilter }
625
- : parseQuery(url, "date_field")
626
- ? {
627
- dateFilter: {
628
- field: parseQuery(url, "date_field")!,
629
- from: parseQuery(url, "date_from") ?? undefined,
630
- to: parseQuery(url, "date_to") ?? undefined,
631
- },
632
- }
633
- : {
634
- dateFrom: parseQuery(url, "date_from") ?? undefined,
635
- dateTo: parseQuery(url, "date_to") ?? undefined,
636
- }),
637
- sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
638
- orderBy: parseQuery(url, "order_by") ?? undefined,
639
- limit: parseInt10(parseQuery(url, "limit")) ?? 50,
640
- offset: parseInt10(parseQuery(url, "offset")),
641
- cursor: cursorParam ?? undefined,
642
- };
643
848
  try {
644
849
  if (cursorParam) {
645
850
  const page = await store.queryNotesPaged(queryOpts);
@@ -677,7 +882,24 @@ async function handleNotesInner(
677
882
  }
678
883
  const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
679
884
  const relationship = parseQuery(url, "near[relationship]") ?? undefined;
680
- const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship });
885
+ // Tag-scope policy for `near[]` (vault#439 hop-guard, symmetric with
886
+ // find-path): for a tag-scoped token the BFS refuses to traverse
887
+ // THROUGH out-of-scope notes — scope is a wall, not a sieve. So a token
888
+ // scoped to ["work"] can't reach an in-scope note at depth 2 via a
889
+ // #personal intermediary at depth 1; that note is simply unreachable.
890
+ // The `filterNotesByTagScope` pass below still runs (defense in depth),
891
+ // but the wall makes it redundant for the `near[]` result set.
892
+ // Unscoped tokens (`tagScope.raw === null`) install no predicate → the
893
+ // FULL graph is walked exactly as before, behavior unchanged.
894
+ const isTraversable = tagScope.raw
895
+ ? (id: string) =>
896
+ noteWithinTagScope(
897
+ { id, tags: getNoteTags(db, id) } as Note,
898
+ tagScope.allowed,
899
+ tagScope.raw,
900
+ )
901
+ : undefined;
902
+ const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship, isTraversable });
681
903
  const nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
682
904
  results = results.filter((n) => nearScope.has(n.id));
683
905
  }
@@ -690,9 +912,10 @@ async function handleNotesInner(
690
912
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
691
913
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
692
914
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
915
+ const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
693
916
  const inclMeta = parseIncludeMetadata(url);
694
917
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
695
- const expand = parseExpandParams(url, db);
918
+ const expand = parseExpandParams(url, db, tagScope);
696
919
  if (expand && includeContent) {
697
920
  for (const n of output) expand.ctx.expanded.add(n.id);
698
921
  for (const n of output) {
@@ -704,6 +927,24 @@ async function handleNotesInner(
704
927
  if (inclMeta !== undefined && inclMeta !== true) {
705
928
  output = output.map((n: any) => filterMetadata(n, inclMeta));
706
929
  }
930
+ // Opt-in link degree (vault feedback #4). ONE batch count over all
931
+ // result ids — NOT a per-note query — so the field stays O(2 index
932
+ // scans) per request regardless of page size. Mutates `output` in
933
+ // place; injected on the same objects the enrichment loop below
934
+ // touches, the same way `links`/`attachments` are surfaced.
935
+ // Ordering: this runs AFTER the filterMetadata pass above on purpose —
936
+ // filterMetadata only touches the `metadata` key, so a linkCount
937
+ // injected here survives. Don't casually swap the order; injecting
938
+ // before filterMetadata would still survive today but couples the two
939
+ // to filterMetadata's current narrow behavior.
940
+ if (includeLinkCount) {
941
+ const counts = linkOps.getLinkCounts(
942
+ db,
943
+ output.map((n: any) => n.id),
944
+ parseLinkCountDirection(url),
945
+ );
946
+ for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
947
+ }
707
948
 
708
949
  // Graph format — reshape into { nodes, edges }
709
950
  if (parseQuery(url, "format") === "graph") {
@@ -727,7 +968,14 @@ async function handleNotesInner(
727
968
  const enrichedOut: any[] = [];
728
969
  for (const n of output) {
729
970
  const enriched: any = { ...n };
730
- if (includeLinks) enriched.links = linkOps.getLinksHydrated(db, n.id);
971
+ if (includeLinks) {
972
+ // Tag-scope: strip out-of-scope-neighbor links (no-op unscoped).
973
+ enriched.links = filterHydratedLinksByTagScope(
974
+ linkOps.getLinksHydrated(db, n.id),
975
+ tagScope.allowed,
976
+ tagScope.raw,
977
+ );
978
+ }
731
979
  if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
732
980
  enrichedOut.push(enriched);
733
981
  }
@@ -830,19 +1078,33 @@ async function handleNotesInner(
830
1078
  throw e;
831
1079
  }
832
1080
 
833
- // Apply tag schema defaults
1081
+ // Apply tag schema defaults, then re-read the notes whose metadata was
1082
+ // actually default-filled so the response reflects the final on-disk
1083
+ // state (the `created` entries were read before `applySchemaDefaults`
1084
+ // ran). Mirrors the MCP create-note path (vault#316). Batched re-read
1085
+ // (`getNotes` = one `WHERE id IN (...)`), skipped when no defaults
1086
+ // applied so the common no-defaults path adds zero extra reads.
1087
+ const mutatedIds = new Set<string>();
834
1088
  for (const note of created) {
835
1089
  if (note.tags?.length) {
836
- await applySchemaDefaults(store, db, [note.id], note.tags);
1090
+ for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
1091
+ mutatedIds.add(id);
1092
+ }
837
1093
  }
838
1094
  }
1095
+ const refreshed =
1096
+ mutatedIds.size === 0
1097
+ ? created
1098
+ : (() => {
1099
+ const byId = new Map(getNotes(db, [...mutatedIds]).map((n) => [n.id, n]));
1100
+ return created.map((n) => byId.get(n.id) ?? n);
1101
+ })();
839
1102
 
840
1103
  // Attach `validation_status` so HTTP create-note matches the MCP
841
- // surface (vault#287). Mirrors the MCP create-note attach site at
842
- // `core/src/mcp.ts:451`. `attachValidationStatus` returns the note
1104
+ // surface (vault#287). `attachValidationStatus` returns the note
843
1105
  // unchanged when no tag declares fields, so vaults without any tag
844
1106
  // schemas see no behavior change.
845
- const final = created.map((n) => attachValidationStatus(store, db, n));
1107
+ const final = refreshed.map((n) => attachValidationStatus(store, db, n));
846
1108
  return json(body.notes ? final : final[0], 201);
847
1109
  }
848
1110
 
@@ -882,7 +1144,14 @@ async function handleNotesInner(
882
1144
  // Explicit `transcribe: true` wins — if the caller asked, we honor that
883
1145
  // regardless of the auto-transcribe toggle (back-compat).
884
1146
  const explicitOptIn = body.transcribe === true;
885
- const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType);
1147
+ // Per-vault auto-transcribe: read THIS vault's `auto_transcribe.enabled`
1148
+ // (vault.yaml) and pass it as the precedence-winning toggle. A vault that
1149
+ // set its own value uses it; one that left it unset falls through to the
1150
+ // global toggle inside `shouldAutoTranscribe` (per-vault → global → true).
1151
+ const perVaultEnabled = vault
1152
+ ? readVaultConfig(vault)?.auto_transcribe?.enabled
1153
+ : undefined;
1154
+ const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
886
1155
  const attMeta = (explicitOptIn || autoOptIn)
887
1156
  ? {
888
1157
  transcribe_status: "pending" as const,
@@ -942,22 +1211,26 @@ async function handleNotesInner(
942
1211
  return json({ error: "Method not allowed" }, 405);
943
1212
  }
944
1213
 
945
- // POST /notes/:idOrPath/retry-transcription — vault#353 design Q5.
1214
+ // POST /notes/:idOrPath/retry-transcription — vault#353 design Q5 + finding F.
946
1215
  //
947
- // Re-runs the auto-transcribe pipeline against the original audio
948
- // attachment recorded in the transcript note's `transcript_attachment_id`
949
- // frontmatter. Only valid on transcript notes (the target idOrPath must
950
- // be a transcript note with `transcript_status: "failed"`); calling on
951
- // anything else returns 400 with a clear reason.
1216
+ // Re-runs transcription against a failed audio attachment. Two target
1217
+ // shapes, dispatched in handleRetryTranscription by whether the note carries
1218
+ // `transcript_status` frontmatter:
1219
+ // - Auto-flow (vault#353): target is a `<audio>.transcript.md` note with
1220
+ // `transcript_status: "failed"`; audio located via
1221
+ // `transcript_attachment_id`, origin preserved as "auto".
1222
+ // - Legacy in-body memo (finding F): target is the memo note itself (no
1223
+ // `transcript_status`); finds its own failed attachment, resets it
1224
+ // preserving `transcribe_origin: "legacy"`, and re-arms `transcribe_stub`.
952
1225
  //
953
1226
  // Wire shape:
954
1227
  // POST .../notes/<idOrPath>/retry-transcription
955
- // → 202 { attachment_id, transcript_path } when re-enqueued
956
- // 400 invalid_target (not a transcript note)
957
- // 400 not_failed (transcript already succeeded; nothing to retry)
958
- // 404 attachment_missing (transcript_attachment_id row deleted)
1228
+ // → 202 { attachment_id, attachment_path, transcript_note_id, worker }
1229
+ // 400 not_failed (auto-flow: transcript already succeeded)
1230
+ // 400 missing_attachment_id (auto-flow: transcript_attachment_id absent)
1231
+ // 400 no_failed_attachment (legacy: no failed audio attachment to retry)
1232
+ // 404 attachment_missing (auto-flow: transcript_attachment_id row deleted)
959
1233
  // 404 audio_missing (audio file unlinked from disk)
960
- // 503 scribe_unavailable (no worker configured this boot)
961
1234
  if (sub === "/retry-transcription") {
962
1235
  if (method !== "POST") return json({ error: "Method not allowed" }, 405);
963
1236
  if (!vault) return json({ error: "Vault context required" }, 400);
@@ -980,18 +1253,26 @@ async function handleNotesInner(
980
1253
  }
981
1254
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
982
1255
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
983
- const expand = parseExpandParams(url, db);
1256
+ const expand = parseExpandParams(url, db, tagScope);
984
1257
  if (expand && includeContent && typeof result.content === "string") {
985
1258
  expand.ctx.expanded.add(note.id);
986
1259
  result.content = expandContent(result.content, expand.ctx, expand.depth);
987
1260
  }
988
1261
  result = filterMetadata(result, parseIncludeMetadata(url));
989
1262
  if (parseBool(parseQuery(url, "include_links"), false)) {
990
- result.links = linkOps.getLinksHydrated(db, note.id);
1263
+ // Tag-scope: drop out-of-scope-neighbor links (no-op unscoped).
1264
+ result.links = filterHydratedLinksByTagScope(
1265
+ linkOps.getLinksHydrated(db, note.id),
1266
+ tagScope.allowed,
1267
+ tagScope.raw,
1268
+ );
991
1269
  }
992
1270
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
993
1271
  result.attachments = await store.getAttachments(note.id);
994
1272
  }
1273
+ if (parseBool(parseQuery(url, "include_link_count"), false)) {
1274
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
1275
+ }
995
1276
  return json(result);
996
1277
  }
997
1278
 
@@ -1256,7 +1537,27 @@ async function handleNotesInner(
1256
1537
  // `toNoteIndex` drops unknown fields).
1257
1538
  const updatedNote = await store.getNote(note.id);
1258
1539
  if (updatedNote === null) return json({ error: "Note disappeared" }, 404);
1259
- const validated = attachValidationStatus(store, db, updatedNote);
1540
+ const validated: any = attachValidationStatus(store, db, updatedNote);
1541
+ // Echo hydrated links when a link mutation was part of this request,
1542
+ // OR the caller explicitly asked for them via `?include_links=true`
1543
+ // (vault feedback #8). Previously the update response omitted links
1544
+ // entirely (`getNote` populates tags but not links), forcing callers
1545
+ // to re-GET with `?include_links=true` just to confirm a link they
1546
+ // had just added/removed. Additive field, scoped to UPDATE: present
1547
+ // only when mutated or requested. Mirrors the GET / query-notes
1548
+ // hydration call form exactly (`linkOps.getLinksHydrated`).
1549
+ const linkMutated = body.links?.add !== undefined || body.links?.remove !== undefined;
1550
+ const includeLinksResp = linkMutated || parseBool(parseQuery(url, "include_links"), false);
1551
+ if (includeLinksResp) {
1552
+ // Tag-scope: strip out-of-scope-neighbor links from the echoed set
1553
+ // (no-op unscoped). A write token tag-scoped to #work mustn't learn
1554
+ // about a #personal note it happened to link to.
1555
+ validated.links = filterHydratedLinksByTagScope(
1556
+ linkOps.getLinksHydrated(db, updatedNote.id),
1557
+ tagScope.allowed,
1558
+ tagScope.raw,
1559
+ );
1560
+ }
1260
1561
  const includeContentResp = body.include_content !== false;
1261
1562
  // `created: false` is appended to every update-path response so
1262
1563
  // sync-loop callers using `if_missing: "create"` can distinguish
@@ -1266,6 +1567,9 @@ async function handleNotesInner(
1266
1567
  const lean: any = toNoteIndex(validated);
1267
1568
  const vs = (validated as any).validation_status;
1268
1569
  if (vs !== undefined) lean.validation_status = vs;
1570
+ // Carry the link echo across the lean conversion — `toNoteIndex`
1571
+ // drops unknown fields, same as the `validation_status` recipe above.
1572
+ if (validated.links !== undefined) lean.links = validated.links;
1269
1573
  lean.created = false;
1270
1574
  return json(lean);
1271
1575
  } catch (e: any) {
@@ -1418,7 +1722,7 @@ export async function handleTags(
1418
1722
  // that token's allowlist. Aggregate matches across sources for a single
1419
1723
  // 409 envelope.
1420
1724
  const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
1421
- const db = (store as any).db;
1725
+ const db = store.db;
1422
1726
  for (const src of sources) {
1423
1727
  const tokens = findTokensReferencingTag(db, src as string);
1424
1728
  if (tokens.length > 0) referenced.push({ source: src as string, tokens });
@@ -1513,10 +1817,12 @@ export async function handleTags(
1513
1817
  parent_names?: unknown;
1514
1818
  };
1515
1819
 
1516
- // Validate relationships shape + cardinality vocabulary up front so
1517
- // a bad payload returns 400, not a thrown 500.
1820
+ // Validate the relationships payload up front so a bad payload returns
1821
+ // 400, not a thrown 500. `relationships` is an opaque vocabulary map
1822
+ // (relationship-name → arbitrary JSON the app interprets); we only check
1823
+ // that it's a JSON object (a map), then persist verbatim.
1518
1824
  let relationshipsPatch:
1519
- | Record<string, tagSchemaOps.TagRelationship>
1825
+ | tagSchemaOps.TagRelationshipMap
1520
1826
  | null
1521
1827
  | undefined;
1522
1828
  if (body.relationships === null) {
@@ -1580,7 +1886,7 @@ export async function handleTags(
1580
1886
  // tag would silently orphan the token's allowlist. Fail closed (409)
1581
1887
  // and name the offending tokens so the operator can revoke or re-mint
1582
1888
  // before retrying. patterns/tag-scoped-tokens.md §Dependencies.
1583
- const referenced_by = findTokensReferencingTag((store as any).db, tagName);
1889
+ const referenced_by = findTokensReferencingTag(store.db, tagName);
1584
1890
  if (referenced_by.length > 0) {
1585
1891
  return json(
1586
1892
  {
@@ -1615,7 +1921,7 @@ export async function handleFindPath(
1615
1921
  const target = parseQuery(url, "target");
1616
1922
  if (!source || !target) return json({ error: "source and target parameters are required" }, 400);
1617
1923
 
1618
- const db = (store as any).db;
1924
+ const db = store.db;
1619
1925
  try {
1620
1926
  const sourceNote = await resolveNote(store, source);
1621
1927
  if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
@@ -1661,16 +1967,36 @@ type VaultConfigLike = {
1661
1967
  name: string;
1662
1968
  description?: string;
1663
1969
  audio_retention?: "keep" | "until_transcribed" | "never";
1970
+ auto_transcribe?: { enabled?: boolean };
1664
1971
  };
1665
1972
 
1666
1973
  const VALID_AUDIO_RETENTION = ["keep", "until_transcribed", "never"] as const;
1667
1974
 
1975
+ /**
1976
+ * Resolve the effective auto-transcribe toggle for a vault's GET response, the
1977
+ * SAME resolution `shouldAutoTranscribe` uses at the decision point:
1978
+ * **per-vault → global → true**. A vault that set its own `auto_transcribe`
1979
+ * shows that; one that left it unset shows the server-wide default (itself
1980
+ * default-ON). This keeps the GET in lock-step with what the worker actually
1981
+ * does, with no field-name drift.
1982
+ */
1983
+ function resolveAutoTranscribeEnabled(vaultConfig: VaultConfigLike): boolean {
1984
+ return (
1985
+ vaultConfig.auto_transcribe?.enabled
1986
+ ?? readGlobalConfig().auto_transcribe?.enabled
1987
+ ?? true
1988
+ );
1989
+ }
1990
+
1668
1991
  function vaultResponse(vaultConfig: VaultConfigLike): Record<string, unknown> {
1669
1992
  return {
1670
1993
  name: vaultConfig.name,
1671
1994
  description: vaultConfig.description ?? null,
1672
1995
  config: {
1673
1996
  audio_retention: vaultConfig.audio_retention ?? "keep",
1997
+ auto_transcribe: {
1998
+ enabled: resolveAutoTranscribeEnabled(vaultConfig),
1999
+ },
1674
2000
  },
1675
2001
  };
1676
2002
  }
@@ -1694,7 +2020,7 @@ export async function handleVault(
1694
2020
  if (req.method === "PATCH") {
1695
2021
  const body = await req.json() as {
1696
2022
  description?: string;
1697
- config?: { audio_retention?: string };
2023
+ config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
1698
2024
  };
1699
2025
  let dirty = false;
1700
2026
 
@@ -1718,6 +2044,28 @@ export async function handleVault(
1718
2044
  dirty = true;
1719
2045
  }
1720
2046
 
2047
+ // auto_transcribe.enabled — PER-VAULT toggle persisted to THIS vault's
2048
+ // vault.yaml (via `persist`, the same writeVaultConfig path as
2049
+ // description/audio_retention). Flipping it for vault X affects only X;
2050
+ // scribe's "link to vault X" PATCHes this and never touches other vaults.
2051
+ // The worker reads the same per-vault field (per-vault → global → true).
2052
+ // Validate the shape: when `auto_transcribe` is present it must carry a
2053
+ // boolean `enabled`.
2054
+ if (body.config?.auto_transcribe !== undefined) {
2055
+ const enabled = body.config.auto_transcribe?.enabled;
2056
+ if (typeof enabled !== "boolean") {
2057
+ return json(
2058
+ {
2059
+ error: "invalid_auto_transcribe",
2060
+ message: "auto_transcribe.enabled must be a boolean",
2061
+ },
2062
+ 400,
2063
+ );
2064
+ }
2065
+ vaultConfig.auto_transcribe = { ...vaultConfig.auto_transcribe, enabled };
2066
+ dirty = true;
2067
+ }
2068
+
1721
2069
  if (dirty && persist) persist();
1722
2070
  return json(vaultResponse(vaultConfig));
1723
2071
  }
@@ -1729,12 +2077,32 @@ export async function handleVault(
1729
2077
  // Unresolved wikilinks — REST-only (admin/maintenance)
1730
2078
  // ---------------------------------------------------------------------------
1731
2079
 
1732
- export function handleUnresolvedWikilinks(req: Request, store: Store): Response {
2080
+ export function handleUnresolvedWikilinks(
2081
+ req: Request,
2082
+ store: Store,
2083
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
2084
+ ): Response {
1733
2085
  const url = new URL(req.url);
1734
2086
  const limitStr = url.searchParams.get("limit");
1735
2087
  const limit = limitStr ? parseInt(limitStr, 10) : 50;
1736
- const db = (store as any).db;
1737
- return Response.json(listUnresolvedWikilinks(db, limit));
2088
+ const db = store.db;
2089
+ const result = listUnresolvedWikilinks(db, limit);
2090
+
2091
+ // Unscoped token → return as-is (unchanged behavior).
2092
+ if (tagScope.raw === null) return Response.json(result);
2093
+
2094
+ // Tag-scope confidentiality (security review): each unresolved row carries
2095
+ // a `source_id` (+ `source_path`) plus the raw `target_path` wikilink
2096
+ // string. For a tag-scoped token, surface ONLY rows whose SOURCE note is
2097
+ // within the token's tag scope — otherwise we'd leak out-of-scope note IDs
2098
+ // and the wikilink target strings those notes contain. Filter the page and
2099
+ // recompute `count` from the filtered set so the aggregate total of
2100
+ // out-of-scope rows doesn't leak either.
2101
+ const filtered = result.unresolved.filter((row) => {
2102
+ const note = getNote(db, row.source_id);
2103
+ return note !== null && noteWithinTagScope(note, tagScope.allowed, tagScope.raw);
2104
+ });
2105
+ return Response.json({ unresolved: filtered, count: filtered.length });
1738
2106
  }
1739
2107
 
1740
2108
  // ---------------------------------------------------------------------------
@@ -1923,21 +2291,35 @@ ${rendered}
1923
2291
  // ---------------------------------------------------------------------------
1924
2292
 
1925
2293
  /**
1926
- * Re-enqueue the original audio attachment for a `transcript_status: failed`
1927
- * transcript note. Steps:
2294
+ * Re-enqueue the original audio attachment for a failed transcription.
1928
2295
  *
1929
- * 1. Validate target is a transcript note (`transcript_status` set in
1930
- * metadata) AND that status is `failed`.
1931
- * 2. Find the original audio attachment by id from
1932
- * `transcript_attachment_id` frontmatter. 404 if the row's gone.
1933
- * 3. Validate the audio file still exists on disk (retention=keep is
1934
- * assumed by the retry contract; retention=until_transcribed unlinks
1935
- * only on success, retention=never unlinks on failure that last one
1936
- * explicitly breaks retry, by design).
1937
- * 4. Reset `transcribe_status = "pending"`, clear backoff + error fields.
1938
- * The auto-origin marker is preserved so the worker writes a transcript
1939
- * note (overwriting this one in place).
1940
- * 5. Kick the worker if registered; otherwise the sweep picks it up.
2296
+ * Two target shapes are accepted:
2297
+ *
2298
+ * - **Auto-flow (vault#353):** the target is a `<audio>.transcript.md` note
2299
+ * carrying `transcript_status` frontmatter. Requires that status be
2300
+ * `failed`; locates the audio via `transcript_attachment_id`; preserves
2301
+ * `transcribe_origin: "auto"` so a retried success overwrites this
2302
+ * transcript note in place. Behavior is byte-identical to the original
2303
+ * vault#353 contract.
2304
+ *
2305
+ * - **Legacy in-body memo (finding F):** the target is the memo note
2306
+ * itself no `transcript_status` frontmatter. The original capture body
2307
+ * holds `![[<audio>]]` + a `_Transcription unavailable._` marker (written
2308
+ * by the worker on terminal failure). We find the note's own failed audio
2309
+ * attachment, reset it to pending **preserving `transcribe_origin:
2310
+ * "legacy"`** (forcing "auto" would switch to the sibling-transcript-note
2311
+ * shape and orphan the in-body embed), and **re-stamp `transcribe_stub:
2312
+ * true`** on the note. The stub re-arm is load-bearing: the legacy success
2313
+ * path early-returns unless `transcribe_stub === true`, so without it the
2314
+ * retried success would never write the transcript back into the body.
2315
+ * On success the worker replaces the `_Transcription unavailable._` marker
2316
+ * with the transcript in place, yielding the same final shape a first-try
2317
+ * success would.
2318
+ *
2319
+ * Common steps for both: validate the audio attachment row exists (404 if
2320
+ * gone) and its file is still on disk (404 if unlinked — e.g. retention=never
2321
+ * already dropped it), reset the transcribe_status fields, then kick the
2322
+ * worker if registered (otherwise the sweep picks it up).
1941
2323
  */
1942
2324
  async function handleRetryTranscription(
1943
2325
  store: Store,
@@ -1945,15 +2327,13 @@ async function handleRetryTranscription(
1945
2327
  vault: string,
1946
2328
  ): Promise<Response> {
1947
2329
  const meta = (note.metadata as Record<string, unknown> | undefined) ?? {};
2330
+
2331
+ // Legacy in-body memo: no `transcript_status` frontmatter. The note owns
2332
+ // the failed audio attachment directly; there's no sibling transcript note.
1948
2333
  if (typeof meta.transcript_status !== "string") {
1949
- return json(
1950
- {
1951
- error: "invalid_target",
1952
- message: "Target note is not a transcript note (no transcript_status frontmatter).",
1953
- },
1954
- 400,
1955
- );
2334
+ return handleRetryLegacyInBody(store, note, vault);
1956
2335
  }
2336
+
1957
2337
  if (meta.transcript_status !== "failed") {
1958
2338
  return json(
1959
2339
  {
@@ -2040,13 +2420,171 @@ async function handleRetryTranscription(
2040
2420
  );
2041
2421
  }
2042
2422
 
2423
+ /**
2424
+ * Retry path for a legacy in-body voice memo (finding F). The target note is
2425
+ * the memo itself (no `transcript_status` frontmatter); it directly owns the
2426
+ * audio attachment whose transcription failed.
2427
+ *
2428
+ * Steps:
2429
+ * 1. Find the note's own audio attachment with `transcribe_status ===
2430
+ * "failed"`. 400 `no_failed_attachment` if none — there's nothing to
2431
+ * retry.
2432
+ * 2. Validate the audio file is still on disk (404 `audio_missing`).
2433
+ * 3. Reset the attachment to pending, **preserving `transcribe_origin:
2434
+ * "legacy"`** (never force "auto" — that switches to the sibling-
2435
+ * transcript-note shape and orphans the in-body `![[memo]]` embed).
2436
+ * 4. **Re-stamp `transcribe_stub: true`** on the note. The legacy worker
2437
+ * success path early-returns unless the note carries this flag (it was
2438
+ * cleared when the failure marker was written), so re-arming it is what
2439
+ * lets the retried success replace the `_Transcription unavailable._`
2440
+ * marker with the transcript.
2441
+ * 5. Kick the worker if registered; otherwise the sweep picks it up.
2442
+ */
2443
+ async function handleRetryLegacyInBody(
2444
+ store: Store,
2445
+ note: Note,
2446
+ vault: string,
2447
+ ): Promise<Response> {
2448
+ const attachments = await store.getAttachments(note.id);
2449
+ const failed = attachments.find((a) => {
2450
+ const m = (a.metadata as Record<string, unknown> | undefined) ?? {};
2451
+ return m.transcribe_status === "failed";
2452
+ });
2453
+ if (!failed) {
2454
+ return json(
2455
+ {
2456
+ error: "no_failed_attachment",
2457
+ message:
2458
+ "Target note is not a transcript note and has no audio attachment with a failed transcription to retry.",
2459
+ },
2460
+ 400,
2461
+ );
2462
+ }
2463
+
2464
+ // Audio file existence + safety: defense-in-depth against a bad attachment
2465
+ // row pointing outside the vault assets dir. Same guard as the worker.
2466
+ const assetsRoot = assetsDir(vault);
2467
+ const audioFilePath = normalize(join(assetsRoot, failed.path));
2468
+ if (!audioFilePath.startsWith(normalize(assetsRoot)) || !existsSync(audioFilePath)) {
2469
+ return json(
2470
+ {
2471
+ error: "audio_missing",
2472
+ message: `Original audio file at "${failed.path}" no longer exists on disk.`,
2473
+ },
2474
+ 404,
2475
+ );
2476
+ }
2477
+
2478
+ // Reset the attachment back to pending. Preserve `transcribe_origin:
2479
+ // "legacy"` (a default of `undefined` is also legacy, but make it explicit
2480
+ // so a retried row reads unambiguously) — forcing "auto" here would make
2481
+ // the worker materialize a sibling transcript note instead of patching the
2482
+ // in-body embed, orphaning the memo.
2483
+ const attMeta = { ...(failed.metadata ?? {}) } as Record<string, unknown>;
2484
+ attMeta.transcribe_status = "pending";
2485
+ attMeta.transcribe_requested_at = new Date().toISOString();
2486
+ attMeta.transcribe_origin = "legacy";
2487
+ delete attMeta.transcribe_backoff_until;
2488
+ delete attMeta.transcribe_error;
2489
+ delete attMeta.transcribe_error_code;
2490
+ delete attMeta.transcribe_attempts;
2491
+ await store.setAttachmentMetadata(failed.id, attMeta);
2492
+
2493
+ // Re-arm the stub on the note. The worker's legacy success path gates on
2494
+ // `transcribe_stub === true` and CLEARED it when it wrote the failure
2495
+ // marker; without re-stamping it the retried success early-returns and
2496
+ // never writes the transcript back into the body. Use skipUpdatedAt so the
2497
+ // note's modification time still reflects user intent, matching the
2498
+ // worker's own note writes.
2499
+ //
2500
+ // OC-guarded (vault#435): this read-transform-write would otherwise clobber
2501
+ // a user edit landing between `resolveNote` (above) and this write. Thread
2502
+ // `if_updated_at` and retry once on conflict — re-read, re-apply the
2503
+ // metadata-only re-stamp against the fresh note, write with the fresh
2504
+ // precondition. A second conflict surfaces as 409 so the user can retry.
2505
+ // Only the `transcribe_stub` flag is stamped (never content), so re-applying
2506
+ // against the fresh note is always the correct surgical transform.
2507
+ const restampStub = (current: Note): Record<string, unknown> => ({
2508
+ ...((current.metadata as Record<string, unknown> | undefined) ?? {}),
2509
+ transcribe_stub: true,
2510
+ });
2511
+ try {
2512
+ try {
2513
+ await store.updateNote(note.id, {
2514
+ metadata: restampStub(note),
2515
+ skipUpdatedAt: true,
2516
+ if_updated_at: note.updatedAt,
2517
+ });
2518
+ } catch (err: any) {
2519
+ if (!err || err.code !== "CONFLICT") throw err;
2520
+ // Conflict — re-read, re-apply the stub re-stamp, retry once.
2521
+ const fresh = await store.getNote(note.id);
2522
+ if (!fresh) {
2523
+ return json(
2524
+ { error: "note_missing", message: "Target note disappeared during retry." },
2525
+ 404,
2526
+ );
2527
+ }
2528
+ await store.updateNote(fresh.id, {
2529
+ metadata: restampStub(fresh),
2530
+ skipUpdatedAt: true,
2531
+ if_updated_at: fresh.updatedAt,
2532
+ });
2533
+ }
2534
+ } catch (err: any) {
2535
+ if (err && err.code === "CONFLICT") {
2536
+ // Double conflict — the note kept changing under us. It's a user-facing
2537
+ // request; return 409 so the caller can retry against fresh state. The
2538
+ // attachment was already reset to pending above; a successful re-stamp
2539
+ // on the user's next retry (or the next sweep, if they re-arm the stub)
2540
+ // will let the worker patch the transcript in.
2541
+ return json(
2542
+ {
2543
+ error_type: "conflict",
2544
+ error: "conflict",
2545
+ note_id: note.id,
2546
+ current_updated_at: err.current_updated_at ?? null,
2547
+ your_updated_at: err.expected_updated_at,
2548
+ // Mirror the standard PATCH 409 shape (see the notes-update handler
2549
+ // above) — both `your_updated_at` and `expected_updated_at` carry the
2550
+ // same value, kept for shape-congruence with existing callers.
2551
+ expected_updated_at: err.expected_updated_at,
2552
+ message:
2553
+ "Note was modified concurrently while arming the retry; re-fetch and try again.",
2554
+ },
2555
+ 409,
2556
+ );
2557
+ }
2558
+ throw err;
2559
+ }
2560
+
2561
+ const { getTranscriptionWorker } = await import("./transcription-registry.ts");
2562
+ const worker = getTranscriptionWorker();
2563
+ if (worker) {
2564
+ const fresh = await store.getAttachment(failed.id) ?? failed;
2565
+ void worker.kick(vault, fresh);
2566
+ }
2567
+
2568
+ return json(
2569
+ {
2570
+ status: "queued",
2571
+ attachment_id: failed.id,
2572
+ attachment_path: failed.path,
2573
+ transcript_note_id: note.id,
2574
+ worker: worker ? "kicked" : "sweep-only",
2575
+ },
2576
+ 202,
2577
+ );
2578
+ }
2579
+
2043
2580
  // ---------------------------------------------------------------------------
2044
2581
  // Storage (file upload/serve) — kept as-is, Daily needs it
2582
+ //
2583
+ // `assetsDir` moved to config.ts (next to the other path helpers) to break the
2584
+ // usage.ts↔routes.ts import cycle; it's re-exported from this module's top so
2585
+ // existing importers are unaffected.
2045
2586
  // ---------------------------------------------------------------------------
2046
2587
 
2047
- export function assetsDir(vault: string): string {
2048
- return process.env.ASSETS_DIR ?? join(vaultDir(vault), "assets");
2049
- }
2050
2588
  const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
2051
2589
 
2052
2590
  // Storage allowlist policy:
@@ -2112,10 +2650,41 @@ export async function handleStorage(
2112
2650
  const relativePath = `${date}/${filename}`;
2113
2651
  const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
2114
2652
 
2653
+ // Invalidate the usage dir-walk cache for this vault — the new attachment
2654
+ // changed the assets-directory footprint, so the next /.parachute/usage
2655
+ // read must re-walk rather than report a stale (smaller) number. Without
2656
+ // this hook the cache's 60s TTL would briefly under-report after an
2657
+ // upload. (usage.ts:invalidateUsageCache is a no-op-cheap map delete.)
2658
+ invalidateUsageCache(vault);
2659
+
2115
2660
  return json({ path: relativePath, size: buffer.length, mimeType }, 201);
2116
2661
  }
2117
2662
 
2118
- const fileMatch = path.match(/^\/([^/]+)\/(.+)$/);
2663
+ // Decode percent-encoding BEFORE matching. `path` arrives from
2664
+ // `url.pathname`, which (per WHATWG) keeps an encoded `%2F` slash literal —
2665
+ // so a caller requesting `/api/storage/<date>%2F<file>` would never satisfy
2666
+ // the literal-slash match below and would fall through to the 404. Decoding
2667
+ // first accepts both the literal-slash and `%2F`-encoded forms, and yields
2668
+ // the literal-slash path that the DB stores (`${date}/${filename}`) so the
2669
+ // tag-scope reverse-lookup matches. This intentionally diverges from the
2670
+ // single-note routes, which decode their *first* segment only and therefore
2671
+ // REQUIRE `%2F` for slashes-in-an-id — a trap-grade asymmetry we accept here
2672
+ // because storage paths are always multi-segment date/file pairs.
2673
+ //
2674
+ // Guard-safety (verified): the traversal guard below operates on the
2675
+ // post-`normalize(join())` filesystem path, so a decoded `..` is still
2676
+ // caught → 403. Decode is idempotent for today's unencoded callers
2677
+ // (filenames are `<Date.now()>-<uuid>.<ext>` — no stray `%`). A malformed
2678
+ // `%` (e.g. `2026%2`) throws → 404, consistent with the no-existence-oracle
2679
+ // stance (and an improvement over the prior catch-all 500).
2680
+ let decodedPath: string;
2681
+ try {
2682
+ decodedPath = decodeURIComponent(path);
2683
+ } catch {
2684
+ return json({ error: "Not found" }, 404);
2685
+ }
2686
+
2687
+ const fileMatch = decodedPath.match(/^\/([^/]+)\/(.+)$/);
2119
2688
  if (req.method === "GET" && fileMatch) {
2120
2689
  const reqPath = `${fileMatch[1]}/${fileMatch[2]}`;
2121
2690
  const filePath = normalize(join(assets, reqPath));
@@ -2178,9 +2747,12 @@ export async function handleStorage(
2178
2747
  // Tag schema defaults — same logic as core/src/mcp.ts applySchemaDefaults
2179
2748
  // ---------------------------------------------------------------------------
2180
2749
 
2181
- async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<void> {
2750
+ // Returns the IDs of notes whose metadata was actually default-filled, so
2751
+ // the caller can re-read ONLY the mutated notes (and skip the re-read when
2752
+ // nothing changed). Mirrors the core/src/mcp.ts contract.
2753
+ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<string[]> {
2182
2754
  const schemas = tagSchemaOps.getTagSchemaMap(db);
2183
- if (Object.keys(schemas).length === 0) return;
2755
+ if (Object.keys(schemas).length === 0) return [];
2184
2756
 
2185
2757
  const defaults: Record<string, unknown> = {};
2186
2758
  for (const tag of tags) {
@@ -2192,8 +2764,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
2192
2764
  }
2193
2765
  }
2194
2766
  }
2195
- if (Object.keys(defaults).length === 0) return;
2767
+ if (Object.keys(defaults).length === 0) return [];
2196
2768
 
2769
+ const mutated: string[] = [];
2197
2770
  for (const noteId of noteIds) {
2198
2771
  const note = await store.getNote(noteId);
2199
2772
  if (!note) continue;
@@ -2207,7 +2780,9 @@ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tag
2207
2780
  metadata: { ...existing, ...missing },
2208
2781
  skipUpdatedAt: true,
2209
2782
  });
2783
+ mutated.push(noteId);
2210
2784
  }
2785
+ return mutated;
2211
2786
  }
2212
2787
 
2213
2788
  function defaultForField(field: { type: string; enum?: string[] }): unknown {