@openparachute/vault 0.5.1 → 0.5.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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;
@@ -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", () => {
@@ -2721,6 +2863,171 @@ describe("HTTP /notes", async () => {
2721
2863
  const body = await res.json() as any[];
2722
2864
  expect(body.map((n) => n.content)).toEqual(["new"]);
2723
2865
  });
2866
+
2867
+ // ---- JSON `metadata=<json>` alias (symmetric with the MCP nested obj) ----
2868
+ //
2869
+ // Before this alias, a `?metadata={...}` param was silently dropped: the
2870
+ // bracket grammar never matched it, `queryOpts.metadata` stayed undefined,
2871
+ // and the query returned ALL tag-matching notes — a silent wrong result.
2872
+
2873
+ test("alias `metadata={field:{op:value}}` filters on an indexed field", async () => {
2874
+ await declareIndexed();
2875
+ await store.createNote("open-1", { metadata: { status: "open" } });
2876
+ await store.createNote("open-2", { metadata: { status: "open" } });
2877
+ await store.createNote("closed", { metadata: { status: "closed" } });
2878
+ const q = encodeURIComponent(JSON.stringify({ status: { eq: "open" } }));
2879
+ const res = await handleNotes(
2880
+ mkReq("GET", `/notes?metadata=${q}&include_content=true`),
2881
+ store,
2882
+ "",
2883
+ );
2884
+ expect(res.status).toBe(200);
2885
+ const body = await res.json() as any[];
2886
+ expect(body.map((n) => n.content).sort()).toEqual(["open-1", "open-2"]);
2887
+ });
2888
+
2889
+ test("alias shorthand equality `metadata={field:value}` works via json_extract fallback", async () => {
2890
+ // No declareIndexed — shorthand routes through the engine's json_extract
2891
+ // exact-match path, no indexed declaration required.
2892
+ await store.createNote("matches", { metadata: { status: "open" } });
2893
+ await store.createNote("other", { metadata: { status: "closed" } });
2894
+ const q = encodeURIComponent(JSON.stringify({ status: "open" }));
2895
+ const res = await handleNotes(
2896
+ mkReq("GET", `/notes?metadata=${q}&include_content=true`),
2897
+ store,
2898
+ "",
2899
+ );
2900
+ expect(res.status).toBe(200);
2901
+ const body = await res.json() as any[];
2902
+ expect(body.map((n) => n.content)).toEqual(["matches"]);
2903
+ });
2904
+
2905
+ test("alias and bracket form return identical results for the same indexed-field operator query", async () => {
2906
+ await declareIndexed();
2907
+ for (const p of [1, 2, 3, 4, 5]) {
2908
+ await store.createNote(`p${p}`, { metadata: { priority: p } });
2909
+ }
2910
+ // JSON preserves the real number type 3; bracket form passes "3" as a
2911
+ // string. Both must coerce to the same range result against the INTEGER
2912
+ // indexed column — this guards the type-coercion edge.
2913
+ const aliasQ = encodeURIComponent(JSON.stringify({ priority: { gte: 3 } }));
2914
+ const aliasRes = await handleNotes(
2915
+ mkReq("GET", `/notes?metadata=${aliasQ}&include_content=true`),
2916
+ store,
2917
+ "",
2918
+ );
2919
+ const bracketRes = await handleNotes(
2920
+ mkReq("GET", "/notes?meta[priority][gte]=3&include_content=true"),
2921
+ store,
2922
+ "",
2923
+ );
2924
+ const aliasBody = await aliasRes.json() as any[];
2925
+ const bracketBody = await bracketRes.json() as any[];
2926
+ expect(aliasBody.map((n) => n.content).sort()).toEqual(["p3", "p4", "p5"]);
2927
+ expect(aliasBody.map((n) => n.content).sort()).toEqual(
2928
+ bracketBody.map((n) => n.content).sort(),
2929
+ );
2930
+ });
2931
+
2932
+ test("malformed JSON in `metadata=` rejects with 400 INVALID_QUERY", async () => {
2933
+ const res = await handleNotes(
2934
+ mkReq("GET", "/notes?metadata=" + encodeURIComponent("{not json")),
2935
+ store,
2936
+ "",
2937
+ );
2938
+ expect(res.status).toBe(400);
2939
+ const body = await res.json() as any;
2940
+ expect(body.code).toBe("INVALID_QUERY");
2941
+ expect(body.error).toContain("JSON object");
2942
+ });
2943
+
2944
+ test("non-object `metadata=` JSON (array) rejects with 400 INVALID_QUERY", async () => {
2945
+ const res = await handleNotes(
2946
+ mkReq("GET", "/notes?metadata=" + encodeURIComponent(JSON.stringify(["status"]))),
2947
+ store,
2948
+ "",
2949
+ );
2950
+ expect(res.status).toBe(400);
2951
+ const body = await res.json() as any;
2952
+ expect(body.code).toBe("INVALID_QUERY");
2953
+ });
2954
+
2955
+ test("primitive-scalar `metadata=` JSON (number / bare string) rejects with 400 INVALID_QUERY", async () => {
2956
+ // `metadata=42` and `metadata="open"` are valid JSON but not objects —
2957
+ // both fall through the non-object branch.
2958
+ for (const raw of ["42", JSON.stringify("open")]) {
2959
+ const res = await handleNotes(
2960
+ mkReq("GET", "/notes?metadata=" + encodeURIComponent(raw)),
2961
+ store,
2962
+ "",
2963
+ );
2964
+ expect(res.status).toBe(400);
2965
+ const body = await res.json() as any;
2966
+ expect(body.code).toBe("INVALID_QUERY");
2967
+ }
2968
+ });
2969
+
2970
+ test("empty-object alias `metadata={}` is treated as absent and composes with a bracket filter", async () => {
2971
+ // `{}` carries no filter intent — it must neither set a metadata filter
2972
+ // NOR trip the both-forms 400 guard. So `metadata={}` + a bracket
2973
+ // metadata filter is a 200 filtered by the bracket form only.
2974
+ await declareIndexed();
2975
+ await store.createNote("hi", { metadata: { priority: 5 } });
2976
+ await store.createNote("lo", { metadata: { priority: 1 } });
2977
+ const res = await handleNotes(
2978
+ mkReq("GET", "/notes?metadata=" + encodeURIComponent("{}") + "&meta[priority][gte]=3&include_content=true"),
2979
+ store,
2980
+ "",
2981
+ );
2982
+ expect(res.status).toBe(200);
2983
+ const body = await res.json() as any[];
2984
+ expect(body.map((n) => n.content)).toEqual(["hi"]);
2985
+ });
2986
+
2987
+ test("both `metadata=` alias AND `meta[...]` bracket params present rejects with 400 INVALID_QUERY", async () => {
2988
+ await declareIndexed();
2989
+ const q = encodeURIComponent(JSON.stringify({ status: { eq: "open" } }));
2990
+ const res = await handleNotes(
2991
+ mkReq("GET", `/notes?metadata=${q}&meta[priority][gte]=3`),
2992
+ store,
2993
+ "",
2994
+ );
2995
+ expect(res.status).toBe(400);
2996
+ const body = await res.json() as any;
2997
+ expect(body.code).toBe("INVALID_QUERY");
2998
+ expect(body.error).toContain("not both");
2999
+ });
3000
+
3001
+ test("regression: previously-silently-dropped `?metadata={status:{eq:pending}}` now actually filters", async () => {
3002
+ await declareIndexed();
3003
+ await store.createNote("pending-1", { metadata: { status: "pending" } });
3004
+ await store.createNote("pending-2", { metadata: { status: "pending" } });
3005
+ await store.createNote("done", { metadata: { status: "done" } });
3006
+ const q = encodeURIComponent(JSON.stringify({ status: { eq: "pending" } }));
3007
+ const res = await handleNotes(
3008
+ mkReq("GET", `/notes?metadata=${q}&include_content=true`),
3009
+ store,
3010
+ "",
3011
+ );
3012
+ expect(res.status).toBe(200);
3013
+ const body = await res.json() as any[];
3014
+ // Before the fix this returned ALL three notes (filter dropped). Now it
3015
+ // returns only the two pending ones.
3016
+ expect(body.map((n) => n.content).sort()).toEqual(["pending-1", "pending-2"]);
3017
+ });
3018
+
3019
+ test("alias with an unknown operator surfaces the engine's 400 UNKNOWN_OPERATOR", async () => {
3020
+ await declareIndexed();
3021
+ const q = encodeURIComponent(JSON.stringify({ priority: { bogus: 5 } }));
3022
+ const res = await handleNotes(
3023
+ mkReq("GET", `/notes?metadata=${q}`),
3024
+ store,
3025
+ "",
3026
+ );
3027
+ expect(res.status).toBe(400);
3028
+ const body = await res.json() as any;
3029
+ expect(body.code).toBe("UNKNOWN_OPERATOR");
3030
+ });
2724
3031
  });
