@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.
- package/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +70 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +603 -19
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +157 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +92 -0
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +29 -1
- package/docs/HTTP_API.md +105 -1
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/cli.ts +179 -121
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +136 -0
- package/src/scopes.ts +105 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +57 -5
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +583 -0
- package/src/transcription-worker.ts +346 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- 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).
|
|
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).
|
|
79
|
+
expect(note.updatedAt).toBe(note.createdAt);
|
|
74
80
|
|
|
75
|
-
// Fresh note: a machine write must not
|
|
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).
|
|
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("
|
|
526
|
-
test("vault-info
|
|
527
|
-
const {
|
|
528
|
-
const { writeVaultConfig
|
|
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 = `
|
|
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 =
|
|
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({
|
|
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
|
|
558
|
-
const {
|
|
559
|
-
const { writeVaultConfig
|
|
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 =
|
|
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({
|
|
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 {
|
|
592
|
-
const { writeVaultConfig
|
|
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 =
|
|
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({
|
|
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({
|
|
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 {
|
|
645
|
-
const { writeVaultConfig
|
|
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 =
|
|
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({
|
|
679
|
-
await updateNote.execute({
|
|
680
|
-
const after = await queryNotes.execute({
|
|
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({
|
|
691
|
-
const after2 = await queryNotes.execute({
|
|
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({
|
|
697
|
-
await updateNote.execute({
|
|
698
|
-
const after4 = await queryNotes.execute({
|
|
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({
|
|
705
|
-
await updateNote.execute({
|
|
706
|
-
const after5 = await queryNotes.execute({
|
|
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 {
|
|
717
|
-
const { writeVaultConfig
|
|
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 =
|
|
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({
|
|
737
|
+
const note = await createNote.execute({ content: "Test" }) as any;
|
|
740
738
|
const originalUpdatedAt = note.updatedAt;
|
|
741
|
-
await updateNote.execute({
|
|
742
|
-
const after = await queryNotes.execute({
|
|
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 {
|
|
1234
|
-
const { writeVaultConfig
|
|
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(
|
|
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: {
|
|
1573
|
+
params: { name: "vault-info", arguments: { include_stats: true } },
|
|
1260
1574
|
}),
|
|
1261
1575
|
});
|
|
1262
1576
|
|
|
1263
|
-
const res = await
|
|
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 {
|
|
1276
|
-
const { writeVaultConfig
|
|
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(
|
|
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
|
|
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 {
|
|
1316
|
-
const { writeVaultConfig
|
|
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(
|
|
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
|
|
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(
|
|
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
|
+
|