@openparachute/vault 0.3.1 → 0.4.0

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 (82) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +9 -5
  3. package/core/src/core.test.ts +2252 -7
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +801 -67
  6. package/core/src/note-schemas.ts +232 -0
  7. package/core/src/notes.ts +313 -35
  8. package/core/src/obsidian.ts +3 -3
  9. package/core/src/paths.ts +1 -1
  10. package/core/src/query-operators.ts +23 -7
  11. package/core/src/schema-defaults.ts +287 -0
  12. package/core/src/schema.ts +393 -9
  13. package/core/src/store.ts +248 -6
  14. package/core/src/tag-hierarchy.ts +137 -0
  15. package/core/src/tag-schemas.ts +242 -42
  16. package/core/src/types.ts +100 -6
  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 +231 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +144 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +384 -78
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +296 -0
  30. package/src/hub-jwt.ts +79 -0
  31. package/src/init-summary.test.ts +133 -0
  32. package/src/init-summary.ts +90 -0
  33. package/src/init.test.ts +216 -0
  34. package/src/mcp-http.ts +30 -28
  35. package/src/mcp-install.ts +1 -1
  36. package/src/mcp-tools.ts +294 -6
  37. package/src/module-config.ts +1 -1
  38. package/src/oauth.test.ts +345 -0
  39. package/src/oauth.ts +85 -14
  40. package/src/owner-auth.ts +57 -1
  41. package/src/prompt.ts +31 -14
  42. package/src/routes.ts +686 -58
  43. package/src/routing.test.ts +466 -1
  44. package/src/routing.ts +108 -24
  45. package/src/scopes.test.ts +66 -8
  46. package/src/scopes.ts +163 -37
  47. package/src/server.ts +24 -2
  48. package/src/services-manifest.test.ts +20 -0
  49. package/src/services-manifest.ts +9 -2
  50. package/src/stop-signal.test.ts +85 -0
  51. package/src/storage.test.ts +92 -0
  52. package/src/tag-scope.ts +118 -0
  53. package/src/token-store.test.ts +47 -0
  54. package/src/token-store.ts +128 -13
  55. package/src/tokens-routes.test.ts +720 -0
  56. package/src/tokens-routes.ts +392 -0
  57. package/src/transcription-worker.test.ts +5 -0
  58. package/src/triggers.ts +1 -1
  59. package/src/two-factor.ts +2 -2
  60. package/src/vault-create.test.ts +193 -0
  61. package/src/vault-name.test.ts +123 -0
  62. package/src/vault-name.ts +80 -0
  63. package/src/vault.test.ts +868 -3
  64. package/tsconfig.json +8 -1
  65. package/.claude/settings.local.json +0 -8
  66. package/.dockerignore +0 -8
  67. package/.env.example +0 -9
  68. package/CHANGELOG.md +0 -175
  69. package/CLAUDE.md +0 -125
  70. package/Caddyfile +0 -3
  71. package/Dockerfile +0 -22
  72. package/bun.lock +0 -219
  73. package/bunfig.toml +0 -2
  74. package/deploy/parachute-vault.service +0 -20
  75. package/docker-compose.yml +0 -50
  76. package/docs/HTTP_API.md +0 -434
  77. package/docs/auth-model.md +0 -340
  78. package/fly.toml +0 -24
  79. package/package/package.json +0 -32
  80. package/railway.json +0 -14
  81. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  82. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/vault.test.ts CHANGED
@@ -10,7 +10,7 @@ import { tmpdir } from "os";
10
10
  import { BunStore } from "./vault-store.ts";
11
11
  import { generateMcpTools } from "../core/src/mcp.ts";
12
12
  import { getLinksHydrated } from "../core/src/links.ts";
13
- import { handleNotes, handleTags, handleFindPath, handleVault } from "./routes.ts";
13
+ import { handleNotes, handleTags, handleNoteSchemas, handleFindPath, handleVault } from "./routes.ts";
14
14
  import { extractApiKey } from "./auth.ts";
15
15
 
16
16
  let db: Database;
@@ -476,9 +476,9 @@ 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
- expect(tools.length).toBe(9);
481
+ expect(tools.length).toBe(16);
482
482
 
483
483
  const names = tools.map((t) => t.name);
484
484
  expect(names).toContain("query-notes");
@@ -488,7 +488,14 @@ describe("MCP tools", async () => {
488
488
  expect(names).toContain("list-tags");
489
489
  expect(names).toContain("update-tag");
490
490
  expect(names).toContain("delete-tag");
491
+ expect(names).toContain("list-note-schemas");
492
+ expect(names).toContain("update-note-schema");
493
+ expect(names).toContain("delete-note-schema");
494
+ expect(names).toContain("list-schema-mappings");
495
+ expect(names).toContain("set-schema-mapping");
496
+ expect(names).toContain("delete-schema-mapping");
491
497
  expect(names).toContain("find-path");
498
+ expect(names).toContain("synthesize-notes");
492
499
  expect(names).toContain("vault-info");
493
500
  });
494
501
 
@@ -711,6 +718,275 @@ describe("scoped MCP wrapper", async () => {
711
718
  close();
712
719
  });
713
720
 