2725
3032
 
2726
3033
  // -------------------------------------------------------------------------
@@ -2803,7 +3110,10 @@ describe("HTTP /notes", async () => {
2803
3110
  expect(res.status).toBe(404);
2804
3111
  });
2805
3112
 
2806
- test("400 invalid_target when target is not a transcript note", async () => {
3113
+ test("400 no_failed_attachment when target is a regular note with no failed audio", async () => {
3114
+ // A note without `transcript_status` frontmatter is treated as a
3115
+ // possible legacy in-body memo (finding F). With no attachment carrying
3116
+ // a failed transcription there's nothing to retry → no_failed_attachment.
2807
3117
  await store.createNote("regular note", { id: "regular" });
2808
3118
  const res = await handleNotes(
2809
3119
  mkReq("POST", "/notes/regular/retry-transcription"),
@@ -2813,7 +3123,7 @@ describe("HTTP /notes", async () => {
2813
3123
  );
2814
3124
  expect(res.status).toBe(400);
2815
3125
  const body = await res.json() as any;
2816
- expect(body.error).toBe("invalid_target");
3126
+ expect(body.error).toBe("no_failed_attachment");
2817
3127
  });
2818
3128
 
2819
3129
  test("400 not_failed when transcript already succeeded", async () => {
@@ -2897,6 +3207,606 @@ describe("HTTP /notes", async () => {
2897
3207
  expect(res.status).toBe(405);
2898
3208
  delete process.env.ASSETS_DIR;
2899
3209
  });
3210
+
3211
+ // -----------------------------------------------------------------------
3212
+ // Legacy in-body memo retry (finding F). The target is the memo note
3213
+ // itself (no `transcript_status` frontmatter); it directly owns a failed
3214
+ // audio attachment. The request must reset the attachment preserving
3215
+ // `transcribe_origin: "legacy"` and re-arm `transcribe_stub: true` so the
3216
+ // worker's legacy success path will write the transcript back into the
3217
+ // body. End-to-end re-transcription is covered in
3218
+ // transcription-worker.test.ts.
3219
+ // -----------------------------------------------------------------------
3220
+ async function seedLegacyFailedMemo(opts: {
3221
+ noteId?: string;
3222
+ audioPath?: string;
3223
+ withFile?: boolean;
3224
+ } = {}): Promise<{ noteId: string; attachmentId: string; audioPath: string }> {
3225
+ const noteId = opts.noteId ?? "legacy-memo";
3226
+ const audioPath = opts.audioPath ?? `${noteId}/voice.webm`;
3227
+ // The capture body after a terminal failure: marker replaced the
3228
+ // placeholder, embed intact, stub cleared by the worker.
3229
+ const note = await store.createNote(
3230
+ `# 🎙️ Voice memo\n\n_Recorded sometime._\n\n_Transcription unavailable._\n\n![[${audioPath}]]\n`,
3231
+ { id: noteId },
3232
+ );
3233
+ const att = await store.addAttachment(note.id, audioPath, "audio/webm", {
3234
+ transcribe_status: "failed",
3235
+ // legacy origin is the default (undefined); leave it off to model the
3236
+ // genuine legacy capture shape.
3237
+ transcribe_error: "scribe down",
3238
+ transcribe_attempts: 3,
3239
+ });
3240
+ const assetsRoot = join(tmpDir, "assets");
3241
+ if (opts.withFile !== false) {
3242
+ mkdirSync(join(assetsRoot, audioPath.split("/").slice(0, -1).join("/")), { recursive: true });
3243
+ writeFileSync(join(assetsRoot, audioPath), Buffer.from([1, 2, 3]));
3244
+ }
3245
+ process.env.ASSETS_DIR = assetsRoot;
3246
+ return { noteId, attachmentId: att.id, audioPath };
3247
+ }
3248
+
3249
+ test("legacy in-body memo: 202, resets attachment (legacy origin) + re-arms stub", async () => {
3250
+ const { noteId, attachmentId, audioPath } = await seedLegacyFailedMemo();
3251
+ const res = await handleNotes(
3252
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3253
+ store,
3254
+ `/${noteId}/retry-transcription`,
3255
+ "default",
3256
+ );
3257
+ expect(res.status).toBe(202);
3258
+ const body = await res.json() as any;
3259
+ expect(body.status).toBe("queued");
3260
+ expect(body.attachment_id).toBe(attachmentId);
3261
+ expect(body.attachment_path).toBe(audioPath);
3262
+ expect(body.transcript_note_id).toBe(noteId);
3263
+
3264
+ // Attachment reset to pending, legacy origin preserved (NOT flipped to
3265
+ // auto — that would orphan the in-body embed), failure state cleared.
3266
+ const att = await store.getAttachment(attachmentId);
3267
+ expect(att?.metadata?.transcribe_status).toBe("pending");
3268
+ expect(att?.metadata?.transcribe_origin).toBe("legacy");
3269
+ expect(att?.metadata?.transcribe_error).toBeUndefined();
3270
+ expect(att?.metadata?.transcribe_attempts).toBeUndefined();
3271
+
3272
+ // Stub re-armed on the note — without this the worker's legacy success
3273
+ // path early-returns and never writes the transcript back.
3274
+ const updated = await store.getNote(noteId);
3275
+ expect((updated!.metadata as any)?.transcribe_stub).toBe(true);
3276
+ // Body untouched by the retry request itself (embed + marker intact).
3277
+ expect(updated!.content).toContain(`![[${audioPath}]]`);
3278
+ expect(updated!.content).toContain("_Transcription unavailable._");
3279
+
3280
+ delete process.env.ASSETS_DIR;
3281
+ });
3282
+
3283
+ test("legacy in-body memo: 404 audio_missing when the file is gone", async () => {
3284
+ const { noteId } = await seedLegacyFailedMemo({
3285
+ noteId: "legacy-gone",
3286
+ withFile: false,
3287
+ });
3288
+ const res = await handleNotes(
3289
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3290
+ store,
3291
+ `/${noteId}/retry-transcription`,
3292
+ "default",
3293
+ );
3294
+ expect(res.status).toBe(404);
3295
+ const body = await res.json() as any;
3296
+ expect(body.error).toBe("audio_missing");
3297
+ delete process.env.ASSETS_DIR;
3298
+ });
3299
+
3300
+ test("legacy in-body memo: end-to-end retry round-trip (capture → fail → retry → success)", async () => {
3301
+ // Start from the CANONICAL capture body (recorder.ts memoNoteContent
3302
+ // shape): header + _Recorded_ + _Transcript pending._ + ![[embed]],
3303
+ // with transcribe_stub: true.
3304
+ const audioPath = "e2e/voice.webm";
3305
+ const captureBody =
3306
+ "# 🎙️ Voice memo\n\n_Recorded sometime._\n\n_Transcript pending._\n\n![[e2e/voice.webm]]\n";
3307
+ await store.createNote(captureBody, {
3308
+ id: "e2e-memo",
3309
+ metadata: { transcribe_stub: true },
3310
+ });
3311
+ const att = await store.addAttachment("e2e-memo", audioPath, "audio/webm", {
3312
+ transcribe_status: "pending",
3313
+ transcribe_attempts: 2, // one more failure flips to terminal at maxAttempts=3
3314
+ });
3315
+ const assetsRoot = join(tmpDir, "assets");
3316
+ mkdirSync(join(assetsRoot, "e2e"), { recursive: true });
3317
+ writeFileSync(join(assetsRoot, audioPath), Buffer.from([1, 2, 3]));
3318
+ process.env.ASSETS_DIR = assetsRoot;
3319
+
3320
+ // What a first-try success would have produced (for the final assert).
3321
+ const firstTrySuccessBody =
3322
+ "# 🎙️ Voice memo\n\n_Recorded sometime._\n\nthe spoken words\n\n![[e2e/voice.webm]]\n";
3323
+
3324
+ // --- Phase 1: terminal failure. Worker writes the marker in place,
3325
+ // preserving the embed, and clears the stub.
3326
+ let fetchMode: "fail" | "succeed" = "fail";
3327
+ const fetchImpl = (async () => {
3328
+ if (fetchMode === "fail") {
3329
+ return new Response("scribe down", { status: 500 });
3330
+ }
3331
+ return new Response(JSON.stringify({ text: "the spoken words" }), {
3332
+ status: 200,
3333
+ headers: { "content-type": "application/json" },
3334
+ });
3335
+ }) as typeof fetch;
3336
+
3337
+ const worker = startTranscriptionWorker({
3338
+ vaultList: () => ["default"],
3339
+ getStore: () => store as unknown as Store,
3340
+ scribeUrl: "http://scribe.test",
3341
+ resolveAssetsDir: () => process.env.ASSETS_DIR!,
3342
+ pollIntervalMs: 10_000_000,
3343
+ maxAttempts: 3,
3344
+ fetchImpl,
3345
+ logger: { error: () => {}, info: () => {} },
3346
+ });
3347
+ setTranscriptionWorker(worker);
3348
+ try {
3349
+ await worker.tick();
3350
+
3351
+ const failedNote = await store.getNote("e2e-memo");
3352
+ // Marker replaced the placeholder in place; embed + surrounding body intact.
3353
+ expect(failedNote!.content).toBe(
3354
+ "# 🎙️ Voice memo\n\n_Recorded sometime._\n\n_Transcription unavailable._\n\n![[e2e/voice.webm]]\n",
3355
+ );
3356
+ expect((failedNote!.metadata as any)?.transcribe_stub).toBeUndefined();
3357
+ const failedAtt = await store.getAttachment(att.id);
3358
+ expect(failedAtt?.metadata?.transcribe_status).toBe("failed");
3359
+
3360
+ // --- Phase 2: retry via the legacy route form (POST on the memo note).
3361
+ // Deregister the worker so the retry is "sweep-only" — that lets us
3362
+ // observe the reset + stub re-arm deterministically before the worker
3363
+ // picks the row back up (otherwise the route's fire-and-forget kick
3364
+ // would race our assertions and complete the success in-line).
3365
+ setTranscriptionWorker(null);
3366
+ fetchMode = "succeed";
3367
+ const retryRes = await handleNotes(
3368
+ mkReq("POST", "/notes/e2e-memo/retry-transcription"),
3369
+ store,
3370
+ "/e2e-memo/retry-transcription",
3371
+ "default",
3372
+ );
3373
+ expect(retryRes.status).toBe(202);
3374
+ expect((await retryRes.json() as any).worker).toBe("sweep-only");
3375
+
3376
+ // Attachment back to pending + legacy origin; stub re-armed on the note.
3377
+ const pendingAtt = await store.getAttachment(att.id);
3378
+ expect(pendingAtt?.metadata?.transcribe_status).toBe("pending");
3379
+ expect(pendingAtt?.metadata?.transcribe_origin).toBe("legacy");
3380
+ const rearmed = await store.getNote("e2e-memo");
3381
+ expect((rearmed!.metadata as any)?.transcribe_stub).toBe(true);
3382
+
3383
+ // --- Phase 3: worker succeeds on the retry (sweep tick). Transcript
3384
+ // replaces the _Transcription unavailable._ marker IN PLACE; embed
3385
+ // preserved; final body is byte-identical to a first-try success.
3386
+ setTranscriptionWorker(worker);
3387
+ await worker.tick();
3388
+ const success = await store.getNote("e2e-memo");
3389
+ expect(success!.content).toBe(firstTrySuccessBody);
3390
+ expect(success!.content).toContain("![[e2e/voice.webm]]");
3391
+ expect((success!.metadata as any)?.transcribe_stub).toBeUndefined();
3392
+ const doneAtt = await store.getAttachment(att.id);
3393
+ expect(doneAtt?.metadata?.transcribe_status).toBe("done");
3394
+ expect(doneAtt?.metadata?.transcript).toBe("the spoken words");
3395
+ } finally {
3396
+ await worker.stop();
3397
+ setTranscriptionWorker(null);
3398
+ delete process.env.ASSETS_DIR;
3399
+ }
3400
+ });
3401
+
3402
+ // ---- Optimistic concurrency on the stub re-stamp (vault#435) ----------
3403
+ // The retry endpoint does a read-transform-write on the memo note to
3404
+ // re-arm `transcribe_stub: true`. Without an `if_updated_at` precondition,
3405
+ // a user edit landing between the read (`resolveNote`) and this write is
3406
+ // silently clobbered — the static-write/stale-read class of vault#208.
3407
+ //
3408
+ // We inject a store wrapper that fires a concurrent USER edit immediately
3409
+ // before the route's first OC `updateNote` runs, making its precondition
3410
+ // stale. The route must NOT clobber the user's edit; it must re-read and
3411
+ // re-apply the metadata-only re-stamp against fresh content.
3412
+
3413
+ /**
3414
+ * Wrap a store so the first `N` `updateNote` calls carrying an
3415
+ * `if_updated_at` precondition fire `userEdit()` (a concurrent user write
3416
+ * that bumps `updated_at`) just before delegating — forcing the precondition
3417
+ * stale exactly `interfereTimes` times. Non-OC writes pass through.
3418
+ *
3419
+ * NOTE: duplicated in src/transcription-worker.test.ts (worker-layer race
3420
+ * tests) — keep in sync.
3421
+ */
3422
+ function withRace(
3423
+ base: Store,
3424
+ interfereTimes: number,
3425
+ userEdit: () => Promise<void>,
3426
+ ): Store {
3427
+ let fired = 0;
3428
+ return new Proxy(base, {
3429
+ get(target, prop, receiver) {
3430
+ if (prop === "updateNote") {
3431
+ return async (id: string, updates: any) => {
3432
+ if (updates?.if_updated_at !== undefined && fired < interfereTimes) {
3433
+ fired++;
3434
+ // bun:sqlite stamps `updated_at` at ms granularity. Sleep so
3435
+ // the concurrent user write lands at a strictly-greater
3436
+ // timestamp than the precondition the route captured — making
3437
+ // the conflict deterministic rather than racing inside the
3438
+ // same millisecond.
3439
+ await new Promise((r) => setTimeout(r, 5));
3440
+ await userEdit();
3441
+ }
3442
+ return (target as any).updateNote(id, updates);
3443
+ };
3444
+ }
3445
+ return Reflect.get(target, prop, receiver);
3446
+ },
3447
+ }) as Store;
3448
+ }
3449
+
3450
+ test("OC: single race → user edit survives, stub still re-armed (no clobber)", async () => {
3451
+ const { noteId, attachmentId } = await seedLegacyFailedMemo({ noteId: "race-1" });
3452
+
3453
+ // One interference: the very first OC write conflicts; the route re-reads
3454
+ // and re-applies against the user's new content.
3455
+ const raceStore = withRace(store, 1, async () => {
3456
+ // User appends a line to the body while the retry is in flight.
3457
+ await store.updateNote(noteId, { append: "\n\nMY EDIT WHILE PENDING" });
3458
+ });
3459
+
3460
+ const res = await handleNotes(
3461
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3462
+ raceStore,
3463
+ `/${noteId}/retry-transcription`,
3464
+ "default",
3465
+ );
3466
+ // (a) User edit NOT clobbered + (c) re-stamp succeeded on retry → 202.
3467
+ expect(res.status).toBe(202);
3468
+
3469
+ const after = await store.getNote(noteId);
3470
+ // (a) The user's concurrent edit survives.
3471
+ expect(after!.content).toContain("MY EDIT WHILE PENDING");
3472
+ // Original capture body also intact (re-stamp is metadata-only).
3473
+ expect(after!.content).toContain("_Transcription unavailable._");
3474
+ // (c) Stub re-armed despite the race.
3475
+ expect((after!.metadata as any)?.transcribe_stub).toBe(true);
3476
+
3477
+ const att = await store.getAttachment(attachmentId);
3478
+ expect(att?.metadata?.transcribe_status).toBe("pending");
3479
+ expect(att?.metadata?.transcribe_origin).toBe("legacy");
3480
+
3481
+ delete process.env.ASSETS_DIR;
3482
+ });
3483
+
3484
+ test("OC: double race → 409 (user-facing request can retry)", async () => {
3485
+ const { noteId } = await seedLegacyFailedMemo({ noteId: "race-2" });
3486
+
3487
+ // Interfere on BOTH the first write and the retry write → the route
3488
+ // exhausts its single retry and surfaces 409.
3489
+ const raceStore = withRace(store, 2, async () => {
3490
+ await store.updateNote(noteId, { append: " x" });
3491
+ });
3492
+
3493
+ const res = await handleNotes(
3494
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3495
+ raceStore,
3496
+ `/${noteId}/retry-transcription`,
3497
+ "default",
3498
+ );
3499
+ // (c) Double-conflict policy for a user-facing endpoint: 409.
3500
+ expect(res.status).toBe(409);
3501
+ const body = await res.json() as any;
3502
+ expect(body.error_type).toBe("conflict");
3503
+ expect(body.note_id).toBe(noteId);
3504
+
3505
+ // The note was never clobbered — the user's two appends are both present.
3506
+ const after = await store.getNote(noteId);
3507
+ expect(after!.content).toContain(" x x");
3508
+
3509
+ delete process.env.ASSETS_DIR;
3510
+ });
3511
+
3512
+ test("OC: happy path unchanged when no race occurs", async () => {
3513
+ // With zero interference the OC write lands first-try, byte-identical to
3514
+ // the pre-#435 behavior — guards against the precondition breaking the
3515
+ // common path.
3516
+ const { noteId } = await seedLegacyFailedMemo({ noteId: "race-0" });
3517
+ const res = await handleNotes(
3518
+ mkReq("POST", `/notes/${noteId}/retry-transcription`),
3519
+ store,
3520
+ `/${noteId}/retry-transcription`,
3521
+ "default",
3522
+ );
3523
+ expect(res.status).toBe(202);
3524
+ const after = await store.getNote(noteId);
3525
+ expect((after!.metadata as any)?.transcribe_stub).toBe(true);
3526
+ delete process.env.ASSETS_DIR;
3527
+ });
3528
+ });
3529
+ });
3530
+
3531
+ // ---------------------------------------------------------------------------
3532
+ // REST tag-scope confidentiality (security review). expand_links must not
3533
+ // inline out-of-scope wikilinked content; include_links must not hydrate
3534
+ // out-of-scope neighbor summaries; unresolved-wikilinks must not surface
3535
+ // out-of-scope source rows. Unscoped path stays fully functional. Each
3536
+ // security assertion MUST fail without the fix.
3537
+ // ---------------------------------------------------------------------------
3538
+ describe("HTTP tag-scope confidentiality (security review)", async () => {
3539
+ // Build a TagScopeCtx the same way routing.ts does, so handlers see the
3540
+ // exact shape a real tag-scoped request produces.
3541
+ async function scopeCtx(roots: string[]): Promise<TagScopeCtx> {
3542
+ return { allowed: await expandTokenTagScope(store, roots), raw: roots };
3543
+ }
3544
+ const NO_SCOPE: TagScopeCtx = { allowed: null, raw: null };
3545
+
3546
+ test("expand_links does NOT inline out-of-scope wikilinked content", async () => {
3547
+ await store.createNote("SECRET PERSONAL BODY", { path: "Secret", tags: ["personal"] });
3548
+ const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
3549
+
3550
+ const res = await handleNotes(
3551
+ mkReq("GET", `/notes?id=${work.id}&include_content=true&expand_links=true`),
3552
+ store,
3553
+ "",
3554
+ "v",
3555
+ await scopeCtx(["work"]),
3556
+ );
3557
+ const body = await res.json() as any;
3558
+ expect(body.content).not.toContain("SECRET PERSONAL BODY");
3559
+ expect(body.content).toContain("[[Secret]]"); // literal — like not-found
3560
+ });
3561
+
3562
+ test("UNSCOPED expand_links still inlines content (regression)", async () => {
3563
+ await store.createNote("PERSONAL BODY", { path: "Secret", tags: ["personal"] });
3564
+ const work = await store.createNote("intro [[Secret]]", { path: "Work", tags: ["work"] });
3565
+
3566
+ const res = await handleNotes(
3567
+ mkReq("GET", `/notes?id=${work.id}&include_content=true&expand_links=true`),
3568
+ store,
3569
+ "",
3570
+ "v",
3571
+ NO_SCOPE,
3572
+ );
3573
+ const body = await res.json() as any;
3574
+ expect(body.content).toContain("PERSONAL BODY");
3575
+ });
3576
+
3577
+ test("expand_links multi-hop (depth>1) does not leak out-of-scope content", async () => {
3578
+ await store.createNote("DEEP PERSONAL SECRET", { path: "Deep", tags: ["personal"] });
3579
+ await store.createNote("mid [[Deep]]", { path: "Mid", tags: ["work"] });
3580
+ const top = await store.createNote("top [[Mid]]", { path: "Top", tags: ["work"] });
3581
+
3582
+ const res = await handleNotes(
3583
+ mkReq("GET", `/notes?id=${top.id}&include_content=true&expand_links=true&expand_depth=3`),
3584
+ store,
3585
+ "",
3586
+ "v",
3587
+ await scopeCtx(["work"]),
3588
+ );
3589
+ const body = await res.json() as any;
3590
+ expect(body.content).toContain("mid");
3591
+ expect(body.content).not.toContain("DEEP PERSONAL SECRET");
3592
+ });
3593
+
3594
+ test("include_links strips out-of-scope NEIGHBOR summaries", async () => {
3595
+ const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
3596
+ const work = await store.createNote("work", { path: "Work", tags: ["work"] });
3597
+ await store.createLink(work.id, secret.id, "references");
3598
+
3599
+ const res = await handleNotes(
3600
+ mkReq("GET", `/notes?id=${work.id}&include_links=true`),
3601
+ store,
3602
+ "",
3603
+ "v",
3604
+ await scopeCtx(["work"]),
3605
+ );
3606
+ const body = await res.json() as any;
3607
+ const serialized = JSON.stringify(body.links ?? []);
3608
+ expect(serialized).not.toContain(secret.id);
3609
+ expect(serialized).not.toContain("Secret");
3610
+ });
3611
+
3612
+ test("UNSCOPED include_links hydrates the full neighbor (regression)", async () => {
3613
+ const secret = await store.createNote("secret", { path: "Secret", tags: ["personal"] });
3614
+ const work = await store.createNote("work", { path: "Work", tags: ["work"] });
3615
+ await store.createLink(work.id, secret.id, "references");
3616
+
3617
+ const res = await handleNotes(
3618
+ mkReq("GET", `/notes?id=${work.id}&include_links=true`),
3619
+ store,
3620
+ "",
3621
+ "v",
3622
+ NO_SCOPE,
3623
+ );
3624
+ const body = await res.json() as any;
3625
+ expect((body.links ?? []).length).toBe(1);
3626
+ expect(JSON.stringify(body.links)).toContain(secret.id);
3627
+ });
3628
+
3629
+ test("unresolved-wikilinks surfaces only in-scope source rows", async () => {
3630
+ // #personal source with a dangling wikilink → out-of-scope row.
3631
+ await store.createNote("p [[NoSuchPersonal]]", { path: "P", tags: ["personal"] });
3632
+ // #work source with a dangling wikilink → in-scope row.
3633
+ await store.createNote("w [[NoSuchWork]]", { path: "W", tags: ["work"] });
3634
+
3635
+ const res = handleUnresolvedWikilinks(
3636
+ mkReq("GET", "/unresolved-wikilinks"),
3637
+ store,
3638
+ await scopeCtx(["work"]),
3639
+ );
3640
+ const body = await res.json() as any;
3641
+ const targets = (body.unresolved as any[]).map((r) => r.target_path);
3642
+ expect(targets).toContain("NoSuchWork");
3643
+ expect(targets).not.toContain("NoSuchPersonal");
3644
+ expect(body.count).toBe(1);
3645
+ });
3646
+
3647
+ test("UNSCOPED unresolved-wikilinks surfaces every row (regression)", async () => {
3648
+ await store.createNote("p [[NoSuchPersonal]]", { path: "P", tags: ["personal"] });
3649
+ await store.createNote("w [[NoSuchWork]]", { path: "W", tags: ["work"] });
3650
+
3651
+ const res = handleUnresolvedWikilinks(
3652
+ mkReq("GET", "/unresolved-wikilinks"),
3653
+ store,
3654
+ NO_SCOPE,
3655
+ );
3656
+ const body = await res.json() as any;
3657
+ const targets = (body.unresolved as any[]).map((r) => r.target_path);
3658
+ expect(targets).toContain("NoSuchWork");
3659
+ expect(targets).toContain("NoSuchPersonal");
3660
+ });
3661
+
3662
+ });
3663
+
3664
+ describe("HTTP /notes include_link_count + order_by=link_count (vault feedback #4)", async () => {
3665
+ // Mirrors the MCP-surface tests in core/src/link-count.test.ts on the
3666
+ // same fixtures so REST and MCP agree on the degree semantics.
3667
+ async function seed() {
3668
+ await store.createNote("Hub", { id: "hub", path: "hub", tags: ["t"] });
3669
+ await store.createNote("Leaf", { id: "leaf", path: "leaf", tags: ["t"] });
3670
+ await store.createNote("Self", { id: "self", path: "self", tags: ["t"] });
3671
+ await store.createLink("hub", "leaf", "a"); // hub out 1, leaf in 1
3672
+ await store.createLink("leaf", "hub", "b"); // hub in 1, leaf out 1 => both degree 2
3673
+ await store.createLink("self", "self", "loop"); // self-loop => degree 2
3674
+ }
3675
+
3676
+ test("list mode: include_link_count injects linkCount (both directions)", async () => {
3677
+ await seed();
3678
+ const res = await handleNotes(mkReq("GET", "/notes?include_link_count=true"), store, "");
3679
+ const body = (await res.json()) as any[];
3680
+ const byId = Object.fromEntries(body.map((n) => [n.id, n]));
3681
+ expect(byId.hub.linkCount).toBe(2);
3682
+ expect(byId.leaf.linkCount).toBe(2);
3683
+ expect(byId.self.linkCount).toBe(2); // self-loop = 2
3684
+ });
3685
+
3686
+ test("absent flag → no linkCount key (no behavior change)", async () => {
3687
+ await seed();
3688
+ const res = await handleNotes(mkReq("GET", "/notes"), store, "");
3689
+ const body = (await res.json()) as any[];
3690
+ expect(body.every((n) => !("linkCount" in n))).toBe(true);
3691
+ });
3692
+
3693
+ test("note with 0 links → linkCount: 0", async () => {
3694
+ await store.createNote("Lonely", { id: "lonely", path: "lonely" });
3695
+ const res = await handleNotes(mkReq("GET", "/notes?include_link_count=true"), store, "");
3696
+ const body = (await res.json()) as any[];
3697
+ expect(body.find((n) => n.id === "lonely").linkCount).toBe(0);
3698
+ });
3699
+
3700
+ test("single-note (?id=) mode: include_link_count → correct degree", async () => {
3701
+ await seed();
3702
+ const res = await handleNotes(mkReq("GET", "/notes?id=self&include_link_count=true"), store, "");
3703
+ const body = (await res.json()) as any;
3704
+ expect(body.linkCount).toBe(2);
3705
+ });
3706
+
3707
+ test("single-note (/notes/:id) mode: include_link_count → correct degree", async () => {
3708
+ await seed();
3709
+ const res = await handleNotes(mkReq("GET", "/notes/self?include_link_count=true"), store, "/self");
3710
+ const body = (await res.json()) as any;
3711
+ expect(body.linkCount).toBe(2);
3712
+ });
3713
+
3714
+ test("link_count_direction outbound / inbound variants", async () => {
3715
+ await seed();
3716
+ const out = await handleNotes(
3717
+ mkReq("GET", "/notes?id=hub&include_link_count=true&link_count_direction=outbound"),
3718
+ store,
3719
+ "",
3720
+ );
3721
+ expect(((await out.json()) as any).linkCount).toBe(1); // hub→leaf
3722
+ const inb = await handleNotes(
3723
+ mkReq("GET", "/notes?id=hub&include_link_count=true&link_count_direction=inbound"),
3724
+ store,
3725
+ "",
3726
+ );
3727
+ expect(((await inb.json()) as any).linkCount).toBe(1); // leaf→hub
3728
+ });
3729
+
3730
+ test("unrecognized link_count_direction falls back to both (REST parseLinkCountDirection)", async () => {
3731
+ await seed();
3732
+ // hub: both=2, outbound=1, inbound=1. A bogus value must degrade to
3733
+ // `both` (2), distinct from either directional value (1).
3734
+ const res = await handleNotes(
3735
+ mkReq("GET", "/notes?id=hub&include_link_count=true&link_count_direction=sideways"),
3736
+ store,
3737
+ "",
3738
+ );
3739
+ expect(((await res.json()) as any).linkCount).toBe(2);
3740
+ });
3741
+
3742
+ test("FTS branch: search + include_link_count → results carry linkCount", async () => {
3743
+ // The full-text-search branch is a separate return path from the
3744
+ // structured query; exercise the flag there explicitly.
3745
+ await store.createNote("quokka sighting near the hub", { id: "fts-hub", path: "fts-hub" });
3746
+ await store.createNote("a quokka friend", { id: "fts-friend", path: "fts-friend" });
3747
+ await store.createLink("fts-hub", "fts-friend", "a"); // hub out1, friend in1
3748
+ await store.createLink("fts-friend", "fts-hub", "b"); // hub in1 => hub degree 2
3749
+ const res = await handleNotes(
3750
+ mkReq("GET", "/notes?search=quokka&include_link_count=true"),
3751
+ store,
3752
+ "",
3753
+ );
3754
+ const body = (await res.json()) as any[];
3755
+ const byId = Object.fromEntries(body.map((n) => [n.id, n]));
3756
+ expect(byId["fts-hub"].linkCount).toBe(2);
3757
+ expect(byId["fts-friend"].linkCount).toBe(2);
3758
+ });
3759
+
3760
+ test("FTS branch: absent flag → no linkCount key", async () => {
3761
+ await store.createNote("quokka sighting near the hub", { id: "fts-hub", path: "fts-hub" });
3762
+ await store.createLink("fts-hub", "fts-hub", "loop");
3763
+ const res = await handleNotes(mkReq("GET", "/notes?search=quokka"), store, "");
3764
+ const body = (await res.json()) as any[];
3765
+ expect(body.every((n) => !("linkCount" in n))).toBe(true);
3766
+ });
3767
+
3768
+ test("order_by=link_count desc: field value == sort key for every note", async () => {
3769
+ // Distinct degrees so the ordering is unambiguous: big=3, mid=2, small=0.
3770
+ await store.createNote("Big", { id: "big", path: "big" });
3771
+ await store.createNote("Mid", { id: "mid", path: "mid" });
3772
+ await store.createNote("Small", { id: "small", path: "small" });
3773
+ await store.createLink("big", "mid", "a"); // big out1, mid in1
3774
+ await store.createLink("big", "small", "b"); // big out2, small in1
3775
+ await store.createLink("mid", "big", "c"); // big in1 => big degree 3; mid out1 => mid degree 2
3776
+ // small degree 1 (in from big). Adjust: make small degree 0 by removing
3777
+ // — instead assert monotonic + field==sortkey, which is the real invariant.
3778
+
3779
+ const res = await handleNotes(
3780
+ mkReq("GET", "/notes?order_by=link_count&sort=desc&include_link_count=true"),
3781
+ store,
3782
+ "",
3783
+ );
3784
+ const body = (await res.json()) as any[];
3785
+ const seq = body.map((n) => n.linkCount as number);
3786
+ // The injected field equals the sort key, so the sequence is non-increasing.
3787
+ expect(seq).toEqual([...seq].sort((a, b) => b - a));
3788
+ expect(body[0].id).toBe("big"); // degree 3 — the most-connected note
3789
+ expect(body[0].linkCount).toBe(3);
3790
+ });
3791
+
3792
+ test("order_by=link_count: self-loop note ranks by its degree-2 field value", async () => {
3793
+ await store.createNote("Selfy", { id: "selfy", path: "selfy" });
3794
+ await store.createNote("Plain", { id: "plain", path: "plain" });
3795
+ await store.createNote("Zero", { id: "zero", path: "zero" });
3796
+ await store.createLink("selfy", "selfy", "loop"); // degree 2
3797
+ await store.createLink("zero", "plain", "ref"); // plain in1, zero out1
3798
+
3799
+ const res = await handleNotes(
3800
+ mkReq("GET", "/notes?order_by=link_count&sort=desc&include_link_count=true"),
3801
+ store,
3802
+ "",
3803
+ );
3804
+ const body = (await res.json()) as any[];
3805
+ expect(body[0].id).toBe("selfy"); // degree 2 outranks the degree-1 notes
3806
+ expect(body[0].linkCount).toBe(2); // field == the sort key that put it first
3807
+ const byId = Object.fromEntries(body.map((n) => [n.id, n]));
3808
+ expect(byId.plain.linkCount).toBe(1);
3809
+ expect(byId.zero.linkCount).toBe(1);
2900
3810
  });
2901
3811
  });
2902
3812
 
@@ -3026,6 +3936,78 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
3026
3936
  expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
3027
3937
  });
