@openparachute/vault 0.2.3 → 0.3.0-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.
Files changed (58) hide show
  1. package/.claude/settings.local.json +8 -0
  2. package/CHANGELOG.md +70 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +603 -19
  6. package/core/src/indexed-fields.test.ts +285 -0
  7. package/core/src/indexed-fields.ts +238 -0
  8. package/core/src/mcp.ts +127 -6
  9. package/core/src/notes.ts +157 -11
  10. package/core/src/query-operators.ts +174 -0
  11. package/core/src/schema.ts +69 -2
  12. package/core/src/store.ts +92 -0
  13. package/core/src/tag-schemas.ts +5 -0
  14. package/core/src/types.ts +29 -1
  15. package/docs/HTTP_API.md +105 -1
  16. package/package/package.json +32 -0
  17. package/package.json +2 -2
  18. package/src/auth.test.ts +83 -114
  19. package/src/auth.ts +68 -6
  20. package/src/backup-launchd.ts +1 -1
  21. package/src/backup.test.ts +1 -1
  22. package/src/backup.ts +18 -17
  23. package/src/cli.ts +179 -121
  24. package/src/config-triggers.test.ts +49 -0
  25. package/src/config.test.ts +317 -2
  26. package/src/config.ts +420 -40
  27. package/src/context.test.ts +136 -0
  28. package/src/context.ts +115 -0
  29. package/src/daemon.ts +17 -16
  30. package/src/doctor.test.ts +9 -7
  31. package/src/launchd.test.ts +1 -1
  32. package/src/launchd.ts +6 -6
  33. package/src/mcp-http.ts +75 -21
  34. package/src/mcp-install.test.ts +125 -0
  35. package/src/mcp-install.ts +60 -0
  36. package/src/mcp-tools.ts +34 -96
  37. package/src/module-config.ts +109 -0
  38. package/src/oauth.test.ts +345 -57
  39. package/src/oauth.ts +155 -35
  40. package/src/published.test.ts +2 -2
  41. package/src/routes.ts +209 -33
  42. package/src/routing.test.ts +817 -300
  43. package/src/routing.ts +204 -202
  44. package/src/scopes.test.ts +136 -0
  45. package/src/scopes.ts +105 -0
  46. package/src/scribe-env.test.ts +49 -0
  47. package/src/scribe-env.ts +33 -0
  48. package/src/server.ts +57 -5
  49. package/src/services-manifest.test.ts +140 -0
  50. package/src/services-manifest.ts +99 -0
  51. package/src/systemd.ts +3 -3
  52. package/src/token-store.ts +42 -9
  53. package/src/transcription-worker.test.ts +583 -0
  54. package/src/transcription-worker.ts +346 -0
  55. package/src/triggers.test.ts +191 -1
  56. package/src/triggers.ts +17 -2
  57. package/src/vault.test.ts +693 -77
  58. package/src/version.test.ts +1 -1
package/src/vault.test.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, test, expect, beforeEach, afterEach } from "bun:test";
6
6
  import { Database } from "bun:sqlite";
7
- import { mkdirSync, rmSync, existsSync } from "fs";
7
+ import { mkdirSync, rmSync, existsSync, writeFileSync } from "fs";
8
8
  import { join } from "path";
9
9
  import { tmpdir } from "os";
10
10
  import { BunStore } from "./vault-store.ts";