721
+ // -- tag-scoped MCP wrappers (patterns/tag-scoped-tokens.md) ------------
722
+ //
723
+ // These pin the behavior of `applyTagScopeWrappers` in mcp-tools.ts: each
724
+ // wrapped tool's execute() honors the auth's scoped_tags allowlist. The
725
+ // unscoped path (auth.scoped_tags === null) remains identical to the
726
+ // baseline scoped MCP tests above; here we only assert the *scoped*
727
+ // path's deltas.
728
+
729
+ function authForTags(tags: string[]) {
730
+ return {
731
+ scopes: ["vault:read", "vault:write", "vault:admin"],
732
+ legacyDerived: false,
733
+ scoped_tags: tags,
734
+ } as const;
735
+ }
736
+
737
+ test("scoped query-notes filters list to in-scope notes only", async () => {
738
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
739
+ const { writeVaultConfig } = await import("./config.ts");
740
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
741
+
742
+ const vaultName = `tagscope-query-${Date.now()}`;
743
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
744
+ const store = getVaultStore(vaultName);
745
+ await store.createNote("h", { tags: ["health"] });
746
+ await store.createNote("w", { tags: ["work"] });
747
+
748
+ const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
749
+ const query = tools.find((t) => t.name === "query-notes")!;
750
+ const result = await query.execute({}) as any[];
751
+ expect(Array.isArray(result)).toBe(true);
752
+ expect(result.every((n: any) => n.tags.includes("health"))).toBe(true);
753
+ expect(result.find((n: any) => n.content === "w")).toBeUndefined();
754
+
755
+ closeAllStores();
756
+ });
757
+
758
+ test("scoped query-notes by id 404s on out-of-scope note", async () => {
759
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
760
+ const { writeVaultConfig } = await import("./config.ts");
761
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
762
+
763
+ const vaultName = `tagscope-byid-${Date.now()}`;
764
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
765
+ const store = getVaultStore(vaultName);
766
+ const w = await store.createNote("w", { tags: ["work"] });
767
+
768
+ const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
769
+ const query = tools.find((t) => t.name === "query-notes")!;
770
+ const result = await query.execute({ id: w.id }) as any;
771
+ expect(result.error).toBe("Note not found");
772
+ expect(result.id).toBe(w.id);
773
+
774
+ closeAllStores();
775
+ });
776
+
777
+ test("scoped list-tags filters to allowlisted root + descendants", async () => {
778
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
779
+ const { writeVaultConfig } = await import("./config.ts");
780
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
781
+
782
+ const vaultName = `tagscope-tags-${Date.now()}`;
783
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
784
+ const store = getVaultStore(vaultName);
785
+ await store.upsertTagRecord("health/food", { parent_names: ["health"] });
786
+ await store.createNote("h", { tags: ["health"] });
787
+ await store.createNote("hf", { tags: ["health/food"] });
788
+ await store.createNote("w", { tags: ["work"] });
789
+
790
+ const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
791
+ const listTags = tools.find((t) => t.name === "list-tags")!;
792
+ const result = await listTags.execute({}) as any[];
793
+ const names = result.map((t) => t.name);
794
+ expect(names).toContain("health");
795
+ expect(names).toContain("health/food");
796
+ expect(names).not.toContain("work");
797
+
798
+ closeAllStores();
799
+ });
800
+
801
+ test("scoped create-note rejects a note whose tags fall outside the allowlist", async () => {
802
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
803
+ const { writeVaultConfig } = await import("./config.ts");
804
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
805
+
806
+ const vaultName = `tagscope-create-${Date.now()}`;
807
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
808
+ getVaultStore(vaultName);
809
+
810
+ const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
811
+ const create = tools.find((t) => t.name === "create-note")!;
812
+ const result = await create.execute({ content: "denied", tags: ["work"] }) as any;
813
+ expect(result.error).toBe("Forbidden");
814
+ expect(result.error_type).toBe("tag_scope_violation");
815
+ expect(result.scoped_tags).toEqual(["health"]);
816
+
817
+ closeAllStores();
818
+ });
819
+
820
+ test("scoped create-note batch rejects atomically when any note is out of scope", async () => {
821
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
822
+ const { writeVaultConfig } = await import("./config.ts");
823
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
824
+
825
+ const vaultName = `tagscope-batch-${Date.now()}`;
826
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
827
+ const store = getVaultStore(vaultName);
828
+
829
+ const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
830
+ const create = tools.find((t) => t.name === "create-note")!;
831
+ const result = await create.execute({
832
+ notes: [
833
+ { content: "ok", tags: ["health"] },
834
+ { content: "no", tags: ["work"] },
835
+ ],
836
+ }) as any;
837
+ expect(result.error).toBe("Forbidden");
838
+ // Atomic — neither write should have landed.
839
+ expect((await store.listTags()).length).toBe(0);
840
+
841
+ closeAllStores();
842
+ });
843
+
844
+ test("scoped delete-note 404s on an out-of-scope note (no leak)", async () => {
845
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
846
+ const { writeVaultConfig } = await import("./config.ts");
847
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
848
+
849
+ const vaultName = `tagscope-del-${Date.now()}`;
850
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
851
+ const store = getVaultStore(vaultName);
852
+ const w = await store.createNote("w", { tags: ["work"] });
853
+
854
+ const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
855
+ const del = tools.find((t) => t.name === "delete-note")!;
856
+ const result = await del.execute({ id: w.id }) as any;
857
+ expect(result.error).toBe("Note not found");
858
+ // Untouched.
859
+ expect(await store.getNote(w.id)).toBeTruthy();
860
+
861
+ closeAllStores();
862
+ });
863
+
864
+ test("scoped update-tag/delete-tag refuse to touch out-of-scope tags", async () => {
865
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
866
+ const { writeVaultConfig } = await import("./config.ts");
867
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
868
+
869
+ const vaultName = `tagscope-tagop-${Date.now()}`;
870
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
871
+ const store = getVaultStore(vaultName);
872
+ await store.createNote("w", { tags: ["work"] });
873
+
874
+ const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
875
+ const update = tools.find((t) => t.name === "update-tag")!;
876
+ const del = tools.find((t) => t.name === "delete-tag")!;
877
+
878
+ const updateRes = await update.execute({ tag: "work", description: "denied" }) as any;
879
+ expect(updateRes.error).toBe("Forbidden");
880
+ expect(updateRes.error_type).toBe("tag_scope_violation");
881
+
882
+ const delRes = await del.execute({ tag: "work" }) as any;
883
+ expect(delRes.error).toBe("Forbidden");
884
+
885
+ // The `work` tag is still attached to its note.
886
+ expect((await store.listTags()).find((t) => t.name === "work")).toBeTruthy();
887
+
888
+ closeAllStores();
889
+ });
890
+
891
+ // -- Q6: orphan sub-tag fail-open via string-form root ------------------
892
+
893
+ test("scoped query-notes sees orphan sub-tag via string-form root (no schema)", async () => {
894
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
895
+ const { writeVaultConfig } = await import("./config.ts");
896
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
897
+
898
+ const vaultName = `tagscope-orphan-${Date.now()}`;
899
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
900
+ const store = getVaultStore(vaultName);
901
+ // No `_tags/health/food` schema is created — the hierarchy is implicit.
902
+ const orphan = await store.createNote("orphan", { tags: ["health/food"] });
903
+
904
+ const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
905
+ const query = tools.find((t) => t.name === "query-notes")!;
906
+
907
+ const res = await query.execute({ id: orphan.id }) as any;
908
+ // String-form fallback: `health/food` → root `health` → in allowlist.
909
+ expect(res.error).toBeUndefined();
910
+ expect(res.id).toBe(orphan.id);
911
+
912
+ closeAllStores();
913
+ });
914
+
915
+ test("scoped create-note allows orphan sub-tag via string-form root", async () => {
916
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
917
+ const { writeVaultConfig } = await import("./config.ts");
918
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
919
+
920
+ const vaultName = `tagscope-orphan-write-${Date.now()}`;
921
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
922
+ getVaultStore(vaultName);
923
+
924
+ const tools = generateScopedMcpTools(vaultName, authForTags(["health"]) as any);
925
+ const create = tools.find((t) => t.name === "create-note")!;
926
+
927
+ const res = await create.execute({ content: "ok", tags: ["health/food"] }) as any;
928
+ expect(res.error).toBeUndefined();
929
+ expect(res.id).toBeDefined();
930
+
931
+ closeAllStores();
932
+ });
933
+
934
+ // -- Q5: MCP delete-tag dependency check -------------------------------
935
+
936
+ test("MCP delete-tag returns tag_in_use_by_tokens when a tag-scoped token references the tag", async () => {
937
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
938
+ const { writeVaultConfig } = await import("./config.ts");
939
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
940
+ const { generateToken, createToken } = await import("./token-store.ts");
941
+
942
+ const vaultName = `tagscope-dep-${Date.now()}`;
943
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
944
+ const store = getVaultStore(vaultName);
945
+ await store.createNote("h", { tags: ["health"] });
946
+
947
+ // Mint a tag-scoped token that references "health".
948
+ const { fullToken } = generateToken();
949
+ createToken(store.db, fullToken, {
950
+ label: "health-claw",
951
+ permission: "read",
952
+ scopes: ["vault:read"],
953
+ scoped_tags: ["health"],
954
+ });
955
+
956
+ // Unscoped admin attempts to delete `health` via MCP — should 409.
957
+ const tools = generateScopedMcpTools(vaultName);
958
+ const del = tools.find((t) => t.name === "delete-tag")!;
959
+ const res = await del.execute({ tag: "health" }) as any;
960
+ expect(res.error).toBe("TagInUseByTokens");
961
+ expect(res.error_type).toBe("tag_in_use_by_tokens");
962
+ expect(res.tag).toBe("health");
963
+ expect(res.referenced_by?.length).toBe(1);
964
+ expect(res.referenced_by?.[0]?.label).toBe("health-claw");
965
+
966
+ // Tag is still attached to its note.
967
+ expect((await store.listTags()).find((t) => t.name === "health")).toBeTruthy();
968
+
969
+ closeAllStores();
970
+ });
971
+
972
+ test("MCP delete-tag proceeds when no token references the tag", async () => {
973
+ const { generateScopedMcpTools } = await import("./mcp-tools.ts");
974
+ const { writeVaultConfig } = await import("./config.ts");
975
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
976
+
977
+ const vaultName = `tagscope-nodep-${Date.now()}`;
978
+ writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
979
+ const store = getVaultStore(vaultName);
980
+ await store.createNote("h", { tags: ["health"] });
981
+
982
+ const tools = generateScopedMcpTools(vaultName);
983
+ const del = tools.find((t) => t.name === "delete-tag")!;
984
+ const res = await del.execute({ tag: "health" }) as any;
985
+ expect(res.error).toBeUndefined();
986
+
987
+ closeAllStores();
988
+ });
989
+
714
990
  test("update-note tags.add auto-populate does not bump updatedAt", async () => {
715
991
  const { generateScopedMcpTools } = await import("./mcp-tools.ts");
716
992
  const { writeVaultConfig } = await import("./config.ts");
@@ -1145,6 +1421,152 @@ describe("HTTP /notes", async () => {
1145
1421
  expect(res.status).toBe(405);
1146
1422
  });
1147
1423
  });