3028
3938
 
3939
+ // vault feedback #8 — the update response now echoes hydrated links when
3940
+ // the request mutated links OR `?include_links=true` is passed, so callers
3941
+ // no longer have to re-GET to confirm a link they just added/removed.
3942
+ test("PATCH links.add echoes hydrated links on the response", async () => {
3943
+ await store.createNote("a", { id: "a" });
3944
+ await store.createNote("b", { id: "b", path: "People/Bob", tags: ["person"] });
3945
+ const res = await handleNotes(
3946
+ mkReq("PATCH", "/notes/a", { links: { add: [{ target: "b", relationship: "mentions" }] }, force: true }),
3947
+ store,
3948
+ "/a",
3949
+ );
3950
+ expect(res.status).toBe(200);
3951
+ const body = await res.json() as any;
3952
+ expect(Array.isArray(body.links)).toBe(true);
3953
+ expect(body.links).toHaveLength(1);
3954
+ const link = body.links[0];
3955
+ expect(link.sourceId).toBe("a");
3956
+ expect(link.targetId).toBe("b");
3957
+ expect(link.relationship).toBe("mentions");
3958
+ // Hydrated shape matches GET / query-notes: targetNote summary present.
3959
+ expect(link.targetNote.id).toBe("b");
3960
+ expect(link.targetNote.path).toBe("People/Bob");
3961
+ expect(link.targetNote.tags).toEqual(["person"]);
3962
+ });
3963
+
3964
+ test("PATCH links.remove echoes the post-removal link set", async () => {
3965
+ await store.createNote("a", { id: "a" });
3966
+ await store.createNote("b", { id: "b" });
3967
+ await store.createNote("c", { id: "c" });
3968
+ await store.createLink("a", "b", "mentions");
3969
+ await store.createLink("a", "c", "mentions");
3970
+ const res = await handleNotes(
3971
+ mkReq("PATCH", "/notes/a", { links: { remove: [{ target: "b", relationship: "mentions" }] }, force: true }),
3972
+ store,
3973
+ "/a",
3974
+ );
3975
+ const body = await res.json() as any;
3976
+ expect(Array.isArray(body.links)).toBe(true);
3977
+ expect(body.links).toHaveLength(1);
3978
+ expect(body.links[0].targetId).toBe("c");
3979
+ });
3980
+
3981
+ test("PATCH without a link mutation or flag does NOT include links", async () => {
3982
+ await store.createNote("a", { id: "a" });
3983
+ await store.createNote("b", { id: "b" });
3984
+ await store.createLink("a", "b", "mentions");
3985
+ const res = await handleNotes(
3986
+ mkReq("PATCH", "/notes/a", { content: "updated", force: true }),
3987
+ store,
3988
+ "/a",
3989
+ );
3990
+ const body = await res.json() as any;
3991
+ expect(body.content).toBe("updated");
3992
+ expect(body).not.toHaveProperty("links");
3993
+ });
3994
+
3995
+ test("PATCH ?include_links=true echoes current links even without a mutation", async () => {
3996
+ await store.createNote("a", { id: "a" });
3997
+ await store.createNote("b", { id: "b" });
3998
+ await store.createLink("a", "b", "mentions");
3999
+ const res = await handleNotes(
4000
+ mkReq("PATCH", "/notes/a?include_links=true", { content: "updated", force: true }),
4001
+ store,
4002
+ "/a",
4003
+ );
4004
+ const body = await res.json() as any;
4005
+ expect(body.content).toBe("updated");
4006
+ expect(Array.isArray(body.links)).toBe(true);
4007
+ expect(body.links).toHaveLength(1);
4008
+ expect(body.links[0].targetId).toBe("b");
4009
+ });
4010
+
3029
4011
  test("PATCH resolves note by path", async () => {
3030
4012
  await store.createNote("x", { path: "Projects/README" });
3031
4013
  const res = await handleNotes(
@@ -3702,11 +4684,86 @@ describe("HTTP /tags", async () => {
3702
4684
  expect(buildVaultProjection(db).indexed_fields.map((f) => f.name)).toContain("aspect_ratio");
3703
4685
  });
3704
4686
 
3705
- test("PUT /tags/:name returns 400 with error_type: invalid_relationships on bad shape", async () => {
4687
+ // ---- relationships is an opaque vocabulary map (vault#428) ----
4688
+ // PUT persists the value verbatim with no inner-shape enforcement; GET
4689
+ // returns it byte-for-byte. Only a top-level non-map (array/primitive)
4690
+ // or non-serializable input is rejected with invalid_relationships.
4691
+
4692
+ test("PUT /tags/:name persists the opaque vocabulary map; GET returns it verbatim (vault#428)", async () => {
4693
+ const vocab = {
4694
+ "works-on": { from: "person", to: "project" },
4695
+ "member-of": { from: "person", to: "organization" },
4696
+ "partner-of": { from: "person", to: "person" },
4697
+ "based-at": { from: "project", to: "place" },
4698
+ };
4699
+ const put = await handleTags(
4700
+ mkReq("PUT", "/tags/person", { relationships: vocab }),
4701
+ store,
4702
+ "/person",
4703
+ );
4704
+ expect(put.status).toBe(200);
4705
+
4706
+ const get = await handleTags(mkReq("GET", "/tags/person"), store, "/person");
4707
+ expect(get.status).toBe(200);
4708
+ const body = await get.json() as any;
4709
+ // Byte-for-byte: serialize both sides and compare exactly.
4710
+ expect(JSON.stringify(body.relationships)).toBe(JSON.stringify(vocab));
4711
+ expect(body.relationships).toEqual(vocab);
4712
+ });
4713
+
4714
+ test("PUT /tags/:name still accepts the historical typed relationships shape (backwards-compat)", async () => {
4715
+ const typed = { owned_by: { target_tag: "person", cardinality: "one", description: "DRI" } };
4716
+ const put = await handleTags(
4717
+ mkReq("PUT", "/tags/project", { relationships: typed }),
4718
+ store,
4719
+ "/project",
4720
+ );
4721
+ expect(put.status).toBe(200);
4722
+ const get = await handleTags(mkReq("GET", "/tags/project"), store, "/project");
4723
+ const body = await get.json() as any;
4724
+ expect(body.relationships).toEqual(typed);
4725
+ });
4726
+
4727
+ test("PUT /tags/:name round-trips nested arbitrary relationship values verbatim", async () => {
4728
+ const vocab = { rel: { from: "a", to: "b", note: "freeform", weight: 3, tags: ["x", "y"] } };
4729
+ const put = await handleTags(
4730
+ mkReq("PUT", "/tags/thing", { relationships: vocab }),
4731
+ store,
4732
+ "/thing",
4733
+ );
4734
+ expect(put.status).toBe(200);
4735
+ const get = await handleTags(mkReq("GET", "/tags/thing"), store, "/thing");
4736
+ const body = await get.json() as any;
4737
+ expect(body.relationships).toEqual(vocab);
4738
+ });
4739
+
4740
+ test("PUT /tags/:name returns 400 invalid_relationships for a top-level array", async () => {
3706
4741
  const res = await handleTags(
3707
- mkReq("PUT", "/tags/person", {
3708
- relationships: { mentions: { target_tag: "topic", cardinality: "infinite" } },
3709
- }),
4742
+ mkReq("PUT", "/tags/person", { relationships: ["not", "a", "map"] }),
4743
+ store,
4744
+ "/person",
4745
+ );
4746
+ expect(res.status).toBe(400);
4747
+ const body = await res.json() as any;
4748
+ expect(body.error_type).toBe("invalid_relationships");
4749
+ expect(typeof body.error).toBe("string");
4750
+ expect(body.error.length).toBeGreaterThan(0);
4751
+ });
4752
+
4753
+ test("PUT /tags/:name returns 400 invalid_relationships for a top-level primitive", async () => {
4754
+ const res = await handleTags(
4755
+ mkReq("PUT", "/tags/person", { relationships: "just-a-string" as unknown as Record<string, unknown> }),
4756
+ store,
4757
+ "/person",
4758
+ );
4759
+ expect(res.status).toBe(400);
4760
+ const body = await res.json() as any;
4761
+ expect(body.error_type).toBe("invalid_relationships");
4762
+ });
4763
+
4764
+ test("PUT /tags/:name returns 400 invalid_relationships for an empty relationship key", async () => {
4765
+ const res = await handleTags(
4766
+ mkReq("PUT", "/tags/person", { relationships: { "": { from: "a", to: "b" } } }),
3710
4767
  store,
3711
4768
  "/person",
3712
4769
  );