@@ -60,25 +60,31 @@ describe("BunStore", async () => {
60
60
 
61
61
  test("user updates bump updatedAt", async () => {
62
62
  const note = await store.createNote("Original");
63
- expect(note.updatedAt).toBeUndefined();
63
+ expect(note.updatedAt).toBe(note.createdAt);
64
64
  const updated = await store.updateNote(note.id, { content: "Edited by user" });
65
65
  expect(updated.updatedAt).toBeTruthy();
66
+ // Must be monotonically non-decreasing — and strictly greater when the
67
+ // caller passed if_updated_at (tested elsewhere). Same-millisecond
68
+ // collisions are possible here since no if_updated_at is supplied.
69
+ expect(updated.updatedAt! >= note.createdAt).toBe(true);
66
70
  });
67
71
 
68
72
  test("skipUpdatedAt preserves updatedAt (hook-style writes)", async () => {
69
73
  // Hook writes (e.g., the reader-audio hook's metadata markers) must not
70
74
  // count as user activity. See issue #44 — hook writes were bumping
71
- // updatedAt and wrecking Daily's reader sort.
75
+ // updatedAt and wrecking Daily's reader sort. Fresh notes have
76
+ // `updatedAt === createdAt`; a hook write must leave it at that value so
77
+ // `updatedAt > createdAt` remains the correct "user-touched" signal.
72
78
  const note = await store.createNote("Content");
73
- expect(note.updatedAt).toBeUndefined();
79
+ expect(note.updatedAt).toBe(note.createdAt);
74
80
 
75
- // Fresh note: a machine write must not set updatedAt.
81
+ // Fresh note: a machine write must not advance updatedAt past createdAt.
76
82
  await store.updateNote(note.id, {
77
83
  metadata: { audio_pending_at: "2026-04-09T10:00:00.000Z" },
78
84
  skipUpdatedAt: true,
79
85
  });
80
86
  let fetched = (await store.getNote(note.id))!;
81
- expect(fetched.updatedAt).toBeUndefined();
87
+ expect(fetched.updatedAt).toBe(note.createdAt);
82
88
  expect((fetched.metadata as { audio_pending_at?: string } | undefined)?.audio_pending_at).toBe(
83
89
  "2026-04-09T10:00:00.000Z",
84
90
  );
@@ -522,30 +528,29 @@ describe("MCP tools", async () => {
522
528
  });
523
529
  });
524
530
 
525
- describe("unified MCP wrapper", async () => {
526
- test("vault-info routes through vault param", async () => {
527
- const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
528
- const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
531
+ describe("scoped MCP wrapper", async () => {
532
+ test("vault-info returns the vault's stats", async () => {
533
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
534
+ const { writeVaultConfig } = await import("./config.ts");
529
535
  const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
530
536
 
531
- const vaultName = `unified-stats-${Date.now()}`;
537
+ const vaultName = `scoped-stats-${Date.now()}`;
532
538
  writeVaultConfig({
533
539
  name: vaultName,
534
540
  api_keys: [],
535
541
  created_at: new Date().toISOString(),
536
542
  description: "Test vault",
537
543
  });
538
- writeGlobalConfig({ port: 1940, default_vault: vaultName });
539
544
 
540
545
  const vaultStore = getVaultStore(vaultName);
541
546
  await vaultStore.createNote("alpha", { tags: ["x", "y"] });
542
547
  await vaultStore.createNote("beta", { tags: ["x"] });
543
548
 
544
- const tools = generateUnifiedMcpTools();
549
+ const tools = generateScopedMcpTools(vaultName);
545
550
  const vaultInfo = tools.find((t) => t.name === "vault-info");
546
551
  expect(vaultInfo).toBeTruthy();
547
552
 
548
- const result = await vaultInfo!.execute({ vault: vaultName, include_stats: true }) as any;
553
+ const result = await vaultInfo!.execute({ include_stats: true }) as any;
549
554
  expect(result.name).toBe(vaultName);
550
555
  expect(result.description).toBe("Test vault");
551
556
  expect(result.stats.totalNotes).toBe(2);
@@ -554,9 +559,9 @@ describe("unified MCP wrapper", async () => {
554
559
  closeAllStores();
555
560
  });
556
561
 
557
- test("list-tags with schema works through unified wrapper", async () => {
558
- const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
559
- const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
562
+ test("list-tags with schema returns per-tag detail", async () => {
563
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
564
+ const { writeVaultConfig } = await import("./config.ts");
560
565
  const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
561
566
 
562
567
  const vaultName = `tag-schema-${Date.now()}`;
@@ -565,7 +570,6 @@ describe("unified MCP wrapper", async () => {
565
570
  api_keys: [],
566
571
  created_at: new Date().toISOString(),
567
572
  });
568
- writeGlobalConfig({ port: 1940, default_vault: vaultName });
569
573
 
570
574
  const vaultStore = getVaultStore(vaultName);
571
575
  await vaultStore.createNote("A", { tags: ["person"] });
@@ -574,11 +578,11 @@ describe("unified MCP wrapper", async () => {
574
578
  fields: { name: { type: "string", description: "Full name" } },
575
579
  });
576
580
 
577
- const tools = generateUnifiedMcpTools();
581
+ const tools = generateScopedMcpTools(vaultName);
578
582
 
579
583
  // list-tags with tag param for single tag detail
580
584
  const listTags = tools.find((t) => t.name === "list-tags")!;
581
- const detail = await listTags.execute({ vault: vaultName, tag: "person" }) as any;
585
+ const detail = await listTags.execute({ tag: "person" }) as any;
582
586
  expect(detail.name).toBe("person");
583
587
  expect(detail.count).toBe(1);
584
588
  expect(detail.description).toBe("A person");
@@ -588,8 +592,8 @@ describe("unified MCP wrapper", async () => {
588
592
  });
589
593
 
590
594
  test("create-note with schema tag auto-populates defaults", async () => {
591
- const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
592
- const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
595
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
596
+ const { writeVaultConfig } = await import("./config.ts");
593
597
  const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
594
598
 
595
599
  const vaultName = `schema-create-${Date.now()}`;
@@ -598,7 +602,6 @@ describe("unified MCP wrapper", async () => {
598
602
  api_keys: [],
599
603
  created_at: new Date().toISOString(),
600
604
  });
601
- writeGlobalConfig({ port: 1940, default_vault: vaultName });
602
605
 
603
606
  const vaultStore = getVaultStore(vaultName);
604
607
  await vaultStore.upsertTagSchema("person", {
@@ -609,31 +612,29 @@ describe("unified MCP wrapper", async () => {
609
612
  },
610
613
  });
611
614
 
612
- const tools = generateUnifiedMcpTools();
615
+ const tools = generateScopedMcpTools(vaultName);
613
616
  const createNote = tools.find((t) => t.name === "create-note")!;
614
617
  const queryNotes = tools.find((t) => t.name === "query-notes")!;
615
618
 
616
619
  // Create a note tagged person with no metadata — defaults auto-populated
617
620
  const result = await createNote.execute({
618
- vault: vaultName,
619
621
  content: "Alice",
620
622
  tags: ["person"],
621
623
  }) as any;
622
624
  expect(result.content).toBe("Alice");
623
625
 
624
626
  // Verify defaults were written
625
- const fresh = await queryNotes.execute({ vault: vaultName, id: result.id }) as any;
627
+ const fresh = await queryNotes.execute({ id: result.id }) as any;
626
628
  expect(fresh.metadata.first_appeared).toBe("");
627
629
  expect(fresh.metadata.relationship).toBe("");
628
630
 
629
631
  // Create with explicit metadata — preserved
630
632
  const result2 = await createNote.execute({
631
- vault: vaultName,
632
633
  content: "Bob",
633
634
  tags: ["person"],
634
635
  metadata: { first_appeared: "2024-01", relationship: "friend" },
635
636
  }) as any;
636
- const fresh2 = await queryNotes.execute({ vault: vaultName, id: result2.id }) as any;
637
+ const fresh2 = await queryNotes.execute({ id: result2.id }) as any;
637
638
  expect(fresh2.metadata.first_appeared).toBe("2024-01");
638
639
  expect(fresh2.metadata.relationship).toBe("friend");
639
640
 
@@ -641,8 +642,8 @@ describe("unified MCP wrapper", async () => {
641
642
  });
642
643
 
643
644
  test("update-note tags.add with schema auto-populates defaults", async () => {
644
- const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
645
- const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
645
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
646
+ const { writeVaultConfig } = await import("./config.ts");
646
647
  const { getVaultStore, closeAllStores: close } = await import("./vault-store.ts");
647
648
 
648
649
  const vaultName = `schema-defaults-${Date.now()}`;
@@ -651,7 +652,6 @@ describe("unified MCP wrapper", async () => {
651
652
  api_keys: [],
652
653
  created_at: new Date().toISOString(),
653
654
  });
654
- writeGlobalConfig({ port: 1940, default_vault: vaultName });
655
655
 
656
656
  const vaultStore = getVaultStore(vaultName);
657
657
  await vaultStore.upsertTagSchema("person", {
@@ -669,41 +669,40 @@ describe("unified MCP wrapper", async () => {
669
669
  priority: { type: "integer", description: "Priority level" },
670
670
  },
671
671
  });
672
- const tools = generateUnifiedMcpTools();
672
+ const tools = generateScopedMcpTools(vaultName);
673
673
  const createNote = tools.find((t) => t.name === "create-note")!;
674
674
  const updateNote = tools.find((t) => t.name === "update-note")!;
675
675
  const queryNotes = tools.find((t) => t.name === "query-notes")!;
676
676
 
677
677
  // Create a note, then add #person tag via update-note
678
- const note = await createNote.execute({ vault: vaultName, content: "Alice" }) as any;
679
- await updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
680
- const after = await queryNotes.execute({ vault: vaultName, id: note.id }) as any;
678
+ const note = await createNote.execute({ content: "Alice" }) as any;
679
+ await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
680
+ const after = await queryNotes.execute({ id: note.id }) as any;
681
681
  expect(after.metadata.first_appeared).toBe("");
682
682
  expect(after.metadata.relationship).toBe("");
683
683
 
684
684
  // Tag note that already has partial metadata — only missing fields populated
685
685
  const note2 = await createNote.execute({
686
- vault: vaultName,
687
686
  content: "Bob",
688
687
  metadata: { first_appeared: "2023-11" },
689
688
  }) as any;
690
- await updateNote.execute({ vault: vaultName, id: note2.id, tags: { add: ["person"] } });
691
- const after2 = await queryNotes.execute({ vault: vaultName, id: note2.id }) as any;
689
+ await updateNote.execute({ id: note2.id, tags: { add: ["person"] }, force: true });
690
+ const after2 = await queryNotes.execute({ id: note2.id }) as any;
692
691
  expect(after2.metadata.first_appeared).toBe("2023-11"); // preserved
693
692
  expect(after2.metadata.relationship).toBe(""); // added
694
693
 
695
694
  // Tag with #project — enum defaults to first value, boolean to false, integer to 0
696
- const note4 = await createNote.execute({ vault: vaultName, content: "My Project" }) as any;
697
- await updateNote.execute({ vault: vaultName, id: note4.id, tags: { add: ["project"] } });
698
- const after4 = await queryNotes.execute({ vault: vaultName, id: note4.id }) as any;
695
+ const note4 = await createNote.execute({ content: "My Project" }) as any;
696
+ await updateNote.execute({ id: note4.id, tags: { add: ["project"] }, force: true });
697
+ const after4 = await queryNotes.execute({ id: note4.id }) as any;
699
698
  expect(after4.metadata.status).toBe("active");
700
699
  expect(after4.metadata.active).toBe(false);
701
700
  expect(after4.metadata.priority).toBe(0);
702
701
 
703
702
  // Multiple schema tags at once — all defaults merged
704
- const note5 = await createNote.execute({ vault: vaultName, content: "Multi" }) as any;
705
- await updateNote.execute({ vault: vaultName, id: note5.id, tags: { add: ["person", "project"] } });
706
- const after5 = await queryNotes.execute({ vault: vaultName, id: note5.id }) as any;
703
+ const note5 = await createNote.execute({ content: "Multi" }) as any;
704
+ await updateNote.execute({ id: note5.id, tags: { add: ["person", "project"] }, force: true });
705
+ const after5 = await queryNotes.execute({ id: note5.id }) as any;
707
706
  expect(after5.metadata.first_appeared).toBe("");
708
707
  expect(after5.metadata.relationship).toBe("");
709
708
  expect(after5.metadata.status).toBe("active");
@@ -713,8 +712,8 @@ describe("unified MCP wrapper", async () => {
713
712
  });
714
713
 
715
714
  test("update-note tags.add auto-populate does not bump updatedAt", async () => {
716
- const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
717
- const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
715
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
716
+ const { writeVaultConfig } = await import("./config.ts");
718
717
  const { getVaultStore, closeAllStores: close } = await import("./vault-store.ts");
719
718
 
720
719
  const vaultName = `schema-noupdate-${Date.now()}`;
@@ -723,7 +722,6 @@ describe("unified MCP wrapper", async () => {
723
722
  api_keys: [],
724
723
  created_at: new Date().toISOString(),
725
724
  });
726
- writeGlobalConfig({ port: 1940, default_vault: vaultName });
727
725
 
728
726
  const vaultStore = getVaultStore(vaultName);
729
727
  await vaultStore.upsertTagSchema("person", {
@@ -731,15 +729,15 @@ describe("unified MCP wrapper", async () => {
731
729
  fields: { name: { type: "string" } },
732
730
  });
733
731
 
734
- const tools = generateUnifiedMcpTools();
732
+ const tools = generateScopedMcpTools(vaultName);
735
733
  const createNote = tools.find((t) => t.name === "create-note")!;
736
734
  const updateNote = tools.find((t) => t.name === "update-note")!;
737
735
  const queryNotes = tools.find((t) => t.name === "query-notes")!;
738
736
 
739
- const note = await createNote.execute({ vault: vaultName, content: "Test" }) as any;
737
+ const note = await createNote.execute({ content: "Test" }) as any;
740
738
  const originalUpdatedAt = note.updatedAt;
741
- await updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
742
- const after = await queryNotes.execute({ vault: vaultName, id: note.id }) as any;
739
+ await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
740
+ const after = await queryNotes.execute({ id: note.id }) as any;
743
741
  expect(after.updatedAt).toBe(originalUpdatedAt);
744
742
  expect(after.metadata.name).toBe("");
745
743
 
@@ -754,7 +752,6 @@ describe("auth permissions", () => {
754
752
  expect(isToolAllowed("list-tags", "read")).toBe(true);
755
753
  expect(isToolAllowed("find-path", "read")).toBe(true);
756
754
  expect(isToolAllowed("vault-info", "read")).toBe(true);
757
- expect(isToolAllowed("list-vaults", "read")).toBe(true);
758
755
  });
759
756
 
760
757
  test("read permission blocks mutation tools", () => {
@@ -834,6 +831,24 @@ describe("HTTP /notes", async () => {
834
831
  expect(body).toHaveLength(1);
835
832
  });
836
833
 
834
+ test("GET /notes?has_tags=false returns only untagged notes", async () => {
835
+ await store.createNote("tagged", { tags: ["x"], path: "t" });
836
+ await store.createNote("plain", { path: "p" });
837
+ const res = await handleNotes(mkReq("GET", "/notes?has_tags=false&include_content=true"), store, "");
838
+ const body = await res.json() as any[];
839
+ expect(body.map((n) => n.content)).toEqual(["plain"]);
840
+ });
841
+
842
+ test("GET /notes?has_links=false returns only orphaned notes", async () => {
843
+ const a = await store.createNote("src", { id: "qa" });
844
+ const b = await store.createNote("tgt", { id: "qb" });
845
+ await store.createNote("orphan", { id: "qo" });
846
+ await store.createLink(a.id, b.id, "mentions");
847
+ const res = await handleNotes(mkReq("GET", "/notes?has_links=false&include_content=true"), store, "");
848
+ const body = await res.json() as any[];
849
+ expect(body.map((n) => n.content)).toEqual(["orphan"]);
850
+ });
851
+
837
852
  test("GET /notes?search=fox&include_metadata=false strips metadata from search results", async () => {
838
853
  await store.createNote("The quick brown fox", { metadata: { summary: "animal" } });
839
854
  const res = await handleNotes(mkReq("GET", "/notes?search=fox&include_metadata=false"), store, "");
@@ -962,6 +977,174 @@ describe("HTTP /notes", async () => {
962
977
  const body = await res.json() as any;
963
978
  expect(body.mimeType).toBe("image/png");
964
979
  });
980
+
981
+ describe("POST /notes/:id/attachments with transcribe flag", async () => {
982
+ test("transcribe: true seeds pending status and marks note as stub", async () => {
983
+ await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending._", { id: "v1" });
984
+ const res = await handleNotes(
985
+ mkReq("POST", "/notes/v1/attachments", {
986
+ path: "memos/memo-1.webm",
987
+ mimeType: "audio/webm",
988
+ transcribe: true,
989
+ }),
990
+ store,
991
+ "/v1/attachments",
992
+ );
993
+ expect(res.status).toBe(201);
994
+ const att = await res.json() as any;
995
+ expect(att.metadata?.transcribe_status).toBe("pending");
996
+ expect(att.metadata?.transcribe_requested_at).toBeTruthy();
997
+
998
+ const note = await store.getNote("v1");
999
+ expect((note!.metadata as any)?.transcribe_stub).toBe(true);
1000
+ });
1001
+
1002
+ test("transcribe: false (default) leaves metadata empty and note untouched", async () => {
1003
+ await store.createNote("note body", { id: "v2" });
1004
+ const res = await handleNotes(
1005
+ mkReq("POST", "/notes/v2/attachments", {
1006
+ path: "memos/memo-2.webm",
1007
+ mimeType: "audio/webm",
1008
+ }),
1009
+ store,
1010
+ "/v2/attachments",
1011
+ );
1012
+ expect(res.status).toBe(201);
1013
+ const att = await res.json() as any;
1014
+ expect(att.metadata?.transcribe_status).toBeUndefined();
1015
+
1016
+ const note = await store.getNote("v2");
1017
+ expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
1018
+ });
1019
+
1020
+ test("transcribe: true preserves other note metadata", async () => {
1021
+ await store.createNote("body", { id: "v3", metadata: { summary: "keep me" } });
1022
+ await handleNotes(
1023
+ mkReq("POST", "/notes/v3/attachments", {
1024
+ path: "memos/memo-3.webm",
1025
+ mimeType: "audio/webm",
1026
+ transcribe: true,
1027
+ }),
1028
+ store,
1029
+ "/v3/attachments",
1030
+ );
1031
+ const note = await store.getNote("v3");
1032
+ const meta = note!.metadata as any;
1033
+ expect(meta?.summary).toBe("keep me");
1034
+ expect(meta?.transcribe_stub).toBe(true);
1035
+ });
1036
+ });
1037
+
1038
+ describe("DELETE /notes/:id/attachments/:attId", async () => {
1039
+ test("happy path: 204, DB row gone, storage file unlinked", async () => {
1040
+ const assetsRoot = join(tmpDir, "assets");
1041
+ mkdirSync(join(assetsRoot, "2026-04-18"), { recursive: true });
1042
+ const relPath = "2026-04-18/shot.png";
1043
+ const filePath = join(assetsRoot, relPath);
1044
+ writeFileSync(filePath, Buffer.from([1, 2, 3]));
1045
+ process.env.ASSETS_DIR = assetsRoot;
1046
+
1047
+ const n = await store.createNote("x", { id: "n1" });
1048
+ const att = await store.addAttachment(n.id, relPath, "image/png");
1049
+
1050
+ const res = await handleNotes(
1051
+ mkReq("DELETE", `/notes/n1/attachments/${att.id}`),
1052
+ store,
1053
+ `/n1/attachments/${att.id}`,
1054
+ "default",
1055
+ );
1056
+ expect(res.status).toBe(204);
1057
+ expect((await store.getAttachments(n.id)).length).toBe(0);
1058
+ expect(existsSync(filePath)).toBe(false);
1059
+
1060
+ delete process.env.ASSETS_DIR;
1061
+ });
1062
+
1063
+ test("404 when attachment does not exist", async () => {
1064
+ await store.createNote("x", { id: "n2" });
1065
+ const res = await handleNotes(
1066
+ mkReq("DELETE", "/notes/n2/attachments/nonexistent"),
1067
+ store,
1068
+ "/n2/attachments/nonexistent",
1069
+ "default",
1070
+ );
1071
+ expect(res.status).toBe(404);
1072
+ });
1073
+
1074
+ test("second delete is idempotent (404)", async () => {
1075
+ const n = await store.createNote("x", { id: "n3" });
1076
+ const att = await store.addAttachment(n.id, "files/a.png", "image/png");
1077
+ const first = await handleNotes(
1078
+ mkReq("DELETE", `/notes/n3/attachments/${att.id}`),
1079
+ store,
1080
+ `/n3/attachments/${att.id}`,
1081
+ );
1082
+ expect(first.status).toBe(204);
1083
+ const second = await handleNotes(
1084
+ mkReq("DELETE", `/notes/n3/attachments/${att.id}`),
1085
+ store,
1086
+ `/n3/attachments/${att.id}`,
1087
+ );
1088
+ expect(second.status).toBe(404);
1089
+ });
1090
+
1091
+ test("cross-note delete attempt returns 404 and leaves record intact", async () => {
1092
+ const a = await store.createNote("a", { id: "na" });
1093
+ const b = await store.createNote("b", { id: "nb" });
1094
+ const attA = await store.addAttachment(a.id, "files/a.png", "image/png");
1095
+
1096
+ const res = await handleNotes(
1097
+ mkReq("DELETE", `/notes/nb/attachments/${attA.id}`),
1098
+ store,
1099
+ `/nb/attachments/${attA.id}`,
1100
+ );
1101
+ expect(res.status).toBe(404);
1102
+ expect((await store.getAttachments(a.id)).length).toBe(1);
1103
+ });
1104
+
1105
+ test("file survives first delete when a sibling attachment still references it", async () => {
1106
+ const assetsRoot = join(tmpDir, "assets");
1107
+ mkdirSync(join(assetsRoot, "shared"), { recursive: true });
1108
+ const relPath = "shared/pic.png";
1109
+ const filePath = join(assetsRoot, relPath);
1110
+ writeFileSync(filePath, Buffer.from([9]));
1111
+ process.env.ASSETS_DIR = assetsRoot;
1112
+
1113
+ const a = await store.createNote("a", { id: "sa" });
1114
+ const b = await store.createNote("b", { id: "sb" });
1115
+ const attA = await store.addAttachment(a.id, relPath, "image/png");
1116
+ const attB = await store.addAttachment(b.id, relPath, "image/png");
1117
+
1118
+ await handleNotes(
1119
+ mkReq("DELETE", `/notes/sa/attachments/${attA.id}`),
1120
+ store,
1121
+ `/sa/attachments/${attA.id}`,
1122
+ "default",
1123
+ );
1124
+ expect(existsSync(filePath)).toBe(true);
1125
+
1126
+ await handleNotes(
1127
+ mkReq("DELETE", `/notes/sb/attachments/${attB.id}`),
1128
+ store,
1129
+ `/sb/attachments/${attB.id}`,
1130
+ "default",
1131
+ );
1132
+ expect(existsSync(filePath)).toBe(false);
1133
+
1134
+ delete process.env.ASSETS_DIR;
1135
+ });
1136
+
1137
+ test("method not allowed on /attachments/:attId returns 405", async () => {
1138
+ const n = await store.createNote("x", { id: "nm" });
1139
+ const att = await store.addAttachment(n.id, "files/a.png", "image/png");
1140
+ const res = await handleNotes(
1141
+ mkReq("PATCH", `/notes/nm/attachments/${att.id}`),
1142
+ store,
1143
+ `/nm/attachments/${att.id}`,
1144
+ );
1145
+ expect(res.status).toBe(405);
1146
+ });
1147
+ });
965
1148
  });
966
1149
 
967
1150
  describe("HTTP GET /notes?format=graph", async () => {
@@ -1048,7 +1231,7 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1048
1231
  test("PATCH updates content and merges metadata", async () => {
1049
1232
  const note = await store.createNote("original", { id: "x", metadata: { a: 1 } });
1050
1233
  const res = await handleNotes(
1051
- mkReq("PATCH", "/notes/x", { content: "updated", metadata: { b: 2 } }),
1234
+ mkReq("PATCH", "/notes/x", { content: "updated", metadata: { b: 2 }, force: true }),
1052
1235
  store,
1053
1236
  "/x",
1054
1237
  );
@@ -1060,7 +1243,7 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1060
1243
  test("PATCH adds/removes tags", async () => {
1061
1244
  await store.createNote("x", { id: "x", tags: ["old"] });
1062
1245
  const res = await handleNotes(
1063
- mkReq("PATCH", "/notes/x", { tags: { add: ["new"], remove: ["old"] } }),
1246
+ mkReq("PATCH", "/notes/x", { tags: { add: ["new"], remove: ["old"] }, force: true }),
1064
1247
  store,
1065
1248
  "/x",
1066
1249
  );
@@ -1073,7 +1256,7 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1073
1256
  await store.createNote("a", { id: "a" });
1074
1257
  await store.createNote("b", { id: "b" });
1075
1258
  const res = await handleNotes(
1076
- mkReq("PATCH", "/notes/a", { links: { add: [{ target: "b", relationship: "mentions" }] } }),
1259
+ mkReq("PATCH", "/notes/a", { links: { add: [{ target: "b", relationship: "mentions" }] }, force: true }),
1077
1260
  store,
1078
1261
  "/a",
1079
1262
  );
@@ -1083,7 +1266,7 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1083
1266
 
1084
1267
  // Remove
1085
1268
  await handleNotes(
1086
- mkReq("PATCH", "/notes/a", { links: { remove: [{ target: "b", relationship: "mentions" }] } }),
1269
+ mkReq("PATCH", "/notes/a", { links: { remove: [{ target: "b", relationship: "mentions" }] }, force: true }),
1087
1270
  store,
1088
1271
  "/a",
1089
1272
  );
@@ -1093,7 +1276,7 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1093
1276
  test("PATCH resolves note by path", async () => {
1094
1277
  await store.createNote("x", { path: "Projects/README" });
1095
1278
  const res = await handleNotes(
1096
- mkReq("PATCH", `/notes/${encodeURIComponent("Projects/README")}`, { content: "updated" }),
1279
+ mkReq("PATCH", `/notes/${encodeURIComponent("Projects/README")}`, { content: "updated", force: true }),
1097
1280
  store,
1098
1281
  `/${encodeURIComponent("Projects/README")}`,
1099
1282
  );
@@ -1105,7 +1288,7 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1105
1288
  const note = await store.createNote("first", { id: "x" });
1106
1289
  // First bump — sets updated_at
1107
1290
  const first = await handleNotes(
1108
- mkReq("PATCH", "/notes/x", { content: "second" }),
1291
+ mkReq("PATCH", "/notes/x", { content: "second", force: true }),
1109
1292
  store,
1110
1293
  "/x",
1111
1294
  );
@@ -1123,8 +1306,8 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1123
1306
  });
1124
1307
 
1125
1308
  test("PATCH with stale if_updated_at returns 409 and does not modify note", async () => {
1126
- await store.createNote("first", { id: "x" });
1127
- await handleNotes(mkReq("PATCH", "/notes/x", { content: "second" }), store, "/x");
1309
+ await store.createNote("first", { id: "x", path: "Inbox/x" });
1310
+ await handleNotes(mkReq("PATCH", "/notes/x", { content: "second", force: true }), store, "/x");
1128
1311
  const current = await store.getNote("x");
1129
1312
 
1130
1313
  const res = await handleNotes(
@@ -1137,6 +1320,11 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1137
1320
  );
1138
1321
  expect(res.status).toBe(409);
1139
1322
  const body = await res.json() as any;
1323
+ // New structured shape
1324
+ expect(body.error_type).toBe("conflict");
1325
+ expect(body.path).toBe("Inbox/x");
1326
+ expect(body.your_updated_at).toBe("2020-01-01T00:00:00.000Z");
1327
+ // Legacy fields retained for compat
1140
1328
  expect(body.error).toBe("conflict");
1141
1329
  expect(body.note_id).toBe("x");
1142
1330
  expect(body.current_updated_at).toBe(current!.updatedAt);
@@ -1146,6 +1334,24 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1146
1334
  expect((await store.getNote("x"))!.content).toBe("second");
1147
1335
  });
1148
1336
 
1337
+ test("PATCH without if_updated_at or force returns 428 and does not modify note", async () => {
1338
+ await store.createNote("first", { id: "x", path: "Inbox/x" });
1339
+
1340
+ const res = await handleNotes(
1341
+ mkReq("PATCH", "/notes/x", { content: "second" }),
1342
+ store,
1343
+ "/x",
1344
+ );
1345
+ expect(res.status).toBe(428);
1346
+ const body = await res.json() as any;
1347
+ expect(body.error_type).toBe("precondition_required");
1348
+ expect(body.note_id).toBe("x");
1349
+ expect(body.path).toBe("Inbox/x");
1350
+
1351
+ // Unchanged
1352
+ expect((await store.getNote("x"))!.content).toBe("first");
1353
+ });
1354
+
1149
1355
  test("DELETE resolves note by path", async () => {
1150
1356
  await store.createNote("x", { path: "Temp/note" });
1151
1357
  const res = await handleNotes(
@@ -1199,6 +1405,115 @@ describe("HTTP /tags", async () => {
1199
1405
  expect(body.deleted).toBe(true);
1200
1406
  expect((await store.listTags()).some((t) => t.name === "doomed")).toBe(false);
1201
1407
  });
1408
+
1409
+ test("POST /tags/:name/rename retags every note in one shot", async () => {
1410
+ const n1 = await store.createNote("A", { tags: ["voice"] });
1411
+ const n2 = await store.createNote("B", { tags: ["voice", "keeper"] });
1412
+ const res = await handleTags(
1413
+ mkReq("POST", "/tags/voice/rename", { new_name: "memo" }),
1414
+ store,
1415
+ "/voice/rename",
1416
+ );
1417
+ expect(res.status).toBe(200);
1418
+ const body = await res.json() as any;
1419
+ expect(body).toEqual({ renamed: 2 });
1420
+ expect((await store.getNote(n1.id))!.tags).toEqual(["memo"]);
1421
+ expect((await store.getNote(n2.id))!.tags?.sort()).toEqual(["keeper", "memo"]);
1422
+ });
1423
+
1424
+ test("POST /tags/:name/rename returns 409 target_exists when new_name is taken", async () => {
1425
+ await store.createNote("A", { tags: ["old"] });
1426
+ await store.createNote("B", { tags: ["new"] });
1427
+ const res = await handleTags(
1428
+ mkReq("POST", "/tags/old/rename", { new_name: "new" }),
1429
+ store,
1430
+ "/old/rename",
1431
+ );
1432
+ expect(res.status).toBe(409);
1433
+ const body = await res.json() as any;
1434
+ expect(body.error).toBe("target_exists");
1435
+ expect(body.target).toBe("new");
1436
+ // Hint at the remediation so clients don't reinvent merge client-side.
1437
+ expect(body.message).toMatch(/merge/i);
1438
+ });
1439
+
1440
+ test("POST /tags/:name/rename returns 404 when source tag does not exist", async () => {
1441
+ const res = await handleTags(
1442
+ mkReq("POST", "/tags/ghost/rename", { new_name: "phantom" }),
1443
+ store,
1444
+ "/ghost/rename",
1445
+ );
1446
+ expect(res.status).toBe(404);
1447
+ const body = await res.json() as any;
1448
+ expect(body.error).toBe("not_found");
1449
+ });
1450
+
1451
+ test("POST /tags/:name/rename rejects empty/missing new_name with 400", async () => {
1452
+ const res = await handleTags(
1453
+ mkReq("POST", "/tags/anything/rename", { new_name: "" }),
1454
+ store,
1455
+ "/anything/rename",
1456
+ );
1457
+ expect(res.status).toBe(400);
1458
+ });
1459
+
1460
+ test("POST /tags/merge combines multiple sources into target", async () => {
1461
+ await store.createNote("A", { tags: ["v1"] });
1462
+ await store.createNote("B", { tags: ["v2"] });
1463
+ await store.createNote("C", { tags: ["v1", "v2"] });
1464
+
1465
+ const res = await handleTags(
1466
+ mkReq("POST", "/tags/merge", { sources: ["v1", "v2"], target: "voice" }),
1467
+ store,
1468
+ "/merge",
1469
+ );
1470
+ expect(res.status).toBe(200);
1471
+ const body = await res.json() as any;
1472
+ expect(body.target).toBe("voice");
1473
+ expect(body.merged).toEqual({ v1: 2, v2: 2 });
1474
+
1475
+ const tags = await store.listTags();
1476
+ expect(tags.find((t: any) => t.name === "voice")!.count).toBe(3);
1477
+ expect(tags.some((t: any) => t.name === "v1")).toBe(false);
1478
+ expect(tags.some((t: any) => t.name === "v2")).toBe(false);
1479
+ });
1480
+
1481
+ test("POST /tags/merge dedupes duplicate sources", async () => {
1482
+ await store.createNote("A", { tags: ["v1"] });
1483
+ const res = await handleTags(
1484
+ mkReq("POST", "/tags/merge", { sources: ["v1", "v1"], target: "voice" }),
1485
+ store,
1486
+ "/merge",
1487
+ );
1488
+ const body = await res.json() as any;
1489
+ expect(body.merged).toEqual({ v1: 1 });
1490
+ });
1491
+
1492
+ test("POST /tags/merge creates the target tag if missing", async () => {
1493
+ await store.createNote("A", { tags: ["legacy"] });
1494
+ const res = await handleTags(
1495
+ mkReq("POST", "/tags/merge", { sources: ["legacy"], target: "fresh" }),
1496
+ store,
1497
+ "/merge",
1498
+ );
1499
+ expect(res.status).toBe(200);
1500
+ const tags = await store.listTags();
1501
+ expect(tags.find((t: any) => t.name === "fresh")!.count).toBe(1);
1502
+ });
1503
+
1504
+ test("POST /tags/merge rejects bad body with 400", async () => {
1505
+ const res = await handleTags(
1506
+ mkReq("POST", "/tags/merge", { sources: "v1", target: "voice" }),
1507
+ store,
1508
+ "/merge",
1509
+ );
1510
+ expect(res.status).toBe(400);
1511
+ });
1512
+
1513
+ test("POST /tags/merge rejects non-POST with 405", async () => {
1514
+ const res = await handleTags(mkReq("GET", "/tags/merge"), store, "/merge");
1515
+ expect(res.status).toBe(405);
1516
+ });
1202
1517
  });
1203
1518
 
1204
1519
  describe("HTTP /find-path", async () => {
@@ -1230,8 +1545,8 @@ describe("HTTP /find-path", async () => {
1230
1545
 
1231
1546
  describe("stateless MCP transport", async () => {
1232
1547
  test("tools/call works without prior initialize handshake", async () => {
1233
- const { handleUnifiedMcp } = await import("./mcp-http.ts");
1234
- const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
1548
+ const { handleScopedMcp } = await import("./mcp-http.ts");
1549
+ const { writeVaultConfig } = await import("./config.ts");
1235
1550
  const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1236
1551
 
1237
1552
  const vaultName = `stateless-mcp-${Date.now()}`;
@@ -1240,13 +1555,12 @@ describe("stateless MCP transport", async () => {
1240
1555
  api_keys: [],
1241
1556
  created_at: new Date().toISOString(),
1242
1557
  });
1243
- writeGlobalConfig({ port: 1940, default_vault: vaultName });
1244
1558
 
1245
1559
  const vaultStore = getVaultStore(vaultName);
1246
1560
  await vaultStore.createNote("test note", { tags: ["daily"] });
1247
1561
 
1248
1562
  // Direct tools/call — no initialize, no session header
1249
- const req = new Request("http://localhost:1940/mcp", {
1563
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
1250
1564
  method: "POST",
1251
1565
  headers: {
1252
1566
  "content-type": "application/json",
@@ -1256,11 +1570,15 @@ describe("stateless MCP transport", async () => {
1256
1570
  jsonrpc: "2.0",
1257
1571
  id: 1,
1258
1572
  method: "tools/call",
1259
- params: { name: "vault-info", arguments: { vault: vaultName, include_stats: true } },
1573
+ params: { name: "vault-info", arguments: { include_stats: true } },
1260
1574
  }),
1261
1575
  });
1262
1576
 
1263
- const res = await handleUnifiedMcp(req, "write");
1577
+ const res = await handleScopedMcp(req, vaultName, {
1578
+ permission: "full",
1579
+ scopes: ["vault:read", "vault:write", "vault:admin"],
1580
+ legacyDerived: false,
1581
+ });
1264
1582
  expect(res.status).toBe(200);
1265
1583
 
1266
1584
  const body = await res.json() as any;
@@ -1272,8 +1590,8 @@ describe("stateless MCP transport", async () => {
1272
1590
  });
1273
1591
 
1274
1592
  test("tools/list works without prior initialize handshake", async () => {
1275
- const { handleUnifiedMcp } = await import("./mcp-http.ts");
1276
- const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
1593
+ const { handleScopedMcp } = await import("./mcp-http.ts");
1594
+ const { writeVaultConfig } = await import("./config.ts");
1277
1595
  const { closeAllStores } = await import("./vault-store.ts");
1278
1596
 
1279
1597
  const vaultName = `stateless-list-${Date.now()}`;
@@ -1282,9 +1600,8 @@ describe("stateless MCP transport", async () => {
1282
1600
  api_keys: [],
1283
1601
  created_at: new Date().toISOString(),
1284
1602
  });
1285
- writeGlobalConfig({ port: 1940, default_vault: vaultName });
1286
1603
 
1287
- const req = new Request("http://localhost:1940/mcp", {
1604
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
1288
1605
  method: "POST",
1289
1606
  headers: {
1290
1607
  "content-type": "application/json",
@@ -1298,7 +1615,11 @@ describe("stateless MCP transport", async () => {
1298
1615
  }),
1299
1616
  });
1300
1617
 
1301
- const res = await handleUnifiedMcp(req, "write");
1618
+ const res = await handleScopedMcp(req, vaultName, {
1619
+ permission: "full",
1620
+ scopes: ["vault:read", "vault:write", "vault:admin"],
1621
+ legacyDerived: false,
1622
+ });
1302
1623
  expect(res.status).toBe(200);
1303
1624
 
1304
1625
  const body = await res.json() as any;
@@ -1311,9 +1632,193 @@ describe("stateless MCP transport", async () => {
1311
1632
  closeAllStores();
1312
1633
  });
1313
1634
 
1635
+ test("tools/list with vault:read scope only advertises read-only tools", async () => {
1636
+ const { handleScopedMcp } = await import("./mcp-http.ts");
1637
+ const { writeVaultConfig } = await import("./config.ts");
1638
+ const { closeAllStores } = await import("./vault-store.ts");
1639
+
1640
+ const vaultName = `scope-list-${Date.now()}`;
1641
+ writeVaultConfig({
1642
+ name: vaultName,
1643
+ api_keys: [],
1644
+ created_at: new Date().toISOString(),
1645
+ });
1646
+
1647
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
1648
+ method: "POST",
1649
+ headers: {
1650
+ "content-type": "application/json",
1651
+ "accept": "application/json, text/event-stream",
1652
+ },
1653
+ body: JSON.stringify({
1654
+ jsonrpc: "2.0",
1655
+ id: 1,
1656
+ method: "tools/list",
1657
+ params: {},
1658
+ }),
1659
+ });
1660
+
1661
+ const res = await handleScopedMcp(req, vaultName, {
1662
+ permission: "read",
1663
+ scopes: ["vault:read"],
1664
+ legacyDerived: false,
1665
+ });
1666
+ expect(res.status).toBe(200);
1667
+
1668
+ const body = await res.json() as any;
1669
+ const toolNames: string[] = body.result.tools.map((t: any) => t.name);
1670
+ // Read-only tools are visible
1671
+ expect(toolNames).toContain("query-notes");
1672
+ expect(toolNames).toContain("list-tags");
1673
+ expect(toolNames).toContain("find-path");
1674
+ expect(toolNames).toContain("vault-info");
1675
+ // Mutation tools are hidden — filter applied before advertising
1676
+ expect(toolNames).not.toContain("create-note");
1677
+ expect(toolNames).not.toContain("update-note");
1678
+ expect(toolNames).not.toContain("delete-note");
1679
+ expect(toolNames).not.toContain("update-tag");
1680
+ expect(toolNames).not.toContain("delete-tag");
1681
+
1682
+ closeAllStores();
1683
+ });
1684
+
1685
+ test("tools/call of vault-info with description arg and vault:read scope is refused", async () => {
1686
+ const { handleScopedMcp } = await import("./mcp-http.ts");
1687
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
1688
+ const { closeAllStores } = await import("./vault-store.ts");
1689
+
1690
+ const vaultName = `scope-vault-info-${Date.now()}`;
1691
+ writeVaultConfig({
1692
+ name: vaultName,
1693
+ api_keys: [],
1694
+ created_at: new Date().toISOString(),
1695
+ description: "original description",
1696
+ });
1697
+
1698
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
1699
+ method: "POST",
1700
+ headers: {
1701
+ "content-type": "application/json",
1702
+ "accept": "application/json, text/event-stream",
1703
+ },
1704
+ body: JSON.stringify({
1705
+ jsonrpc: "2.0",
1706
+ id: 1,
1707
+ method: "tools/call",
1708
+ params: {
1709
+ name: "vault-info",
1710
+ arguments: { description: "hijacked description" },
1711
+ },
1712
+ }),
1713
+ });
1714
+
1715
+ const res = await handleScopedMcp(req, vaultName, {
1716
+ permission: "read",
1717
+ scopes: ["vault:read"],
1718
+ legacyDerived: false,
1719
+ });
1720
+
1721
+ expect(res.status).toBe(200);
1722
+ const body = await res.json() as any;
1723
+ // The tool call must surface as an error (isError: true) and mention
1724
+ // the required scope — the inner guard fired even though the outer tool
1725
+ // gate allowed read-only callers through for stats.
1726
+ expect(body.result.isError).toBe(true);
1727
+ expect(body.result.content[0].text).toContain("vault:write");
1728
+
1729
+ // And critically: the vault description must NOT have been mutated.
1730
+ const cfg = readVaultConfig(vaultName);
1731
+ expect(cfg?.description).toBe("original description");
1732
+
1733
+ closeAllStores();
1734
+ });
1735
+
1736
+ test("tools/call of vault-info with description arg and vault:write scope is allowed", async () => {
1737
+ const { handleScopedMcp } = await import("./mcp-http.ts");
1738
+ const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
1739
+ const { closeAllStores } = await import("./vault-store.ts");
1740
+
1741
+ const vaultName = `scope-vault-info-write-${Date.now()}`;
1742
+ writeVaultConfig({
1743
+ name: vaultName,
1744
+ api_keys: [],
1745
+ created_at: new Date().toISOString(),
1746
+ description: "original",
1747
+ });
1748
+
1749
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
1750
+ method: "POST",
1751
+ headers: {
1752
+ "content-type": "application/json",
1753
+ "accept": "application/json, text/event-stream",
1754
+ },
1755
+ body: JSON.stringify({
1756
+ jsonrpc: "2.0",
1757
+ id: 1,
1758
+ method: "tools/call",
1759
+ params: {
1760
+ name: "vault-info",
1761
+ arguments: { description: "updated via write scope" },
1762
+ },
1763
+ }),
1764
+ });
1765
+
1766
+ const res = await handleScopedMcp(req, vaultName, {
1767
+ permission: "full",
1768
+ scopes: ["vault:read", "vault:write"],
1769
+ legacyDerived: false,
1770
+ });
1771
+
1772
+ expect(res.status).toBe(200);
1773
+ const body = await res.json() as any;
1774
+ expect(body.result.isError).toBeFalsy();
1775
+ expect(readVaultConfig(vaultName)?.description).toBe("updated via write scope");
1776
+
1777
+ closeAllStores();
1778
+ });
1779
+
1780
+ test("tools/call of create-note with vault:read scope is refused (not silently allowed)", async () => {
1781
+ const { handleScopedMcp } = await import("./mcp-http.ts");
1782
+ const { writeVaultConfig } = await import("./config.ts");
1783
+ const { closeAllStores } = await import("./vault-store.ts");
1784
+
1785
+ const vaultName = `scope-call-${Date.now()}`;
1786
+ writeVaultConfig({
1787
+ name: vaultName,
1788
+ api_keys: [],
1789
+ created_at: new Date().toISOString(),
1790
+ });
1791
+
1792
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
1793
+ method: "POST",
1794
+ headers: {
1795
+ "content-type": "application/json",
1796
+ "accept": "application/json, text/event-stream",
1797
+ },
1798
+ body: JSON.stringify({
1799
+ jsonrpc: "2.0",
1800
+ id: 1,
1801
+ method: "tools/call",
1802
+ params: { name: "create-note", arguments: { content: "nope" } },
1803
+ }),
1804
+ });
1805
+
1806
+ const res = await handleScopedMcp(req, vaultName, {
1807
+ permission: "read",
1808
+ scopes: ["vault:read"],
1809
+ legacyDerived: false,
1810
+ });
1811
+ expect(res.status).toBe(200); // JSON-RPC envelope is 200 even for tool errors
1812
+ const body = await res.json() as any;
1813
+ expect(body.result.isError).toBe(true);
1814
+ expect(body.result.content[0].text).toContain("vault:write");
1815
+
1816
+ closeAllStores();
1817
+ });
1818
+
1314
1819
  test("initialize still works for clients that send it", async () => {
1315
- const { handleUnifiedMcp } = await import("./mcp-http.ts");
1316
- const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
1820
+ const { handleScopedMcp } = await import("./mcp-http.ts");
1821
+ const { writeVaultConfig } = await import("./config.ts");
1317
1822
  const { closeAllStores } = await import("./vault-store.ts");
1318
1823
 
1319
1824
  const vaultName = `stateless-init-${Date.now()}`;
@@ -1322,9 +1827,8 @@ describe("stateless MCP transport", async () => {
1322
1827
  api_keys: [],
1323
1828
  created_at: new Date().toISOString(),
1324
1829
  });
1325
- writeGlobalConfig({ port: 1940, default_vault: vaultName });
1326
1830
 
1327
- const req = new Request("http://localhost:1940/mcp", {
1831
+ const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
1328
1832
  method: "POST",
1329
1833
  headers: {
1330
1834
  "content-type": "application/json",
@@ -1342,12 +1846,16 @@ describe("stateless MCP transport", async () => {
1342
1846
  }),
1343
1847
  });
1344
1848
 
1345
- const res = await handleUnifiedMcp(req, "write");
1849
+ const res = await handleScopedMcp(req, vaultName, {
1850
+ permission: "full",
1851
+ scopes: ["vault:read", "vault:write", "vault:admin"],
1852
+ legacyDerived: false,
1853
+ });
1346
1854
  expect(res.status).toBe(200);
1347
1855
 
1348
1856
  const body = await res.json() as any;
1349
1857
  expect(body.result.protocolVersion).toBe("2024-11-05");
1350
- expect(body.result.serverInfo.name).toBe("parachute-vault");
1858
+ expect(body.result.serverInfo.name).toBe(`parachute-vault/${vaultName}`);
1351
1859
  expect(body.result.capabilities.tools).toBeDefined();
1352
1860
 
1353
1861
  closeAllStores();
@@ -1401,3 +1909,111 @@ describe("extractApiKey", () => {
1401
1909
  });
1402
1910
  });
1403
1911
 
1912
+ describe("handleVault: audio_retention", async () => {
1913
+ function mkVaultReq(method: string, body?: unknown): Request {
1914
+ const init: RequestInit = { method };
1915
+ if (body !== undefined) {
1916
+ init.body = JSON.stringify(body);
1917
+ init.headers = { "Content-Type": "application/json" };
1918
+ }
1919
+ return new Request(`${BASE}/vault`, init);
1920
+ }
1921
+
1922
+ test("GET returns config.audio_retention defaulting to 'keep' when unset", async () => {
1923
+ const cfg = { name: "default" } as { name: string; audio_retention?: string };
1924
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
1925
+ expect(res.status).toBe(200);
1926
+ const body = await res.json() as any;
1927
+ expect(body.name).toBe("default");
1928
+ expect(body.config.audio_retention).toBe("keep");
1929
+ });
1930
+
1931
+ test("GET reflects the currently stored value", async () => {
1932
+ const cfg = { name: "default", audio_retention: "until_transcribed" };
1933
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
1934
+ const body = await res.json() as any;
1935
+ expect(body.config.audio_retention).toBe("until_transcribed");
1936
+ });
1937
+
1938
+ test("PATCH sets audio_retention and invokes persist", async () => {
1939
+ const cfg: { name: string; audio_retention?: string } = { name: "default" };
1940
+ let persisted = 0;
1941
+ const res = await handleVault(
1942
+ mkVaultReq("PATCH", { config: { audio_retention: "until_transcribed" } }),
1943
+ store,
1944
+ cfg as any,
1945
+ () => { persisted++; },
1946
+ );
1947
+ expect(res.status).toBe(200);
1948
+ const body = await res.json() as any;
1949
+ expect(body.config.audio_retention).toBe("until_transcribed");
1950
+ expect(cfg.audio_retention).toBe("until_transcribed");
1951
+ expect(persisted).toBe(1);
1952
+ });
1953
+
1954
+ test("PATCH accepts 'never'", async () => {
1955
+ const cfg: { name: string; audio_retention?: string } = { name: "default" };
1956
+ const res = await handleVault(
1957
+ mkVaultReq("PATCH", { config: { audio_retention: "never" } }),
1958
+ store,
1959
+ cfg as any,
1960
+ () => {},
1961
+ );
1962
+ expect(res.status).toBe(200);
1963
+ expect(cfg.audio_retention).toBe("never");
1964
+ });
1965
+
1966
+ test("PATCH rejects invalid modes with 400 and does not mutate", async () => {
1967
+ const cfg: { name: string; audio_retention?: string } = {
1968
+ name: "default",
1969
+ audio_retention: "keep",
1970
+ };
1971
+ let persisted = 0;
1972
+ const res = await handleVault(
1973
+ mkVaultReq("PATCH", { config: { audio_retention: "forever" } }),
1974
+ store,
1975
+ cfg as any,
1976
+ () => { persisted++; },
1977
+ );
1978
+ expect(res.status).toBe(400);
1979
+ const body = await res.json() as any;
1980
+ expect(body.error).toBe("invalid_audio_retention");
1981
+ expect(cfg.audio_retention).toBe("keep");
1982
+ expect(persisted).toBe(0);
1983
+ });
1984
+
1985
+ test("PATCH with only description leaves audio_retention alone", async () => {
1986
+ const cfg: { name: string; description?: string; audio_retention?: string } = {
1987
+ name: "default",
1988
+ audio_retention: "until_transcribed",
1989
+ };
1990
+ const res = await handleVault(
1991
+ mkVaultReq("PATCH", { description: "new desc" }),
1992
+ store,
1993
+ cfg as any,
1994
+ () => {},
1995
+ );
1996
+ expect(res.status).toBe(200);
1997
+ expect(cfg.description).toBe("new desc");
1998
+ expect(cfg.audio_retention).toBe("until_transcribed");
1999
+ });
2000
+
2001
+ test("PATCH with empty body is a no-op that still returns current state", async () => {
2002
+ const cfg: { name: string; audio_retention?: string } = {
2003
+ name: "default",
2004
+ audio_retention: "never",
2005
+ };
2006
+ let persisted = 0;
2007
+ const res = await handleVault(
2008
+ mkVaultReq("PATCH", {}),
2009
+ store,
2010
+ cfg as any,
2011
+ () => { persisted++; },
2012
+ );
2013
+ expect(res.status).toBe(200);
2014
+ const body = await res.json() as any;
2015
+ expect(body.config.audio_retention).toBe("never");
2016
+ expect(persisted).toBe(0);
2017
+ });
2018
+ });
2019
+