1424
+
1425
+ // -------------------------------------------------------------------------
1426
+ // Empty-note guard + batch cap (#213) — runaway-client protection
1427
+ // -------------------------------------------------------------------------
1428
+
1429
+ describe("empty-note guard (#213)", async () => {
1430
+ test("POST bare {} body → 400 EmptyNoteError", async () => {
1431
+ const res = await handleNotes(mkReq("POST", "/notes", {}), store, "");
1432
+ expect(res.status).toBe(400);
1433
+ const body = await res.json() as any;
1434
+ expect(body.error_type).toBe("empty_note");
1435
+ expect(body.error).toBe("EmptyNoteError");
1436
+ });
1437
+
1438
+ test("POST batch with one empty entry → 400 EmptyNoteError, NOTHING created (atomic)", async () => {
1439
+ // Pre-validate the batch before any DB writes so a mixed batch with one
1440
+ // bad entry rolls back the whole call. The runaway-client signature
1441
+ // (#213) is "thousands of empties" — partial-create semantics would
1442
+ // still leak the prefix on every burst. Atomic is the only safe shape.
1443
+ const beforeCount = (await store.queryNotes({ path: "ok-1" })).length;
1444
+ const res = await handleNotes(
1445
+ mkReq("POST", "/notes", { notes: [{ path: "ok-1" }, {}] }),
1446
+ store,
1447
+ "",
1448
+ );
1449
+ expect(res.status).toBe(400);
1450
+ const body = await res.json() as any;
1451
+ expect(body.error_type).toBe("empty_note");
1452
+ expect(body.item_index).toBe(1);
1453
+ // ok-1 must NOT have been created — atomic rollback.
1454
+ const afterCount = (await store.queryNotes({ path: "ok-1" })).length;
1455
+ expect(afterCount).toBe(beforeCount);
1456
+ });
1457
+
1458
+ test("POST single content-only (path absent) → 201", async () => {
1459
+ const res = await handleNotes(
1460
+ mkReq("POST", "/notes", { content: "un-pathed jot" }),
1461
+ store,
1462
+ "",
1463
+ );
1464
+ expect(res.status).toBe(201);
1465
+ });
1466
+
1467
+ test("POST single path-only (content absent) → 201, no warning log", async () => {
1468
+ // Path-only is a wikilink placeholder / `_schemas/*` shape — must
1469
+ // remain accepted (per #223 design Q3).
1470
+ const res = await handleNotes(
1471
+ mkReq("POST", "/notes", { path: "wiki/placeholder" }),
1472
+ store,
1473
+ "",
1474
+ );
1475
+ expect(res.status).toBe(201);
1476
+ });
1477
+
1478
+ test("PATCH that would clear both content and path → 400 EmptyNoteError", async () => {
1479
+ const note = await store.createNote("starts with content", { id: "ep1" });
1480
+ const updated = await store.getNote("ep1");
1481
+ const res = await handleNotes(
1482
+ mkReq("PATCH", "/notes/ep1", {
1483
+ content: "",
1484
+ path: "",
1485
+ if_updated_at: updated!.updatedAt,
1486
+ }),
1487
+ store,
1488
+ "/ep1",
1489
+ );
1490
+ expect(res.status).toBe(400);
1491
+ const body = await res.json() as any;
1492
+ expect(body.error_type).toBe("empty_note");
1493
+ expect(body.note_id).toBe("ep1");
1494
+ });
1495
+
1496
+ test("PATCH that clears content but preserves path → 200", async () => {
1497
+ const note = await store.createNote("body", { id: "ep2", path: "p2" });
1498
+ const updated = await store.getNote("ep2");
1499
+ const res = await handleNotes(
1500
+ mkReq("PATCH", "/notes/ep2", {
1501
+ content: "",
1502
+ if_updated_at: updated!.updatedAt,
1503
+ }),
1504
+ store,
1505
+ "/ep2",
1506
+ );
1507
+ expect(res.status).toBe(200);
1508
+ });
1509
+ });
1510
+
1511
+ describe("batch atomicity (#236)", async () => {
1512
+ test("POST batch where mid-item triggers PATH_CONFLICT → 409, NOTHING created", async () => {
1513
+ // The empty-note pre-walk catches `{}` before any DB write (#213); a
1514
+ // path-conflict only surfaces on the actual INSERT, mid-loop. Without
1515
+ // the BEGIN/COMMIT wrap the prefix would have already landed by then.
1516
+ await store.createNote("existing", { path: "taken" });
1517
+ const beforeIds = (await store.queryNotes({})).map((n) => n.id).sort();
1518
+
1519
+ const res = await handleNotes(
1520
+ mkReq("POST", "/notes", {
1521
+ notes: [
1522
+ { content: "ok-1", path: "fresh-1" },
1523
+ { content: "ok-2", path: "fresh-2" },
1524
+ { content: "boom", path: "taken" },
1525
+ { content: "ok-3", path: "fresh-3" },
1526
+ ],
1527
+ }),
1528
+ store,
1529
+ "",
1530
+ );
1531
+ expect(res.status).toBe(409);
1532
+ const body = await res.json() as any;
1533
+ expect(body.error_type).toBe("path_conflict");
1534
+
1535
+ // The two prefix items must NOT have been created — atomic rollback.
1536
+ const afterIds = (await store.queryNotes({})).map((n) => n.id).sort();
1537
+ expect(afterIds).toEqual(beforeIds);
1538
+ expect(await store.queryNotes({ path: "fresh-1" })).toHaveLength(0);
1539
+ expect(await store.queryNotes({ path: "fresh-2" })).toHaveLength(0);
1540
+ });
1541
+ });
1542
+
1543
+ describe("batch cap (#213)", async () => {
1544
+ test("POST with 501-item batch → 413 BatchTooLarge", async () => {
1545
+ const oversized = Array.from({ length: 501 }, (_, i) => ({ content: `n${i}` }));
1546
+ const res = await handleNotes(
1547
+ mkReq("POST", "/notes", { notes: oversized }),
1548
+ store,
1549
+ "",
1550
+ );
1551
+ expect(res.status).toBe(413);
1552
+ const body = await res.json() as any;
1553
+ expect(body.error_type).toBe("batch_too_large");
1554
+ expect(body.error).toBe("BatchTooLarge");
1555
+ expect(body.limit).toBe(500);
1556
+ });
1557
+
1558
+ test("POST with exactly 500-item batch → 201 (boundary)", async () => {
1559
+ const exactly500 = Array.from({ length: 500 }, (_, i) => ({ content: `n${i}` }));
1560
+ const res = await handleNotes(
1561
+ mkReq("POST", "/notes", { notes: exactly500 }),
1562
+ store,
1563
+ "",
1564
+ );
1565
+ expect(res.status).toBe(201);
1566
+ const body = await res.json() as any[];
1567
+ expect(body).toHaveLength(500);
1568
+ });
1569
+ });
1148
1570
  });
