@openparachute/vault 0.3.3 → 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.
- package/.parachute/module.json +15 -0
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/vault.test.ts
CHANGED
|
@@ -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
|
|
479
|
+
test("generates the consolidated tool set", () => {
|
|
480
480
|
const tools = generateMcpTools(store);
|
|
481
|
-
expect(tools.length).toBe(
|
|
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
|
|