@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/vault.test.ts CHANGED
@@ -11,8 +11,13 @@ import { BunStore } from "./vault-store.ts";
11
11
  import { generateMcpTools } from "../core/src/mcp.ts";
12
12
  import { getLinksHydrated } from "../core/src/links.ts";
13
13
  import { buildVaultProjection } from "../core/src/vault-projection.ts";
14
- import { handleNotes, handleTags, handleFindPath, handleVault } from "./routes.ts";
14
+ import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks } from "./routes.ts";
15
+ import { expandTokenTagScope } from "./tag-scope.ts";
16
+ import type { TagScopeCtx } from "./routes.ts";
15
17
  import { extractApiKey } from "./auth.ts";
18
+ import { startTranscriptionWorker } from "./transcription-worker.ts";
19
+ import { setTranscriptionWorker } from "./transcription-registry.ts";
20
+ import type { Store } from "../core/src/types.ts";
16
21
 
17
22
  let db: Database;
18
23
  let store: BunStore;
@@ -1382,7 +1387,7 @@ describe("scoped MCP wrapper", async () => {
1382
1387
  await store.createNote("h", { tags: ["health"] });
1383
1388
 
1384
1389
  // Seed a vestigial tag-scoped row referencing "health" (raw INSERT —
1385
- // vault no longer mints these post-0.6.0, but findTokensReferencingTag
1390
+ // vault no longer mints these post-0.5.0, but findTokensReferencingTag
1386
1391
  // still guards the tag-delete path against leftover rows). vault#282.
1387
1392
  store.db
1388
1393
  .prepare(
@@ -1462,6 +1467,143 @@ describe("scoped MCP wrapper", async () => {
1462
1467
 
1463
1468
  close();
1464
1469
  });
1470
+
1471
+ // -- tag-scope confidentiality: expand_links + include_links (security
1472
+ // review) -----------------------------------------------------------
1473
+ //
1474
+ // These pin the MCP side of the expand_links / include_links leaks. A
1475
+ // tag-scoped session must NOT inline out-of-scope note content via
1476
+ // expand_links, and must NOT hydrate out-of-scope neighbor summaries via
1477
+ // include_links. The unscoped path must remain fully functional. They
1478
+ // MUST fail if the predicate / link-scrub is removed.
1479
+
1480
+ test("MCP expand_links does NOT inline out-of-scope wikilinked content", async () => {
1481
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1482
+ const { writeVaultConfig } = await import("./config.ts");
1483
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1484
+
1485
+ const vaultName = `tagscope-expand-${Date.now()}`;
1486
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1487
+ const store = getVaultStore(vaultName);
1488
+ // Out-of-scope #personal note holds the secret; in-scope #work note links it.
1489
+ await store.createNote("SECRET PERSONAL BODY", { path: "Secret", tags: ["personal"] });
1490
+ const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
1491
+
1492
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1493
+ const query = tools.find((t) => t.name === "query-notes")!;
1494
+ const result = await query.execute({
1495
+ id: work.id,
1496
+ include_content: true,
1497
+ expand_links: true,
1498
+ }) as any;
1499
+
1500
+ expect(result.content).not.toContain("SECRET PERSONAL BODY");
1501
+ // Wikilink stays literal — indistinguishable from not-found.
1502
+ expect(result.content).toContain("[[Secret]]");
1503
+
1504
+ closeAllStores();
1505
+ });
1506
+
1507
+ test("MCP expand_links multi-hop (depth>1) does not leak out-of-scope content", async () => {
1508
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1509
+ const { writeVaultConfig } = await import("./config.ts");
1510
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1511
+
1512
+ const vaultName = `tagscope-expand-deep-${Date.now()}`;
1513
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1514
+ const store = getVaultStore(vaultName);
1515
+ await store.createNote("DEEP PERSONAL SECRET", { path: "Deep", tags: ["personal"] });
1516
+ await store.createNote("mid [[Deep]]", { path: "Mid", tags: ["work"] });
1517
+ const top = await store.createNote("top [[Mid]]", { path: "Top", tags: ["work"] });
1518
+
1519
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1520
+ const query = tools.find((t) => t.name === "query-notes")!;
1521
+ const result = await query.execute({
1522
+ id: top.id,
1523
+ include_content: true,
1524
+ expand_links: true,
1525
+ expand_depth: 3,
1526
+ }) as any;
1527
+
1528
+ // In-scope Mid inlines; out-of-scope Deep never does, at any depth.
1529
+ expect(result.content).toContain("mid");
1530
+ expect(result.content).not.toContain("DEEP PERSONAL SECRET");
1531
+
1532
+ closeAllStores();
1533
+ });
1534
+
1535
+ test("UNSCOPED MCP expand_links still inlines wikilinked content (regression)", async () => {
1536
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1537
+ const { writeVaultConfig } = await import("./config.ts");
1538
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1539
+
1540
+ const vaultName = `tagscope-expand-unscoped-${Date.now()}`;
1541
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1542
+ const store = getVaultStore(vaultName);
1543
+ await store.createNote("PERSONAL BODY", { path: "Secret", tags: ["personal"] });
1544
+ const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
1545
+
1546
+ // No auth → unscoped session. Expansion must behave exactly as before.
1547
+ const tools = generateScopedMcpTools(vaultName);
1548
+ const query = tools.find((t) => t.name === "query-notes")!;
1549
+ const result = await query.execute({
1550
+ id: work.id,
1551
+ include_content: true,
1552
+ expand_links: true,
1553
+ }) as any;
1554
+
1555
+ expect(result.content).toContain("PERSONAL BODY");
1556
+
1557
+ closeAllStores();
1558
+ });
1559
+
1560
+ test("MCP include_links strips out-of-scope NEIGHBOR summaries", async () => {
1561
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1562
+ const { writeVaultConfig } = await import("./config.ts");
1563
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1564
+
1565
+ const vaultName = `tagscope-incl-links-${Date.now()}`;
1566
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1567
+ const store = getVaultStore(vaultName);
1568
+ const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
1569
+ const work = await store.createNote("work", { path: "Work", tags: ["work"] });
1570
+ await store.createLink(work.id, secret.id, "references");
1571
+
1572
+ const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
1573
+ const query = tools.find((t) => t.name === "query-notes")!;
1574
+ const result = await query.execute({ id: work.id, include_links: true }) as any;
1575
+
1576
+ const links = (result.links ?? []) as any[];
1577
+ // No surviving link may reference the out-of-scope note's id/path.
1578
+ const serialized = JSON.stringify(links);
1579
+ expect(serialized).not.toContain(secret.id);
1580
+ expect(serialized).not.toContain("Secret");
1581
+
1582
+ closeAllStores();
1583
+ });
1584
+
1585
+ test("UNSCOPED MCP include_links still hydrates the full neighbor (regression)", async () => {
1586
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
1587
+ const { writeVaultConfig } = await import("./config.ts");
1588
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1589
+
1590
+ const vaultName = `tagscope-incl-links-unscoped-${Date.now()}`;
1591
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
1592
+ const store = getVaultStore(vaultName);
1593
+ const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
1594
+ const work = await store.createNote("work", { path: "Work", tags: ["work"] });
1595
+ await store.createLink(work.id, secret.id, "references");
1596
+
1597
+ const tools = generateScopedMcpTools(vaultName);
1598
+ const query = tools.find((t) => t.name === "query-notes")!;
1599
+ const result = await query.execute({ id: work.id, include_links: true }) as any;
1600
+
1601
+ const links = (result.links ?? []) as any[];
1602
+ expect(links.length).toBe(1);
1603
+ expect(JSON.stringify(links)).toContain(secret.id);
1604
+
1605
+ closeAllStores();
1606
+ });
1465
1607
  });
1466
1608
 
1467
1609
  describe("auth permissions", () => {
@@ -1550,6 +1692,56 @@ describe("HTTP /notes", async () => {
1550
1692
  expect(body).toHaveLength(1);
1551
1693
  });
1552
1694
 
1695
+ // ---- search path honors the `expand` axis (vault tag `expand` axis) ----
1696
+ //
1697
+ // Corpus: all three notes share the FTS term "fox". Tags separate the two
1698
+ // axes — `person` is a declared subtype of `entity` (parent_names) but NOT
1699
+ // name-prefixed; `entity/archived` is name-prefixed but NOT a subtype. So
1700
+ // search(tag=entity) returns DIFFERENT sets per `expand` mode, proving the
1701
+ // search branch threads it (regression for the "validated then dropped" bug).
1702
+ async function seedSearchAxisCorpus() {
1703
+ await store.upsertTagRecord("entity", { description: "entity root" });
1704
+ await store.upsertTagRecord("person", { parent_names: ["entity"] });
1705
+ await store.upsertTagRecord("entity/archived", {});
1706
+ await store.createNote("fox literal", { tags: ["entity"], path: "s-entity" });
1707
+ await store.createNote("fox subtype", { tags: ["person"], path: "s-person" });
1708
+ await store.createNote("fox filed", { tags: ["entity/archived"], path: "s-archived" });
1709
+ }
1710
+
1711
+ test("GET /notes?search=fox&tag=entity — absent expand ≡ subtypes (descendants, no namespaced sibling)", async () => {
1712
+ await seedSearchAxisCorpus();
1713
+ const absent = await (await handleNotes(mkReq("GET", "/notes?search=fox&tag=entity&include_content=true"), store, "")).json() as any[];
1714
+ const sub = await (await handleNotes(mkReq("GET", "/notes?search=fox&tag=entity&expand=subtypes&include_content=true"), store, "")).json() as any[];
1715
+ const absentSet = new Set(absent.map((n) => n.content));
1716
+ expect(new Set(sub.map((n) => n.content))).toEqual(absentSet);
1717
+ // entity (literal) + person (subtype); NOT entity/archived.
1718
+ expect(absentSet).toEqual(new Set(["fox literal", "fox subtype"]));
1719
+ });
1720
+
1721
+ test("GET /notes?search=fox&tag=entity&expand=namespace — lexical tag/* only, NOT subtype sibling", async () => {
1722
+ await seedSearchAxisCorpus();
1723
+ const res = await handleNotes(mkReq("GET", "/notes?search=fox&tag=entity&expand=namespace&include_content=true"), store, "");
1724
+ const body = await res.json() as any[];
1725
+ // entity (literal) + entity/archived (name-prefixed); NOT person (subtype).
1726
+ expect(new Set(body.map((n) => n.content))).toEqual(new Set(["fox literal", "fox filed"]));
1727
+ });
1728
+
1729
+ test("GET /notes?search=fox&tag=entity&expand=exact — literal tag only", async () => {
1730
+ await seedSearchAxisCorpus();
1731
+ const res = await handleNotes(mkReq("GET", "/notes?search=fox&tag=entity&expand=exact&include_content=true"), store, "");
1732
+ const body = await res.json() as any[];
1733
+ expect(body.map((n) => n.content)).toEqual(["fox literal"]);
1734
+ });
1735
+
1736
+ test("GET /notes?search=...&expand=bogus → 400 INVALID_QUERY (search branch validates too)", async () => {
1737
+ await store.createNote("fox here");
1738
+ const res = await handleNotes(mkReq("GET", "/notes?search=fox&expand=bogus"), store, "");
1739
+ expect(res.status).toBe(400);
1740
+ const body = await res.json() as any;
1741
+ expect(body.code).toBe("INVALID_QUERY");
1742
+ expect(body.error).toContain("expand");
1743
+ });
1744
+
1553
1745
  test("GET /notes?has_tags=false returns only untagged notes", async () => {
1554
1746
  await store.createNote("tagged", { tags: ["x"], path: "t" });
1555
1747
  await store.createNote("plain", { path: "p" });
@@ -1881,6 +2073,46 @@ describe("HTTP /notes", async () => {
1881
2073
  expect(body.createdAt).toBe("2025-01-01T00:00:00.000Z");
1882
2074
  });
1883
2075
 
2076
+ // vault#316 — the HTTP POST path re-reads each note AFTER
2077
+ // `applySchemaDefaults`, so the response metadata carries the just-written
2078
+ // defaults (mirrors the MCP create-note path). Before the fix the response
2079
+ // mapped over the pre-defaults in-memory objects, so default-filled
2080
+ // metadata was missing from `POST /api/notes` responses.
2081
+ test("POST /notes response reflects post-applySchemaDefaults state (vault#316)", async () => {
2082
+ await store.upsertTagSchema("task", {
2083
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
2084
+ });
2085
+
2086
+ // Single: default lands in the returned metadata and agrees with disk.
2087
+ const single = await handleNotes(
2088
+ mkReq("POST", "/notes", { content: "do the thing", path: "Inbox/task-1", tags: ["task"] }),
2089
+ store,
2090
+ "",
2091
+ );
2092
+ expect(single.status).toBe(201);
2093
+ const singleBody = await single.json() as any;
2094
+ expect(singleBody.metadata?.priority).toBe("high"); // first enum value
2095
+ const onDisk = await store.getNoteByPath("Inbox/task-1");
2096
+ expect((onDisk!.metadata as any)?.priority).toBe("high");
2097
+
2098
+ // Batch: each entry is re-read post-defaults too, in input order.
2099
+ const batch = await handleNotes(
2100
+ mkReq("POST", "/notes", {
2101
+ notes: [
2102
+ { content: "a", path: "Inbox/task-2", tags: ["task"] },
2103
+ { content: "b", path: "Inbox/task-3", tags: ["task"] },
2104
+ ],
2105
+ }),
2106
+ store,
2107
+ "",
2108
+ );
2109
+ expect(batch.status).toBe(201);
2110
+ const batchBody = await batch.json() as any[];
2111
+ expect(batchBody.map((n) => n.path)).toEqual(["Inbox/task-2", "Inbox/task-3"]);
2112
+ expect(batchBody[0].metadata?.priority).toBe("high");
2113
+ expect(batchBody[1].metadata?.priority).toBe("high");
2114
+ });
2115
+
1884
2116
  // ---- Extension field (vault#328) ----
1885
2117
 
1886
2118
  test("POST /notes accepts extension and persists it", async () => {
@@ -2721,6 +2953,171 @@ describe("HTTP /notes", async () => {
2721
2953
  const body = await res.json() as any[];
2722
2954
  expect(body.map((n) => n.content)).toEqual(["new"]);
2723
2955
  });
2956
+
2957
+ // ---- JSON `metadata=<json>` alias (symmetric with the MCP nested obj) ----
2958
+ //
2959
+ // Before this alias, a `?metadata={...}` param was silently dropped: the
2960
+ // bracket grammar never matched it, `queryOpts.metadata` stayed undefined,
2961
+ // and the query returned ALL tag-matching notes — a silent wrong result.
2962
+
2963
+ test("alias `metadata={field:{op:value}}` filters on an indexed field", async () => {
2964
+ await declareIndexed();
2965
+ await store.createNote("open-1", { metadata: { status: "open" } });
2966
+ await store.createNote("open-2", { metadata: { status: "open" } });
2967
+ await store.createNote("closed", { metadata: { status: "closed" } });
2968
+ const q = encodeURIComponent(JSON.stringify({ status: { eq: "open" } }));
2969
+ const res = await handleNotes(
2970
+ mkReq("GET", `/notes?metadata=${q}&include_content=true`),
2971
+ store,
2972
+ "",
2973
+ );
2974
+ expect(res.status).toBe(200);
2975
+ const body = await res.json() as any[];
2976
+ expect(body.map((n) => n.content).sort()).toEqual(["open-1", "open-2"]);
2977
+ });
2978
+
2979
+ test("alias shorthand equality `metadata={field:value}` works via json_extract fallback", async () => {
2980
+ // No declareIndexed — shorthand routes through the engine's json_extract
2981
+ // exact-match path, no indexed declaration required.
2982
+ await store.createNote("matches", { metadata: { status: "open" } });
2983
+ await store.createNote("other", { metadata: { status: "closed" } });
2984
+ const q = encodeURIComponent(JSON.stringify({ status: "open" }));
2985
+ const res = await handleNotes(
2986
+ mkReq("GET", `/notes?metadata=${q}&include_content=true`),
2987
+ store,
2988
+ "",
2989
+ );
2990
+ expect(res.status).toBe(200);
2991
+ const body = await res.json() as any[];
2992
+ expect(body.map((n) => n.content)).toEqual(["matches"]);
2993
+ });
2994
+
2995
+ test("alias and bracket form return identical results for the same indexed-field operator query", async () => {
2996
+ await declareIndexed();
2997
+ for (const p of [1, 2, 3, 4, 5]) {
2998
+ await store.createNote(`p${p}`, { metadata: { priority: p } });
2999
+ }
3000
+ // JSON preserves the real number type 3; bracket form passes "3" as a
3001
+ // string. Both must coerce to the same range result against the INTEGER
3002
+ // indexed column — this guards the type-coercion edge.
3003
+ const aliasQ = encodeURIComponent(JSON.stringify({ priority: { gte: 3 } }));
3004
+ const aliasRes = await handleNotes(
3005
+ mkReq("GET", `/notes?metadata=${aliasQ}&include_content=true`),
3006
+ store,
3007
+ "",
3008
+ );
3009
+ const bracketRes = await handleNotes(
3010
+ mkReq("GET", "/notes?meta[priority][gte]=3&include_content=true"),
3011
+ store,
3012
+ "",
3013
+ );
3014
+ const aliasBody = await aliasRes.json() as any[];
3015
+ const bracketBody = await bracketRes.json() as any[];
3016
+ expect(aliasBody.map((n) => n.content).sort()).toEqual(["p3", "p4", "p5"]);
3017
+ expect(aliasBody.map((n) => n.content).sort()).toEqual(
3018
+ bracketBody.map((n) => n.content).sort(),
3019
+ );
3020
+ });
3021
+
3022
+ test("malformed JSON in `metadata=` rejects with 400 INVALID_QUERY", async () => {
3023
+ const res = await handleNotes(
3024
+ mkReq("GET", "/notes?metadata=" + encodeURIComponent("{not json")),
3025
+ store,
3026
+ "",
3027
+ );
3028
+ expect(res.status).toBe(400);
3029
+ const body = await res.json() as any;
3030
+ expect(body.code).toBe("INVALID_QUERY");
3031
+ expect(body.error).toContain("JSON object");
3032
+ });
3033
+
3034
+ test("non-object `metadata=` JSON (array) rejects with 400 INVALID_QUERY", async () => {
3035
+ const res = await handleNotes(
3036
+ mkReq("GET", "/notes?metadata=" + encodeURIComponent(JSON.stringify(["status"]))),
3037
+ store,
3038
+ "",
3039
+ );
3040
+ expect(res.status).toBe(400);
3041
+ const body = await res.json() as any;
3042
+ expect(body.code).toBe("INVALID_QUERY");
3043
+ });
3044
+
3045
+ test("primitive-scalar `metadata=` JSON (number / bare string) rejects with 400 INVALID_QUERY", async () => {
3046
+ // `metadata=42` and `metadata="open"` are valid JSON but not objects —
3047
+ // both fall through the non-object branch.
3048
+ for (const raw of ["42", JSON.stringify("open")]) {
3049
+ const res = await handleNotes(
3050
+ mkReq("GET", "/notes?metadata=" + encodeURIComponent(raw)),
3051
+ store,
3052
+ "",
3053
+ );
3054
+ expect(res.status).toBe(400);
3055
+ const body = await res.json() as any;
3056
+ expect(body.code).toBe("INVALID_QUERY");
3057
+ }
3058
+ });
3059
+
3060
+ test("empty-object alias `metadata={}` is treated as absent and composes with a bracket filter", async () => {
3061
+ // `{}` carries no filter intent — it must neither set a metadata filter
3062
+ // NOR trip the both-forms 400 guard. So `metadata={}` + a bracket
3063
+ // metadata filter is a 200 filtered by the bracket form only.
3064
+ await declareIndexed();
3065
+ await store.createNote("hi", { metadata: { priority: 5 } });
3066
+ await store.createNote("lo", { metadata: { priority: 1 } });
3067
+ const res = await handleNotes(
3068
+ mkReq("GET", "/notes?metadata=" + encodeURIComponent("{}") + "&meta[priority][gte]=3&include_content=true"),
3069
+ store,
3070
+ "",
3071
+ );
3072
+ expect(res.status).toBe(200);
3073
+ const body = await res.json() as any[];
3074
+ expect(body.map((n) => n.content)).toEqual(["hi"]);
3075
+ });
3076
+
3077
+ test("both `metadata=` alias AND `meta[...]` bracket params present rejects with 400 INVALID_QUERY", async () => {
3078
+ await declareIndexed();
3079
+ const q = encodeURIComponent(JSON.stringify({ status: { eq: "open" } }));
3080
+ const res = await handleNotes(
3081
+ mkReq("GET", `/notes?metadata=${q}&meta[priority][gte]=3`),
3082
+ store,
3083
+ "",
3084
+ );
3085
+ expect(res.status).toBe(400);
3086
+ const body = await res.json() as any;
3087
+ expect(body.code).toBe("INVALID_QUERY");
3088
+ expect(body.error).toContain("not both");
3089
+ });
3090
+
3091
+ test("regression: previously-silently-dropped `?metadata={status:{eq:pending}}` now actually filters", async () => {
3092
+ await declareIndexed();
3093
+ await store.createNote("pending-1", { metadata: { status: "pending" } });
3094
+ await store.createNote("pending-2", { metadata: { status: "pending" } });
3095
+ await store.createNote("done", { metadata: { status: "done" } });
3096
+ const q = encodeURIComponent(JSON.stringify({ status: { eq: "pending" } }));
3097
+ const res = await handleNotes(
3098
+ mkReq("GET", `/notes?metadata=${q}&include_content=true`),
3099
+ store,
3100
+ "",
3101
+ );
3102
+ expect(res.status).toBe(200);
3103
+ const body = await res.json() as any[];
3104
+ // Before the fix this returned ALL three notes (filter dropped). Now it
3105
+ // returns only the two pending ones.
3106
+ expect(body.map((n) => n.content).sort()).toEqual(["pending-1", "pending-2"]);
3107
+ });
3108
+
3109
+ test("alias with an unknown operator surfaces the engine's 400 UNKNOWN_OPERATOR", async () => {
3110
+ await declareIndexed();
3111
+ const q = encodeURIComponent(JSON.stringify({ priority: { bogus: 5 } }));
3112
+ const res = await handleNotes(
3113
+ mkReq("GET", `/notes?metadata=${q}`),
3114
+ store,
3115
+ "",
3116
+ );
3117
+ expect(res.status).toBe(400);
3118
+ const body = await res.json() as any;
3119
+ expect(body.code).toBe("UNKNOWN_OPERATOR");
3120
+ });
2724
3121
  });
2725
3122
 
2726
3123
  // -------------------------------------------------------------------------
@@ -2803,7 +3200,10 @@ describe("HTTP /notes", async () => {
2803
3200
  expect(res.status).toBe(404);
2804
3201
  });
2805
3202
 
2806
- test("400 invalid_target when target is not a transcript note", async () => {
3203
+ test("400 no_failed_attachment when target is a regular note with no failed audio", async () => {
3204
+ // A note without `transcript_status` frontmatter is treated as a
3205
+ // possible legacy in-body memo (finding F). With no attachment carrying
3206
+ // a failed transcription there's nothing to retry → no_failed_attachment.
2807
3207
  await store.createNote("regular note", { id: "regular" });
2808
3208
  const res = await handleNotes(
2809
3209
  mkReq("POST", "/notes/regular/retry-transcription"),
@@ -2813,7 +3213,7 @@ describe("HTTP /notes", async () => {
2813
3213
  );
2814
3214
  expect(res.status).toBe(400);
2815
3215
  const body = await res.json() as any;
2816
- expect(body.error).toBe("invalid_target");
3216
+ expect(body.error).toBe("no_failed_attachment");
2817
3217
  });
2818
3218
 
2819
3219
  test("400 not_failed when transcript already succeeded", async () => {
@@ -2841,62 +3241,662 @@ describe("HTTP /notes", async () => {
2841
3241
  expect(body.transcript_status).toBe("complete");
2842
3242
  });
2843
3243
 
2844
- test("400 missing_attachment_id when frontmatter lacks the id", async () => {
2845
- await seedFailedTranscript({
2846
- transcriptId: "transcript-no-id",
2847
- noteId: "src-no-id",
2848
- omitAttachmentId: true,
2849
- });
2850
- const res = await handleNotes(
2851
- mkReq("POST", "/notes/transcript-no-id/retry-transcription"),
2852
- store,
2853
- "/transcript-no-id/retry-transcription",
2854
- "default",
2855
- );
2856
- expect(res.status).toBe(400);
2857
- const body = await res.json() as any;
2858
- expect(body.error).toBe("missing_attachment_id");
2859
- delete process.env.ASSETS_DIR;
2860
- });
3244
+ test("400 missing_attachment_id when frontmatter lacks the id", async () => {
3245
+ await seedFailedTranscript({
3246
+ transcriptId: "transcript-no-id",
3247
+ noteId: "src-no-id",
3248
+ omitAttachmentId: true,
3249
+ });
3250
+ const res = await handleNotes(
3251
+ mkReq("POST", "/notes/transcript-no-id/retry-transcription"),
3252
+ store,
3253
+ "/transcript-no-id/retry-transcription",
3254
+ "default",
3255
+ );
3256
+ expect(res.status).toBe(400);
3257
+ const body = await res.json() as any;
3258
+ expect(body.error).toBe("missing_attachment_id");
3259
+ delete process.env.ASSETS_DIR;
3260
+ });
3261
+
3262
+ test("404 attachment_missing when the attachment row no longer exists", async () => {
3263
+ const owner = await store.createNote("voice", { id: "src-stale" });
3264
+ await store.createNote("", {
3265
+ id: "transcript-stale",
3266
+ path: "memo/stale.webm.transcript",
3267
+ metadata: {
3268
+ transcript_of: "memo/stale.webm",
3269
+ transcript_attachment_id: "deleted-attachment-id",
3270
+ transcript_status: "failed",
3271
+ transcript_error: "missing_provider",
3272
+ },
3273
+ });
3274
+ const res = await handleNotes(
3275
+ mkReq("POST", "/notes/transcript-stale/retry-transcription"),
3276
+ store,
3277
+ "/transcript-stale/retry-transcription",
3278
+ "default",
3279
+ );
3280
+ expect(res.status).toBe(404);
3281
+ const body = await res.json() as any;
3282
+ expect(body.error).toBe("attachment_missing");
3283
+ });
3284
+
3285
+ test("405 on GET (must POST)", async () => {
3286
+ await seedFailedTranscript({
3287
+ transcriptId: "transcript-405",
3288
+ noteId: "src-405",
3289
+ audioPath: "memo/405.webm",
3290
+ });
3291
+ const res = await handleNotes(
3292
+ mkReq("GET", "/notes/transcript-405/retry-transcription"),
3293
+ store,
3294
+ "/transcript-405/retry-transcription",
3295
+ "default",
3296
+ );
3297
+ expect(res.status).toBe(405);
3298
+ delete process.env.ASSETS_DIR;
3299
+ });
3300
+
3301
+ // -----------------------------------------------------------------------
3302
+ // Legacy in-body memo retry (finding F). The target is the memo note
3303
+ // itself (no `transcript_status` frontmatter); it directly owns a failed
3304
+ // audio attachment. The request must reset the attachment preserving
3305
+ // `transcribe_origin: "legacy"` and re-arm `transcribe_stub: true` so the
3306
+ // worker's legacy success path will write the transcript back into the
3307
+ // body. End-to-end re-transcription is covered in
3308
+ // transcription-worker.test.ts.
3309
+ // -----------------------------------------------------------------------
3310
+ async function seedLegacyFailedMemo(opts: {
3311
+ noteId?: string;
3312
+ audioPath?: string;
3313
+ withFile?: boolean;
3314
+ } = {}): Promise<{ noteId: string; attachmentId: string; audioPath: string }> {
3315
+ const noteId = opts.noteId ?? "legacy-memo";
3316
+ const audioPath = opts.audioPath ?? `${noteId}/voice.webm`;
3317
+ // The capture body after a terminal failure: marker replaced the
3318
+ // placeholder, embed intact, stub cleared by the worker.
3319
+ const note = await store.createNote(
3320
+ `# 🎙️ Voice memo\n\n_Recorded sometime._\n\n_Transcription unavailable._\n\n![[${audioPath}]]\n`,
3321
+ { id: noteId },
3322
+ );
3323
+ const att = await store.addAttachment(note.id, audioPath, "audio/webm", {
3324
+ transcribe_status: "failed",
3325
+ // legacy origin is the default (undefined); leave it off to model the
3326
+ // genuine legacy capture shape.
3327
+ transcribe_error: "scribe down",
3328
+ transcribe_attempts: 3,
3329
+ });
3330
+ const assetsRoot = join(tmpDir, "assets");
3331
+ if (opts.withFile !== false) {
3332
+ mkdirSync(join(assetsRoot, audioPath.split("/").slice(0, -1).join("/")), { recursive: true });
3333
+ writeFileSync(join(assetsRoot, audioPath), Buffer.from([1, 2, 3]));
3334
+ }
3335
+ process.env.ASSETS_DIR = assetsRoot;
3336
+ return { noteId, attachmentId: att.id, audioPath };
3337
+ }
3338
+
3339
+ test("legacy in-body memo: 202, resets attachment (legacy origin) + re-arms stub", async () => {
3340
+ const { noteId, attachmentId, audioPath } = await seedLegacyFailedMemo();
3341
+ const res = await handleNotes(
3342
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3343
+ store,
3344
+ `/${noteId}/retry-transcription`,
3345
+ "default",
3346
+ );
3347
+ expect(res.status).toBe(202);
3348
+ const body = await res.json() as any;
3349
+ expect(body.status).toBe("queued");
3350
+ expect(body.attachment_id).toBe(attachmentId);
3351
+ expect(body.attachment_path).toBe(audioPath);
3352
+ expect(body.transcript_note_id).toBe(noteId);
3353
+
3354
+ // Attachment reset to pending, legacy origin preserved (NOT flipped to
3355
+ // auto — that would orphan the in-body embed), failure state cleared.
3356
+ const att = await store.getAttachment(attachmentId);
3357
+ expect(att?.metadata?.transcribe_status).toBe("pending");
3358
+ expect(att?.metadata?.transcribe_origin).toBe("legacy");
3359
+ expect(att?.metadata?.transcribe_error).toBeUndefined();
3360
+ expect(att?.metadata?.transcribe_attempts).toBeUndefined();
3361
+
3362
+ // Stub re-armed on the note — without this the worker's legacy success
3363
+ // path early-returns and never writes the transcript back.
3364
+ const updated = await store.getNote(noteId);
3365
+ expect((updated!.metadata as any)?.transcribe_stub).toBe(true);
3366
+ // Body untouched by the retry request itself (embed + marker intact).
3367
+ expect(updated!.content).toContain(`![[${audioPath}]]`);
3368
+ expect(updated!.content).toContain("_Transcription unavailable._");
3369
+
3370
+ delete process.env.ASSETS_DIR;
3371
+ });
3372
+
3373
+ test("legacy in-body memo: 404 audio_missing when the file is gone", async () => {
3374
+ const { noteId } = await seedLegacyFailedMemo({
3375
+ noteId: "legacy-gone",
3376
+ withFile: false,
3377
+ });
3378
+ const res = await handleNotes(
3379
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3380
+ store,
3381
+ `/${noteId}/retry-transcription`,
3382
+ "default",
3383
+ );
3384
+ expect(res.status).toBe(404);
3385
+ const body = await res.json() as any;
3386
+ expect(body.error).toBe("audio_missing");
3387
+ delete process.env.ASSETS_DIR;
3388
+ });
3389
+
3390
+ test("legacy in-body memo: end-to-end retry round-trip (capture → fail → retry → success)", async () => {
3391
+ // Start from the CANONICAL capture body (recorder.ts memoNoteContent
3392
+ // shape): header + _Recorded_ + _Transcript pending._ + ![[embed]],
3393
+ // with transcribe_stub: true.
3394
+ const audioPath = "e2e/voice.webm";
3395
+ const captureBody =
3396
+ "# 🎙️ Voice memo\n\n_Recorded sometime._\n\n_Transcript pending._\n\n![[e2e/voice.webm]]\n";
3397
+ await store.createNote(captureBody, {
3398
+ id: "e2e-memo",
3399
+ metadata: { transcribe_stub: true },
3400
+ });
3401
+ const att = await store.addAttachment("e2e-memo", audioPath, "audio/webm", {
3402
+ transcribe_status: "pending",
3403
+ transcribe_attempts: 2, // one more failure flips to terminal at maxAttempts=3
3404
+ });
3405
+ const assetsRoot = join(tmpDir, "assets");
3406
+ mkdirSync(join(assetsRoot, "e2e"), { recursive: true });
3407
+ writeFileSync(join(assetsRoot, audioPath), Buffer.from([1, 2, 3]));
3408
+ process.env.ASSETS_DIR = assetsRoot;
3409
+
3410
+ // What a first-try success would have produced (for the final assert).
3411
+ const firstTrySuccessBody =
3412
+ "# 🎙️ Voice memo\n\n_Recorded sometime._\n\nthe spoken words\n\n![[e2e/voice.webm]]\n";
3413
+
3414
+ // --- Phase 1: terminal failure. Worker writes the marker in place,
3415
+ // preserving the embed, and clears the stub.
3416
+ let fetchMode: "fail" | "succeed" = "fail";
3417
+ const fetchImpl = (async () => {
3418
+ if (fetchMode === "fail") {
3419
+ return new Response("scribe down", { status: 500 });
3420
+ }
3421
+ return new Response(JSON.stringify({ text: "the spoken words" }), {
3422
+ status: 200,
3423
+ headers: { "content-type": "application/json" },
3424
+ });
3425
+ }) as typeof fetch;
3426
+
3427
+ const worker = startTranscriptionWorker({
3428
+ vaultList: () => ["default"],
3429
+ getStore: () => store as unknown as Store,
3430
+ scribeUrl: "http://scribe.test",
3431
+ resolveAssetsDir: () => process.env.ASSETS_DIR!,
3432
+ pollIntervalMs: 10_000_000,
3433
+ maxAttempts: 3,
3434
+ fetchImpl,
3435
+ logger: { error: () => {}, info: () => {} },
3436
+ });
3437
+ setTranscriptionWorker(worker);
3438
+ try {
3439
+ await worker.tick();
3440
+
3441
+ const failedNote = await store.getNote("e2e-memo");
3442
+ // Marker replaced the placeholder in place; embed + surrounding body intact.
3443
+ expect(failedNote!.content).toBe(
3444
+ "# 🎙️ Voice memo\n\n_Recorded sometime._\n\n_Transcription unavailable._\n\n![[e2e/voice.webm]]\n",
3445
+ );
3446
+ expect((failedNote!.metadata as any)?.transcribe_stub).toBeUndefined();
3447
+ const failedAtt = await store.getAttachment(att.id);
3448
+ expect(failedAtt?.metadata?.transcribe_status).toBe("failed");
3449
+
3450
+ // --- Phase 2: retry via the legacy route form (POST on the memo note).
3451
+ // Deregister the worker so the retry is "sweep-only" — that lets us
3452
+ // observe the reset + stub re-arm deterministically before the worker
3453
+ // picks the row back up (otherwise the route's fire-and-forget kick
3454
+ // would race our assertions and complete the success in-line).
3455
+ setTranscriptionWorker(null);
3456
+ fetchMode = "succeed";
3457
+ const retryRes = await handleNotes(
3458
+ mkReq("POST", "/notes/e2e-memo/retry-transcription"),
3459
+ store,
3460
+ "/e2e-memo/retry-transcription",
3461
+ "default",
3462
+ );
3463
+ expect(retryRes.status).toBe(202);
3464
+ expect((await retryRes.json() as any).worker).toBe("sweep-only");
3465
+
3466
+ // Attachment back to pending + legacy origin; stub re-armed on the note.
3467
+ const pendingAtt = await store.getAttachment(att.id);
3468
+ expect(pendingAtt?.metadata?.transcribe_status).toBe("pending");
3469
+ expect(pendingAtt?.metadata?.transcribe_origin).toBe("legacy");
3470
+ const rearmed = await store.getNote("e2e-memo");
3471
+ expect((rearmed!.metadata as any)?.transcribe_stub).toBe(true);
3472
+
3473
+ // --- Phase 3: worker succeeds on the retry (sweep tick). Transcript
3474
+ // replaces the _Transcription unavailable._ marker IN PLACE; embed
3475
+ // preserved; final body is byte-identical to a first-try success.
3476
+ setTranscriptionWorker(worker);
3477
+ await worker.tick();
3478
+ const success = await store.getNote("e2e-memo");
3479
+ expect(success!.content).toBe(firstTrySuccessBody);
3480
+ expect(success!.content).toContain("![[e2e/voice.webm]]");
3481
+ expect((success!.metadata as any)?.transcribe_stub).toBeUndefined();
3482
+ const doneAtt = await store.getAttachment(att.id);
3483
+ expect(doneAtt?.metadata?.transcribe_status).toBe("done");
3484
+ expect(doneAtt?.metadata?.transcript).toBe("the spoken words");
3485
+ } finally {
3486
+ await worker.stop();
3487
+ setTranscriptionWorker(null);
3488
+ delete process.env.ASSETS_DIR;
3489
+ }
3490
+ });
3491
+
3492
+ // ---- Optimistic concurrency on the stub re-stamp (vault#435) ----------
3493
+ // The retry endpoint does a read-transform-write on the memo note to
3494
+ // re-arm `transcribe_stub: true`. Without an `if_updated_at` precondition,
3495
+ // a user edit landing between the read (`resolveNote`) and this write is
3496
+ // silently clobbered — the static-write/stale-read class of vault#208.
3497
+ //
3498
+ // We inject a store wrapper that fires a concurrent USER edit immediately
3499
+ // before the route's first OC `updateNote` runs, making its precondition
3500
+ // stale. The route must NOT clobber the user's edit; it must re-read and
3501
+ // re-apply the metadata-only re-stamp against fresh content.
3502
+
3503
+ /**
3504
+ * Wrap a store so the first `N` `updateNote` calls carrying an
3505
+ * `if_updated_at` precondition fire `userEdit()` (a concurrent user write
3506
+ * that bumps `updated_at`) just before delegating — forcing the precondition
3507
+ * stale exactly `interfereTimes` times. Non-OC writes pass through.
3508
+ *
3509
+ * NOTE: duplicated in src/transcription-worker.test.ts (worker-layer race
3510
+ * tests) — keep in sync.
3511
+ */
3512
+ function withRace(
3513
+ base: Store,
3514
+ interfereTimes: number,
3515
+ userEdit: () => Promise<void>,
3516
+ ): Store {
3517
+ let fired = 0;
3518
+ return new Proxy(base, {
3519
+ get(target, prop, receiver) {
3520
+ if (prop === "updateNote") {
3521
+ return async (id: string, updates: any) => {
3522
+ if (updates?.if_updated_at !== undefined && fired < interfereTimes) {
3523
+ fired++;
3524
+ // bun:sqlite stamps `updated_at` at ms granularity. Sleep so
3525
+ // the concurrent user write lands at a strictly-greater
3526
+ // timestamp than the precondition the route captured — making
3527
+ // the conflict deterministic rather than racing inside the
3528
+ // same millisecond.
3529
+ await new Promise((r) => setTimeout(r, 5));
3530
+ await userEdit();
3531
+ }
3532
+ return (target as any).updateNote(id, updates);
3533
+ };
3534
+ }
3535
+ return Reflect.get(target, prop, receiver);
3536
+ },
3537
+ }) as Store;
3538
+ }
3539
+
3540
+ test("OC: single race → user edit survives, stub still re-armed (no clobber)", async () => {
3541
+ const { noteId, attachmentId } = await seedLegacyFailedMemo({ noteId: "race-1" });
3542
+
3543
+ // One interference: the very first OC write conflicts; the route re-reads
3544
+ // and re-applies against the user's new content.
3545
+ const raceStore = withRace(store, 1, async () => {
3546
+ // User appends a line to the body while the retry is in flight.
3547
+ await store.updateNote(noteId, { append: "\n\nMY EDIT WHILE PENDING" });
3548
+ });
3549
+
3550
+ const res = await handleNotes(
3551
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3552
+ raceStore,
3553
+ `/${noteId}/retry-transcription`,
3554
+ "default",
3555
+ );
3556
+ // (a) User edit NOT clobbered + (c) re-stamp succeeded on retry → 202.
3557
+ expect(res.status).toBe(202);
3558
+
3559
+ const after = await store.getNote(noteId);
3560
+ // (a) The user's concurrent edit survives.
3561
+ expect(after!.content).toContain("MY EDIT WHILE PENDING");
3562
+ // Original capture body also intact (re-stamp is metadata-only).
3563
+ expect(after!.content).toContain("_Transcription unavailable._");
3564
+ // (c) Stub re-armed despite the race.
3565
+ expect((after!.metadata as any)?.transcribe_stub).toBe(true);
3566
+
3567
+ const att = await store.getAttachment(attachmentId);
3568
+ expect(att?.metadata?.transcribe_status).toBe("pending");
3569
+ expect(att?.metadata?.transcribe_origin).toBe("legacy");
3570
+
3571
+ delete process.env.ASSETS_DIR;
3572
+ });
3573
+
3574
+ test("OC: double race → 409 (user-facing request can retry)", async () => {
3575
+ const { noteId } = await seedLegacyFailedMemo({ noteId: "race-2" });
3576
+
3577
+ // Interfere on BOTH the first write and the retry write → the route
3578
+ // exhausts its single retry and surfaces 409.
3579
+ const raceStore = withRace(store, 2, async () => {
3580
+ await store.updateNote(noteId, { append: " x" });
3581
+ });
3582
+
3583
+ const res = await handleNotes(
3584
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3585
+ raceStore,
3586
+ `/${noteId}/retry-transcription`,
3587
+ "default",
3588
+ );
3589
+ // (c) Double-conflict policy for a user-facing endpoint: 409.
3590
+ expect(res.status).toBe(409);
3591
+ const body = await res.json() as any;
3592
+ expect(body.error_type).toBe("conflict");
3593
+ expect(body.note_id).toBe(noteId);
3594
+
3595
+ // The note was never clobbered — the user's two appends are both present.
3596
+ const after = await store.getNote(noteId);
3597
+ expect(after!.content).toContain(" x x");
3598
+
3599
+ delete process.env.ASSETS_DIR;
3600
+ });
3601
+
3602
+ test("OC: happy path unchanged when no race occurs", async () => {
3603
+ // With zero interference the OC write lands first-try, byte-identical to
3604
+ // the pre-#435 behavior — guards against the precondition breaking the
3605
+ // common path.
3606
+ const { noteId } = await seedLegacyFailedMemo({ noteId: "race-0" });
3607
+ const res = await handleNotes(
3608
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3609
+ store,
3610
+ `/${noteId}/retry-transcription`,
3611
+ "default",
3612
+ );
3613
+ expect(res.status).toBe(202);
3614
+ const after = await store.getNote(noteId);
3615
+ expect((after!.metadata as any)?.transcribe_stub).toBe(true);
3616
+ delete process.env.ASSETS_DIR;
3617
+ });
3618
+ });
3619
+ });
3620
+
3621
+ // ---------------------------------------------------------------------------
3622
+ // REST tag-scope confidentiality (security review). expand_links must not
3623
+ // inline out-of-scope wikilinked content; include_links must not hydrate
3624
+ // out-of-scope neighbor summaries; unresolved-wikilinks must not surface
3625
+ // out-of-scope source rows. Unscoped path stays fully functional. Each
3626
+ // security assertion MUST fail without the fix.
3627
+ // ---------------------------------------------------------------------------
3628
+ describe("HTTP tag-scope confidentiality (security review)", async () => {
3629
+ // Build a TagScopeCtx the same way routing.ts does, so handlers see the
3630
+ // exact shape a real tag-scoped request produces.
3631
+ async function scopeCtx(roots: string[]): Promise<TagScopeCtx> {
3632
+ return { allowed: await expandTokenTagScope(store, roots), raw: roots };
3633
+ }
3634
+ const NO_SCOPE: TagScopeCtx = { allowed: null, raw: null };
3635
+
3636
+ test("expand_links does NOT inline out-of-scope wikilinked content", async () => {
3637
+ await store.createNote("SECRET PERSONAL BODY", { path: "Secret", tags: ["personal"] });
3638
+ const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
3639
+
3640
+ const res = await handleNotes(
3641
+ mkReq("GET", `/notes?id=${work.id}&include_content=true&expand_links=true`),
3642
+ store,
3643
+ "",
3644
+ "v",
3645
+ await scopeCtx(["work"]),
3646
+ );
3647
+ const body = await res.json() as any;
3648
+ expect(body.content).not.toContain("SECRET PERSONAL BODY");
3649
+ expect(body.content).toContain("[[Secret]]"); // literal — like not-found
3650
+ });
3651
+
3652
+ test("UNSCOPED expand_links still inlines content (regression)", async () => {
3653
+ await store.createNote("PERSONAL BODY", { path: "Secret", tags: ["personal"] });
3654
+ const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
3655
+
3656
+ const res = await handleNotes(
3657
+ mkReq("GET", `/notes?id=${work.id}&include_content=true&expand_links=true`),
3658
+ store,
3659
+ "",
3660
+ "v",
3661
+ NO_SCOPE,
3662
+ );
3663
+ const body = await res.json() as any;
3664
+ expect(body.content).toContain("PERSONAL BODY");
3665
+ });
3666
+
3667
+ test("expand_links multi-hop (depth>1) does not leak out-of-scope content", async () => {
3668
+ await store.createNote("DEEP PERSONAL SECRET", { path: "Deep", tags: ["personal"] });
3669
+ await store.createNote("mid [[Deep]]", { path: "Mid", tags: ["work"] });
3670
+ const top = await store.createNote("top [[Mid]]", { path: "Top", tags: ["work"] });
3671
+
3672
+ const res = await handleNotes(
3673
+ mkReq("GET", `/notes?id=${top.id}&include_content=true&expand_links=true&expand_depth=3`),
3674
+ store,
3675
+ "",
3676
+ "v",
3677
+ await scopeCtx(["work"]),
3678
+ );
3679
+ const body = await res.json() as any;
3680
+ expect(body.content).toContain("mid");
3681
+ expect(body.content).not.toContain("DEEP PERSONAL SECRET");
3682
+ });
3683
+
3684
+ test("include_links strips out-of-scope NEIGHBOR summaries", async () => {
3685
+ const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
3686
+ const work = await store.createNote("work", { path: "Work", tags: ["work"] });
3687
+ await store.createLink(work.id, secret.id, "references");
3688
+
3689
+ const res = await handleNotes(
3690
+ mkReq("GET", `/notes?id=${work.id}&include_links=true`),
3691
+ store,
3692
+ "",
3693
+ "v",
3694
+ await scopeCtx(["work"]),
3695
+ );
3696
+ const body = await res.json() as any;
3697
+ const serialized = JSON.stringify(body.links ?? []);
3698
+ expect(serialized).not.toContain(secret.id);
3699
+ expect(serialized).not.toContain("Secret");
3700
+ });
3701
+
3702
+ test("UNSCOPED include_links hydrates the full neighbor (regression)", async () => {
3703
+ const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
3704
+ const work = await store.createNote("work", { path: "Work", tags: ["work"] });
3705
+ await store.createLink(work.id, secret.id, "references");
3706
+
3707
+ const res = await handleNotes(
3708
+ mkReq("GET", `/notes?id=${work.id}&include_links=true`),
3709
+ store,
3710
+ "",
3711
+ "v",
3712
+ NO_SCOPE,
3713
+ );
3714
+ const body = await res.json() as any;
3715
+ expect((body.links ?? []).length).toBe(1);
3716
+ expect(JSON.stringify(body.links)).toContain(secret.id);
3717
+ });
3718
+
3719
+ test("unresolved-wikilinks surfaces only in-scope source rows", async () => {
3720
+ // #personal source with a dangling wikilink → out-of-scope row.
3721
+ await store.createNote("p [[NoSuchPersonal]]", { path: "P", tags: ["personal"] });
3722
+ // #work source with a dangling wikilink → in-scope row.
3723
+ await store.createNote("w [[NoSuchWork]]", { path: "W", tags: ["work"] });
3724
+
3725
+ const res = handleUnresolvedWikilinks(
3726
+ mkReq("GET", "/unresolved-wikilinks"),
3727
+ store,
3728
+ await scopeCtx(["work"]),
3729
+ );
3730
+ const body = await res.json() as any;
3731
+ const targets = (body.unresolved as any[]).map((r) => r.target_path);
3732
+ expect(targets).toContain("NoSuchWork");
3733
+ expect(targets).not.toContain("NoSuchPersonal");
3734
+ expect(body.count).toBe(1);
3735
+ });
3736
+
3737
+ test("UNSCOPED unresolved-wikilinks surfaces every row (regression)", async () => {
3738
+ await store.createNote("p [[NoSuchPersonal]]", { path: "P", tags: ["personal"] });
3739
+ await store.createNote("w [[NoSuchWork]]", { path: "W", tags: ["work"] });
3740
+
3741
+ const res = handleUnresolvedWikilinks(
3742
+ mkReq("GET", "/unresolved-wikilinks"),
3743
+ store,
3744
+ NO_SCOPE,
3745
+ );
3746
+ const body = await res.json() as any;
3747
+ const targets = (body.unresolved as any[]).map((r) => r.target_path);
3748
+ expect(targets).toContain("NoSuchWork");
3749
+ expect(targets).toContain("NoSuchPersonal");
3750
+ });
3751
+
3752
+ });
3753
+
3754
+ describe("HTTP /notes include_link_count + order_by=link_count (vault feedback #4)", async () => {
3755
+ // Mirrors the MCP-surface tests in core/src/link-count.test.ts on the
3756
+ // same fixtures so REST and MCP agree on the degree semantics.
3757
+ async function seed() {
3758
+ await store.createNote("Hub", { id: "hub", path: "hub", tags: ["t"] });
3759
+ await store.createNote("Leaf", { id: "leaf", path: "leaf", tags: ["t"] });
3760
+ await store.createNote("Self", { id: "self", path: "self", tags: ["t"] });
3761
+ await store.createLink("hub", "leaf", "a"); // hub out 1, leaf in 1
3762
+ await store.createLink("leaf", "hub", "b"); // hub in 1, leaf out 1 => both degree 2
3763
+ await store.createLink("self", "self", "loop"); // self-loop => degree 2
3764
+ }
3765
+
3766
+ test("list mode: include_link_count injects linkCount (both directions)", async () => {
3767
+ await seed();
3768
+ const res = await handleNotes(mkReq("GET", "/notes?include_link_count=true"), store, "");
3769
+ const body = (await res.json()) as any[];
3770
+ const byId = Object.fromEntries(body.map((n) => [n.id, n]));
3771
+ expect(byId.hub.linkCount).toBe(2);
3772
+ expect(byId.leaf.linkCount).toBe(2);
3773
+ expect(byId.self.linkCount).toBe(2); // self-loop = 2
3774
+ });
3775
+
3776
+ test("absent flag → no linkCount key (no behavior change)", async () => {
3777
+ await seed();
3778
+ const res = await handleNotes(mkReq("GET", "/notes"), store, "");
3779
+ const body = (await res.json()) as any[];
3780
+ expect(body.every((n) => !("linkCount" in n))).toBe(true);
3781
+ });
3782
+
3783
+ test("note with 0 links → linkCount: 0", async () => {
3784
+ await store.createNote("Lonely", { id: "lonely", path: "lonely" });
3785
+ const res = await handleNotes(mkReq("GET", "/notes?include_link_count=true"), store, "");
3786
+ const body = (await res.json()) as any[];
3787
+ expect(body.find((n) => n.id === "lonely").linkCount).toBe(0);
3788
+ });
2861
3789
 
2862
- test("404 attachment_missing when the attachment row no longer exists", async () => {
2863
- const owner = await store.createNote("voice", { id: "src-stale" });
2864
- await store.createNote("", {
2865
- id: "transcript-stale",
2866
- path: "memo/stale.webm.transcript",
2867
- metadata: {
2868
- transcript_of: "memo/stale.webm",
2869
- transcript_attachment_id: "deleted-attachment-id",
2870
- transcript_status: "failed",
2871
- transcript_error: "missing_provider",
2872
- },
2873
- });
2874
- const res = await handleNotes(
2875
- mkReq("POST", "/notes/transcript-stale/retry-transcription"),
2876
- store,
2877
- "/transcript-stale/retry-transcription",
2878
- "default",
2879
- );
2880
- expect(res.status).toBe(404);
2881
- const body = await res.json() as any;
2882
- expect(body.error).toBe("attachment_missing");
2883
- });
3790
+ test("single-note (?id=) mode: include_link_count correct degree", async () => {
3791
+ await seed();
3792
+ const res = await handleNotes(mkReq("GET", "/notes?id=self&include_link_count=true"), store, "");
3793
+ const body = (await res.json()) as any;
3794
+ expect(body.linkCount).toBe(2);
3795
+ });
2884
3796
 
2885
- test("405 on GET (must POST)", async () => {
2886
- await seedFailedTranscript({
2887
- transcriptId: "transcript-405",
2888
- noteId: "src-405",
2889
- audioPath: "memo/405.webm",
2890
- });
2891
- const res = await handleNotes(
2892
- mkReq("GET", "/notes/transcript-405/retry-transcription"),
2893
- store,
2894
- "/transcript-405/retry-transcription",
2895
- "default",
2896
- );
2897
- expect(res.status).toBe(405);
2898
- delete process.env.ASSETS_DIR;
2899
- });
3797
+ test("single-note (/notes/:id) mode: include_link_count → correct degree", async () => {
3798
+ await seed();
3799
+ const res = await handleNotes(mkReq("GET", "/notes/self?include_link_count=true"), store, "/self");
3800
+ const body = (await res.json()) as any;
3801
+ expect(body.linkCount).toBe(2);
3802
+ });
3803
+
3804
+ test("link_count_direction outbound / inbound variants", async () => {
3805
+ await seed();
3806
+ const out = await handleNotes(
3807
+ mkReq("GET", "/notes?id=hub&include_link_count=true&link_count_direction=outbound"),
3808
+ store,
3809
+ "",
3810
+ );
3811
+ expect(((await out.json()) as any).linkCount).toBe(1); // hub→leaf
3812
+ const inb = await handleNotes(
3813
+ mkReq("GET", "/notes?id=hub&include_link_count=true&link_count_direction=inbound"),
3814
+ store,
3815
+ "",
3816
+ );
3817
+ expect(((await inb.json()) as any).linkCount).toBe(1); // leaf→hub
3818
+ });
3819
+
3820
+ test("unrecognized link_count_direction falls back to both (REST parseLinkCountDirection)", async () => {
3821
+ await seed();
3822
+ // hub: both=2, outbound=1, inbound=1. A bogus value must degrade to
3823
+ // `both` (2), distinct from either directional value (1).
3824
+ const res = await handleNotes(
3825
+ mkReq("GET", "/notes?id=hub&include_link_count=true&link_count_direction=sideways"),
3826
+ store,
3827
+ "",
3828
+ );
3829
+ expect(((await res.json()) as any).linkCount).toBe(2);
3830
+ });
3831
+
3832
+ test("FTS branch: search + include_link_count → results carry linkCount", async () => {
3833
+ // The full-text-search branch is a separate return path from the
3834
+ // structured query; exercise the flag there explicitly.
3835
+ await store.createNote("quokka sighting near the hub", { id: "fts-hub", path: "fts-hub" });
3836
+ await store.createNote("a quokka friend", { id: "fts-friend", path: "fts-friend" });
3837
+ await store.createLink("fts-hub", "fts-friend", "a"); // hub out1, friend in1
3838
+ await store.createLink("fts-friend", "fts-hub", "b"); // hub in1 => hub degree 2
3839
+ const res = await handleNotes(
3840
+ mkReq("GET", "/notes?search=quokka&include_link_count=true"),
3841
+ store,
3842
+ "",
3843
+ );
3844
+ const body = (await res.json()) as any[];
3845
+ const byId = Object.fromEntries(body.map((n) => [n.id, n]));
3846
+ expect(byId["fts-hub"].linkCount).toBe(2);
3847
+ expect(byId["fts-friend"].linkCount).toBe(2);
3848
+ });
3849
+
3850
+ test("FTS branch: absent flag → no linkCount key", async () => {
3851
+ await store.createNote("quokka sighting near the hub", { id: "fts-hub", path: "fts-hub" });
3852
+ await store.createLink("fts-hub", "fts-hub", "loop");
3853
+ const res = await handleNotes(mkReq("GET", "/notes?search=quokka"), store, "");
3854
+ const body = (await res.json()) as any[];
3855
+ expect(body.every((n) => !("linkCount" in n))).toBe(true);
3856
+ });
3857
+
3858
+ test("order_by=link_count desc: field value == sort key for every note", async () => {
3859
+ // Distinct degrees so the ordering is unambiguous: big=3, mid=2, small=0.
3860
+ await store.createNote("Big", { id: "big", path: "big" });
3861
+ await store.createNote("Mid", { id: "mid", path: "mid" });
3862
+ await store.createNote("Small", { id: "small", path: "small" });
3863
+ await store.createLink("big", "mid", "a"); // big out1, mid in1
3864
+ await store.createLink("big", "small", "b"); // big out2, small in1
3865
+ await store.createLink("mid", "big", "c"); // big in1 => big degree 3; mid out1 => mid degree 2
3866
+ // small degree 1 (in from big). Adjust: make small degree 0 by removing
3867
+ // — instead assert monotonic + field==sortkey, which is the real invariant.
3868
+
3869
+ const res = await handleNotes(
3870
+ mkReq("GET", "/notes?order_by=link_count&sort=desc&include_link_count=true"),
3871
+ store,
3872
+ "",
3873
+ );
3874
+ const body = (await res.json()) as any[];
3875
+ const seq = body.map((n) => n.linkCount as number);
3876
+ // The injected field equals the sort key, so the sequence is non-increasing.
3877
+ expect(seq).toEqual([...seq].sort((a, b) => b - a));
3878
+ expect(body[0].id).toBe("big"); // degree 3 — the most-connected note
3879
+ expect(body[0].linkCount).toBe(3);
3880
+ });
3881
+
3882
+ test("order_by=link_count: self-loop note ranks by its degree-2 field value", async () => {
3883
+ await store.createNote("Selfy", { id: "selfy", path: "selfy" });
3884
+ await store.createNote("Plain", { id: "plain", path: "plain" });
3885
+ await store.createNote("Zero", { id: "zero", path: "zero" });
3886
+ await store.createLink("selfy", "selfy", "loop"); // degree 2
3887
+ await store.createLink("zero", "plain", "ref"); // plain in1, zero out1
3888
+
3889
+ const res = await handleNotes(
3890
+ mkReq("GET", "/notes?order_by=link_count&sort=desc&include_link_count=true"),
3891
+ store,
3892
+ "",
3893
+ );
3894
+ const body = (await res.json()) as any[];
3895
+ expect(body[0].id).toBe("selfy"); // degree 2 outranks the degree-1 notes
3896
+ expect(body[0].linkCount).toBe(2); // field == the sort key that put it first
3897
+ const byId = Object.fromEntries(body.map((n) => [n.id, n]));
3898
+ expect(byId.plain.linkCount).toBe(1);
3899
+ expect(byId.zero.linkCount).toBe(1);
2900
3900
  });
2901
3901
  });
2902
3902
 
@@ -3026,6 +4026,78 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
3026
4026
  expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
3027
4027
  });
3028
4028
 
4029
+ // vault feedback #8 — the update response now echoes hydrated links when
4030
+ // the request mutated links OR `?include_links=true` is passed, so callers
4031
+ // no longer have to re-GET to confirm a link they just added/removed.
4032
+ test("PATCH links.add echoes hydrated links on the response", async () => {
4033
+ await store.createNote("a", { id: "a" });
4034
+ await store.createNote("b", { id: "b", path: "People/Bob", tags: ["person"] });
4035
+ const res = await handleNotes(
4036
+ mkReq("PATCH", "/notes/a", { links: { add: [{ target: "b", relationship: "mentions" }] }, force: true }),
4037
+ store,
4038
+ "/a",
4039
+ );
4040
+ expect(res.status).toBe(200);
4041
+ const body = await res.json() as any;
4042
+ expect(Array.isArray(body.links)).toBe(true);
4043
+ expect(body.links).toHaveLength(1);
4044
+ const link = body.links[0];
4045
+ expect(link.sourceId).toBe("a");
4046
+ expect(link.targetId).toBe("b");
4047
+ expect(link.relationship).toBe("mentions");
4048
+ // Hydrated shape matches GET / query-notes: targetNote summary present.
4049
+ expect(link.targetNote.id).toBe("b");
4050
+ expect(link.targetNote.path).toBe("People/Bob");
4051
+ expect(link.targetNote.tags).toEqual(["person"]);
4052
+ });
4053
+
4054
+ test("PATCH links.remove echoes the post-removal link set", async () => {
4055
+ await store.createNote("a", { id: "a" });
4056
+ await store.createNote("b", { id: "b" });
4057
+ await store.createNote("c", { id: "c" });
4058
+ await store.createLink("a", "b", "mentions");
4059
+ await store.createLink("a", "c", "mentions");
4060
+ const res = await handleNotes(
4061
+ mkReq("PATCH", "/notes/a", { links: { remove: [{ target: "b", relationship: "mentions" }] }, force: true }),
4062
+ store,
4063
+ "/a",
4064
+ );
4065
+ const body = await res.json() as any;
4066
+ expect(Array.isArray(body.links)).toBe(true);
4067
+ expect(body.links).toHaveLength(1);
4068
+ expect(body.links[0].targetId).toBe("c");
4069
+ });
4070
+
4071
+ test("PATCH without a link mutation or flag does NOT include links", async () => {
4072
+ await store.createNote("a", { id: "a" });
4073
+ await store.createNote("b", { id: "b" });
4074
+ await store.createLink("a", "b", "mentions");
4075
+ const res = await handleNotes(
4076
+ mkReq("PATCH", "/notes/a", { content: "updated", force: true }),
4077
+ store,
4078
+ "/a",
4079
+ );
4080
+ const body = await res.json() as any;
4081
+ expect(body.content).toBe("updated");
4082
+ expect(body).not.toHaveProperty("links");
4083
+ });
4084
+
4085
+ test("PATCH ?include_links=true echoes current links even without a mutation", async () => {
4086
+ await store.createNote("a", { id: "a" });
4087
+ await store.createNote("b", { id: "b" });
4088
+ await store.createLink("a", "b", "mentions");
4089
+ const res = await handleNotes(
4090
+ mkReq("PATCH", "/notes/a?include_links=true", { content: "updated", force: true }),
4091
+ store,
4092
+ "/a",
4093
+ );
4094
+ const body = await res.json() as any;
4095
+ expect(body.content).toBe("updated");
4096
+ expect(Array.isArray(body.links)).toBe(true);
4097
+ expect(body.links).toHaveLength(1);
4098
+ expect(body.links[0].targetId).toBe("b");
4099
+ });
4100
+
3029
4101
  test("PATCH resolves note by path", async () => {
3030
4102
  await store.createNote("x", { path: "Projects/README" });
3031
4103
  const res = await handleNotes(
@@ -3702,11 +4774,86 @@ describe("HTTP /tags", async () => {
3702
4774
  expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).toContain("aspect_ratio");
3703
4775
  });
3704
4776
 
3705
- test("PUT /tags/:name returns 400 with error_type: invalid_relationships on bad shape", async () => {
4777
+ // ---- relationships is an opaque vocabulary map (vault#428) ----
4778
+ // PUT persists the value verbatim with no inner-shape enforcement; GET
4779
+ // returns it byte-for-byte. Only a top-level non-map (array/primitive)
4780
+ // or non-serializable input is rejected with invalid_relationships.
4781
+
4782
+ test("PUT /tags/:name persists the opaque vocabulary map; GET returns it verbatim (vault#428)", async () => {
4783
+ const vocab = {
4784
+ "works-on": { from: "person", to: "project" },
4785
+ "member-of": { from: "person", to: "organization" },
4786
+ "partner-of": { from: "person", to: "person" },
4787
+ "based-at": { from: "project", to: "place" },
4788
+ };
4789
+ const put = await handleTags(
4790
+ mkReq("PUT", "/tags/person", { relationships: vocab }),
4791
+ store,
4792
+ "/person",
4793
+ );
4794
+ expect(put.status).toBe(200);
4795
+
4796
+ const get = await handleTags(mkReq("GET", "/tags/person"), store, "/person");
4797
+ expect(get.status).toBe(200);
4798
+ const body = await get.json() as any;
4799
+ // Byte-for-byte: serialize both sides and compare exactly.
4800
+ expect(JSON.stringify(body.relationships)).toBe(JSON.stringify(vocab));
4801
+ expect(body.relationships).toEqual(vocab);
4802
+ });
4803
+
4804
+ test("PUT /tags/:name still accepts the historical typed relationships shape (backwards-compat)", async () => {
4805
+ const typed = { owned_by: { target_tag: "person", cardinality: "one", description: "DRI" } };
4806
+ const put = await handleTags(
4807
+ mkReq("PUT", "/tags/project", { relationships: typed }),
4808
+ store,
4809
+ "/project",
4810
+ );
4811
+ expect(put.status).toBe(200);
4812
+ const get = await handleTags(mkReq("GET", "/tags/project"), store, "/project");
4813
+ const body = await get.json() as any;
4814
+ expect(body.relationships).toEqual(typed);
4815
+ });
4816
+
4817
+ test("PUT /tags/:name round-trips nested arbitrary relationship values verbatim", async () => {
4818
+ const vocab = { rel: { from: "a", to: "b", note: "freeform", weight: 3, tags: ["x", "y"] } };
4819
+ const put = await handleTags(
4820
+ mkReq("PUT", "/tags/thing", { relationships: vocab }),
4821
+ store,
4822
+ "/thing",
4823
+ );
4824
+ expect(put.status).toBe(200);
4825
+ const get = await handleTags(mkReq("GET", "/tags/thing"), store, "/thing");
4826
+ const body = await get.json() as any;
4827
+ expect(body.relationships).toEqual(vocab);
4828
+ });
4829
+
4830
+ test("PUT /tags/:name returns 400 invalid_relationships for a top-level array", async () => {
3706
4831
  const res = await handleTags(
3707
- mkReq("PUT", "/tags/person", {
3708
- relationships: { mentions: { target_tag: "topic", cardinality: "infinite" } },
3709
- }),
4832
+ mkReq("PUT", "/tags/person", { relationships: ["not", "a", "map"] }),
4833
+ store,
4834
+ "/person",
4835
+ );
4836
+ expect(res.status).toBe(400);
4837
+ const body = await res.json() as any;
4838
+ expect(body.error_type).toBe("invalid_relationships");
4839
+ expect(typeof body.error).toBe("string");
4840
+ expect(body.error.length).toBeGreaterThan(0);
4841
+ });
4842
+
4843
+ test("PUT /tags/:name returns 400 invalid_relationships for a top-level primitive", async () => {
4844
+ const res = await handleTags(
4845
+ mkReq("PUT", "/tags/person", { relationships: "just-a-string" as unknown as Record<string, unknown> }),
4846
+ store,
4847
+ "/person",
4848
+ );
4849
+ expect(res.status).toBe(400);
4850
+ const body = await res.json() as any;
4851
+ expect(body.error_type).toBe("invalid_relationships");
4852
+ });
4853
+
4854
+ test("PUT /tags/:name returns 400 invalid_relationships for an empty relationship key", async () => {
4855
+ const res = await handleTags(
4856
+ mkReq("PUT", "/tags/person", { relationships: { "": { from: "a", to: "b" } } }),
3710
4857
  store,
3711
4858
  "/person",
3712
4859
  );
@@ -4968,3 +6115,147 @@ describe("handleVault: audio_retention", async () => {
4968
6115
  });
4969
6116
  });
4970
6117
 
6118
+ describe("handleVault: auto_transcribe (per-vault)", async () => {
6119
+ function mkVaultReq(method: string, body?: unknown): Request {
6120
+ const init: RequestInit = { method };
6121
+ if (body !== undefined) {
6122
+ init.body = JSON.stringify(body);
6123
+ init.headers = { "Content-Type": "application/json" };
6124
+ }
6125
+ return new Request(`${BASE}/vault`, init);
6126
+ }
6127
+
6128
+ test("GET reflects the per-vault auto_transcribe.enabled when set", async () => {
6129
+ const cfg = { name: "vaultA", auto_transcribe: { enabled: false } };
6130
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
6131
+ expect(res.status).toBe(200);
6132
+ const body = await res.json() as any;
6133
+ // The vault's OWN value wins over global — per-vault → global → true.
6134
+ expect(body.config.auto_transcribe.enabled).toBe(false);
6135
+ });
6136
+
6137
+ test("GET reflects per-vault true override even if global is off", async () => {
6138
+ const cfg = { name: "vaultA", auto_transcribe: { enabled: true } };
6139
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
6140
+ const body = await res.json() as any;
6141
+ expect(body.config.auto_transcribe.enabled).toBe(true);
6142
+ });
6143
+
6144
+ test("PATCH writes auto_transcribe to THIS vault's config object (per-vault)", async () => {
6145
+ const cfg: { name: string; auto_transcribe?: { enabled?: boolean } } = { name: "vaultA" };
6146
+ let persisted = 0;
6147
+ const res = await handleVault(
6148
+ mkVaultReq("PATCH", { config: { auto_transcribe: { enabled: true } } }),
6149
+ store,
6150
+ cfg as any,
6151
+ () => { persisted++; },
6152
+ );
6153
+ expect(res.status).toBe(200);
6154
+ const body = await res.json() as any;
6155
+ expect(body.config.auto_transcribe.enabled).toBe(true);
6156
+ // Persisted onto the per-vault config object (writeVaultConfig path),
6157
+ // NOT a server-wide global — this is the field the worker reads per-vault.
6158
+ expect(cfg.auto_transcribe?.enabled).toBe(true);
6159
+ expect(persisted).toBe(1);
6160
+
6161
+ // GET round-trips the persisted per-vault value.
6162
+ const getRes = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
6163
+ const getBody = await getRes.json() as any;
6164
+ expect(getBody.config.auto_transcribe.enabled).toBe(true);
6165
+ });
6166
+
6167
+ test("enabling vault X does NOT affect vault Y (genuinely per-vault)", async () => {
6168
+ const vaultX: { name: string; auto_transcribe?: { enabled?: boolean } } = { name: "vaultX" };
6169
+ const vaultY: { name: string; auto_transcribe?: { enabled?: boolean } } = { name: "vaultY" };
6170
+
6171
+ // Link scribe to X only.
6172
+ await handleVault(
6173
+ mkVaultReq("PATCH", { config: { auto_transcribe: { enabled: true } } }),
6174
+ store,
6175
+ vaultX as any,
6176
+ () => {},
6177
+ );
6178
+
6179
+ expect(vaultX.auto_transcribe?.enabled).toBe(true);
6180
+ // Y is untouched — no global toggle was flipped, so Y still has no
6181
+ // per-vault override (the old global-write behavior would have moved Y too).
6182
+ expect(vaultY.auto_transcribe).toBeUndefined();
6183
+ });
6184
+
6185
+ test("PATCH accepts auto_transcribe.enabled=false", async () => {
6186
+ const cfg: { name: string; auto_transcribe?: { enabled?: boolean } } = {
6187
+ name: "vaultA",
6188
+ auto_transcribe: { enabled: true },
6189
+ };
6190
+ const res = await handleVault(
6191
+ mkVaultReq("PATCH", { config: { auto_transcribe: { enabled: false } } }),
6192
+ store,
6193
+ cfg as any,
6194
+ () => {},
6195
+ );
6196
+ expect(res.status).toBe(200);
6197
+ const body = await res.json() as any;
6198
+ expect(body.config.auto_transcribe.enabled).toBe(false);
6199
+ expect(cfg.auto_transcribe?.enabled).toBe(false);
6200
+ });
6201
+
6202
+ test("PATCH rejects a non-boolean enabled with 400 and does not mutate or persist", async () => {
6203
+ const cfg: { name: string; auto_transcribe?: { enabled?: boolean } } = {
6204
+ name: "vaultA",
6205
+ auto_transcribe: { enabled: true },
6206
+ };
6207
+ let persisted = 0;
6208
+ const res = await handleVault(
6209
+ mkVaultReq("PATCH", { config: { auto_transcribe: { enabled: "yes" } } }),
6210
+ store,
6211
+ cfg as any,
6212
+ () => { persisted++; },
6213
+ );
6214
+ expect(res.status).toBe(400);
6215
+ const body = await res.json() as any;
6216
+ expect(body.error).toBe("invalid_auto_transcribe");
6217
+ // Unchanged — the bad write never landed.
6218
+ expect(cfg.auto_transcribe?.enabled).toBe(true);
6219
+ expect(persisted).toBe(0);
6220
+ });
6221
+
6222
+ test("PATCH rejects auto_transcribe missing enabled with 400", async () => {
6223
+ const cfg = { name: "vaultA" } as { name: string };
6224
+ let persisted = 0;
6225
+ const res = await handleVault(
6226
+ mkVaultReq("PATCH", { config: { auto_transcribe: {} } }),
6227
+ store,
6228
+ cfg as any,
6229
+ () => { persisted++; },
6230
+ );
6231
+ expect(res.status).toBe(400);
6232
+ const body = await res.json() as any;
6233
+ expect(body.error).toBe("invalid_auto_transcribe");
6234
+ expect(persisted).toBe(0);
6235
+ });
6236
+
6237
+ test("auto_transcribe and audio_retention can be set in one PATCH (single persist)", async () => {
6238
+ const cfg: { name: string; audio_retention?: string; auto_transcribe?: { enabled?: boolean } } = {
6239
+ name: "vaultA",
6240
+ };
6241
+ let persisted = 0;
6242
+ const res = await handleVault(
6243
+ mkVaultReq("PATCH", {
6244
+ config: { audio_retention: "until_transcribed", auto_transcribe: { enabled: true } },
6245
+ }),
6246
+ store,
6247
+ cfg as any,
6248
+ () => { persisted++; },
6249
+ );
6250
+ expect(res.status).toBe(200);
6251
+ const body = await res.json() as any;
6252
+ expect(body.config.audio_retention).toBe("until_transcribed");
6253
+ expect(body.config.auto_transcribe.enabled).toBe(true);
6254
+ expect(cfg.audio_retention).toBe("until_transcribed");
6255
+ expect(cfg.auto_transcribe?.enabled).toBe(true);
6256
+ // Both fields persisted in one writeVaultConfig call.
6257
+ expect(persisted).toBe(1);
6258
+ });
6259
+
6260
+ });
6261
+