1149
1571
 
1150
1572
  describe("HTTP GET /notes?format=graph", async () => {
@@ -1352,6 +1774,98 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1352
1774
  expect((await store.getNote("x"))!.content).toBe("first");
1353
1775
  });
1354
1776
 
1777
+ test("PATCH append without precondition succeeds (no-conflict-by-design)", async () => {
1778
+ await store.createNote("seed:", { id: "x" });
1779
+
1780
+ const res = await handleNotes(
1781
+ mkReq("PATCH", "/notes/x", { append: " A" }),
1782
+ store,
1783
+ "/x",
1784
+ );
1785
+ expect(res.status).toBe(200);
1786
+ expect((await store.getNote("x"))!.content).toBe("seed: A");
1787
+ });
1788
+
1789
+ test("PATCH append + tags without precondition is rejected (#201)", async () => {
1790
+ // The append-only exemption is justified by SQL-atomic concat. Tag
1791
+ // mutations don't share that property — they're idempotent, but the
1792
+ // caller should still observe the prior state before re-asserting.
1793
+ await store.createNote("seed:", { id: "x", path: "Inbox/x" });
1794
+
1795
+ const res = await handleNotes(
1796
+ mkReq("PATCH", "/notes/x", { append: " A", tags: { add: ["important"] } }),
1797
+ store,
1798
+ "/x",
1799
+ );
1800
+ expect(res.status).toBe(428);
1801
+ const body = await res.json() as any;
1802
+ expect(body.error_type).toBe("precondition_required");
1803
+ // Unchanged on rejection.
1804
+ expect((await store.getNote("x"))!.content).toBe("seed:");
1805
+ });
1806
+
1807
+ test("PATCH content_edit replaces a single occurrence", async () => {
1808
+ const note = await store.createNote("hello world", { id: "x" });
1809
+
1810
+ const res = await handleNotes(
1811
+ mkReq("PATCH", "/notes/x", {
1812
+ content_edit: { old_text: "hello", new_text: "hi" },
1813
+ if_updated_at: note.updatedAt,
1814
+ }),
1815
+ store,
1816
+ "/x",
1817
+ );
1818
+ expect(res.status).toBe(200);
1819
+ expect((await store.getNote("x"))!.content).toBe("hi world");
1820
+ });
1821
+
1822
+ test("PATCH content_edit returns 422 when old_text is not found (#202)", async () => {
1823
+ // 404 misleadingly read as "note doesn't exist"; 422 says "request is
1824
+ // valid, but old_text doesn't apply to the current content."
1825
+ const note = await store.createNote("hello world", { id: "x" });
1826
+
1827
+ const res = await handleNotes(
1828
+ mkReq("PATCH", "/notes/x", {
1829
+ content_edit: { old_text: "missing", new_text: "x" },
1830
+ if_updated_at: note.updatedAt,
1831
+ }),
1832
+ store,
1833
+ "/x",
1834
+ );
1835
+ expect(res.status).toBe(422);
1836
+ const body = await res.json() as any;
1837
+ expect(body.error).toBe("unprocessable_content");
1838
+ expect((await store.getNote("x"))!.content).toBe("hello world");
1839
+ });
1840
+
1841
+ test("PATCH content_edit returns 409 on multiple matches", async () => {
1842
+ const note = await store.createNote("hi hi", { id: "x" });
1843
+
1844
+ const res = await handleNotes(
1845
+ mkReq("PATCH", "/notes/x", {
1846
+ content_edit: { old_text: "hi", new_text: "hello" },
1847
+ if_updated_at: note.updatedAt,
1848
+ }),
1849
+ store,
1850
+ "/x",
1851
+ );
1852
+ expect(res.status).toBe(409);
1853
+ expect((await store.getNote("x"))!.content).toBe("hi hi");
1854
+ });
1855
+
1856
+ test("PATCH rejects content + append combination with 400", async () => {
1857
+ await store.createNote("seed", { id: "x" });
1858
+
1859
+ const res = await handleNotes(
1860
+ mkReq("PATCH", "/notes/x", { content: "new", append: "more", force: true }),
1861
+ store,
1862
+ "/x",
1863
+ );
1864
+ expect(res.status).toBe(400);
1865
+ const body = await res.json() as any;
1866
+ expect(body.error).toBe("mutually_exclusive");
1867
+ });
1868
+
1355
1869
  test("DELETE resolves note by path", async () => {
1356
1870
  await store.createNote("x", { path: "Temp/note" });
1357
1871
  const res = await handleNotes(
@@ -1363,6 +1877,47 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
1363
1877
  expect(body.deleted).toBe(true);
1364
1878
  expect(await store.getNoteByPath("Temp/note")).toBeNull();
1365
1879
  });
1880
+
1881
+ test("POST /notes returns 409 path_conflict when path already exists (#126)", async () => {
1882
+ await store.createNote("first", { path: "Inbox/note" });
1883
+ const res = await handleNotes(
1884
+ mkReq("POST", "/notes", { content: "second", path: "Inbox/note" }),
1885
+ store,
1886
+ "",
1887
+ );
1888
+ expect(res.status).toBe(409);
1889
+ const body = await res.json() as any;
1890
+ expect(body.error_type).toBe("path_conflict");
1891
+ expect(body.error).toBe("path_conflict");
1892
+ expect(body.path).toBe("Inbox/note");
1893
+ });
1894
+
1895
+ test("POST /notes path_conflict — second note never lands in DB (#126)", async () => {
1896
+ await store.createNote("first", { path: "Inbox/note" });
1897
+ const before = (await store.queryNotes({})).length;
1898
+ await handleNotes(
1899
+ mkReq("POST", "/notes", { content: "second", path: "Inbox/note" }),
1900
+ store,
1901
+ "",
1902
+ );
1903
+ expect((await store.queryNotes({})).length).toBe(before);
1904
+ });
1905
+
1906
+ test("PATCH /notes returns 409 path_conflict when renaming onto existing path (#126)", async () => {
1907
+ const a = await store.createNote("first", { id: "a", path: "alpha" });
1908
+ await store.createNote("second", { id: "b", path: "beta" });
1909
+ const res = await handleNotes(
1910
+ mkReq("PATCH", "/notes/a", { path: "beta", if_updated_at: a.createdAt }),
1911
+ store,
1912
+ "/a",
1913
+ );
1914
+ expect(res.status).toBe(409);
1915
+ const body = await res.json() as any;
1916
+ expect(body.error_type).toBe("path_conflict");
1917
+ expect(body.path).toBe("beta");
1918
+ // Source note unchanged
1919
+ expect((await store.getNote("a"))!.path).toBe("alpha");
1920
+ });
1366
1921
  });
