@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.
Files changed (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. 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 all 9 core tools", () => {
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
- test("update-note tags.add auto-populate does not bump updatedAt", async () => {
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: close } = await import("./vault-store.ts");
1088
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
718
1089
 
719
- const vaultName = `schema-noupdate-${Date.now()}`;
720
- writeVaultConfig({
721
- name: vaultName,
722
- api_keys: [],
723
- created_at: new Date().toISOString(),
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 vaultStore = getVaultStore(vaultName);
727
- await vaultStore.upsertTagSchema("person", {
728
- description: "A person",
729
- fields: { name: { type: "string" } },
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
- const tools = generateScopedMcpTools(vaultName);
733
- const createNote = tools.find((t) => t.name === "create-note")!;
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
- const note = await createNote.execute({ content: "Test" }) as any;
738
- const originalUpdatedAt = note.updatedAt;
739
- await updateNote.execute({ id: note.id, tags: { add: ["person"] }, force: true });
740
- const after = await queryNotes.execute({ id: note.id }) as any;
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
- close();
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
- describe("auth permissions", () => {
749
- test("read permission allows read-only tools", () => {
750
- const { isToolAllowed } = require("./auth.ts");
751
- expect(isToolAllowed("query-notes", "read")).toBe(true);
752
- expect(isToolAllowed("list-tags", "read")).toBe(true);
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
- test("read permission blocks mutation tools", () => {
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("full permission allows all tools", () => {
767
- const { isToolAllowed } = require("./auth.ts");
768
- expect(isToolAllowed("create-note", "full")).toBe(true);
769
- expect(isToolAllowed("update-note", "full")).toBe(true);
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
- test("read permission allows GET but not POST/PATCH/DELETE", () => {
777
- const { isMethodAllowed } = require("./auth.ts");
778
- expect(isMethodAllowed("GET", "read")).toBe(true);
779
- expect(isMethodAllowed("HEAD", "read")).toBe(true);
780
- expect(isMethodAllowed("POST", "read")).toBe(false);
781
- expect(isMethodAllowed("PATCH", "read")).toBe(false);
782
- expect(isMethodAllowed("DELETE", "read")).toBe(false);
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
- test("full permission allows all methods", () => {
786
- const { isMethodAllowed } = require("./auth.ts");
787
- expect(isMethodAllowed("GET", "full")).toBe(true);
788
- expect(isMethodAllowed("POST", "full")).toBe(true);
789
- expect(isMethodAllowed("PATCH", "full")).toBe(true);
790
- expect(isMethodAllowed("DELETE", "full")).toBe(true);
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
- // ---- HTTP route handlers ----
1146
+ closeAllStores();
1147
+ });
795
1148
 
796
- const BASE = "http://localhost/api";
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("POST", "/notes/v1/attachments", {
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
- "/v1/attachments",
2136
+ "",
992
2137
  );
993
- expect(res.status).toBe(201);
994
- const att = await res.json() as any;
995
- expect(att.metadata?.transcribe_status).toBe("pending");
996
- expect(att.metadata?.transcribe_requested_at).toBeTruthy();
2138
+ const body = await res.json() as any[];
2139
+ expect(body.map((n) => n.content)).toEqual(["a"]);
2140
+ });
997
2141
 
998
- const note = await store.getNote("v1");
999
- expect((note!.metadata as any)?.transcribe_stub).toBe(true);
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("transcribe: false (default) leaves metadata empty and note untouched", async () => {
1003
- await store.createNote("note body", { id: "v2" });
2164
+ test("exists with non-boolean value rejects with INVALID_OPERATOR_VALUE", async () => {
2165
+ await declareIndexed();
1004
2166
  const res = await handleNotes(
1005
- mkReq("POST", "/notes/v2/attachments", {
1006
- path: "memos/memo-2.webm",
1007
- mimeType: "audio/webm",
1008
- }),
2167
+ mkReq("GET", "/notes?meta[priority][exists]=yes"),
1009
2168
  store,
1010
- "/v2/attachments",
2169
+ "",
1011
2170
  );
1012
- expect(res.status).toBe(201);
1013
- const att = await res.json() as any;
1014
- expect(att.metadata?.transcribe_status).toBeUndefined();
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
- const note = await store.getNote("v2");
1017
- expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
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("transcribe: true preserves other note metadata", async () => {
1021
- await store.createNote("body", { id: "v3", metadata: { summary: "keep me" } });
1022
- await handleNotes(
1023
- mkReq("POST", "/notes/v3/attachments", {
1024
- path: "memos/memo-3.webm",
1025
- mimeType: "audio/webm",
1026
- transcribe: true,
1027
- }),
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
- "/v3/attachments",
2196
+ "",
1030
2197
  );
1031
- const note = await store.getNote("v3");
1032
- const meta = note!.metadata as any;
1033
- expect(meta?.summary).toBe("keep me");
1034
- expect(meta?.transcribe_stub).toBe(true);
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
- describe("DELETE /notes/:id/attachments/:attId", async () => {
1039
- test("happy path: 204, DB row gone, storage file unlinked", async () => {
1040
- const assetsRoot = join(tmpDir, "assets");
1041
- mkdirSync(join(assetsRoot, "2026-04-18"), { recursive: true });
1042
- const relPath = "2026-04-18/shot.png";
1043
- const filePath = join(assetsRoot, relPath);
1044
- writeFileSync(filePath, Buffer.from([1, 2, 3]));
1045
- process.env.ASSETS_DIR = assetsRoot;
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
- const n = await store.createNote("x", { id: "n1" });
1048
- const att = await store.addAttachment(n.id, relPath, "image/png");
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 res = await handleNotes(
1051
- mkReq("DELETE", `/notes/n1/attachments/${att.id}`),
2220
+ const bracketRes = await handleNotes(
2221
+ mkReq("GET", "/notes?meta[created_at][gte]=2026-04-01&include_content=true"),
1052
2222
  store,
1053
- `/n1/attachments/${att.id}`,
1054
- "default",
2223
+ "",
1055
2224
  );
1056
- expect(res.status).toBe(204);
1057
- expect((await store.getAttachments(n.id)).length).toBe(0);
1058
- expect(existsSync(filePath)).toBe(false);
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
- delete process.env.ASSETS_DIR;
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("404 when attachment does not exist", async () => {
1064
- await store.createNote("x", { id: "n2" });
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("DELETE", "/notes/n2/attachments/nonexistent"),
2256
+ mkReq("GET", "/notes?meta[created_at][lt]=2026-05-01&include_content=true"),
1067
2257
  store,
1068
- "/n2/attachments/nonexistent",
1069
- "default",
2258
+ "",
1070
2259
  );
1071
- expect(res.status).toBe(404);
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("second delete is idempotent (404)", async () => {
1075
- const n = await store.createNote("x", { id: "n3" });
1076
- const att = await store.addAttachment(n.id, "files/a.png", "image/png");
1077
- const first = await handleNotes(
1078
- mkReq("DELETE", `/notes/n3/attachments/${att.id}`),
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
- `/n3/attachments/${att.id}`,
2269
+ "",
1081
2270
  );
1082
- expect(first.status).toBe(204);
1083
- const second = await handleNotes(
1084
- mkReq("DELETE", `/notes/n3/attachments/${att.id}`),
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
- `/n3/attachments/${att.id}`,
2283
+ "",
1087
2284
  );
1088
- expect(second.status).toBe(404);
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
- test("cross-note delete attempt returns 404 and leaves record intact", async () => {
1092
- const a = await store.createNote("a", { id: "na" });
1093
- const b = await store.createNote("b", { id: "nb" });
1094
- const attA = await store.addAttachment(a.id, "files/a.png", "image/png");
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("DELETE", `/notes/nb/attachments/${attA.id}`),
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
- `/nb/attachments/${attA.id}`,
2303
+ "",
1100
2304
  );
1101
- expect(res.status).toBe(404);
1102
- expect((await store.getAttachments(a.id)).length).toBe(1);
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("file survives first delete when a sibling attachment still references it", async () => {
1106
- const assetsRoot = join(tmpDir, "assets");
1107
- mkdirSync(join(assetsRoot, "shared"), { recursive: true });
1108
- const relPath = "shared/pic.png";
1109
- const filePath = join(assetsRoot, relPath);
1110
- writeFileSync(filePath, Buffer.from([9]));
1111
- process.env.ASSETS_DIR = assetsRoot;
1112
-
1113
- const a = await store.createNote("a", { id: "sa" });
1114
- const b = await store.createNote("b", { id: "sb" });
1115
- const attA = await store.addAttachment(a.id, relPath, "image/png");
1116
- const attB = await store.addAttachment(b.id, relPath, "image/png");
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
- await handleNotes(
1119
- mkReq("DELETE", `/notes/sa/attachments/${attA.id}`),
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
- `/sa/attachments/${attA.id}`,
1122
- "default",
2341
+ "",
1123
2342
  );
1124
- expect(existsSync(filePath)).toBe(true);
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
- await handleNotes(
1127
- mkReq("DELETE", `/notes/sb/attachments/${attB.id}`),
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
- `/sb/attachments/${attB.id}`,
1130
- "default",
2357
+ "",
1131
2358
  );
1132
- expect(existsSync(filePath)).toBe(false);
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
- delete process.env.ASSETS_DIR;
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
- test("method not allowed on /attachments/:attId returns 405", async () => {
1138
- const n = await store.createNote("x", { id: "nm" });
1139
- const att = await store.addAttachment(n.id, "files/a.png", "image/png");
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("PATCH", `/notes/nm/attachments/${att.id}`),
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
- `/nm/attachments/${att.id}`,
2396
+ "",
1144
2397
  );
1145
- expect(res.status).toBe(405);
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).toEqual({ renamed: 2 });
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