@openparachute/vault 0.3.3 → 0.4.3
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/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +727 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +1626 -183
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/vault.test.ts
CHANGED
|
@@ -476,7 +476,7 @@ describe("deeper link queries", async () => {
|
|
|
476
476
|
});
|
|
477
477
|
|
|
478
478
|
describe("MCP tools", async () => {
|
|
479
|
-
test("generates
|
|
479
|
+
test("generates the consolidated tool set", () => {
|
|
480
480
|
const tools = generateMcpTools(store);
|
|
481
481
|
expect(tools.length).toBe(9);
|
|
482
482
|
|
|
@@ -490,6 +490,12 @@ describe("MCP tools", async () => {
|
|
|
490
490
|
expect(names).toContain("delete-tag");
|
|
491
491
|
expect(names).toContain("find-path");
|
|
492
492
|
expect(names).toContain("vault-info");
|
|
493
|
+
// Six note-schema MCP tools (list/update/delete-note-schema +
|
|
494
|
+
// list/set/delete-schema-mapping) retired in v17 — vault#267.
|
|
495
|
+
expect(names).not.toContain("list-note-schemas");
|
|
496
|
+
expect(names).not.toContain("set-schema-mapping");
|
|
497
|
+
// synthesize-notes retired in v17 — vault#268.
|
|
498
|
+
expect(names).not.toContain("synthesize-notes");
|
|
493
499
|
});
|
|
494
500
|
|
|
495
501
|
test("query-notes by id works", async () => {
|
|
@@ -559,6 +565,355 @@ describe("scoped MCP wrapper", async () => {
|
|
|
559
565
|
closeAllStores();
|
|
560
566
|
});
|
|
561
567
|
|
|
568
|
+
test("vault-info projection includes tags-with-schemas + indexed_fields + query_hints (vault#271)", async () => {
|
|
569
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
570
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
571
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
572
|
+
|
|
573
|
+
const vaultName = `proj-${Date.now()}`;
|
|
574
|
+
writeVaultConfig({
|
|
575
|
+
name: vaultName,
|
|
576
|
+
api_keys: [],
|
|
577
|
+
created_at: new Date().toISOString(),
|
|
578
|
+
description: "vault for #271",
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const vaultStore = getVaultStore(vaultName);
|
|
582
|
+
await vaultStore.upsertTagRecord("_default", {
|
|
583
|
+
fields: { created_by: { type: "string", description: "Origin" } },
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Indexed-field lifecycle is owned by the update-tag MCP tool — go
|
|
587
|
+
// through the tool, not the store, so indexed_fields gets populated.
|
|
588
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
589
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
590
|
+
await updateTag.execute({
|
|
591
|
+
tag: "person",
|
|
592
|
+
description: "A person",
|
|
593
|
+
fields: { email: { type: "string", indexed: true } },
|
|
594
|
+
});
|
|
595
|
+
await updateTag.execute({
|
|
596
|
+
tag: "employee",
|
|
597
|
+
description: "Employee",
|
|
598
|
+
fields: { title: { type: "string" } },
|
|
599
|
+
parent_names: ["person"],
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const vaultInfo = tools.find((t) => t.name === "vault-info")!;
|
|
603
|
+
const result = await vaultInfo.execute({}) as any;
|
|
604
|
+
|
|
605
|
+
expect(result.name).toBe(vaultName);
|
|
606
|
+
expect(result.description).toBe("vault for #271");
|
|
607
|
+
|
|
608
|
+
// tags array — only schema-bearing rows, with effective inheritance
|
|
609
|
+
const byName = Object.fromEntries(
|
|
610
|
+
(result.tags as any[]).map((t) => [t.name, t]),
|
|
611
|
+
);
|
|
612
|
+
expect(byName.person).toBeTruthy();
|
|
613
|
+
expect(byName.person.effective_parents).toEqual(["_default"]);
|
|
614
|
+
expect(Object.keys(byName.person.effective_fields).sort()).toEqual([
|
|
615
|
+
"created_by",
|
|
616
|
+
"email",
|
|
617
|
+
]);
|
|
618
|
+
expect(byName.employee.effective_parents).toEqual(["person", "_default"]);
|
|
619
|
+
expect(Object.keys(byName.employee.effective_fields).sort()).toEqual([
|
|
620
|
+
"created_by",
|
|
621
|
+
"email",
|
|
622
|
+
"title",
|
|
623
|
+
]);
|
|
624
|
+
|
|
625
|
+
// indexed_fields catalog
|
|
626
|
+
const indexed = result.indexed_fields as any[];
|
|
627
|
+
const emailEntry = indexed.find((f) => f.name === "email");
|
|
628
|
+
expect(emailEntry).toBeTruthy();
|
|
629
|
+
expect(emailEntry.type).toBe("string");
|
|
630
|
+
expect(emailEntry.tags).toEqual(["person"]);
|
|
631
|
+
|
|
632
|
+
// query_hints — static catalog, present even without include_stats
|
|
633
|
+
expect(Array.isArray(result.query_hints)).toBe(true);
|
|
634
|
+
expect((result.query_hints as string[]).length).toBeGreaterThan(0);
|
|
635
|
+
|
|
636
|
+
// stats omitted unless requested
|
|
637
|
+
expect(result.stats).toBeUndefined();
|
|
638
|
+
|
|
639
|
+
const withStats = await vaultInfo.execute({ include_stats: true }) as any;
|
|
640
|
+
expect(withStats.stats).toBeTruthy();
|
|
641
|
+
|
|
642
|
+
closeAllStores();
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("getServerInstruction renders projection markdown for a populated vault (vault#271)", async () => {
|
|
646
|
+
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
647
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
648
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
649
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
650
|
+
|
|
651
|
+
const vaultName = `instr-${Date.now()}`;
|
|
652
|
+
writeVaultConfig({
|
|
653
|
+
name: vaultName,
|
|
654
|
+
api_keys: [],
|
|
655
|
+
created_at: new Date().toISOString(),
|
|
656
|
+
description: "Working notebook for the daily team.",
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const vaultStore = getVaultStore(vaultName);
|
|
660
|
+
await vaultStore.createNote("A", { tags: ["person"] });
|
|
661
|
+
|
|
662
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
663
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
664
|
+
await updateTag.execute({
|
|
665
|
+
tag: "person",
|
|
666
|
+
description: "A person",
|
|
667
|
+
fields: { email: { type: "string", indexed: true } },
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const md = await getServerInstruction(vaultName);
|
|
671
|
+
|
|
672
|
+
expect(md).toContain(`Parachute Vault "${vaultName}"`);
|
|
673
|
+
expect(md).toContain("Working notebook for the daily team.");
|
|
674
|
+
// vault#274: stats line distinguishes total tag count (note-usage)
|
|
675
|
+
// from schema-bearing count. One note tagged `person`, one tag
|
|
676
|
+
// overall, that one tag has a schema → "1 tag total, 1 with schemas".
|
|
677
|
+
expect(md).toContain("1 note, 1 tag total, 1 with schemas");
|
|
678
|
+
expect(md).toContain("1 tag with schemas: person");
|
|
679
|
+
expect(md).toContain("Indexed metadata fields");
|
|
680
|
+
expect(md).toContain("email");
|
|
681
|
+
expect(md).toContain("#person");
|
|
682
|
+
expect(md).toContain("Querying");
|
|
683
|
+
expect(md).toContain("vault-info");
|
|
684
|
+
expect(md).toContain("list-tags { include_schema: true }");
|
|
685
|
+
|
|
686
|
+
closeAllStores();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("getServerInstruction degrades gracefully on an empty vault (vault#271)", async () => {
|
|
690
|
+
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
691
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
692
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
693
|
+
|
|
694
|
+
const vaultName = `instr-empty-${Date.now()}`;
|
|
695
|
+
writeVaultConfig({
|
|
696
|
+
name: vaultName,
|
|
697
|
+
api_keys: [],
|
|
698
|
+
created_at: new Date().toISOString(),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const md = await getServerInstruction(vaultName);
|
|
702
|
+
|
|
703
|
+
expect(md).toContain(`Parachute Vault "${vaultName}"`);
|
|
704
|
+
// vault#274: empty vault — no schemas, so the suffix is omitted.
|
|
705
|
+
// 0 is plural per English convention. The not.toContain guard
|
|
706
|
+
// pins that "with schemas" doesn't leak through anywhere — when
|
|
707
|
+
// schemas exist it appears in two places (stats suffix + the
|
|
708
|
+
// tags-with-schemas list line); on an empty vault both branches
|
|
709
|
+
// are unreachable, so the phrase shouldn't appear at all.
|
|
710
|
+
expect(md).toContain("0 notes, 0 tags total");
|
|
711
|
+
expect(md).not.toContain("with schemas");
|
|
712
|
+
expect(md).toContain("No tag schemas declared");
|
|
713
|
+
expect(md).toContain("No indexed metadata fields");
|
|
714
|
+
// Refresh hints surface both pointers so the agent knows where to look.
|
|
715
|
+
expect(md).toContain("vault-info");
|
|
716
|
+
expect(md).toContain("list-tags");
|
|
717
|
+
|
|
718
|
+
closeAllStores();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("getServerInstruction stays under ~5K tokens at 50 tags-with-schemas (vault#271)", async () => {
|
|
722
|
+
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
723
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
724
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
725
|
+
|
|
726
|
+
const vaultName = `instr-big-${Date.now()}`;
|
|
727
|
+
writeVaultConfig({
|
|
728
|
+
name: vaultName,
|
|
729
|
+
api_keys: [],
|
|
730
|
+
created_at: new Date().toISOString(),
|
|
731
|
+
description: "Stress-test fixture for the connect-time projection size.",
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
const vaultStore = getVaultStore(vaultName);
|
|
735
|
+
for (let i = 0; i < 50; i++) {
|
|
736
|
+
await vaultStore.upsertTagRecord(`schema_tag_${i}`, {
|
|
737
|
+
description: `Description for tag ${i} — covers a meaningful chunk of the vault's domain.`,
|
|
738
|
+
fields: {
|
|
739
|
+
[`field_${i}_a`]: { type: "string" },
|
|
740
|
+
[`field_${i}_b`]: { type: "integer" },
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const md = await getServerInstruction(vaultName);
|
|
746
|
+
// Rough token approximation: 1 token ≈ 4 chars. Budget: 5K tokens.
|
|
747
|
+
const approxTokens = md.length / 4;
|
|
748
|
+
expect(approxTokens).toBeLessThan(5000);
|
|
749
|
+
|
|
750
|
+
closeAllStores();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("getServerInstruction filters projection by tag-scoped allowlist (vault#271 fold 5)", async () => {
|
|
754
|
+
const { getServerInstruction, generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
755
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
756
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
757
|
+
|
|
758
|
+
const vaultName = `instr-scoped-${Date.now()}`;
|
|
759
|
+
writeVaultConfig({
|
|
760
|
+
name: vaultName,
|
|
761
|
+
api_keys: [],
|
|
762
|
+
created_at: new Date().toISOString(),
|
|
763
|
+
description: "Scoped session brief.",
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Seed three schema-bearing tags. Cross-declarer indexed `status`
|
|
767
|
+
// exercises the same shape the JSON wrapper test pinned: scoped to
|
|
768
|
+
// `task`, the brief should mention `status` via `task` but never
|
|
769
|
+
// surface `project` or `person`.
|
|
770
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
771
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
772
|
+
await updateTag.execute({
|
|
773
|
+
tag: "task",
|
|
774
|
+
description: "A task",
|
|
775
|
+
fields: { status: { type: "string", indexed: true } },
|
|
776
|
+
});
|
|
777
|
+
await updateTag.execute({
|
|
778
|
+
tag: "project",
|
|
779
|
+
description: "A project",
|
|
780
|
+
fields: { status: { type: "string", indexed: true } },
|
|
781
|
+
});
|
|
782
|
+
await updateTag.execute({
|
|
783
|
+
tag: "person",
|
|
784
|
+
description: "A person",
|
|
785
|
+
fields: { email: { type: "string", indexed: true } },
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
const auth = {
|
|
789
|
+
permission: "full" as const,
|
|
790
|
+
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
791
|
+
legacyDerived: false,
|
|
792
|
+
scoped_tags: ["task"],
|
|
793
|
+
};
|
|
794
|
+
const md = await getServerInstruction(vaultName, auth as any);
|
|
795
|
+
|
|
796
|
+
// Allowlisted tag surfaces; out-of-scope tags do not. Use word-boundary
|
|
797
|
+
// regex — the static refresh-pointer text mentions "full projection"
|
|
798
|
+
// which contains the substring "project".
|
|
799
|
+
expect(md).toMatch(/\btask\b/);
|
|
800
|
+
expect(md).not.toMatch(/\bproject\b/);
|
|
801
|
+
expect(md).not.toMatch(/\bperson\b/);
|
|
802
|
+
|
|
803
|
+
// Indexed-field catalog: `status` survives (declared by `task`);
|
|
804
|
+
// `email` (person-only) is filtered out entirely.
|
|
805
|
+
expect(md).toMatch(/\bstatus\b/);
|
|
806
|
+
expect(md).not.toMatch(/\bemail\b/);
|
|
807
|
+
|
|
808
|
+
// Aggregate stats line still flows through unchanged — counts are
|
|
809
|
+
// pre-existing leak surface (per the rc.3 design discussion).
|
|
810
|
+
expect(md).toMatch(/\d+ note/);
|
|
811
|
+
|
|
812
|
+
closeAllStores();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("getServerInstruction passes the full projection through for unscoped tokens (vault#271 fold 5)", async () => {
|
|
816
|
+
const { getServerInstruction, generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
817
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
818
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
819
|
+
|
|
820
|
+
const vaultName = `instr-unscoped-${Date.now()}`;
|
|
821
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
822
|
+
|
|
823
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
824
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
825
|
+
await updateTag.execute({
|
|
826
|
+
tag: "task",
|
|
827
|
+
description: "A task",
|
|
828
|
+
fields: { status: { type: "string" } },
|
|
829
|
+
});
|
|
830
|
+
await updateTag.execute({
|
|
831
|
+
tag: "project",
|
|
832
|
+
description: "A project",
|
|
833
|
+
fields: { priority: { type: "integer" } },
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// No `auth` arg → no scoping applied, full projection rendered.
|
|
837
|
+
const md = await getServerInstruction(vaultName);
|
|
838
|
+
expect(md).toContain("task");
|
|
839
|
+
expect(md).toContain("project");
|
|
840
|
+
expect(md).toContain("tags with schemas");
|
|
841
|
+
// Same for explicit auth with `scoped_tags: null`.
|
|
842
|
+
const mdNullScoped = await getServerInstruction(vaultName, {
|
|
843
|
+
permission: "full",
|
|
844
|
+
scopes: ["vault:read"],
|
|
845
|
+
legacyDerived: false,
|
|
846
|
+
scoped_tags: null,
|
|
847
|
+
} as any);
|
|
848
|
+
expect(mdNullScoped).toContain("task");
|
|
849
|
+
expect(mdNullScoped).toContain("project");
|
|
850
|
+
|
|
851
|
+
closeAllStores();
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test("MCP initialize response carries scope-filtered instructions for tag-scoped tokens (vault#271 fold 5)", async () => {
|
|
855
|
+
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
856
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
857
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
858
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
859
|
+
|
|
860
|
+
const vaultName = `init-scoped-${Date.now()}`;
|
|
861
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
862
|
+
|
|
863
|
+
// Same fixture shape as the unit test, but driven through the actual
|
|
864
|
+
// `handleScopedMcp` initialize path the MCP client invokes at session
|
|
865
|
+
// start. The reviewer asked for an integration assertion on the
|
|
866
|
+
// `instructions` field carried in the `initialize` response.
|
|
867
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
868
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
869
|
+
await updateTag.execute({
|
|
870
|
+
tag: "task",
|
|
871
|
+
description: "A task",
|
|
872
|
+
fields: { status: { type: "string" } },
|
|
873
|
+
});
|
|
874
|
+
await updateTag.execute({
|
|
875
|
+
tag: "project",
|
|
876
|
+
description: "A project",
|
|
877
|
+
fields: { priority: { type: "integer" } },
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
|
|
881
|
+
method: "POST",
|
|
882
|
+
headers: {
|
|
883
|
+
"content-type": "application/json",
|
|
884
|
+
"accept": "application/json, text/event-stream",
|
|
885
|
+
},
|
|
886
|
+
body: JSON.stringify({
|
|
887
|
+
jsonrpc: "2.0",
|
|
888
|
+
id: 1,
|
|
889
|
+
method: "initialize",
|
|
890
|
+
params: {
|
|
891
|
+
protocolVersion: "2024-11-05",
|
|
892
|
+
capabilities: {},
|
|
893
|
+
clientInfo: { name: "test", version: "1.0" },
|
|
894
|
+
},
|
|
895
|
+
}),
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
const res = await handleScopedMcp(req, vaultName, {
|
|
899
|
+
permission: "full",
|
|
900
|
+
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
901
|
+
legacyDerived: false,
|
|
902
|
+
scoped_tags: ["task"],
|
|
903
|
+
} as any);
|
|
904
|
+
expect(res.status).toBe(200);
|
|
905
|
+
|
|
906
|
+
const body = await res.json() as any;
|
|
907
|
+
const instructions: string = body.result.instructions;
|
|
908
|
+
expect(instructions).toBeTruthy();
|
|
909
|
+
// Word-boundary match — the static refresh text mentions "full
|
|
910
|
+
// projection" which would false-trigger a substring check.
|
|
911
|
+
expect(instructions).toMatch(/\btask\b/);
|
|
912
|
+
expect(instructions).not.toMatch(/\bproject\b/);
|
|
913
|
+
|
|
914
|
+
closeAllStores();
|
|
915
|
+
});
|
|
916
|
+
|
|
562
917
|
test("list-tags with schema returns per-tag detail", async () => {
|
|
563
918
|
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
564
919
|
const { writeVaultConfig } = await import("./config.ts");
|
|
@@ -711,89 +1066,445 @@ describe("scoped MCP wrapper", async () => {
|
|
|
711
1066
|
close();
|
|
712
1067
|
});
|
|
713
1068
|
|
|
714
|
-
|
|
1069
|
+
// -- tag-scoped MCP wrappers (patterns/tag-scoped-tokens.md) ------------
|
|
1070
|
+
//
|
|
1071
|
+
// These pin the behavior of `applyTagScopeWrappers` in mcp-tools.ts: each
|
|
1072
|
+
// wrapped tool's execute() honors the auth's scoped_tags allowlist. The
|
|
1073
|
+
// unscoped path (auth.scoped_tags === null) remains identical to the
|
|
1074
|
+
// baseline scoped MCP tests above; here we only assert the *scoped*
|
|
1075
|
+
// path's deltas.
|
|
1076
|
+
|
|
1077
|
+
function authForTags(tags: string[]) {
|
|
1078
|
+
return {
|
|
1079
|
+
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
1080
|
+
legacyDerived: false,
|
|
1081
|
+
scoped_tags: tags,
|
|
1082
|
+
} as const;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
test("scoped query-notes filters list to in-scope notes only", async () => {
|
|
715
1086
|
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
716
1087
|
const { writeVaultConfig } = await import("./config.ts");
|
|
717
|
-
const { getVaultStore, closeAllStores
|
|
1088
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
718
1089
|
|
|
719
|
-
const vaultName = `
|
|
720
|
-
writeVaultConfig({
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
});
|
|
1090
|
+
const vaultName = `tagscope-query-${Date.now()}`;
|
|
1091
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1092
|
+
const store = getVaultStore(vaultName);
|
|
1093
|
+
await store.createNote("h", { tags: ["health"] });
|
|
1094
|
+
await store.createNote("w", { tags: ["work"] });
|
|
725
1095
|
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
1096
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
|
|
1097
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1098
|
+
const result = await query.execute({}) as any[];
|
|
1099
|
+
expect(Array.isArray(result)).toBe(true);
|
|
1100
|
+
expect(result.every((n: any) => n.tags.includes("health"))).toBe(true);
|
|
1101
|
+
expect(result.find((n: any) => n.content === "w")).toBeUndefined();
|
|
731
1102
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
735
|
-
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
1103
|
+
closeAllStores();
|
|
1104
|
+
});
|
|
736
1105
|
|
|
737
|
-
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
const
|
|
741
|
-
expect(after.updatedAt).toBe(originalUpdatedAt);
|
|
742
|
-
expect(after.metadata.name).toBe("");
|
|
1106
|
+
test("scoped query-notes by id 404s on out-of-scope note", async () => {
|
|
1107
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1108
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1109
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
743
1110
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1111
|
+
const vaultName = `tagscope-byid-${Date.now()}`;
|
|
1112
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1113
|
+
const store = getVaultStore(vaultName);
|
|
1114
|
+
const w = await store.createNote("w", { tags: ["work"] });
|
|
747
1115
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const {
|
|
751
|
-
expect(
|
|
752
|
-
expect(
|
|
753
|
-
expect(isToolAllowed("find-path", "read")).toBe(true);
|
|
754
|
-
expect(isToolAllowed("vault-info", "read")).toBe(true);
|
|
755
|
-
});
|
|
1116
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
|
|
1117
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1118
|
+
const result = await query.execute({ id: w.id }) as any;
|
|
1119
|
+
expect(result.error).toBe("Note not found");
|
|
1120
|
+
expect(result.id).toBe(w.id);
|
|
756
1121
|
|
|
757
|
-
|
|
758
|
-
const { isToolAllowed } = require("./auth.ts");
|
|
759
|
-
expect(isToolAllowed("create-note", "read")).toBe(false);
|
|
760
|
-
expect(isToolAllowed("update-note", "read")).toBe(false);
|
|
761
|
-
expect(isToolAllowed("delete-note", "read")).toBe(false);
|
|
762
|
-
expect(isToolAllowed("update-tag", "read")).toBe(false);
|
|
763
|
-
expect(isToolAllowed("delete-tag", "read")).toBe(false);
|
|
1122
|
+
closeAllStores();
|
|
764
1123
|
});
|
|
765
1124
|
|
|
766
|
-
test("
|
|
767
|
-
const {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
expect(isToolAllowed("delete-note", "full")).toBe(true);
|
|
771
|
-
expect(isToolAllowed("update-tag", "full")).toBe(true);
|
|
772
|
-
expect(isToolAllowed("delete-tag", "full")).toBe(true);
|
|
773
|
-
expect(isToolAllowed("query-notes", "full")).toBe(true);
|
|
774
|
-
});
|
|
1125
|
+
test("scoped list-tags filters to allowlisted root + descendants", async () => {
|
|
1126
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1127
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1128
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
775
1129
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
});
|
|
1130
|
+
const vaultName = `tagscope-tags-${Date.now()}`;
|
|
1131
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1132
|
+
const store = getVaultStore(vaultName);
|
|
1133
|
+
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
1134
|
+
await store.createNote("h", { tags: ["health"] });
|
|
1135
|
+
await store.createNote("hf", { tags: ["health/food"] });
|
|
1136
|
+
await store.createNote("w", { tags: ["work"] });
|
|
784
1137
|
|
|
785
|
-
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
expect(
|
|
790
|
-
expect(
|
|
791
|
-
|
|
792
|
-
});
|
|
1138
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
|
|
1139
|
+
const listTags = tools.find((t) => t.name === "list-tags")!;
|
|
1140
|
+
const result = await listTags.execute({}) as any[];
|
|
1141
|
+
const names = result.map((t) => t.name);
|
|
1142
|
+
expect(names).toContain("health");
|
|
1143
|
+
expect(names).toContain("health/food");
|
|
1144
|
+
expect(names).not.toContain("work");
|
|
793
1145
|
|
|
794
|
-
|
|
1146
|
+
closeAllStores();
|
|
1147
|
+
});
|
|
795
1148
|
|
|
796
|
-
|
|
1149
|
+
test("scoped vault-info filters projection.tags + indexed_fields to the allowlist (vault#271 fold)", async () => {
|
|
1150
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1151
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1152
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1153
|
+
|
|
1154
|
+
const vaultName = `tagscope-vault-info-${Date.now()}`;
|
|
1155
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1156
|
+
|
|
1157
|
+
// Seed two schema-bearing tags. `task` and `project` BOTH declare a
|
|
1158
|
+
// shared indexed `status` field — this exercises the cross-declarer
|
|
1159
|
+
// case the reviewer called out: a token scoped to `task` should see
|
|
1160
|
+
// `status` (because task is a declarer) but the entry's `tags` array
|
|
1161
|
+
// must list only `task`, not `project`.
|
|
1162
|
+
const unscopedTools = generateScopedMcpTools(vaultName);
|
|
1163
|
+
const updateTag = unscopedTools.find((t) => t.name === "update-tag")!;
|
|
1164
|
+
await updateTag.execute({
|
|
1165
|
+
tag: "task",
|
|
1166
|
+
description: "A task",
|
|
1167
|
+
fields: { status: { type: "string", indexed: true } },
|
|
1168
|
+
});
|
|
1169
|
+
await updateTag.execute({
|
|
1170
|
+
tag: "project",
|
|
1171
|
+
description: "A project",
|
|
1172
|
+
fields: {
|
|
1173
|
+
status: { type: "string", indexed: true },
|
|
1174
|
+
priority: { type: "integer", indexed: true },
|
|
1175
|
+
},
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// Now mint scoped tools, scoped to `task` only.
|
|
1179
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["task"]) as any);
|
|
1180
|
+
const vaultInfo = tools.find((t) => t.name === "vault-info")!;
|
|
1181
|
+
const result = await vaultInfo.execute({}) as any;
|
|
1182
|
+
|
|
1183
|
+
// tags array: only `task`, not `project`.
|
|
1184
|
+
const tagNames = (result.tags as { name: string }[]).map((t) => t.name);
|
|
1185
|
+
expect(tagNames).toContain("task");
|
|
1186
|
+
expect(tagNames).not.toContain("project");
|
|
1187
|
+
|
|
1188
|
+
// indexed_fields: `status` survives (task is a declarer), `priority`
|
|
1189
|
+
// dropped entirely (only project declared it).
|
|
1190
|
+
const indexedNames = (result.indexed_fields as { name: string }[]).map((f) => f.name);
|
|
1191
|
+
expect(indexedNames).toContain("status");
|
|
1192
|
+
expect(indexedNames).not.toContain("priority");
|
|
1193
|
+
|
|
1194
|
+
// Cross-declarer attribution leak: `status` lists declarer tags. The
|
|
1195
|
+
// scoped response must show only `task`, never the out-of-scope
|
|
1196
|
+
// `project`.
|
|
1197
|
+
const status = (result.indexed_fields as { name: string; tags: string[] }[]).find(
|
|
1198
|
+
(f) => f.name === "status",
|
|
1199
|
+
)!;
|
|
1200
|
+
expect(status.tags).toEqual(["task"]);
|
|
1201
|
+
|
|
1202
|
+
// Top-level passthrough sanity: name, description, and query_hints are
|
|
1203
|
+
// not tag-scoped surfaces — they must still flow through.
|
|
1204
|
+
expect(result.name).toBe(vaultName);
|
|
1205
|
+
expect(Array.isArray(result.query_hints)).toBe(true);
|
|
1206
|
+
expect((result.query_hints as string[]).length).toBeGreaterThan(0);
|
|
1207
|
+
|
|
1208
|
+
closeAllStores();
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
test("unscoped vault-info still sees the full projection (vault#271 fold)", async () => {
|
|
1212
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1213
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1214
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1215
|
+
|
|
1216
|
+
const vaultName = `tagscope-vault-info-unscoped-${Date.now()}`;
|
|
1217
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1218
|
+
|
|
1219
|
+
const tools0 = generateScopedMcpTools(vaultName);
|
|
1220
|
+
const updateTag = tools0.find((t) => t.name === "update-tag")!;
|
|
1221
|
+
await updateTag.execute({ tag: "task", description: "T", fields: { status: { type: "string", indexed: true } } });
|
|
1222
|
+
await updateTag.execute({ tag: "project", description: "P", fields: { status: { type: "string", indexed: true } } });
|
|
1223
|
+
|
|
1224
|
+
// No `auth` and no `scoped_tags` — unscoped path must remain
|
|
1225
|
+
// identical to pre-fold behavior (full projection).
|
|
1226
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
1227
|
+
const result = await tools.find((t) => t.name === "vault-info")!.execute({}) as any;
|
|
1228
|
+
const tagNames = (result.tags as { name: string }[]).map((t) => t.name);
|
|
1229
|
+
expect(tagNames.sort()).toEqual(["project", "task"]);
|
|
1230
|
+
const status = (result.indexed_fields as { name: string; tags: string[] }[]).find((f) => f.name === "status")!;
|
|
1231
|
+
expect(status.tags.sort()).toEqual(["project", "task"]);
|
|
1232
|
+
|
|
1233
|
+
closeAllStores();
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test("scoped create-note rejects a note whose tags fall outside the allowlist", async () => {
|
|
1237
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1238
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1239
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1240
|
+
|
|
1241
|
+
const vaultName = `tagscope-create-${Date.now()}`;
|
|
1242
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1243
|
+
getVaultStore(vaultName);
|
|
1244
|
+
|
|
1245
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
|
|
1246
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
1247
|
+
const result = await create.execute({ content: "denied", tags: ["work"] }) as any;
|
|
1248
|
+
expect(result.error).toBe("Forbidden");
|
|
1249
|
+
expect(result.error_type).toBe("tag_scope_violation");
|
|
1250
|
+
expect(result.scoped_tags).toEqual(["health"]);
|
|
1251
|
+
|
|
1252
|
+
closeAllStores();
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
test("scoped create-note batch rejects atomically when any note is out of scope", async () => {
|
|
1256
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1257
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1258
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1259
|
+
|
|
1260
|
+
const vaultName = `tagscope-batch-${Date.now()}`;
|
|
1261
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1262
|
+
const store = getVaultStore(vaultName);
|
|
1263
|
+
|
|
1264
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
|
|
1265
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
1266
|
+
const result = await create.execute({
|
|
1267
|
+
notes: [
|
|
1268
|
+
{ content: "ok", tags: ["health"] },
|
|
1269
|
+
{ content: "no", tags: ["work"] },
|
|
1270
|
+
],
|
|
1271
|
+
}) as any;
|
|
1272
|
+
expect(result.error).toBe("Forbidden");
|
|
1273
|
+
// Atomic — neither write should have landed.
|
|
1274
|
+
expect((await store.listTags()).length).toBe(0);
|
|
1275
|
+
|
|
1276
|
+
closeAllStores();
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
test("scoped delete-note 404s on an out-of-scope note (no leak)", async () => {
|
|
1280
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1281
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1282
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1283
|
+
|
|
1284
|
+
const vaultName = `tagscope-del-${Date.now()}`;
|
|
1285
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1286
|
+
const store = getVaultStore(vaultName);
|
|
1287
|
+
const w = await store.createNote("w", { tags: ["work"] });
|
|
1288
|
+
|
|
1289
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
|
|
1290
|
+
const del = tools.find((t) => t.name === "delete-note")!;
|
|
1291
|
+
const result = await del.execute({ id: w.id }) as any;
|
|
1292
|
+
expect(result.error).toBe("Note not found");
|
|
1293
|
+
// Untouched.
|
|
1294
|
+
expect(await store.getNote(w.id)).toBeTruthy();
|
|
1295
|
+
|
|
1296
|
+
closeAllStores();
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
test("scoped update-tag/delete-tag refuse to touch out-of-scope tags", async () => {
|
|
1300
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1301
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1302
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1303
|
+
|
|
1304
|
+
const vaultName = `tagscope-tagop-${Date.now()}`;
|
|
1305
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1306
|
+
const store = getVaultStore(vaultName);
|
|
1307
|
+
await store.createNote("w", { tags: ["work"] });
|
|
1308
|
+
|
|
1309
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
|
|
1310
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
1311
|
+
const del = tools.find((t) => t.name === "delete-tag")!;
|
|
1312
|
+
|
|
1313
|
+
const updateRes = await update.execute({ tag: "work", description: "denied" }) as any;
|
|
1314
|
+
expect(updateRes.error).toBe("Forbidden");
|
|
1315
|
+
expect(updateRes.error_type).toBe("tag_scope_violation");
|
|
1316
|
+
|
|
1317
|
+
const delRes = await del.execute({ tag: "work" }) as any;
|
|
1318
|
+
expect(delRes.error).toBe("Forbidden");
|
|
1319
|
+
|
|
1320
|
+
// The `work` tag is still attached to its note.
|
|
1321
|
+
expect((await store.listTags()).find((t) => t.name === "work")).toBeTruthy();
|
|
1322
|
+
|
|
1323
|
+
closeAllStores();
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
// -- Q6: orphan sub-tag fail-open via string-form root ------------------
|
|
1327
|
+
|
|
1328
|
+
test("scoped query-notes sees orphan sub-tag via string-form root (no schema)", async () => {
|
|
1329
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1330
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1331
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1332
|
+
|
|
1333
|
+
const vaultName = `tagscope-orphan-${Date.now()}`;
|
|
1334
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1335
|
+
const store = getVaultStore(vaultName);
|
|
1336
|
+
// No `_tags/health/food` schema is created — the hierarchy is implicit.
|
|
1337
|
+
const orphan = await store.createNote("orphan", { tags: ["health/food"] });
|
|
1338
|
+
|
|
1339
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
|
|
1340
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1341
|
+
|
|
1342
|
+
const res = await query.execute({ id: orphan.id }) as any;
|
|
1343
|
+
// String-form fallback: `health/food` → root `health` → in allowlist.
|
|
1344
|
+
expect(res.error).toBeUndefined();
|
|
1345
|
+
expect(res.id).toBe(orphan.id);
|
|
1346
|
+
|
|
1347
|
+
closeAllStores();
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test("scoped create-note allows orphan sub-tag via string-form root", async () => {
|
|
1351
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1352
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1353
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1354
|
+
|
|
1355
|
+
const vaultName = `tagscope-orphan-write-${Date.now()}`;
|
|
1356
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1357
|
+
getVaultStore(vaultName);
|
|
1358
|
+
|
|
1359
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
|
|
1360
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
1361
|
+
|
|
1362
|
+
const res = await create.execute({ content: "ok", tags: ["health/food"] }) as any;
|
|
1363
|
+
expect(res.error).toBeUndefined();
|
|
1364
|
+
expect(res.id).toBeDefined();
|
|
1365
|
+
|
|
1366
|
+
closeAllStores();
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
// -- Q5: MCP delete-tag dependency check -------------------------------
|
|
1370
|
+
|
|
1371
|
+
test("MCP delete-tag returns tag_in_use_by_tokens when a tag-scoped token references the tag", async () => {
|
|
1372
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1373
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1374
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1375
|
+
const { generateToken, createToken } = await import("./token-store.ts");
|
|
1376
|
+
|
|
1377
|
+
const vaultName = `tagscope-dep-${Date.now()}`;
|
|
1378
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1379
|
+
const store = getVaultStore(vaultName);
|
|
1380
|
+
await store.createNote("h", { tags: ["health"] });
|
|
1381
|
+
|
|
1382
|
+
// Mint a tag-scoped token that references "health".
|
|
1383
|
+
const { fullToken } = generateToken();
|
|
1384
|
+
createToken(store.db, fullToken, {
|
|
1385
|
+
label: "health-claw",
|
|
1386
|
+
permission: "read",
|
|
1387
|
+
scopes: ["vault:read"],
|
|
1388
|
+
scoped_tags: ["health"],
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
// Unscoped admin attempts to delete `health` via MCP — should 409.
|
|
1392
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
1393
|
+
const del = tools.find((t) => t.name === "delete-tag")!;
|
|
1394
|
+
const res = await del.execute({ tag: "health" }) as any;
|
|
1395
|
+
expect(res.error).toBe("TagInUseByTokens");
|
|
1396
|
+
expect(res.error_type).toBe("tag_in_use_by_tokens");
|
|
1397
|
+
expect(res.tag).toBe("health");
|
|
1398
|
+
expect(res.referenced_by?.length).toBe(1);
|
|
1399
|
+
expect(res.referenced_by?.[0]?.label).toBe("health-claw");
|
|
1400
|
+
|
|
1401
|
+
// Tag is still attached to its note.
|
|
1402
|
+
expect((await store.listTags()).find((t) => t.name === "health")).toBeTruthy();
|
|
1403
|
+
|
|
1404
|
+
closeAllStores();
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
test("MCP delete-tag proceeds when no token references the tag", async () => {
|
|
1408
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1409
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1410
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1411
|
+
|
|
1412
|
+
const vaultName = `tagscope-nodep-${Date.now()}`;
|
|
1413
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1414
|
+
const store = getVaultStore(vaultName);
|
|
1415
|
+
await store.createNote("h", { tags: ["health"] });
|
|
1416
|
+
|
|
1417
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
1418
|
+
const del = tools.find((t) => t.name === "delete-tag")!;
|
|
1419
|
+
const res = await del.execute({ tag: "health" }) as any;
|
|
1420
|
+
expect(res.error).toBeUndefined();
|
|
1421
|
+
|
|
1422
|
+
closeAllStores();
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
test("update-note tags.add auto-populate does not bump updatedAt", async () => {
|
|
1426
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1427
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1428
|
+
const { getVaultStore, closeAllStores: close } = await import("./vault-store.ts");
|
|
1429
|
+
|
|
1430
|
+
const vaultName = `schema-noupdate-${Date.now()}`;
|
|
1431
|
+
writeVaultConfig({
|
|
1432
|
+
name: vaultName,
|
|
1433
|
+
api_keys: [],
|
|
1434
|
+
created_at: new Date().toISOString(),
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
const vaultStore = getVaultStore(vaultName);
|
|
1438
|
+
await vaultStore.upsertTagSchema("person", {
|
|
1439
|
+
description: "A person",
|
|
1440
|
+
fields: { name: { type: "string" } },
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
1444
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
1445
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1446
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
1447
|
+
|
|
1448
|
+
const note = await createNote.execute({ content: "Test" }) as any;
|
|
1449
|
+
const originalUpdatedAt = note.updatedAt;
|
|
1450
|
+
await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
|
|
1451
|
+
const after = await queryNotes.execute({ id: note.id }) as any;
|
|
1452
|
+
expect(after.updatedAt).toBe(originalUpdatedAt);
|
|
1453
|
+
expect(after.metadata.name).toBe("");
|
|
1454
|
+
|
|
1455
|
+
close();
|
|
1456
|
+
});
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
describe("auth permissions", () => {
|
|
1460
|
+
test("read permission allows read-only tools", () => {
|
|
1461
|
+
const { isToolAllowed } = require("./auth.ts");
|
|
1462
|
+
expect(isToolAllowed("query-notes", "read")).toBe(true);
|
|
1463
|
+
expect(isToolAllowed("list-tags", "read")).toBe(true);
|
|
1464
|
+
expect(isToolAllowed("find-path", "read")).toBe(true);
|
|
1465
|
+
expect(isToolAllowed("vault-info", "read")).toBe(true);
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
test("read permission blocks mutation tools", () => {
|
|
1469
|
+
const { isToolAllowed } = require("./auth.ts");
|
|
1470
|
+
expect(isToolAllowed("create-note", "read")).toBe(false);
|
|
1471
|
+
expect(isToolAllowed("update-note", "read")).toBe(false);
|
|
1472
|
+
expect(isToolAllowed("delete-note", "read")).toBe(false);
|
|
1473
|
+
expect(isToolAllowed("update-tag", "read")).toBe(false);
|
|
1474
|
+
expect(isToolAllowed("delete-tag", "read")).toBe(false);
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
test("full permission allows all tools", () => {
|
|
1478
|
+
const { isToolAllowed } = require("./auth.ts");
|
|
1479
|
+
expect(isToolAllowed("create-note", "full")).toBe(true);
|
|
1480
|
+
expect(isToolAllowed("update-note", "full")).toBe(true);
|
|
1481
|
+
expect(isToolAllowed("delete-note", "full")).toBe(true);
|
|
1482
|
+
expect(isToolAllowed("update-tag", "full")).toBe(true);
|
|
1483
|
+
expect(isToolAllowed("delete-tag", "full")).toBe(true);
|
|
1484
|
+
expect(isToolAllowed("query-notes", "full")).toBe(true);
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
test("read permission allows GET but not POST/PATCH/DELETE", () => {
|
|
1488
|
+
const { isMethodAllowed } = require("./auth.ts");
|
|
1489
|
+
expect(isMethodAllowed("GET", "read")).toBe(true);
|
|
1490
|
+
expect(isMethodAllowed("HEAD", "read")).toBe(true);
|
|
1491
|
+
expect(isMethodAllowed("POST", "read")).toBe(false);
|
|
1492
|
+
expect(isMethodAllowed("PATCH", "read")).toBe(false);
|
|
1493
|
+
expect(isMethodAllowed("DELETE", "read")).toBe(false);
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
test("full permission allows all methods", () => {
|
|
1497
|
+
const { isMethodAllowed } = require("./auth.ts");
|
|
1498
|
+
expect(isMethodAllowed("GET", "full")).toBe(true);
|
|
1499
|
+
expect(isMethodAllowed("POST", "full")).toBe(true);
|
|
1500
|
+
expect(isMethodAllowed("PATCH", "full")).toBe(true);
|
|
1501
|
+
expect(isMethodAllowed("DELETE", "full")).toBe(true);
|
|
1502
|
+
});
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
// ---- HTTP route handlers ----
|
|
1506
|
+
|
|
1507
|
+
const BASE = "http://localhost/api";
|
|
797
1508
|
|
|
798
1509
|
function mkReq(method: string, path: string, body?: unknown): Request {
|
|
799
1510
|
const init: RequestInit = { method };
|
|
@@ -839,6 +1550,31 @@ describe("HTTP /notes", async () => {
|
|
|
839
1550
|
expect(body.map((n) => n.content)).toEqual(["plain"]);
|
|
840
1551
|
});
|
|
841
1552
|
|
|
1553
|
+
// ---- updated_at filter via date_field (vault#285 friction point 1.5) ----
|
|
1554
|
+
//
|
|
1555
|
+
// HTTP plumbing routes `date_field=updated_at&date_from=…` straight to
|
|
1556
|
+
// the core `dateFilter` resolver, which now recognizes `updated_at` as
|
|
1557
|
+
// a real column. Smoke-tests the end-to-end HTTP path; the engine-side
|
|
1558
|
+
// semantics are exercised in core.test.ts.
|
|
1559
|
+
test("GET /notes?date_field=updated_at filters by last-write time", async () => {
|
|
1560
|
+
const a = await store.createNote("untouched", { id: "ua", path: "ua" });
|
|
1561
|
+
const b = await store.createNote("modified", { id: "ub", path: "ub" });
|
|
1562
|
+
// Bump b's updated_at into the test window, leave a's at its createdAt.
|
|
1563
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1564
|
+
.run("2026-01-15T00:00:00.000Z", a.id);
|
|
1565
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1566
|
+
.run("2026-04-25T00:00:00.000Z", b.id);
|
|
1567
|
+
|
|
1568
|
+
const res = await handleNotes(
|
|
1569
|
+
mkReq("GET", "/notes?date_field=updated_at&date_from=2026-04-01&include_content=true"),
|
|
1570
|
+
store,
|
|
1571
|
+
"",
|
|
1572
|
+
);
|
|
1573
|
+
expect(res.status).toBe(200);
|
|
1574
|
+
const body = await res.json() as any[];
|
|
1575
|
+
expect(body.map((n) => n.content)).toEqual(["modified"]);
|
|
1576
|
+
});
|
|
1577
|
+
|
|
842
1578
|
test("GET /notes?has_links=false returns only orphaned notes", async () => {
|
|
843
1579
|
const a = await store.createNote("src", { id: "qa" });
|
|
844
1580
|
const b = await store.createNote("tgt", { id: "qb" });
|
|
@@ -978,171 +1714,689 @@ describe("HTTP /notes", async () => {
|
|
|
978
1714
|
expect(body.mimeType).toBe("image/png");
|
|
979
1715
|
});
|
|
980
1716
|
|
|
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" });
|
|
1717
|
+
describe("POST /notes/:id/attachments with transcribe flag", async () => {
|
|
1718
|
+
test("transcribe: true seeds pending status and marks note as stub", async () => {
|
|
1719
|
+
await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending._", { id: "v1" });
|
|
1720
|
+
const res = await handleNotes(
|
|
1721
|
+
mkReq("POST", "/notes/v1/attachments", {
|
|
1722
|
+
path: "memos/memo-1.webm",
|
|
1723
|
+
mimeType: "audio/webm",
|
|
1724
|
+
transcribe: true,
|
|
1725
|
+
}),
|
|
1726
|
+
store,
|
|
1727
|
+
"/v1/attachments",
|
|
1728
|
+
);
|
|
1729
|
+
expect(res.status).toBe(201);
|
|
1730
|
+
const att = await res.json() as any;
|
|
1731
|
+
expect(att.metadata?.transcribe_status).toBe("pending");
|
|
1732
|
+
expect(att.metadata?.transcribe_requested_at).toBeTruthy();
|
|
1733
|
+
|
|
1734
|
+
const note = await store.getNote("v1");
|
|
1735
|
+
expect((note!.metadata as any)?.transcribe_stub).toBe(true);
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
test("transcribe: false (default) leaves metadata empty and note untouched", async () => {
|
|
1739
|
+
await store.createNote("note body", { id: "v2" });
|
|
1740
|
+
const res = await handleNotes(
|
|
1741
|
+
mkReq("POST", "/notes/v2/attachments", {
|
|
1742
|
+
path: "memos/memo-2.webm",
|
|
1743
|
+
mimeType: "audio/webm",
|
|
1744
|
+
}),
|
|
1745
|
+
store,
|
|
1746
|
+
"/v2/attachments",
|
|
1747
|
+
);
|
|
1748
|
+
expect(res.status).toBe(201);
|
|
1749
|
+
const att = await res.json() as any;
|
|
1750
|
+
expect(att.metadata?.transcribe_status).toBeUndefined();
|
|
1751
|
+
|
|
1752
|
+
const note = await store.getNote("v2");
|
|
1753
|
+
expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
test("transcribe: true preserves other note metadata", async () => {
|
|
1757
|
+
await store.createNote("body", { id: "v3", metadata: { summary: "keep me" } });
|
|
1758
|
+
await handleNotes(
|
|
1759
|
+
mkReq("POST", "/notes/v3/attachments", {
|
|
1760
|
+
path: "memos/memo-3.webm",
|
|
1761
|
+
mimeType: "audio/webm",
|
|
1762
|
+
transcribe: true,
|
|
1763
|
+
}),
|
|
1764
|
+
store,
|
|
1765
|
+
"/v3/attachments",
|
|
1766
|
+
);
|
|
1767
|
+
const note = await store.getNote("v3");
|
|
1768
|
+
const meta = note!.metadata as any;
|
|
1769
|
+
expect(meta?.summary).toBe("keep me");
|
|
1770
|
+
expect(meta?.transcribe_stub).toBe(true);
|
|
1771
|
+
});
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
describe("DELETE /notes/:id/attachments/:attId", async () => {
|
|
1775
|
+
test("happy path: 204, DB row gone, storage file unlinked", async () => {
|
|
1776
|
+
const assetsRoot = join(tmpDir, "assets");
|
|
1777
|
+
mkdirSync(join(assetsRoot, "2026-04-18"), { recursive: true });
|
|
1778
|
+
const relPath = "2026-04-18/shot.png";
|
|
1779
|
+
const filePath = join(assetsRoot, relPath);
|
|
1780
|
+
writeFileSync(filePath, Buffer.from([1, 2, 3]));
|
|
1781
|
+
process.env.ASSETS_DIR = assetsRoot;
|
|
1782
|
+
|
|
1783
|
+
const n = await store.createNote("x", { id: "n1" });
|
|
1784
|
+
const att = await store.addAttachment(n.id, relPath, "image/png");
|
|
1785
|
+
|
|
1786
|
+
const res = await handleNotes(
|
|
1787
|
+
mkReq("DELETE", `/notes/n1/attachments/${att.id}`),
|
|
1788
|
+
store,
|
|
1789
|
+
`/n1/attachments/${att.id}`,
|
|
1790
|
+
"default",
|
|
1791
|
+
);
|
|
1792
|
+
expect(res.status).toBe(204);
|
|
1793
|
+
expect((await store.getAttachments(n.id)).length).toBe(0);
|
|
1794
|
+
expect(existsSync(filePath)).toBe(false);
|
|
1795
|
+
|
|
1796
|
+
delete process.env.ASSETS_DIR;
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
test("404 when attachment does not exist", async () => {
|
|
1800
|
+
await store.createNote("x", { id: "n2" });
|
|
1801
|
+
const res = await handleNotes(
|
|
1802
|
+
mkReq("DELETE", "/notes/n2/attachments/nonexistent"),
|
|
1803
|
+
store,
|
|
1804
|
+
"/n2/attachments/nonexistent",
|
|
1805
|
+
"default",
|
|
1806
|
+
);
|
|
1807
|
+
expect(res.status).toBe(404);
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
test("second delete is idempotent (404)", async () => {
|
|
1811
|
+
const n = await store.createNote("x", { id: "n3" });
|
|
1812
|
+
const att = await store.addAttachment(n.id, "files/a.png", "image/png");
|
|
1813
|
+
const first = await handleNotes(
|
|
1814
|
+
mkReq("DELETE", `/notes/n3/attachments/${att.id}`),
|
|
1815
|
+
store,
|
|
1816
|
+
`/n3/attachments/${att.id}`,
|
|
1817
|
+
);
|
|
1818
|
+
expect(first.status).toBe(204);
|
|
1819
|
+
const second = await handleNotes(
|
|
1820
|
+
mkReq("DELETE", `/notes/n3/attachments/${att.id}`),
|
|
1821
|
+
store,
|
|
1822
|
+
`/n3/attachments/${att.id}`,
|
|
1823
|
+
);
|
|
1824
|
+
expect(second.status).toBe(404);
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
test("cross-note delete attempt returns 404 and leaves record intact", async () => {
|
|
1828
|
+
const a = await store.createNote("a", { id: "na" });
|
|
1829
|
+
const b = await store.createNote("b", { id: "nb" });
|
|
1830
|
+
const attA = await store.addAttachment(a.id, "files/a.png", "image/png");
|
|
1831
|
+
|
|
1832
|
+
const res = await handleNotes(
|
|
1833
|
+
mkReq("DELETE", `/notes/nb/attachments/${attA.id}`),
|
|
1834
|
+
store,
|
|
1835
|
+
`/nb/attachments/${attA.id}`,
|
|
1836
|
+
);
|
|
1837
|
+
expect(res.status).toBe(404);
|
|
1838
|
+
expect((await store.getAttachments(a.id)).length).toBe(1);
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
test("file survives first delete when a sibling attachment still references it", async () => {
|
|
1842
|
+
const assetsRoot = join(tmpDir, "assets");
|
|
1843
|
+
mkdirSync(join(assetsRoot, "shared"), { recursive: true });
|
|
1844
|
+
const relPath = "shared/pic.png";
|
|
1845
|
+
const filePath = join(assetsRoot, relPath);
|
|
1846
|
+
writeFileSync(filePath, Buffer.from([9]));
|
|
1847
|
+
process.env.ASSETS_DIR = assetsRoot;
|
|
1848
|
+
|
|
1849
|
+
const a = await store.createNote("a", { id: "sa" });
|
|
1850
|
+
const b = await store.createNote("b", { id: "sb" });
|
|
1851
|
+
const attA = await store.addAttachment(a.id, relPath, "image/png");
|
|
1852
|
+
const attB = await store.addAttachment(b.id, relPath, "image/png");
|
|
1853
|
+
|
|
1854
|
+
await handleNotes(
|
|
1855
|
+
mkReq("DELETE", `/notes/sa/attachments/${attA.id}`),
|
|
1856
|
+
store,
|
|
1857
|
+
`/sa/attachments/${attA.id}`,
|
|
1858
|
+
"default",
|
|
1859
|
+
);
|
|
1860
|
+
expect(existsSync(filePath)).toBe(true);
|
|
1861
|
+
|
|
1862
|
+
await handleNotes(
|
|
1863
|
+
mkReq("DELETE", `/notes/sb/attachments/${attB.id}`),
|
|
1864
|
+
store,
|
|
1865
|
+
`/sb/attachments/${attB.id}`,
|
|
1866
|
+
"default",
|
|
1867
|
+
);
|
|
1868
|
+
expect(existsSync(filePath)).toBe(false);
|
|
1869
|
+
|
|
1870
|
+
delete process.env.ASSETS_DIR;
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
test("method not allowed on /attachments/:attId returns 405", async () => {
|
|
1874
|
+
const n = await store.createNote("x", { id: "nm" });
|
|
1875
|
+
const att = await store.addAttachment(n.id, "files/a.png", "image/png");
|
|
1876
|
+
const res = await handleNotes(
|
|
1877
|
+
mkReq("PATCH", `/notes/nm/attachments/${att.id}`),
|
|
1878
|
+
store,
|
|
1879
|
+
`/nm/attachments/${att.id}`,
|
|
1880
|
+
);
|
|
1881
|
+
expect(res.status).toBe(405);
|
|
1882
|
+
});
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
// -------------------------------------------------------------------------
|
|
1886
|
+
// Empty-note guard + batch cap (#213) — runaway-client protection
|
|
1887
|
+
// -------------------------------------------------------------------------
|
|
1888
|
+
|
|
1889
|
+
describe("empty-note guard (#213)", async () => {
|
|
1890
|
+
test("POST bare {} body → 400 EmptyNoteError", async () => {
|
|
1891
|
+
const res = await handleNotes(mkReq("POST", "/notes", {}), store, "");
|
|
1892
|
+
expect(res.status).toBe(400);
|
|
1893
|
+
const body = await res.json() as any;
|
|
1894
|
+
expect(body.error_type).toBe("empty_note");
|
|
1895
|
+
expect(body.error).toBe("EmptyNoteError");
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
test("POST batch with one empty entry → 400 EmptyNoteError, NOTHING created (atomic)", async () => {
|
|
1899
|
+
// Pre-validate the batch before any DB writes so a mixed batch with one
|
|
1900
|
+
// bad entry rolls back the whole call. The runaway-client signature
|
|
1901
|
+
// (#213) is "thousands of empties" — partial-create semantics would
|
|
1902
|
+
// still leak the prefix on every burst. Atomic is the only safe shape.
|
|
1903
|
+
const beforeCount = (await store.queryNotes({ path: "ok-1" })).length;
|
|
1904
|
+
const res = await handleNotes(
|
|
1905
|
+
mkReq("POST", "/notes", { notes: [{ path: "ok-1" }, {}] }),
|
|
1906
|
+
store,
|
|
1907
|
+
"",
|
|
1908
|
+
);
|
|
1909
|
+
expect(res.status).toBe(400);
|
|
1910
|
+
const body = await res.json() as any;
|
|
1911
|
+
expect(body.error_type).toBe("empty_note");
|
|
1912
|
+
expect(body.item_index).toBe(1);
|
|
1913
|
+
// ok-1 must NOT have been created — atomic rollback.
|
|
1914
|
+
const afterCount = (await store.queryNotes({ path: "ok-1" })).length;
|
|
1915
|
+
expect(afterCount).toBe(beforeCount);
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
test("POST single content-only (path absent) → 201", async () => {
|
|
1919
|
+
const res = await handleNotes(
|
|
1920
|
+
mkReq("POST", "/notes", { content: "un-pathed jot" }),
|
|
1921
|
+
store,
|
|
1922
|
+
"",
|
|
1923
|
+
);
|
|
1924
|
+
expect(res.status).toBe(201);
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
test("POST single path-only (content absent) → 201, no warning log", async () => {
|
|
1928
|
+
// Path-only is a wikilink placeholder / `_schemas/*` shape — must
|
|
1929
|
+
// remain accepted (per #223 design Q3).
|
|
1930
|
+
const res = await handleNotes(
|
|
1931
|
+
mkReq("POST", "/notes", { path: "wiki/placeholder" }),
|
|
1932
|
+
store,
|
|
1933
|
+
"",
|
|
1934
|
+
);
|
|
1935
|
+
expect(res.status).toBe(201);
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
test("PATCH that would clear both content and path → 400 EmptyNoteError", async () => {
|
|
1939
|
+
const note = await store.createNote("starts with content", { id: "ep1" });
|
|
1940
|
+
const updated = await store.getNote("ep1");
|
|
1941
|
+
const res = await handleNotes(
|
|
1942
|
+
mkReq("PATCH", "/notes/ep1", {
|
|
1943
|
+
content: "",
|
|
1944
|
+
path: "",
|
|
1945
|
+
if_updated_at: updated!.updatedAt,
|
|
1946
|
+
}),
|
|
1947
|
+
store,
|
|
1948
|
+
"/ep1",
|
|
1949
|
+
);
|
|
1950
|
+
expect(res.status).toBe(400);
|
|
1951
|
+
const body = await res.json() as any;
|
|
1952
|
+
expect(body.error_type).toBe("empty_note");
|
|
1953
|
+
expect(body.note_id).toBe("ep1");
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
test("PATCH that clears content but preserves path → 200", async () => {
|
|
1957
|
+
const note = await store.createNote("body", { id: "ep2", path: "p2" });
|
|
1958
|
+
const updated = await store.getNote("ep2");
|
|
1959
|
+
const res = await handleNotes(
|
|
1960
|
+
mkReq("PATCH", "/notes/ep2", {
|
|
1961
|
+
content: "",
|
|
1962
|
+
if_updated_at: updated!.updatedAt,
|
|
1963
|
+
}),
|
|
1964
|
+
store,
|
|
1965
|
+
"/ep2",
|
|
1966
|
+
);
|
|
1967
|
+
expect(res.status).toBe(200);
|
|
1968
|
+
});
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
describe("batch atomicity (#236)", async () => {
|
|
1972
|
+
test("POST batch where mid-item triggers PATH_CONFLICT → 409, NOTHING created", async () => {
|
|
1973
|
+
// The empty-note pre-walk catches `{}` before any DB write (#213); a
|
|
1974
|
+
// path-conflict only surfaces on the actual INSERT, mid-loop. Without
|
|
1975
|
+
// the BEGIN/COMMIT wrap the prefix would have already landed by then.
|
|
1976
|
+
await store.createNote("existing", { path: "taken" });
|
|
1977
|
+
const beforeIds = (await store.queryNotes({})).map((n) => n.id).sort();
|
|
1978
|
+
|
|
1979
|
+
const res = await handleNotes(
|
|
1980
|
+
mkReq("POST", "/notes", {
|
|
1981
|
+
notes: [
|
|
1982
|
+
{ content: "ok-1", path: "fresh-1" },
|
|
1983
|
+
{ content: "ok-2", path: "fresh-2" },
|
|
1984
|
+
{ content: "boom", path: "taken" },
|
|
1985
|
+
{ content: "ok-3", path: "fresh-3" },
|
|
1986
|
+
],
|
|
1987
|
+
}),
|
|
1988
|
+
store,
|
|
1989
|
+
"",
|
|
1990
|
+
);
|
|
1991
|
+
expect(res.status).toBe(409);
|
|
1992
|
+
const body = await res.json() as any;
|
|
1993
|
+
expect(body.error_type).toBe("path_conflict");
|
|
1994
|
+
|
|
1995
|
+
// The two prefix items must NOT have been created — atomic rollback.
|
|
1996
|
+
const afterIds = (await store.queryNotes({})).map((n) => n.id).sort();
|
|
1997
|
+
expect(afterIds).toEqual(beforeIds);
|
|
1998
|
+
expect(await store.queryNotes({ path: "fresh-1" })).toHaveLength(0);
|
|
1999
|
+
expect(await store.queryNotes({ path: "fresh-2" })).toHaveLength(0);
|
|
2000
|
+
});
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
describe("batch cap (#213)", async () => {
|
|
2004
|
+
test("POST with 501-item batch → 413 BatchTooLarge", async () => {
|
|
2005
|
+
const oversized = Array.from({ length: 501 }, (_, i) => ({ content: `n${i}` }));
|
|
2006
|
+
const res = await handleNotes(
|
|
2007
|
+
mkReq("POST", "/notes", { notes: oversized }),
|
|
2008
|
+
store,
|
|
2009
|
+
"",
|
|
2010
|
+
);
|
|
2011
|
+
expect(res.status).toBe(413);
|
|
2012
|
+
const body = await res.json() as any;
|
|
2013
|
+
expect(body.error_type).toBe("batch_too_large");
|
|
2014
|
+
expect(body.error).toBe("BatchTooLarge");
|
|
2015
|
+
expect(body.limit).toBe(500);
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
test("POST with exactly 500-item batch → 201 (boundary)", async () => {
|
|
2019
|
+
const exactly500 = Array.from({ length: 500 }, (_, i) => ({ content: `n${i}` }));
|
|
2020
|
+
const res = await handleNotes(
|
|
2021
|
+
mkReq("POST", "/notes", { notes: exactly500 }),
|
|
2022
|
+
store,
|
|
2023
|
+
"",
|
|
2024
|
+
);
|
|
2025
|
+
expect(res.status).toBe(201);
|
|
2026
|
+
const body = await res.json() as any[];
|
|
2027
|
+
expect(body).toHaveLength(500);
|
|
2028
|
+
});
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
// ---- Bracket-style metadata filter (vault#285 friction point 1.3) ----
|
|
2032
|
+
//
|
|
2033
|
+
// Exposes vault's engine-level metadata-value filtering to HTTP REST
|
|
2034
|
+
// callers (today: only MCP). Uses `meta[field][op]=value` (Stripe /
|
|
2035
|
+
// JSON:API / Strapi convention). The HTTP layer translates to the engine's
|
|
2036
|
+
// existing `metadata` filter; engine semantics + gates are unchanged.
|
|
2037
|
+
// Bridges `created_at` / `updated_at` through `dateFilter`.
|
|
2038
|
+
describe("bracket-style metadata filter", () => {
|
|
2039
|
+
async function declareIndexed() {
|
|
2040
|
+
const { declareField } = await import("../core/src/indexed-fields.ts");
|
|
2041
|
+
declareField(db, "priority", "INTEGER", "project");
|
|
2042
|
+
declareField(db, "status", "TEXT", "project");
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
test("shorthand `?meta[field]=value` does exact equality (JSON scan, no indexed gate)", async () => {
|
|
2046
|
+
// Deliberately NOT calling declareIndexed — shorthand should work on
|
|
2047
|
+
// any field via the json_extract fallback at core/src/notes.ts:504-507.
|
|
2048
|
+
await store.createNote("matches", { metadata: { kind: "draft" } });
|
|
2049
|
+
await store.createNote("other", { metadata: { kind: "final" } });
|
|
2050
|
+
const res = await handleNotes(
|
|
2051
|
+
mkReq("GET", "/notes?meta[kind]=draft&include_content=true"),
|
|
2052
|
+
store,
|
|
2053
|
+
"",
|
|
2054
|
+
);
|
|
2055
|
+
expect(res.status).toBe(200);
|
|
2056
|
+
const body = await res.json() as any[];
|
|
2057
|
+
expect(body.map((n) => n.content)).toEqual(["matches"]);
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
test("eq operator on indexed field", async () => {
|
|
2061
|
+
await declareIndexed();
|
|
2062
|
+
await store.createNote("p5", { metadata: { priority: 5 } });
|
|
2063
|
+
await store.createNote("p1", { metadata: { priority: 1 } });
|
|
2064
|
+
const res = await handleNotes(
|
|
2065
|
+
mkReq("GET", "/notes?meta[priority][eq]=5&include_content=true"),
|
|
2066
|
+
store,
|
|
2067
|
+
"",
|
|
2068
|
+
);
|
|
2069
|
+
const body = await res.json() as any[];
|
|
2070
|
+
expect(body.map((n) => n.content)).toEqual(["p5"]);
|
|
2071
|
+
});
|
|
2072
|
+
|
|
2073
|
+
test("ne operator returns non-matching rows plus rows without the field", async () => {
|
|
2074
|
+
await declareIndexed();
|
|
2075
|
+
await store.createNote("has-1", { metadata: { priority: 1 } });
|
|
2076
|
+
await store.createNote("has-2", { metadata: { priority: 2 } });
|
|
2077
|
+
await store.createNote("missing");
|
|
2078
|
+
const res = await handleNotes(
|
|
2079
|
+
mkReq("GET", "/notes?meta[priority][ne]=1&include_content=true"),
|
|
2080
|
+
store,
|
|
2081
|
+
"",
|
|
2082
|
+
);
|
|
2083
|
+
const body = await res.json() as any[];
|
|
2084
|
+
expect(body.map((n) => n.content).sort()).toEqual(["has-2", "missing"]);
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
test("gt / gte / lt / lte compose into a range query on one field", async () => {
|
|
2088
|
+
await declareIndexed();
|
|
2089
|
+
for (const p of [1, 2, 3, 4, 5]) {
|
|
2090
|
+
await store.createNote(`p${p}`, { metadata: { priority: p } });
|
|
2091
|
+
}
|
|
2092
|
+
const res = await handleNotes(
|
|
2093
|
+
mkReq("GET", "/notes?meta[priority][gte]=2&meta[priority][lt]=5&include_content=true"),
|
|
2094
|
+
store,
|
|
2095
|
+
"",
|
|
2096
|
+
);
|
|
2097
|
+
const body = await res.json() as any[];
|
|
2098
|
+
expect(body.map((n) => n.content).sort()).toEqual(["p2", "p3", "p4"]);
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
test("in array form: `?meta[field][in][]=v1&meta[field][in][]=v2`", async () => {
|
|
2102
|
+
await declareIndexed();
|
|
2103
|
+
await store.createNote("a", { metadata: { status: "active" } });
|
|
2104
|
+
await store.createNote("b", { metadata: { status: "exploring" } });
|
|
2105
|
+
await store.createNote("c", { metadata: { status: "done" } });
|
|
2106
|
+
const res = await handleNotes(
|
|
2107
|
+
mkReq("GET", "/notes?meta[status][in][]=active&meta[status][in][]=exploring&include_content=true"),
|
|
2108
|
+
store,
|
|
2109
|
+
"",
|
|
2110
|
+
);
|
|
2111
|
+
const body = await res.json() as any[];
|
|
2112
|
+
expect(body.map((n) => n.content).sort()).toEqual(["a", "b"]);
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
test("in comma form: `?meta[field][in]=v1,v2`", async () => {
|
|
2116
|
+
await declareIndexed();
|
|
2117
|
+
await store.createNote("a", { metadata: { status: "active" } });
|
|
2118
|
+
await store.createNote("b", { metadata: { status: "exploring" } });
|
|
2119
|
+
await store.createNote("c", { metadata: { status: "done" } });
|
|
2120
|
+
const res = await handleNotes(
|
|
2121
|
+
mkReq("GET", "/notes?meta[status][in]=active,exploring&include_content=true"),
|
|
2122
|
+
store,
|
|
2123
|
+
"",
|
|
2124
|
+
);
|
|
2125
|
+
const body = await res.json() as any[];
|
|
2126
|
+
expect(body.map((n) => n.content).sort()).toEqual(["a", "b"]);
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
test("not_in via comma form", async () => {
|
|
2130
|
+
await declareIndexed();
|
|
2131
|
+
await store.createNote("a", { metadata: { status: "active" } });
|
|
2132
|
+
await store.createNote("b", { metadata: { status: "done" } });
|
|
984
2133
|
const res = await handleNotes(
|
|
985
|
-
mkReq("
|
|
986
|
-
path: "memos/memo-1.webm",
|
|
987
|
-
mimeType: "audio/webm",
|
|
988
|
-
transcribe: true,
|
|
989
|
-
}),
|
|
2134
|
+
mkReq("GET", "/notes?meta[status][not_in]=done&include_content=true"),
|
|
990
2135
|
store,
|
|
991
|
-
"
|
|
2136
|
+
"",
|
|
992
2137
|
);
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
expect(att.metadata?.transcribe_requested_at).toBeTruthy();
|
|
2138
|
+
const body = await res.json() as any[];
|
|
2139
|
+
expect(body.map((n) => n.content)).toEqual(["a"]);
|
|
2140
|
+
});
|
|
997
2141
|
|
|
998
|
-
|
|
999
|
-
|
|
2142
|
+
test("exists: true / false distinguishes present vs absent field", async () => {
|
|
2143
|
+
await declareIndexed();
|
|
2144
|
+
await store.createNote("has", { metadata: { priority: 3 } });
|
|
2145
|
+
await store.createNote("missing");
|
|
2146
|
+
|
|
2147
|
+
const hasRes = await handleNotes(
|
|
2148
|
+
mkReq("GET", "/notes?meta[priority][exists]=true&include_content=true"),
|
|
2149
|
+
store,
|
|
2150
|
+
"",
|
|
2151
|
+
);
|
|
2152
|
+
const hasBody = await hasRes.json() as any[];
|
|
2153
|
+
expect(hasBody.map((n) => n.content)).toEqual(["has"]);
|
|
2154
|
+
|
|
2155
|
+
const missingRes = await handleNotes(
|
|
2156
|
+
mkReq("GET", "/notes?meta[priority][exists]=false&include_content=true"),
|
|
2157
|
+
store,
|
|
2158
|
+
"",
|
|
2159
|
+
);
|
|
2160
|
+
const missingBody = await missingRes.json() as any[];
|
|
2161
|
+
expect(missingBody.map((n) => n.content)).toEqual(["missing"]);
|
|
1000
2162
|
});
|
|
1001
2163
|
|
|
1002
|
-
test("
|
|
1003
|
-
await
|
|
2164
|
+
test("exists with non-boolean value rejects with INVALID_OPERATOR_VALUE", async () => {
|
|
2165
|
+
await declareIndexed();
|
|
1004
2166
|
const res = await handleNotes(
|
|
1005
|
-
mkReq("
|
|
1006
|
-
path: "memos/memo-2.webm",
|
|
1007
|
-
mimeType: "audio/webm",
|
|
1008
|
-
}),
|
|
2167
|
+
mkReq("GET", "/notes?meta[priority][exists]=yes"),
|
|
1009
2168
|
store,
|
|
1010
|
-
"
|
|
2169
|
+
"",
|
|
1011
2170
|
);
|
|
1012
|
-
expect(res.status).toBe(
|
|
1013
|
-
const
|
|
1014
|
-
expect(
|
|
2171
|
+
expect(res.status).toBe(400);
|
|
2172
|
+
const body = await res.json() as any;
|
|
2173
|
+
expect(body.code).toBe("INVALID_OPERATOR_VALUE");
|
|
2174
|
+
});
|
|
1015
2175
|
|
|
1016
|
-
|
|
1017
|
-
|
|
2176
|
+
test("compound filter across two fields ANDs together", async () => {
|
|
2177
|
+
await declareIndexed();
|
|
2178
|
+
await store.createNote("hit", { metadata: { priority: 5, status: "active" } });
|
|
2179
|
+
await store.createNote("priority-only", { metadata: { priority: 5, status: "done" } });
|
|
2180
|
+
await store.createNote("status-only", { metadata: { priority: 1, status: "active" } });
|
|
2181
|
+
const res = await handleNotes(
|
|
2182
|
+
mkReq("GET", "/notes?meta[priority][gte]=4&meta[status][eq]=active&include_content=true"),
|
|
2183
|
+
store,
|
|
2184
|
+
"",
|
|
2185
|
+
);
|
|
2186
|
+
const body = await res.json() as any[];
|
|
2187
|
+
expect(body.map((n) => n.content)).toEqual(["hit"]);
|
|
1018
2188
|
});
|
|
1019
2189
|
|
|
1020
|
-
test("
|
|
1021
|
-
|
|
1022
|
-
await
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
mimeType: "audio/webm",
|
|
1026
|
-
transcribe: true,
|
|
1027
|
-
}),
|
|
2190
|
+
test("operator query on a non-indexed field returns 400 with FIELD_NOT_INDEXED", async () => {
|
|
2191
|
+
// Don't declare the field — the engine's indexed-field gate should fire.
|
|
2192
|
+
await store.createNote("x", { metadata: { mood: "great" } });
|
|
2193
|
+
const res = await handleNotes(
|
|
2194
|
+
mkReq("GET", "/notes?meta[mood][eq]=great"),
|
|
1028
2195
|
store,
|
|
1029
|
-
"
|
|
2196
|
+
"",
|
|
1030
2197
|
);
|
|
1031
|
-
|
|
1032
|
-
const
|
|
1033
|
-
expect(
|
|
1034
|
-
expect(meta?.transcribe_stub).toBe(true);
|
|
2198
|
+
expect(res.status).toBe(400);
|
|
2199
|
+
const body = await res.json() as any;
|
|
2200
|
+
expect(body.code).toBe("FIELD_NOT_INDEXED");
|
|
1035
2201
|
});
|
|
1036
|
-
});
|
|
1037
2202
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
2203
|
+
test("unknown operator returns 400 with UNKNOWN_OPERATOR", async () => {
|
|
2204
|
+
await declareIndexed();
|
|
2205
|
+
const res = await handleNotes(
|
|
2206
|
+
mkReq("GET", "/notes?meta[priority][bogus]=5"),
|
|
2207
|
+
store,
|
|
2208
|
+
"",
|
|
2209
|
+
);
|
|
2210
|
+
expect(res.status).toBe(400);
|
|
2211
|
+
const body = await res.json() as any;
|
|
2212
|
+
expect(body.code).toBe("UNKNOWN_OPERATOR");
|
|
2213
|
+
});
|
|
1046
2214
|
|
|
1047
|
-
|
|
1048
|
-
|
|
2215
|
+
// ---- Bridge: created_at / updated_at via brackets route to dateFilter ----
|
|
2216
|
+
test("`meta[created_at][gte]=…` routes to dateFilter (same result as flat date_field)", async () => {
|
|
2217
|
+
await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
2218
|
+
await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
1049
2219
|
|
|
1050
|
-
const
|
|
1051
|
-
mkReq("
|
|
2220
|
+
const bracketRes = await handleNotes(
|
|
2221
|
+
mkReq("GET", "/notes?meta[created_at][gte]=2026-04-01&include_content=true"),
|
|
1052
2222
|
store,
|
|
1053
|
-
|
|
1054
|
-
"default",
|
|
2223
|
+
"",
|
|
1055
2224
|
);
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
2225
|
+
const bracketBody = await bracketRes.json() as any[];
|
|
2226
|
+
const flatRes = await handleNotes(
|
|
2227
|
+
mkReq("GET", "/notes?date_field=created_at&date_from=2026-04-01&include_content=true"),
|
|
2228
|
+
store,
|
|
2229
|
+
"",
|
|
2230
|
+
);
|
|
2231
|
+
const flatBody = await flatRes.json() as any[];
|
|
2232
|
+
expect(bracketBody.map((n) => n.content)).toEqual(["new"]);
|
|
2233
|
+
expect(bracketBody.map((n) => n.content)).toEqual(flatBody.map((n) => n.content));
|
|
2234
|
+
});
|
|
1059
2235
|
|
|
1060
|
-
|
|
2236
|
+
test("`meta[updated_at][gte]=…` routes to dateFilter on n.updated_at", async () => {
|
|
2237
|
+
const a = await store.createNote("untouched");
|
|
2238
|
+
const b = await store.createNote("modified");
|
|
2239
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
2240
|
+
.run("2026-01-15T00:00:00.000Z", a.id);
|
|
2241
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
2242
|
+
.run("2026-04-25T00:00:00.000Z", b.id);
|
|
2243
|
+
const res = await handleNotes(
|
|
2244
|
+
mkReq("GET", "/notes?meta[updated_at][gte]=2026-04-01&include_content=true"),
|
|
2245
|
+
store,
|
|
2246
|
+
"",
|
|
2247
|
+
);
|
|
2248
|
+
const body = await res.json() as any[];
|
|
2249
|
+
expect(body.map((n) => n.content)).toEqual(["modified"]);
|
|
1061
2250
|
});
|
|
1062
2251
|
|
|
1063
|
-
test("
|
|
1064
|
-
await store.createNote("
|
|
2252
|
+
test("`meta[created_at][lt]=…` maps to dateFilter's exclusive upper bound", async () => {
|
|
2253
|
+
await store.createNote("inside", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2254
|
+
await store.createNote("on-boundary", { created_at: "2026-05-01T00:00:00.000Z" });
|
|
1065
2255
|
const res = await handleNotes(
|
|
1066
|
-
mkReq("
|
|
2256
|
+
mkReq("GET", "/notes?meta[created_at][lt]=2026-05-01&include_content=true"),
|
|
1067
2257
|
store,
|
|
1068
|
-
"
|
|
1069
|
-
"default",
|
|
2258
|
+
"",
|
|
1070
2259
|
);
|
|
1071
|
-
|
|
2260
|
+
const body = await res.json() as any[];
|
|
2261
|
+
// "on-boundary" excluded because `< to` is half-open by design.
|
|
2262
|
+
expect(body.map((n) => n.content)).toEqual(["inside"]);
|
|
1072
2263
|
});
|
|
1073
2264
|
|
|
1074
|
-
test("
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1077
|
-
const first = await handleNotes(
|
|
1078
|
-
mkReq("DELETE", `/notes/n3/attachments/${att.id}`),
|
|
2265
|
+
test("unsupported date-column operator (e.g. gt) rejects with a guiding error", async () => {
|
|
2266
|
+
const res = await handleNotes(
|
|
2267
|
+
mkReq("GET", "/notes?meta[created_at][gt]=2026-01-01"),
|
|
1079
2268
|
store,
|
|
1080
|
-
|
|
2269
|
+
"",
|
|
1081
2270
|
);
|
|
1082
|
-
expect(
|
|
1083
|
-
const
|
|
1084
|
-
|
|
2271
|
+
expect(res.status).toBe(400);
|
|
2272
|
+
const body = await res.json() as any;
|
|
2273
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2274
|
+
// Error must call out the supported ops so callers can self-correct.
|
|
2275
|
+
expect(body.error).toContain("gte");
|
|
2276
|
+
expect(body.error).toContain("lt");
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
test("`meta[created_at]=…` (shorthand, no operator) rejects with a guiding error", async () => {
|
|
2280
|
+
const res = await handleNotes(
|
|
2281
|
+
mkReq("GET", "/notes?meta[created_at]=2026-01-01"),
|
|
1085
2282
|
store,
|
|
1086
|
-
|
|
2283
|
+
"",
|
|
1087
2284
|
);
|
|
1088
|
-
expect(
|
|
2285
|
+
expect(res.status).toBe(400);
|
|
2286
|
+
const body = await res.json() as any;
|
|
2287
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2288
|
+
expect(body.error).toContain("operator");
|
|
1089
2289
|
});
|
|
1090
2290
|
|
|
1091
|
-
|
|
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");
|
|
2291
|
+
// ---- Mutually-exclusive shapes that would silently corrupt input ----
|
|
1095
2292
|
|
|
2293
|
+
test("bracket-date filter spanning created_at AND updated_at in one request rejects (vault#289 F1)", async () => {
|
|
2294
|
+
// Before this guard, the parser flattened both columns onto a single
|
|
2295
|
+
// `dateBucket.field`, so the second column silently won and the first
|
|
2296
|
+
// column's bound was applied against the wrong column.
|
|
1096
2297
|
const res = await handleNotes(
|
|
1097
|
-
mkReq(
|
|
2298
|
+
mkReq(
|
|
2299
|
+
"GET",
|
|
2300
|
+
"/notes?meta[created_at][gte]=2026-04-01&meta[updated_at][lt]=2026-06-01",
|
|
2301
|
+
),
|
|
1098
2302
|
store,
|
|
1099
|
-
|
|
2303
|
+
"",
|
|
1100
2304
|
);
|
|
1101
|
-
expect(res.status).toBe(
|
|
1102
|
-
|
|
2305
|
+
expect(res.status).toBe(400);
|
|
2306
|
+
const body = await res.json() as any;
|
|
2307
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2308
|
+
expect(body.error).toContain("cannot span");
|
|
2309
|
+
expect(body.error).toContain("created_at");
|
|
2310
|
+
expect(body.error).toContain("updated_at");
|
|
1103
2311
|
});
|
|
1104
2312
|
|
|
1105
|
-
test("
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
2313
|
+
test("two bracket-date params on the same column compose into a range (regression)", async () => {
|
|
2314
|
+
// The F1 guard must reject *different* columns only — same-column
|
|
2315
|
+
// gte+lt is the canonical range case and must keep working.
|
|
2316
|
+
await store.createNote("in-window", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2317
|
+
await store.createNote("after-window", { created_at: "2026-05-15T00:00:00.000Z" });
|
|
2318
|
+
await store.createNote("before-window", { created_at: "2026-03-15T00:00:00.000Z" });
|
|
2319
|
+
const res = await handleNotes(
|
|
2320
|
+
mkReq(
|
|
2321
|
+
"GET",
|
|
2322
|
+
"/notes?meta[created_at][gte]=2026-04-01&meta[created_at][lt]=2026-05-01&include_content=true",
|
|
2323
|
+
),
|
|
2324
|
+
store,
|
|
2325
|
+
"",
|
|
2326
|
+
);
|
|
2327
|
+
expect(res.status).toBe(200);
|
|
2328
|
+
const body = await res.json() as any[];
|
|
2329
|
+
expect(body.map((n) => n.content)).toEqual(["in-window"]);
|
|
2330
|
+
});
|
|
1117
2331
|
|
|
1118
|
-
|
|
1119
|
-
|
|
2332
|
+
test("shorthand-then-operator on the same field rejects (vault#289 F2)", async () => {
|
|
2333
|
+
// `URLSearchParams` iteration is insertion-order. Before this guard,
|
|
2334
|
+
// shorthand wrote `metadata[field] = primitive`, then the operator
|
|
2335
|
+
// handler called `metaOpBucket` which overwrote it with a fresh op
|
|
2336
|
+
// object — the shorthand was silently dropped.
|
|
2337
|
+
await declareIndexed();
|
|
2338
|
+
const res = await handleNotes(
|
|
2339
|
+
mkReq("GET", "/notes?meta[priority]=5&meta[priority][gte]=3"),
|
|
1120
2340
|
store,
|
|
1121
|
-
|
|
1122
|
-
"default",
|
|
2341
|
+
"",
|
|
1123
2342
|
);
|
|
1124
|
-
expect(
|
|
2343
|
+
expect(res.status).toBe(400);
|
|
2344
|
+
const body = await res.json() as any;
|
|
2345
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2346
|
+
expect(body.error).toContain("mix shorthand and operator");
|
|
2347
|
+
});
|
|
1125
2348
|
|
|
1126
|
-
|
|
1127
|
-
|
|
2349
|
+
test("operator-then-shorthand on the same field rejects (vault#289 F2, reverse order)", async () => {
|
|
2350
|
+
// Reverse insertion order. Before this guard, the operator was set
|
|
2351
|
+
// first, then the shorthand wrote `metadata[field] = primitive` and
|
|
2352
|
+
// clobbered the op bucket — operator silently dropped.
|
|
2353
|
+
await declareIndexed();
|
|
2354
|
+
const res = await handleNotes(
|
|
2355
|
+
mkReq("GET", "/notes?meta[priority][gte]=3&meta[priority]=5"),
|
|
1128
2356
|
store,
|
|
1129
|
-
|
|
1130
|
-
"default",
|
|
2357
|
+
"",
|
|
1131
2358
|
);
|
|
1132
|
-
expect(
|
|
2359
|
+
expect(res.status).toBe(400);
|
|
2360
|
+
const body = await res.json() as any;
|
|
2361
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2362
|
+
expect(body.error).toContain("mix shorthand and operator");
|
|
2363
|
+
});
|
|
1133
2364
|
|
|
1134
|
-
|
|
2365
|
+
test("`[]` array form on a non-array operator rejects at the parser layer (vault#289 F4)", async () => {
|
|
2366
|
+
// `meta[field][eq][]=value` is a shape error — `eq` takes a scalar.
|
|
2367
|
+
// The engine would also catch this (the value would be an array
|
|
2368
|
+
// SQLite can't bind), but the parser-level error names the issue
|
|
2369
|
+
// more precisely: "use single-value form for `eq`."
|
|
2370
|
+
const res = await handleNotes(
|
|
2371
|
+
mkReq("GET", "/notes?meta[priority][eq][]=5"),
|
|
2372
|
+
store,
|
|
2373
|
+
"",
|
|
2374
|
+
);
|
|
2375
|
+
expect(res.status).toBe(400);
|
|
2376
|
+
const body = await res.json() as any;
|
|
2377
|
+
expect(body.code).toBe("INVALID_OPERATOR_VALUE");
|
|
2378
|
+
expect(body.error).toContain("array form");
|
|
2379
|
+
expect(body.error).toContain("in");
|
|
2380
|
+
expect(body.error).toContain("not_in");
|
|
1135
2381
|
});
|
|
1136
2382
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
2383
|
+
// ---- Precedence on overlap ----
|
|
2384
|
+
test("when both flat and bracket date params overlap, bracket wins", async () => {
|
|
2385
|
+
await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
2386
|
+
await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2387
|
+
// Bracket says "from 2026-04-01"; flat says "from 2020-01-01". If
|
|
2388
|
+
// flat won, both notes would match. The bracket-wins precedence is
|
|
2389
|
+
// verified by getting back only the post-April note.
|
|
1140
2390
|
const res = await handleNotes(
|
|
1141
|
-
mkReq(
|
|
2391
|
+
mkReq(
|
|
2392
|
+
"GET",
|
|
2393
|
+
"/notes?meta[created_at][gte]=2026-04-01&date_field=created_at&date_from=2020-01-01&include_content=true",
|
|
2394
|
+
),
|
|
1142
2395
|
store,
|
|
1143
|
-
|
|
2396
|
+
"",
|
|
1144
2397
|
);
|
|
1145
|
-
|
|
2398
|
+
const body = await res.json() as any[];
|
|
2399
|
+
expect(body.map((n) => n.content)).toEqual(["new"]);
|
|
1146
2400
|
});
|
|
1147
2401
|
});
|
|
1148
2402
|
});
|
|
@@ -1352,6 +2606,98 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
1352
2606
|
expect((await store.getNote("x"))!.content).toBe("first");
|
|
1353
2607
|
});
|
|
1354
2608
|
|
|
2609
|
+
test("PATCH append without precondition succeeds (no-conflict-by-design)", async () => {
|
|
2610
|
+
await store.createNote("seed:", { id: "x" });
|
|
2611
|
+
|
|
2612
|
+
const res = await handleNotes(
|
|
2613
|
+
mkReq("PATCH", "/notes/x", { append: " A" }),
|
|
2614
|
+
store,
|
|
2615
|
+
"/x",
|
|
2616
|
+
);
|
|
2617
|
+
expect(res.status).toBe(200);
|
|
2618
|
+
expect((await store.getNote("x"))!.content).toBe("seed: A");
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
test("PATCH append + tags without precondition is rejected (#201)", async () => {
|
|
2622
|
+
// The append-only exemption is justified by SQL-atomic concat. Tag
|
|
2623
|
+
// mutations don't share that property — they're idempotent, but the
|
|
2624
|
+
// caller should still observe the prior state before re-asserting.
|
|
2625
|
+
await store.createNote("seed:", { id: "x", path: "Inbox/x" });
|
|
2626
|
+
|
|
2627
|
+
const res = await handleNotes(
|
|
2628
|
+
mkReq("PATCH", "/notes/x", { append: " A", tags: { add: ["important"] } }),
|
|
2629
|
+
store,
|
|
2630
|
+
"/x",
|
|
2631
|
+
);
|
|
2632
|
+
expect(res.status).toBe(428);
|
|
2633
|
+
const body = await res.json() as any;
|
|
2634
|
+
expect(body.error_type).toBe("precondition_required");
|
|
2635
|
+
// Unchanged on rejection.
|
|
2636
|
+
expect((await store.getNote("x"))!.content).toBe("seed:");
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
test("PATCH content_edit replaces a single occurrence", async () => {
|
|
2640
|
+
const note = await store.createNote("hello world", { id: "x" });
|
|
2641
|
+
|
|
2642
|
+
const res = await handleNotes(
|
|
2643
|
+
mkReq("PATCH", "/notes/x", {
|
|
2644
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
2645
|
+
if_updated_at: note.updatedAt,
|
|
2646
|
+
}),
|
|
2647
|
+
store,
|
|
2648
|
+
"/x",
|
|
2649
|
+
);
|
|
2650
|
+
expect(res.status).toBe(200);
|
|
2651
|
+
expect((await store.getNote("x"))!.content).toBe("hi world");
|
|
2652
|
+
});
|
|
2653
|
+
|
|
2654
|
+
test("PATCH content_edit returns 422 when old_text is not found (#202)", async () => {
|
|
2655
|
+
// 404 misleadingly read as "note doesn't exist"; 422 says "request is
|
|
2656
|
+
// valid, but old_text doesn't apply to the current content."
|
|
2657
|
+
const note = await store.createNote("hello world", { id: "x" });
|
|
2658
|
+
|
|
2659
|
+
const res = await handleNotes(
|
|
2660
|
+
mkReq("PATCH", "/notes/x", {
|
|
2661
|
+
content_edit: { old_text: "missing", new_text: "x" },
|
|
2662
|
+
if_updated_at: note.updatedAt,
|
|
2663
|
+
}),
|
|
2664
|
+
store,
|
|
2665
|
+
"/x",
|
|
2666
|
+
);
|
|
2667
|
+
expect(res.status).toBe(422);
|
|
2668
|
+
const body = await res.json() as any;
|
|
2669
|
+
expect(body.error).toBe("unprocessable_content");
|
|
2670
|
+
expect((await store.getNote("x"))!.content).toBe("hello world");
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
test("PATCH content_edit returns 409 on multiple matches", async () => {
|
|
2674
|
+
const note = await store.createNote("hi hi", { id: "x" });
|
|
2675
|
+
|
|
2676
|
+
const res = await handleNotes(
|
|
2677
|
+
mkReq("PATCH", "/notes/x", {
|
|
2678
|
+
content_edit: { old_text: "hi", new_text: "hello" },
|
|
2679
|
+
if_updated_at: note.updatedAt,
|
|
2680
|
+
}),
|
|
2681
|
+
store,
|
|
2682
|
+
"/x",
|
|
2683
|
+
);
|
|
2684
|
+
expect(res.status).toBe(409);
|
|
2685
|
+
expect((await store.getNote("x"))!.content).toBe("hi hi");
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
test("PATCH rejects content + append combination with 400", async () => {
|
|
2689
|
+
await store.createNote("seed", { id: "x" });
|
|
2690
|
+
|
|
2691
|
+
const res = await handleNotes(
|
|
2692
|
+
mkReq("PATCH", "/notes/x", { content: "new", append: "more", force: true }),
|
|
2693
|
+
store,
|
|
2694
|
+
"/x",
|
|
2695
|
+
);
|
|
2696
|
+
expect(res.status).toBe(400);
|
|
2697
|
+
const body = await res.json() as any;
|
|
2698
|
+
expect(body.error).toBe("mutually_exclusive");
|
|
2699
|
+
});
|
|
2700
|
+
|
|
1355
2701
|
test("DELETE resolves note by path", async () => {
|
|
1356
2702
|
await store.createNote("x", { path: "Temp/note" });
|
|
1357
2703
|
const res = await handleNotes(
|
|
@@ -1363,6 +2709,80 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
1363
2709
|
expect(body.deleted).toBe(true);
|
|
1364
2710
|
expect(await store.getNoteByPath("Temp/note")).toBeNull();
|
|
1365
2711
|
});
|
|
2712
|
+
|
|
2713
|
+
test("POST /notes returns 409 path_conflict when path already exists (#126)", async () => {
|
|
2714
|
+
await store.createNote("first", { path: "Inbox/note" });
|
|
2715
|
+
const res = await handleNotes(
|
|
2716
|
+
mkReq("POST", "/notes", { content: "second", path: "Inbox/note" }),
|
|
2717
|
+
store,
|
|
2718
|
+
"",
|
|
2719
|
+
);
|
|
2720
|
+
expect(res.status).toBe(409);
|
|
2721
|
+
const body = await res.json() as any;
|
|
2722
|
+
expect(body.error_type).toBe("path_conflict");
|
|
2723
|
+
expect(body.error).toBe("path_conflict");
|
|
2724
|
+
expect(body.path).toBe("Inbox/note");
|
|
2725
|
+
});
|
|
2726
|
+
|
|
2727
|
+
test("POST /notes path_conflict — second note never lands in DB (#126)", async () => {
|
|
2728
|
+
await store.createNote("first", { path: "Inbox/note" });
|
|
2729
|
+
const before = (await store.queryNotes({})).length;
|
|
2730
|
+
await handleNotes(
|
|
2731
|
+
mkReq("POST", "/notes", { content: "second", path: "Inbox/note" }),
|
|
2732
|
+
store,
|
|
2733
|
+
"",
|
|
2734
|
+
);
|
|
2735
|
+
expect((await store.queryNotes({})).length).toBe(before);
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
test("PATCH /notes returns 409 path_conflict when renaming onto existing path (#126)", async () => {
|
|
2739
|
+
const a = await store.createNote("first", { id: "a", path: "alpha" });
|
|
2740
|
+
await store.createNote("second", { id: "b", path: "beta" });
|
|
2741
|
+
const res = await handleNotes(
|
|
2742
|
+
mkReq("PATCH", "/notes/a", { path: "beta", if_updated_at: a.createdAt }),
|
|
2743
|
+
store,
|
|
2744
|
+
"/a",
|
|
2745
|
+
);
|
|
2746
|
+
expect(res.status).toBe(409);
|
|
2747
|
+
const body = await res.json() as any;
|
|
2748
|
+
expect(body.error_type).toBe("path_conflict");
|
|
2749
|
+
expect(body.path).toBe("beta");
|
|
2750
|
+
// Source note unchanged
|
|
2751
|
+
expect((await store.getNote("a"))!.path).toBe("alpha");
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
// ---- include_content response-shape opt-out (vault#285 friction point 2.response) ----
|
|
2755
|
+
test("PATCH defaults to returning the full Note (back-compat)", async () => {
|
|
2756
|
+
await store.createNote("body", { id: "x" });
|
|
2757
|
+
const res = await handleNotes(
|
|
2758
|
+
mkReq("PATCH", "/notes/x", { content: "updated", force: true }),
|
|
2759
|
+
store,
|
|
2760
|
+
"/x",
|
|
2761
|
+
);
|
|
2762
|
+
expect(res.status).toBe(200);
|
|
2763
|
+
const body = await res.json() as any;
|
|
2764
|
+
expect(body.content).toBe("updated");
|
|
2765
|
+
expect(body.byteSize).toBeUndefined();
|
|
2766
|
+
expect(body.preview).toBeUndefined();
|
|
2767
|
+
});
|
|
2768
|
+
|
|
2769
|
+
test("PATCH with include_content: false returns the lean NoteIndex shape", async () => {
|
|
2770
|
+
const longBody = "x".repeat(2_000);
|
|
2771
|
+
await store.createNote(longBody, { id: "big", path: "big" });
|
|
2772
|
+
const res = await handleNotes(
|
|
2773
|
+
mkReq("PATCH", "/notes/big", { append: " edit", include_content: false }),
|
|
2774
|
+
store,
|
|
2775
|
+
"/big",
|
|
2776
|
+
);
|
|
2777
|
+
expect(res.status).toBe(200);
|
|
2778
|
+
const body = await res.json() as any;
|
|
2779
|
+
expect(body.content).toBeUndefined();
|
|
2780
|
+
expect(typeof body.byteSize).toBe("number");
|
|
2781
|
+
expect(body.byteSize).toBe(2_000 + 5);
|
|
2782
|
+
expect(typeof body.preview).toBe("string");
|
|
2783
|
+
expect(body.id).toBe("big");
|
|
2784
|
+
expect(body.path).toBe("big");
|
|
2785
|
+
});
|
|
1366
2786
|
});
|
|
1367
2787
|
|
|
1368
2788
|
describe("HTTP /tags", async () => {
|
|
@@ -1397,6 +2817,21 @@ describe("HTTP /tags", async () => {
|
|
|
1397
2817
|
expect(body.description).toBe("A person");
|
|
1398
2818
|
});
|
|
1399
2819
|
|
|
2820
|
+
test("PUT /tags/:name returns 400 with error_type: invalid_relationships on bad shape", async () => {
|
|
2821
|
+
const res = await handleTags(
|
|
2822
|
+
mkReq("PUT", "/tags/person", {
|
|
2823
|
+
relationships: { mentions: { target_tag: "topic", cardinality: "infinite" } },
|
|
2824
|
+
}),
|
|
2825
|
+
store,
|
|
2826
|
+
"/person",
|
|
2827
|
+
);
|
|
2828
|
+
expect(res.status).toBe(400);
|
|
2829
|
+
const body = await res.json() as any;
|
|
2830
|
+
expect(body.error_type).toBe("invalid_relationships");
|
|
2831
|
+
expect(typeof body.error).toBe("string");
|
|
2832
|
+
expect(body.error.length).toBeGreaterThan(0);
|
|
2833
|
+
});
|
|
2834
|
+
|
|
1400
2835
|
test("DELETE /tags/:name removes tag and schema", async () => {
|
|
1401
2836
|
await store.createNote("A", { tags: ["doomed"] });
|
|
1402
2837
|
await store.upsertTagSchema("doomed", { description: "will be deleted" });
|
|
@@ -1416,7 +2851,7 @@ describe("HTTP /tags", async () => {
|
|
|
1416
2851
|
);
|
|
1417
2852
|
expect(res.status).toBe(200);
|
|
1418
2853
|
const body = await res.json() as any;
|
|
1419
|
-
expect(body).
|
|
2854
|
+
expect(body).toMatchObject({ renamed: 2, sub_tags_renamed: 0 });
|
|
1420
2855
|
expect((await store.getNote(n1.id))!.tags).toEqual(["memo"]);
|
|
1421
2856
|
expect((await store.getNote(n2.id))!.tags?.sort()).toEqual(["keeper", "memo"]);
|
|
1422
2857
|
});
|
|
@@ -1516,6 +2951,7 @@ describe("HTTP /tags", async () => {
|
|
|
1516
2951
|
});
|
|
1517
2952
|
});
|
|
1518
2953
|
|
|
2954
|
+
|
|
1519
2955
|
describe("HTTP /find-path", async () => {
|
|
1520
2956
|
test("finds path between two notes", async () => {
|
|
1521
2957
|
await store.createNote("a", { id: "a" });
|
|
@@ -1578,6 +3014,7 @@ describe("stateless MCP transport", async () => {
|
|
|
1578
3014
|
permission: "full",
|
|
1579
3015
|
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
1580
3016
|
legacyDerived: false,
|
|
3017
|
+
scoped_tags: null,
|
|
1581
3018
|
});
|
|
1582
3019
|
expect(res.status).toBe(200);
|
|
1583
3020
|
|
|
@@ -1619,6 +3056,7 @@ describe("stateless MCP transport", async () => {
|
|
|
1619
3056
|
permission: "full",
|
|
1620
3057
|
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
1621
3058
|
legacyDerived: false,
|
|
3059
|
+
scoped_tags: null,
|
|
1622
3060
|
});
|
|
1623
3061
|
expect(res.status).toBe(200);
|
|
1624
3062
|
|
|
@@ -1662,6 +3100,7 @@ describe("stateless MCP transport", async () => {
|
|
|
1662
3100
|
permission: "read",
|
|
1663
3101
|
scopes: ["vault:read"],
|
|
1664
3102
|
legacyDerived: false,
|
|
3103
|
+
scoped_tags: null,
|
|
1665
3104
|
});
|
|
1666
3105
|
expect(res.status).toBe(200);
|
|
1667
3106
|
|
|
@@ -1716,6 +3155,7 @@ describe("stateless MCP transport", async () => {
|
|
|
1716
3155
|
permission: "read",
|
|
1717
3156
|
scopes: ["vault:read"],
|
|
1718
3157
|
legacyDerived: false,
|
|
3158
|
+
scoped_tags: null,
|
|
1719
3159
|
});
|
|
1720
3160
|
|
|
1721
3161
|
expect(res.status).toBe(200);
|
|
@@ -1767,6 +3207,7 @@ describe("stateless MCP transport", async () => {
|
|
|
1767
3207
|
permission: "full",
|
|
1768
3208
|
scopes: ["vault:read", "vault:write"],
|
|
1769
3209
|
legacyDerived: false,
|
|
3210
|
+
scoped_tags: null,
|
|
1770
3211
|
});
|
|
1771
3212
|
|
|
1772
3213
|
expect(res.status).toBe(200);
|
|
@@ -1807,6 +3248,7 @@ describe("stateless MCP transport", async () => {
|
|
|
1807
3248
|
permission: "read",
|
|
1808
3249
|
scopes: ["vault:read"],
|
|
1809
3250
|
legacyDerived: false,
|
|
3251
|
+
scoped_tags: null,
|
|
1810
3252
|
});
|
|
1811
3253
|
expect(res.status).toBe(200); // JSON-RPC envelope is 200 even for tool errors
|
|
1812
3254
|
const body = await res.json() as any;
|
|
@@ -1850,6 +3292,7 @@ describe("stateless MCP transport", async () => {
|
|
|
1850
3292
|
permission: "full",
|
|
1851
3293
|
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
1852
3294
|
legacyDerived: false,
|
|
3295
|
+
scoped_tags: null,
|
|
1853
3296
|
});
|
|
1854
3297
|
expect(res.status).toBe(200);
|
|
1855
3298
|
|