1367
1922
 
1368
1923
  describe("HTTP /tags", async () => {
@@ -1397,6 +1952,21 @@ describe("HTTP /tags", async () => {
1397
1952
  expect(body.description).toBe("A person");
1398
1953
  });
1399
1954
 
1955
+ test("PUT /tags/:name returns 400 with error_type: invalid_relationships on bad shape", async () => {
1956
+ const res = await handleTags(
1957
+ mkReq("PUT", "/tags/person", {
1958
+ relationships: { mentions: { target_tag: "topic", cardinality: "infinite" } },
1959
+ }),
1960
+ store,
1961
+ "/person",
1962
+ );
1963
+ expect(res.status).toBe(400);
1964
+ const body = await res.json() as any;
1965
+ expect(body.error_type).toBe("invalid_relationships");
1966
+ expect(typeof body.error).toBe("string");
1967
+ expect(body.error.length).toBeGreaterThan(0);
1968
+ });
1969
+
1400
1970
  test("DELETE /tags/:name removes tag and schema", async () => {
1401
1971
  await store.createNote("A", { tags: ["doomed"] });
1402
1972
  await store.upsertTagSchema("doomed", { description: "will be deleted" });
@@ -1516,6 +2086,294 @@ describe("HTTP /tags", async () => {
1516
2086
  });
1517
2087
  });
1518
2088
 
2089
+ describe("HTTP /note-schemas", async () => {
2090
+ test("PUT /note-schemas/:name creates a schema", async () => {
2091
+ const res = await handleNoteSchemas(
2092
+ mkReq("PUT", "/note-schemas/task", {
2093
+ description: "A task",
2094
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
2095
+ required: ["priority"],
2096
+ }),
2097
+ store,
2098
+ "/task",
2099
+ );
2100
+ expect(res.status).toBe(200);
2101
+ const body = await res.json() as any;
2102
+ expect(body.name).toBe("task");
2103
+ expect(body.description).toBe("A task");
2104
+ expect(body.required).toEqual(["priority"]);
2105
+ });
2106
+
2107
+ test("GET /note-schemas lists all schemas", async () => {
2108
+ await store.upsertNoteSchema("task", { description: "t" });
2109
+ await store.upsertNoteSchema("project", { description: "p" });
2110
+ const res = await handleNoteSchemas(mkReq("GET", "/note-schemas"), store);
2111
+ const body = await res.json() as any[];
2112
+ const names = body.map((s) => s.name).sort();
2113
+ expect(names).toEqual(["project", "task"]);
2114
+ });
2115
+
2116
+ test("GET /note-schemas?include_mappings=true inlines mappings per schema", async () => {
2117
+ await store.upsertNoteSchema("task", {});
2118
+ await store.setSchemaMapping("task", "tag", "task");
2119
+ const res = await handleNoteSchemas(
2120
+ mkReq("GET", "/note-schemas?include_mappings=true"),
2121
+ store,
2122
+ );
2123
+ const body = await res.json() as any[];
2124
+ const task = body.find((s) => s.name === "task");
2125
+ expect(task.mappings).toEqual([{ schema_name: "task", match_kind: "tag", match_value: "task" }]);
2126
+ });
2127
+
2128
+ test("GET /note-schemas/:name returns the schema and its mappings", async () => {
2129
+ await store.upsertNoteSchema("task", { description: "A task" });
2130
+ await store.setSchemaMapping("task", "tag", "task");
2131
+ const res = await handleNoteSchemas(mkReq("GET", "/note-schemas/task"), store, "/task");
2132
+ expect(res.status).toBe(200);
2133
+ const body = await res.json() as any;
2134
+ expect(body.name).toBe("task");
2135
+ expect(body.mappings.length).toBe(1);
2136
+ });
2137
+
2138
+ test("GET /note-schemas/:name returns 404 when missing", async () => {
2139
+ const res = await handleNoteSchemas(mkReq("GET", "/note-schemas/missing"), store, "/missing");
2140
+ expect(res.status).toBe(404);
2141
+ });
2142
+
2143
+ test("PUT /note-schemas/:name with required: [] clears required", async () => {
2144
+ await store.upsertNoteSchema("task", { required: ["a"] });
2145
+ const res = await handleNoteSchemas(
2146
+ mkReq("PUT", "/note-schemas/task", { required: [] }),
2147
+ store,
2148
+ "/task",
2149
+ );
2150
+ const body = await res.json() as any;
2151
+ expect(body.required).toBeNull();
2152
+ });
2153
+
2154
+ test("PUT /note-schemas/:name returns 400 when required isn't an array", async () => {
2155
+ const res = await handleNoteSchemas(
2156
+ mkReq("PUT", "/note-schemas/task", { required: "priority" }),
2157
+ store,
2158
+ "/task",
2159
+ );
2160
+ expect(res.status).toBe(400);
2161
+ });
2162
+
2163
+ test("DELETE /note-schemas/:name removes schema (and cascades mappings)", async () => {
2164
+ await store.upsertNoteSchema("task", {});
2165
+ await store.setSchemaMapping("task", "tag", "task");
2166
+ const res = await handleNoteSchemas(mkReq("DELETE", "/note-schemas/task"), store, "/task");
2167
+ expect(res.status).toBe(200);
2168
+ expect(await store.getNoteSchema("task")).toBeNull();
2169
+ expect(await store.listSchemaMappings({ schema_name: "task" })).toEqual([]);
2170
+ });
2171
+
2172
+ test("POST /note-schemas/:name/mappings adds a mapping", async () => {
2173
+ await store.upsertNoteSchema("task", {});
2174
+ const res = await handleNoteSchemas(
2175
+ mkReq("POST", "/note-schemas/task/mappings", { match_kind: "tag", match_value: "task" }),
2176
+ store,
2177
+ "/task/mappings",
2178
+ );
2179
+ expect(res.status).toBe(201);
2180
+ expect((await store.listSchemaMappings({ schema_name: "task" })).length).toBe(1);
2181
+ });
2182
+
2183
+ test("POST /note-schemas/:name/mappings returns 400 on bad match_kind", async () => {
2184
+ await store.upsertNoteSchema("task", {});
2185
+ const res = await handleNoteSchemas(
2186
+ mkReq("POST", "/note-schemas/task/mappings", { match_kind: "BOGUS", match_value: "x" }),
2187
+ store,
2188
+ "/task/mappings",
2189
+ );
2190
+ expect(res.status).toBe(400);
2191
+ const body = await res.json() as any;
2192
+ expect(body.error_type).toBe("invalid_match_kind");
2193
+ });
2194
+
2195
+ test("POST /note-schemas/:name/mappings returns 404 when schema doesn't exist", async () => {
2196
+ const res = await handleNoteSchemas(
2197
+ mkReq("POST", "/note-schemas/missing/mappings", { match_kind: "tag", match_value: "x" }),
2198
+ store,
2199
+ "/missing/mappings",
2200
+ );
2201
+ expect(res.status).toBe(404);
2202
+ });
2203
+
2204
+ test("GET /note-schemas/:name/mappings lists mappings for a schema", async () => {
2205
+ await store.upsertNoteSchema("task", {});
2206
+ await store.setSchemaMapping("task", "tag", "task");
2207
+ await store.setSchemaMapping("task", "path_prefix", "Tasks/");
2208
+ const res = await handleNoteSchemas(
2209
+ mkReq("GET", "/note-schemas/task/mappings"),
2210
+ store,
2211
+ "/task/mappings",
2212
+ );
2213
+ const body = await res.json() as any[];
2214
+ expect(body.length).toBe(2);
2215
+ });
2216
+
2217
+ test("DELETE /note-schemas/:name/mappings?match_kind=...&match_value=... removes one", async () => {
2218
+ await store.upsertNoteSchema("task", {});
2219
+ await store.setSchemaMapping("task", "tag", "task");
2220
+ await store.setSchemaMapping("task", "path_prefix", "Tasks/");
2221
+ const res = await handleNoteSchemas(
2222
+ mkReq("DELETE", "/note-schemas/task/mappings?match_kind=tag&match_value=task"),
2223
+ store,
2224
+ "/task/mappings",
2225
+ );
2226
+ expect(res.status).toBe(200);
2227
+ const body = await res.json() as any;
2228
+ expect(body.deleted).toBe(true);
2229
+ const remaining = await store.listSchemaMappings({ schema_name: "task" });
2230
+ expect(remaining).toEqual([{ schema_name: "task", match_kind: "path_prefix", match_value: "Tasks/" }]);
2231
+ });
2232
+
2233
+ test("DELETE /note-schemas/:name/mappings handles slash-containing path prefixes via query string", async () => {
2234
+ await store.upsertNoteSchema("journal", {});
2235
+ await store.setSchemaMapping("journal", "path_prefix", "journal/2026/");
2236
+ const res = await handleNoteSchemas(
2237
+ mkReq(
2238
+ "DELETE",
2239
+ `/note-schemas/journal/mappings?match_kind=path_prefix&match_value=${encodeURIComponent("journal/2026/")}`,
2240
+ ),
2241
+ store,
2242
+ "/journal/mappings",
2243
+ );
2244
+ expect(res.status).toBe(200);
2245
+ const body = await res.json() as any;
2246
+ expect(body.deleted).toBe(true);
2247
+ });
2248
+
2249
+ // Tag-scoped tokens enumerate `schema_mappings` through the same handler.
2250
+ // Without these gates, a token allowlisted for `health` can both see and
2251
+ // create `tag` mappings for tags outside its scope (e.g. `finance`). The
2252
+ // path_prefix kind carries no tag-axis info and stays visible/writable.
2253
+ describe("tag-scope", async () => {
2254
+ const healthScope = { allowed: new Set(["health"]), raw: ["health"] };
2255
+
2256
+ test("GET /note-schemas?include_mappings filters out-of-scope tag mappings", async () => {
2257
+ await store.upsertNoteSchema("task", {});
2258
+ await store.setSchemaMapping("task", "tag", "health");
2259
+ await store.setSchemaMapping("task", "tag", "finance");
2260
+ await store.setSchemaMapping("task", "path_prefix", "Tasks/");
2261
+ const res = await handleNoteSchemas(
2262
+ mkReq("GET", "/note-schemas?include_mappings=true"),
2263
+ store,
2264
+ "",
2265
+ healthScope,
2266
+ );
2267
+ const body = await res.json() as any[];
2268
+ const task = body.find((s) => s.name === "task");
2269
+ const kinds = task.mappings.map((m: any) => `${m.match_kind}:${m.match_value}`).sort();
2270
+ expect(kinds).toEqual(["path_prefix:Tasks/", "tag:health"]);
2271
+ });
2272
+
2273
+ test("GET /note-schemas/:name filters out-of-scope tag mappings", async () => {
2274
+ await store.upsertNoteSchema("task", {});
2275
+ await store.setSchemaMapping("task", "tag", "health");
2276
+ await store.setSchemaMapping("task", "tag", "finance");
2277
+ const res = await handleNoteSchemas(
2278
+ mkReq("GET", "/note-schemas/task"),
2279
+ store,
2280
+ "/task",
2281
+ healthScope,
2282
+ );
2283
+ const body = await res.json() as any;
2284
+ expect(body.mappings.map((m: any) => m.match_value)).toEqual(["health"]);
2285
+ });
2286
+
2287
+ test("GET /note-schemas/:name/mappings filters out-of-scope tag mappings", async () => {
2288
+ await store.upsertNoteSchema("task", {});
2289
+ await store.setSchemaMapping("task", "tag", "health");
2290
+ await store.setSchemaMapping("task", "tag", "finance");
2291
+ const res = await handleNoteSchemas(
2292
+ mkReq("GET", "/note-schemas/task/mappings"),
2293
+ store,
2294
+ "/task/mappings",
2295
+ healthScope,
2296
+ );
2297
+ const body = await res.json() as any[];
2298
+ expect(body.map((m) => m.match_value)).toEqual(["health"]);
2299
+ });
2300
+
2301
+ test("POST /:name/mappings rejects out-of-scope tag with 403", async () => {
2302
+ await store.upsertNoteSchema("task", {});
2303
+ const res = await handleNoteSchemas(
2304
+ mkReq("POST", "/note-schemas/task/mappings", { match_kind: "tag", match_value: "finance" }),
2305
+ store,
2306
+ "/task/mappings",
2307
+ healthScope,
2308
+ );
2309
+ expect(res.status).toBe(403);
2310
+ const body = await res.json() as any;
2311
+ expect(body.error_type).toBe("tag_scope_violation");
2312
+ expect(await store.listSchemaMappings({ schema_name: "task" })).toEqual([]);
2313
+ });
2314
+
2315
+ test("POST /:name/mappings accepts in-scope tag and string-form fallback descendant", async () => {
2316
+ await store.upsertNoteSchema("task", {});
2317
+ const inScope = await handleNoteSchemas(
2318
+ mkReq("POST", "/note-schemas/task/mappings", { match_kind: "tag", match_value: "health" }),
2319
+ store,
2320
+ "/task/mappings",
2321
+ healthScope,
2322
+ );
2323
+ expect(inScope.status).toBe(201);
2324
+ // String-form fallback: `health/food` has root `health`, which is in
2325
+ // the raw allowlist, so it's permitted even when no `_tags/health/food`
2326
+ // schema declares the descendant relationship.
2327
+ const descendant = await handleNoteSchemas(
2328
+ mkReq("POST", "/note-schemas/task/mappings", { match_kind: "tag", match_value: "health/food" }),
2329
+ store,
2330
+ "/task/mappings",
2331
+ healthScope,
2332
+ );
2333
+ expect(descendant.status).toBe(201);
2334
+ });
2335
+
2336
+ test("POST /:name/mappings allows path_prefix regardless of tag-scope", async () => {
2337
+ await store.upsertNoteSchema("task", {});
2338
+ const res = await handleNoteSchemas(
2339
+ mkReq("POST", "/note-schemas/task/mappings", { match_kind: "path_prefix", match_value: "Tasks/" }),
2340
+ store,
2341
+ "/task/mappings",
2342
+ healthScope,
2343
+ );
2344
+ expect(res.status).toBe(201);
2345
+ });
2346
+
2347
+ test("DELETE /:name/mappings rejects out-of-scope tag with 403", async () => {
2348
+ await store.upsertNoteSchema("task", {});
2349
+ await store.setSchemaMapping("task", "tag", "finance");
2350
+ const res = await handleNoteSchemas(
2351
+ mkReq("DELETE", "/note-schemas/task/mappings?match_kind=tag&match_value=finance"),
2352
+ store,
2353
+ "/task/mappings",
2354
+ healthScope,
2355
+ );
2356
+ expect(res.status).toBe(403);
2357
+ // Mapping still present — write was denied.
2358
+ expect((await store.listSchemaMappings({ schema_name: "task" })).length).toBe(1);
2359
+ });
2360
+
2361
+ test("unscoped tokens see and write everything (regression of fast-path)", async () => {
2362
+ await store.upsertNoteSchema("task", {});
2363
+ await store.setSchemaMapping("task", "tag", "finance");
2364
+ await store.setSchemaMapping("task", "tag", "health");
2365
+ const res = await handleNoteSchemas(
2366
+ mkReq("GET", "/note-schemas/task/mappings"),
2367
+ store,
2368
+ "/task/mappings",
2369
+ // default tagScope (unscoped) — omitted parameter
2370
+ );
2371
+ const body = await res.json() as any[];
2372
+ expect(body.length).toBe(2);
2373
+ });
2374
+ });
2375
+ });
2376
+
1519
2377
  describe("HTTP /find-path", async () => {
1520
2378
  test("finds path between two notes", async () => {
1521
2379
  await store.createNote("a", { id: "a" });
@@ -1578,6 +2436,7 @@ describe("stateless MCP transport", async () => {
1578
2436
  permission: "full",
1579
2437
  scopes: ["vault:read", "vault:write", "vault:admin"],
1580
2438
  legacyDerived: false,
2439
+ scoped_tags: null,
1581
2440
  });
1582
2441
  expect(res.status).toBe(200);
1583
2442
 
@@ -1619,6 +2478,7 @@ describe("stateless MCP transport", async () => {
1619
2478
  permission: "full",
1620
2479
  scopes: ["vault:read", "vault:write", "vault:admin"],
1621
2480
  legacyDerived: false,
2481
+ scoped_tags: null,
1622
2482
  });
1623
2483
  expect(res.status).toBe(200);
1624
2484
 
@@ -1662,6 +2522,7 @@ describe("stateless MCP transport", async () => {
1662
2522
  permission: "read",
1663
2523
  scopes: ["vault:read"],
1664
2524
  legacyDerived: false,
2525
+ scoped_tags: null,
1665
2526
  });
1666
2527
  expect(res.status).toBe(200);
1667
2528
 
@@ -1716,6 +2577,7 @@ describe("stateless MCP transport", async () => {
1716
2577
  permission: "read",
1717
2578
  scopes: ["vault:read"],
1718
2579
  legacyDerived: false,
2580
+ scoped_tags: null,
1719
2581
  });
1720
2582
 
1721
2583
  expect(res.status).toBe(200);
@@ -1767,6 +2629,7 @@ describe("stateless MCP transport", async () => {
1767
2629
  permission: "full",
1768
2630
  scopes: ["vault:read", "vault:write"],
1769
2631
  legacyDerived: false,
2632
+ scoped_tags: null,
1770
2633
  });
1771
2634
 
1772
2635
  expect(res.status).toBe(200);
@@ -1807,6 +2670,7 @@ describe("stateless MCP transport", async () => {
1807
2670
  permission: "read",
1808
2671
  scopes: ["vault:read"],
1809
2672
  legacyDerived: false,
2673
+ scoped_tags: null,
1810
2674
  });
1811
2675
  expect(res.status).toBe(200); // JSON-RPC envelope is 200 even for tool errors
1812
2676
  const body = await res.json() as any;
@@ -1850,6 +2714,7 @@ describe("stateless MCP transport", async () => {
1850
2714
  permission: "full",
1851
2715
  scopes: ["vault:read", "vault:write", "vault:admin"],
1852
2716
  legacyDerived: false,
2717
+ scoped_tags: null,
1853
2718
  });
1854
2719
  expect(res.status).toBe(200);
1855
2720