@openparachute/vault 0.4.0 → 0.4.4-rc.11
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/README.md +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
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,
|
|
13
|
+
import { handleNotes, handleTags, handleFindPath, handleVault } from "./routes.ts";
|
|
14
14
|
import { extractApiKey } from "./auth.ts";
|
|
15
15
|
|
|
16
16
|
let db: Database;
|
|
@@ -478,7 +478,7 @@ describe("deeper link queries", async () => {
|
|
|
478
478
|
describe("MCP tools", async () => {
|
|
479
479
|
test("generates the consolidated tool set", () => {
|
|
480
480
|
const tools = generateMcpTools(store);
|
|
481
|
-
expect(tools.length).toBe(
|
|
481
|
+
expect(tools.length).toBe(9);
|
|
482
482
|
|
|
483
483
|
const names = tools.map((t) => t.name);
|
|
484
484
|
expect(names).toContain("query-notes");
|
|
@@ -488,15 +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");
|
|
497
491
|
expect(names).toContain("find-path");
|
|
498
|
-
expect(names).toContain("synthesize-notes");
|
|
499
492
|
expect(names).toContain("vault-info");
|
|
493
|
+
// Six note-schema MCP tools (list/update/delete-note-schema +
|
|
494
|
+
// list/set/delete-schema-mapping) retired in v17 — vault#267.
|
|
495
|
+
expect(names).not.toContain("list-note-schemas");
|
|
496
|
+
expect(names).not.toContain("set-schema-mapping");
|
|
497
|
+
// synthesize-notes retired in v17 — vault#268.
|
|
498
|
+
expect(names).not.toContain("synthesize-notes");
|
|
500
499
|
});
|
|
501
500
|
|
|
502
501
|
test("query-notes by id works", async () => {
|
|
@@ -566,6 +565,355 @@ describe("scoped MCP wrapper", async () => {
|
|
|
566
565
|
closeAllStores();
|
|
567
566
|
});
|
|
568
567
|
|
|
568
|
+
test("vault-info projection includes tags-with-schemas + indexed_fields + query_hints (vault#271)", async () => {
|
|
569
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
570
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
571
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
572
|
+
|
|
573
|
+
const vaultName = `proj-${Date.now()}`;
|
|
574
|
+
writeVaultConfig({
|
|
575
|
+
name: vaultName,
|
|
576
|
+
api_keys: [],
|
|
577
|
+
created_at: new Date().toISOString(),
|
|
578
|
+
description: "vault for #271",
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const vaultStore = getVaultStore(vaultName);
|
|
582
|
+
await vaultStore.upsertTagRecord("_default", {
|
|
583
|
+
fields: { created_by: { type: "string", description: "Origin" } },
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Indexed-field lifecycle is owned by the update-tag MCP tool — go
|
|
587
|
+
// through the tool, not the store, so indexed_fields gets populated.
|
|
588
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
589
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
590
|
+
await updateTag.execute({
|
|
591
|
+
tag: "person",
|
|
592
|
+
description: "A person",
|
|
593
|
+
fields: { email: { type: "string", indexed: true } },
|
|
594
|
+
});
|
|
595
|
+
await updateTag.execute({
|
|
596
|
+
tag: "employee",
|
|
597
|
+
description: "Employee",
|
|
598
|
+
fields: { title: { type: "string" } },
|
|
599
|
+
parent_names: ["person"],
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const vaultInfo = tools.find((t) => t.name === "vault-info")!;
|
|
603
|
+
const result = await vaultInfo.execute({}) as any;
|
|
604
|
+
|
|
605
|
+
expect(result.name).toBe(vaultName);
|
|
606
|
+
expect(result.description).toBe("vault for #271");
|
|
607
|
+
|
|
608
|
+
// tags array — only schema-bearing rows, with effective inheritance
|
|
609
|
+
const byName = Object.fromEntries(
|
|
610
|
+
(result.tags as any[]).map((t) => [t.name, t]),
|
|
611
|
+
);
|
|
612
|
+
expect(byName.person).toBeTruthy();
|
|
613
|
+
expect(byName.person.effective_parents).toEqual(["_default"]);
|
|
614
|
+
expect(Object.keys(byName.person.effective_fields).sort()).toEqual([
|
|
615
|
+
"created_by",
|
|
616
|
+
"email",
|
|
617
|
+
]);
|
|
618
|
+
expect(byName.employee.effective_parents).toEqual(["person", "_default"]);
|
|
619
|
+
expect(Object.keys(byName.employee.effective_fields).sort()).toEqual([
|
|
620
|
+
"created_by",
|
|
621
|
+
"email",
|
|
622
|
+
"title",
|
|
623
|
+
]);
|
|
624
|
+
|
|
625
|
+
// indexed_fields catalog
|
|
626
|
+
const indexed = result.indexed_fields as any[];
|
|
627
|
+
const emailEntry = indexed.find((f) => f.name === "email");
|
|
628
|
+
expect(emailEntry).toBeTruthy();
|
|
629
|
+
expect(emailEntry.type).toBe("string");
|
|
630
|
+
expect(emailEntry.tags).toEqual(["person"]);
|
|
631
|
+
|
|
632
|
+
// query_hints — static catalog, present even without include_stats
|
|
633
|
+
expect(Array.isArray(result.query_hints)).toBe(true);
|
|
634
|
+
expect((result.query_hints as string[]).length).toBeGreaterThan(0);
|
|
635
|
+
|
|
636
|
+
// stats omitted unless requested
|
|
637
|
+
expect(result.stats).toBeUndefined();
|
|
638
|
+
|
|
639
|
+
const withStats = await vaultInfo.execute({ include_stats: true }) as any;
|
|
640
|
+
expect(withStats.stats).toBeTruthy();
|
|
641
|
+
|
|
642
|
+
closeAllStores();
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("getServerInstruction renders projection markdown for a populated vault (vault#271)", async () => {
|
|
646
|
+
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
647
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
648
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
649
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
650
|
+
|
|
651
|
+
const vaultName = `instr-${Date.now()}`;
|
|
652
|
+
writeVaultConfig({
|
|
653
|
+
name: vaultName,
|
|
654
|
+
api_keys: [],
|
|
655
|
+
created_at: new Date().toISOString(),
|
|
656
|
+
description: "Working notebook for the daily team.",
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const vaultStore = getVaultStore(vaultName);
|
|
660
|
+
await vaultStore.createNote("A", { tags: ["person"] });
|
|
661
|
+
|
|
662
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
663
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
664
|
+
await updateTag.execute({
|
|
665
|
+
tag: "person",
|
|
666
|
+
description: "A person",
|
|
667
|
+
fields: { email: { type: "string", indexed: true } },
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const md = await getServerInstruction(vaultName);
|
|
671
|
+
|
|
672
|
+
expect(md).toContain(`Parachute Vault "${vaultName}"`);
|
|
673
|
+
expect(md).toContain("Working notebook for the daily team.");
|
|
674
|
+
// vault#274: stats line distinguishes total tag count (note-usage)
|
|
675
|
+
// from schema-bearing count. One note tagged `person`, one tag
|
|
676
|
+
// overall, that one tag has a schema → "1 tag total, 1 with schemas".
|
|
677
|
+
expect(md).toContain("1 note, 1 tag total, 1 with schemas");
|
|
678
|
+
expect(md).toContain("1 tag with schemas: person");
|
|
679
|
+
expect(md).toContain("Indexed metadata fields");
|
|
680
|
+
expect(md).toContain("email");
|
|
681
|
+
expect(md).toContain("#person");
|
|
682
|
+
expect(md).toContain("Querying");
|
|
683
|
+
expect(md).toContain("vault-info");
|
|
684
|
+
expect(md).toContain("list-tags { include_schema: true }");
|
|
685
|
+
|
|
686
|
+
closeAllStores();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("getServerInstruction degrades gracefully on an empty vault (vault#271)", async () => {
|
|
690
|
+
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
691
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
692
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
693
|
+
|
|
694
|
+
const vaultName = `instr-empty-${Date.now()}`;
|
|
695
|
+
writeVaultConfig({
|
|
696
|
+
name: vaultName,
|
|
697
|
+
api_keys: [],
|
|
698
|
+
created_at: new Date().toISOString(),
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const md = await getServerInstruction(vaultName);
|
|
702
|
+
|
|
703
|
+
expect(md).toContain(`Parachute Vault "${vaultName}"`);
|
|
704
|
+
// vault#274: empty vault — no schemas, so the suffix is omitted.
|
|
705
|
+
// 0 is plural per English convention. The not.toContain guard
|
|
706
|
+
// pins that "with schemas" doesn't leak through anywhere — when
|
|
707
|
+
// schemas exist it appears in two places (stats suffix + the
|
|
708
|
+
// tags-with-schemas list line); on an empty vault both branches
|
|
709
|
+
// are unreachable, so the phrase shouldn't appear at all.
|
|
710
|
+
expect(md).toContain("0 notes, 0 tags total");
|
|
711
|
+
expect(md).not.toContain("with schemas");
|
|
712
|
+
expect(md).toContain("No tag schemas declared");
|
|
713
|
+
expect(md).toContain("No indexed metadata fields");
|
|
714
|
+
// Refresh hints surface both pointers so the agent knows where to look.
|
|
715
|
+
expect(md).toContain("vault-info");
|
|
716
|
+
expect(md).toContain("list-tags");
|
|
717
|
+
|
|
718
|
+
closeAllStores();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("getServerInstruction stays under ~5K tokens at 50 tags-with-schemas (vault#271)", async () => {
|
|
722
|
+
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
723
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
724
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
725
|
+
|
|
726
|
+
const vaultName = `instr-big-${Date.now()}`;
|
|
727
|
+
writeVaultConfig({
|
|
728
|
+
name: vaultName,
|
|
729
|
+
api_keys: [],
|
|
730
|
+
created_at: new Date().toISOString(),
|
|
731
|
+
description: "Stress-test fixture for the connect-time projection size.",
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
const vaultStore = getVaultStore(vaultName);
|
|
735
|
+
for (let i = 0; i < 50; i++) {
|
|
736
|
+
await vaultStore.upsertTagRecord(`schema_tag_${i}`, {
|
|
737
|
+
description: `Description for tag ${i} — covers a meaningful chunk of the vault's domain.`,
|
|
738
|
+
fields: {
|
|
739
|
+
[`field_${i}_a`]: { type: "string" },
|
|
740
|
+
[`field_${i}_b`]: { type: "integer" },
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const md = await getServerInstruction(vaultName);
|
|
746
|
+
// Rough token approximation: 1 token ≈ 4 chars. Budget: 5K tokens.
|
|
747
|
+
const approxTokens = md.length / 4;
|
|
748
|
+
expect(approxTokens).toBeLessThan(5000);
|
|
749
|
+
|
|
750
|
+
closeAllStores();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("getServerInstruction filters projection by tag-scoped allowlist (vault#271 fold 5)", async () => {
|
|
754
|
+
const { getServerInstruction, generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
755
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
756
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
757
|
+
|
|
758
|
+
const vaultName = `instr-scoped-${Date.now()}`;
|
|
759
|
+
writeVaultConfig({
|
|
760
|
+
name: vaultName,
|
|
761
|
+
api_keys: [],
|
|
762
|
+
created_at: new Date().toISOString(),
|
|
763
|
+
description: "Scoped session brief.",
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Seed three schema-bearing tags. Cross-declarer indexed `status`
|
|
767
|
+
// exercises the same shape the JSON wrapper test pinned: scoped to
|
|
768
|
+
// `task`, the brief should mention `status` via `task` but never
|
|
769
|
+
// surface `project` or `person`.
|
|
770
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
771
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
772
|
+
await updateTag.execute({
|
|
773
|
+
tag: "task",
|
|
774
|
+
description: "A task",
|
|
775
|
+
fields: { status: { type: "string", indexed: true } },
|
|
776
|
+
});
|
|
777
|
+
await updateTag.execute({
|
|
778
|
+
tag: "project",
|
|
779
|
+
description: "A project",
|
|
780
|
+
fields: { status: { type: "string", indexed: true } },
|
|
781
|
+
});
|
|
782
|
+
await updateTag.execute({
|
|
783
|
+
tag: "person",
|
|
784
|
+
description: "A person",
|
|
785
|
+
fields: { email: { type: "string", indexed: true } },
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
const auth = {
|
|
789
|
+
permission: "full" as const,
|
|
790
|
+
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
791
|
+
legacyDerived: false,
|
|
792
|
+
scoped_tags: ["task"],
|
|
793
|
+
};
|
|
794
|
+
const md = await getServerInstruction(vaultName, auth as any);
|
|
795
|
+
|
|
796
|
+
// Allowlisted tag surfaces; out-of-scope tags do not. Use word-boundary
|
|
797
|
+
// regex — the static refresh-pointer text mentions "full projection"
|
|
798
|
+
// which contains the substring "project".
|
|
799
|
+
expect(md).toMatch(/\btask\b/);
|
|
800
|
+
expect(md).not.toMatch(/\bproject\b/);
|
|
801
|
+
expect(md).not.toMatch(/\bperson\b/);
|
|
802
|
+
|
|
803
|
+
// Indexed-field catalog: `status` survives (declared by `task`);
|
|
804
|
+
// `email` (person-only) is filtered out entirely.
|
|
805
|
+
expect(md).toMatch(/\bstatus\b/);
|
|
806
|
+
expect(md).not.toMatch(/\bemail\b/);
|
|
807
|
+
|
|
808
|
+
// Aggregate stats line still flows through unchanged — counts are
|
|
809
|
+
// pre-existing leak surface (per the rc.3 design discussion).
|
|
810
|
+
expect(md).toMatch(/\d+ note/);
|
|
811
|
+
|
|
812
|
+
closeAllStores();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("getServerInstruction passes the full projection through for unscoped tokens (vault#271 fold 5)", async () => {
|
|
816
|
+
const { getServerInstruction, generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
817
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
818
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
819
|
+
|
|
820
|
+
const vaultName = `instr-unscoped-${Date.now()}`;
|
|
821
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
822
|
+
|
|
823
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
824
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
825
|
+
await updateTag.execute({
|
|
826
|
+
tag: "task",
|
|
827
|
+
description: "A task",
|
|
828
|
+
fields: { status: { type: "string" } },
|
|
829
|
+
});
|
|
830
|
+
await updateTag.execute({
|
|
831
|
+
tag: "project",
|
|
832
|
+
description: "A project",
|
|
833
|
+
fields: { priority: { type: "integer" } },
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// No `auth` arg → no scoping applied, full projection rendered.
|
|
837
|
+
const md = await getServerInstruction(vaultName);
|
|
838
|
+
expect(md).toContain("task");
|
|
839
|
+
expect(md).toContain("project");
|
|
840
|
+
expect(md).toContain("tags with schemas");
|
|
841
|
+
// Same for explicit auth with `scoped_tags: null`.
|
|
842
|
+
const mdNullScoped = await getServerInstruction(vaultName, {
|
|
843
|
+
permission: "full",
|
|
844
|
+
scopes: ["vault:read"],
|
|
845
|
+
legacyDerived: false,
|
|
846
|
+
scoped_tags: null,
|
|
847
|
+
} as any);
|
|
848
|
+
expect(mdNullScoped).toContain("task");
|
|
849
|
+
expect(mdNullScoped).toContain("project");
|
|
850
|
+
|
|
851
|
+
closeAllStores();
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test("MCP initialize response carries scope-filtered instructions for tag-scoped tokens (vault#271 fold 5)", async () => {
|
|
855
|
+
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
856
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
857
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
858
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
859
|
+
|
|
860
|
+
const vaultName = `init-scoped-${Date.now()}`;
|
|
861
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
862
|
+
|
|
863
|
+
// Same fixture shape as the unit test, but driven through the actual
|
|
864
|
+
// `handleScopedMcp` initialize path the MCP client invokes at session
|
|
865
|
+
// start. The reviewer asked for an integration assertion on the
|
|
866
|
+
// `instructions` field carried in the `initialize` response.
|
|
867
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
868
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
869
|
+
await updateTag.execute({
|
|
870
|
+
tag: "task",
|
|
871
|
+
description: "A task",
|
|
872
|
+
fields: { status: { type: "string" } },
|
|
873
|
+
});
|
|
874
|
+
await updateTag.execute({
|
|
875
|
+
tag: "project",
|
|
876
|
+
description: "A project",
|
|
877
|
+
fields: { priority: { type: "integer" } },
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
|
|
881
|
+
method: "POST",
|
|
882
|
+
headers: {
|
|
883
|
+
"content-type": "application/json",
|
|
884
|
+
"accept": "application/json, text/event-stream",
|
|
885
|
+
},
|
|
886
|
+
body: JSON.stringify({
|
|
887
|
+
jsonrpc: "2.0",
|
|
888
|
+
id: 1,
|
|
889
|
+
method: "initialize",
|
|
890
|
+
params: {
|
|
891
|
+
protocolVersion: "2024-11-05",
|
|
892
|
+
capabilities: {},
|
|
893
|
+
clientInfo: { name: "test", version: "1.0" },
|
|
894
|
+
},
|
|
895
|
+
}),
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
const res = await handleScopedMcp(req, vaultName, {
|
|
899
|
+
permission: "full",
|
|
900
|
+
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
901
|
+
legacyDerived: false,
|
|
902
|
+
scoped_tags: ["task"],
|
|
903
|
+
} as any);
|
|
904
|
+
expect(res.status).toBe(200);
|
|
905
|
+
|
|
906
|
+
const body = await res.json() as any;
|
|
907
|
+
const instructions: string = body.result.instructions;
|
|
908
|
+
expect(instructions).toBeTruthy();
|
|
909
|
+
// Word-boundary match — the static refresh text mentions "full
|
|
910
|
+
// projection" which would false-trigger a substring check.
|
|
911
|
+
expect(instructions).toMatch(/\btask\b/);
|
|
912
|
+
expect(instructions).not.toMatch(/\bproject\b/);
|
|
913
|
+
|
|
914
|
+
closeAllStores();
|
|
915
|
+
});
|
|
916
|
+
|
|
569
917
|
test("list-tags with schema returns per-tag detail", async () => {
|
|
570
918
|
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
571
919
|
const { writeVaultConfig } = await import("./config.ts");
|
|
@@ -798,6 +1146,93 @@ describe("scoped MCP wrapper", async () => {
|
|
|
798
1146
|
closeAllStores();
|
|
799
1147
|
});
|
|
800
1148
|
|
|
1149
|
+
test("scoped vault-info filters projection.tags + indexed_fields to the allowlist (vault#271 fold)", async () => {
|
|
1150
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1151
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1152
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1153
|
+
|
|
1154
|
+
const vaultName = `tagscope-vault-info-${Date.now()}`;
|
|
1155
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1156
|
+
|
|
1157
|
+
// Seed two schema-bearing tags. `task` and `project` BOTH declare a
|
|
1158
|
+
// shared indexed `status` field — this exercises the cross-declarer
|
|
1159
|
+
// case the reviewer called out: a token scoped to `task` should see
|
|
1160
|
+
// `status` (because task is a declarer) but the entry's `tags` array
|
|
1161
|
+
// must list only `task`, not `project`.
|
|
1162
|
+
const unscopedTools = generateScopedMcpTools(vaultName);
|
|
1163
|
+
const updateTag = unscopedTools.find((t) => t.name === "update-tag")!;
|
|
1164
|
+
await updateTag.execute({
|
|
1165
|
+
tag: "task",
|
|
1166
|
+
description: "A task",
|
|
1167
|
+
fields: { status: { type: "string", indexed: true } },
|
|
1168
|
+
});
|
|
1169
|
+
await updateTag.execute({
|
|
1170
|
+
tag: "project",
|
|
1171
|
+
description: "A project",
|
|
1172
|
+
fields: {
|
|
1173
|
+
status: { type: "string", indexed: true },
|
|
1174
|
+
priority: { type: "integer", indexed: true },
|
|
1175
|
+
},
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// Now mint scoped tools, scoped to `task` only.
|
|
1179
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["task"]) as any);
|
|
1180
|
+
const vaultInfo = tools.find((t) => t.name === "vault-info")!;
|
|
1181
|
+
const result = await vaultInfo.execute({}) as any;
|
|
1182
|
+
|
|
1183
|
+
// tags array: only `task`, not `project`.
|
|
1184
|
+
const tagNames = (result.tags as { name: string }[]).map((t) => t.name);
|
|
1185
|
+
expect(tagNames).toContain("task");
|
|
1186
|
+
expect(tagNames).not.toContain("project");
|
|
1187
|
+
|
|
1188
|
+
// indexed_fields: `status` survives (task is a declarer), `priority`
|
|
1189
|
+
// dropped entirely (only project declared it).
|
|
1190
|
+
const indexedNames = (result.indexed_fields as { name: string }[]).map((f) => f.name);
|
|
1191
|
+
expect(indexedNames).toContain("status");
|
|
1192
|
+
expect(indexedNames).not.toContain("priority");
|
|
1193
|
+
|
|
1194
|
+
// Cross-declarer attribution leak: `status` lists declarer tags. The
|
|
1195
|
+
// scoped response must show only `task`, never the out-of-scope
|
|
1196
|
+
// `project`.
|
|
1197
|
+
const status = (result.indexed_fields as { name: string; tags: string[] }[]).find(
|
|
1198
|
+
(f) => f.name === "status",
|
|
1199
|
+
)!;
|
|
1200
|
+
expect(status.tags).toEqual(["task"]);
|
|
1201
|
+
|
|
1202
|
+
// Top-level passthrough sanity: name, description, and query_hints are
|
|
1203
|
+
// not tag-scoped surfaces — they must still flow through.
|
|
1204
|
+
expect(result.name).toBe(vaultName);
|
|
1205
|
+
expect(Array.isArray(result.query_hints)).toBe(true);
|
|
1206
|
+
expect((result.query_hints as string[]).length).toBeGreaterThan(0);
|
|
1207
|
+
|
|
1208
|
+
closeAllStores();
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
test("unscoped vault-info still sees the full projection (vault#271 fold)", async () => {
|
|
1212
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1213
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1214
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1215
|
+
|
|
1216
|
+
const vaultName = `tagscope-vault-info-unscoped-${Date.now()}`;
|
|
1217
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1218
|
+
|
|
1219
|
+
const tools0 = generateScopedMcpTools(vaultName);
|
|
1220
|
+
const updateTag = tools0.find((t) => t.name === "update-tag")!;
|
|
1221
|
+
await updateTag.execute({ tag: "task", description: "T", fields: { status: { type: "string", indexed: true } } });
|
|
1222
|
+
await updateTag.execute({ tag: "project", description: "P", fields: { status: { type: "string", indexed: true } } });
|
|
1223
|
+
|
|
1224
|
+
// No `auth` and no `scoped_tags` — unscoped path must remain
|
|
1225
|
+
// identical to pre-fold behavior (full projection).
|
|
1226
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
1227
|
+
const result = await tools.find((t) => t.name === "vault-info")!.execute({}) as any;
|
|
1228
|
+
const tagNames = (result.tags as { name: string }[]).map((t) => t.name);
|
|
1229
|
+
expect(tagNames.sort()).toEqual(["project", "task"]);
|
|
1230
|
+
const status = (result.indexed_fields as { name: string; tags: string[] }[]).find((f) => f.name === "status")!;
|
|
1231
|
+
expect(status.tags.sort()).toEqual(["project", "task"]);
|
|
1232
|
+
|
|
1233
|
+
closeAllStores();
|
|
1234
|
+
});
|
|
1235
|
+
|
|
801
1236
|
test("scoped create-note rejects a note whose tags fall outside the allowlist", async () => {
|
|
802
1237
|
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
803
1238
|
const { writeVaultConfig } = await import("./config.ts");
|
|
@@ -1115,6 +1550,31 @@ describe("HTTP /notes", async () => {
|
|
|
1115
1550
|
expect(body.map((n) => n.content)).toEqual(["plain"]);
|
|
1116
1551
|
});
|
|
1117
1552
|
|
|
1553
|
+
// ---- updated_at filter via date_field (vault#285 friction point 1.5) ----
|
|
1554
|
+
//
|
|
1555
|
+
// HTTP plumbing routes `date_field=updated_at&date_from=…` straight to
|
|
1556
|
+
// the core `dateFilter` resolver, which now recognizes `updated_at` as
|
|
1557
|
+
// a real column. Smoke-tests the end-to-end HTTP path; the engine-side
|
|
1558
|
+
// semantics are exercised in core.test.ts.
|
|
1559
|
+
test("GET /notes?date_field=updated_at filters by last-write time", async () => {
|
|
1560
|
+
const a = await store.createNote("untouched", { id: "ua", path: "ua" });
|
|
1561
|
+
const b = await store.createNote("modified", { id: "ub", path: "ub" });
|
|
1562
|
+
// Bump b's updated_at into the test window, leave a's at its createdAt.
|
|
1563
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1564
|
+
.run("2026-01-15T00:00:00.000Z", a.id);
|
|
1565
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1566
|
+
.run("2026-04-25T00:00:00.000Z", b.id);
|
|
1567
|
+
|
|
1568
|
+
const res = await handleNotes(
|
|
1569
|
+
mkReq("GET", "/notes?date_field=updated_at&date_from=2026-04-01&include_content=true"),
|
|
1570
|
+
store,
|
|
1571
|
+
"",
|
|
1572
|
+
);
|
|
1573
|
+
expect(res.status).toBe(200);
|
|
1574
|
+
const body = await res.json() as any[];
|
|
1575
|
+
expect(body.map((n) => n.content)).toEqual(["modified"]);
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1118
1578
|
test("GET /notes?has_links=false returns only orphaned notes", async () => {
|
|
1119
1579
|
const a = await store.createNote("src", { id: "qa" });
|
|
1120
1580
|
const b = await store.createNote("tgt", { id: "qb" });
|
|
@@ -1567,46 +2027,418 @@ describe("HTTP /notes", async () => {
|
|
|
1567
2027
|
expect(body).toHaveLength(500);
|
|
1568
2028
|
});
|
|
1569
2029
|
});
|
|
1570
|
-
});
|
|
1571
2030
|
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
2031
|
+
// ---- Bracket-style metadata filter (vault#285 friction point 1.3) ----
|
|
2032
|
+
//
|
|
2033
|
+
// Exposes vault's engine-level metadata-value filtering to HTTP REST
|
|
2034
|
+
// callers (today: only MCP). Uses `meta[field][op]=value` (Stripe /
|
|
2035
|
+
// JSON:API / Strapi convention). The HTTP layer translates to the engine's
|
|
2036
|
+
// existing `metadata` filter; engine semantics + gates are unchanged.
|
|
2037
|
+
// Bridges `created_at` / `updated_at` through `dateFilter`.
|
|
2038
|
+
describe("bracket-style metadata filter", () => {
|
|
2039
|
+
async function declareIndexed() {
|
|
2040
|
+
const { declareField } = await import("../core/src/indexed-fields.ts");
|
|
2041
|
+
declareField(db, "priority", "INTEGER", "project");
|
|
2042
|
+
declareField(db, "status", "TEXT", "project");
|
|
2043
|
+
}
|
|
1579
2044
|
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
"",
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
expect(body.edges).toContainEqual({ source: "a", target: "c", relationship: "works-on" });
|
|
1595
|
-
});
|
|
2045
|
+
test("shorthand `?meta[field]=value` does exact equality (JSON scan, no indexed gate)", async () => {
|
|
2046
|
+
// Deliberately NOT calling declareIndexed — shorthand should work on
|
|
2047
|
+
// any field via the json_extract fallback at core/src/notes.ts:504-507.
|
|
2048
|
+
await store.createNote("matches", { metadata: { kind: "draft" } });
|
|
2049
|
+
await store.createNote("other", { metadata: { kind: "final" } });
|
|
2050
|
+
const res = await handleNotes(
|
|
2051
|
+
mkReq("GET", "/notes?meta[kind]=draft&include_content=true"),
|
|
2052
|
+
store,
|
|
2053
|
+
"",
|
|
2054
|
+
);
|
|
2055
|
+
expect(res.status).toBe(200);
|
|
2056
|
+
const body = await res.json() as any[];
|
|
2057
|
+
expect(body.map((n) => n.content)).toEqual(["matches"]);
|
|
2058
|
+
});
|
|
1596
2059
|
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2060
|
+
test("eq operator on indexed field", async () => {
|
|
2061
|
+
await declareIndexed();
|
|
2062
|
+
await store.createNote("p5", { metadata: { priority: 5 } });
|
|
2063
|
+
await store.createNote("p1", { metadata: { priority: 1 } });
|
|
2064
|
+
const res = await handleNotes(
|
|
2065
|
+
mkReq("GET", "/notes?meta[priority][eq]=5&include_content=true"),
|
|
2066
|
+
store,
|
|
2067
|
+
"",
|
|
2068
|
+
);
|
|
2069
|
+
const body = await res.json() as any[];
|
|
2070
|
+
expect(body.map((n) => n.content)).toEqual(["p5"]);
|
|
2071
|
+
});
|
|
1601
2072
|
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
store,
|
|
1605
|
-
"",
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
2073
|
+
test("ne operator returns non-matching rows plus rows without the field", async () => {
|
|
2074
|
+
await declareIndexed();
|
|
2075
|
+
await store.createNote("has-1", { metadata: { priority: 1 } });
|
|
2076
|
+
await store.createNote("has-2", { metadata: { priority: 2 } });
|
|
2077
|
+
await store.createNote("missing");
|
|
2078
|
+
const res = await handleNotes(
|
|
2079
|
+
mkReq("GET", "/notes?meta[priority][ne]=1&include_content=true"),
|
|
2080
|
+
store,
|
|
2081
|
+
"",
|
|
2082
|
+
);
|
|
2083
|
+
const body = await res.json() as any[];
|
|
2084
|
+
expect(body.map((n) => n.content).sort()).toEqual(["has-2", "missing"]);
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
test("gt / gte / lt / lte compose into a range query on one field", async () => {
|
|
2088
|
+
await declareIndexed();
|
|
2089
|
+
for (const p of [1, 2, 3, 4, 5]) {
|
|
2090
|
+
await store.createNote(`p${p}`, { metadata: { priority: p } });
|
|
2091
|
+
}
|
|
2092
|
+
const res = await handleNotes(
|
|
2093
|
+
mkReq("GET", "/notes?meta[priority][gte]=2&meta[priority][lt]=5&include_content=true"),
|
|
2094
|
+
store,
|
|
2095
|
+
"",
|
|
2096
|
+
);
|
|
2097
|
+
const body = await res.json() as any[];
|
|
2098
|
+
expect(body.map((n) => n.content).sort()).toEqual(["p2", "p3", "p4"]);
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
test("in array form: `?meta[field][in][]=v1&meta[field][in][]=v2`", async () => {
|
|
2102
|
+
await declareIndexed();
|
|
2103
|
+
await store.createNote("a", { metadata: { status: "active" } });
|
|
2104
|
+
await store.createNote("b", { metadata: { status: "exploring" } });
|
|
2105
|
+
await store.createNote("c", { metadata: { status: "done" } });
|
|
2106
|
+
const res = await handleNotes(
|
|
2107
|
+
mkReq("GET", "/notes?meta[status][in][]=active&meta[status][in][]=exploring&include_content=true"),
|
|
2108
|
+
store,
|
|
2109
|
+
"",
|
|
2110
|
+
);
|
|
2111
|
+
const body = await res.json() as any[];
|
|
2112
|
+
expect(body.map((n) => n.content).sort()).toEqual(["a", "b"]);
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
test("in comma form: `?meta[field][in]=v1,v2`", async () => {
|
|
2116
|
+
await declareIndexed();
|
|
2117
|
+
await store.createNote("a", { metadata: { status: "active" } });
|
|
2118
|
+
await store.createNote("b", { metadata: { status: "exploring" } });
|
|
2119
|
+
await store.createNote("c", { metadata: { status: "done" } });
|
|
2120
|
+
const res = await handleNotes(
|
|
2121
|
+
mkReq("GET", "/notes?meta[status][in]=active,exploring&include_content=true"),
|
|
2122
|
+
store,
|
|
2123
|
+
"",
|
|
2124
|
+
);
|
|
2125
|
+
const body = await res.json() as any[];
|
|
2126
|
+
expect(body.map((n) => n.content).sort()).toEqual(["a", "b"]);
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
test("not_in via comma form", async () => {
|
|
2130
|
+
await declareIndexed();
|
|
2131
|
+
await store.createNote("a", { metadata: { status: "active" } });
|
|
2132
|
+
await store.createNote("b", { metadata: { status: "done" } });
|
|
2133
|
+
const res = await handleNotes(
|
|
2134
|
+
mkReq("GET", "/notes?meta[status][not_in]=done&include_content=true"),
|
|
2135
|
+
store,
|
|
2136
|
+
"",
|
|
2137
|
+
);
|
|
2138
|
+
const body = await res.json() as any[];
|
|
2139
|
+
expect(body.map((n) => n.content)).toEqual(["a"]);
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
test("exists: true / false distinguishes present vs absent field", async () => {
|
|
2143
|
+
await declareIndexed();
|
|
2144
|
+
await store.createNote("has", { metadata: { priority: 3 } });
|
|
2145
|
+
await store.createNote("missing");
|
|
2146
|
+
|
|
2147
|
+
const hasRes = await handleNotes(
|
|
2148
|
+
mkReq("GET", "/notes?meta[priority][exists]=true&include_content=true"),
|
|
2149
|
+
store,
|
|
2150
|
+
"",
|
|
2151
|
+
);
|
|
2152
|
+
const hasBody = await hasRes.json() as any[];
|
|
2153
|
+
expect(hasBody.map((n) => n.content)).toEqual(["has"]);
|
|
2154
|
+
|
|
2155
|
+
const missingRes = await handleNotes(
|
|
2156
|
+
mkReq("GET", "/notes?meta[priority][exists]=false&include_content=true"),
|
|
2157
|
+
store,
|
|
2158
|
+
"",
|
|
2159
|
+
);
|
|
2160
|
+
const missingBody = await missingRes.json() as any[];
|
|
2161
|
+
expect(missingBody.map((n) => n.content)).toEqual(["missing"]);
|
|
2162
|
+
});
|
|
2163
|
+
|
|
2164
|
+
test("exists with non-boolean value rejects with INVALID_OPERATOR_VALUE", async () => {
|
|
2165
|
+
await declareIndexed();
|
|
2166
|
+
const res = await handleNotes(
|
|
2167
|
+
mkReq("GET", "/notes?meta[priority][exists]=yes"),
|
|
2168
|
+
store,
|
|
2169
|
+
"",
|
|
2170
|
+
);
|
|
2171
|
+
expect(res.status).toBe(400);
|
|
2172
|
+
const body = await res.json() as any;
|
|
2173
|
+
expect(body.code).toBe("INVALID_OPERATOR_VALUE");
|
|
2174
|
+
});
|
|
2175
|
+
|
|
2176
|
+
test("compound filter across two fields ANDs together", async () => {
|
|
2177
|
+
await declareIndexed();
|
|
2178
|
+
await store.createNote("hit", { metadata: { priority: 5, status: "active" } });
|
|
2179
|
+
await store.createNote("priority-only", { metadata: { priority: 5, status: "done" } });
|
|
2180
|
+
await store.createNote("status-only", { metadata: { priority: 1, status: "active" } });
|
|
2181
|
+
const res = await handleNotes(
|
|
2182
|
+
mkReq("GET", "/notes?meta[priority][gte]=4&meta[status][eq]=active&include_content=true"),
|
|
2183
|
+
store,
|
|
2184
|
+
"",
|
|
2185
|
+
);
|
|
2186
|
+
const body = await res.json() as any[];
|
|
2187
|
+
expect(body.map((n) => n.content)).toEqual(["hit"]);
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
test("operator query on a non-indexed field returns 400 with FIELD_NOT_INDEXED", async () => {
|
|
2191
|
+
// Don't declare the field — the engine's indexed-field gate should fire.
|
|
2192
|
+
await store.createNote("x", { metadata: { mood: "great" } });
|
|
2193
|
+
const res = await handleNotes(
|
|
2194
|
+
mkReq("GET", "/notes?meta[mood][eq]=great"),
|
|
2195
|
+
store,
|
|
2196
|
+
"",
|
|
2197
|
+
);
|
|
2198
|
+
expect(res.status).toBe(400);
|
|
2199
|
+
const body = await res.json() as any;
|
|
2200
|
+
expect(body.code).toBe("FIELD_NOT_INDEXED");
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
test("unknown operator returns 400 with UNKNOWN_OPERATOR", async () => {
|
|
2204
|
+
await declareIndexed();
|
|
2205
|
+
const res = await handleNotes(
|
|
2206
|
+
mkReq("GET", "/notes?meta[priority][bogus]=5"),
|
|
2207
|
+
store,
|
|
2208
|
+
"",
|
|
2209
|
+
);
|
|
2210
|
+
expect(res.status).toBe(400);
|
|
2211
|
+
const body = await res.json() as any;
|
|
2212
|
+
expect(body.code).toBe("UNKNOWN_OPERATOR");
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
// ---- Bridge: created_at / updated_at via brackets route to dateFilter ----
|
|
2216
|
+
test("`meta[created_at][gte]=…` routes to dateFilter (same result as flat date_field)", async () => {
|
|
2217
|
+
await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
2218
|
+
await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2219
|
+
|
|
2220
|
+
const bracketRes = await handleNotes(
|
|
2221
|
+
mkReq("GET", "/notes?meta[created_at][gte]=2026-04-01&include_content=true"),
|
|
2222
|
+
store,
|
|
2223
|
+
"",
|
|
2224
|
+
);
|
|
2225
|
+
const bracketBody = await bracketRes.json() as any[];
|
|
2226
|
+
const flatRes = await handleNotes(
|
|
2227
|
+
mkReq("GET", "/notes?date_field=created_at&date_from=2026-04-01&include_content=true"),
|
|
2228
|
+
store,
|
|
2229
|
+
"",
|
|
2230
|
+
);
|
|
2231
|
+
const flatBody = await flatRes.json() as any[];
|
|
2232
|
+
expect(bracketBody.map((n) => n.content)).toEqual(["new"]);
|
|
2233
|
+
expect(bracketBody.map((n) => n.content)).toEqual(flatBody.map((n) => n.content));
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
test("`meta[updated_at][gte]=…` routes to dateFilter on n.updated_at", async () => {
|
|
2237
|
+
const a = await store.createNote("untouched");
|
|
2238
|
+
const b = await store.createNote("modified");
|
|
2239
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
2240
|
+
.run("2026-01-15T00:00:00.000Z", a.id);
|
|
2241
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
2242
|
+
.run("2026-04-25T00:00:00.000Z", b.id);
|
|
2243
|
+
const res = await handleNotes(
|
|
2244
|
+
mkReq("GET", "/notes?meta[updated_at][gte]=2026-04-01&include_content=true"),
|
|
2245
|
+
store,
|
|
2246
|
+
"",
|
|
2247
|
+
);
|
|
2248
|
+
const body = await res.json() as any[];
|
|
2249
|
+
expect(body.map((n) => n.content)).toEqual(["modified"]);
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
test("`meta[created_at][lt]=…` maps to dateFilter's exclusive upper bound", async () => {
|
|
2253
|
+
await store.createNote("inside", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2254
|
+
await store.createNote("on-boundary", { created_at: "2026-05-01T00:00:00.000Z" });
|
|
2255
|
+
const res = await handleNotes(
|
|
2256
|
+
mkReq("GET", "/notes?meta[created_at][lt]=2026-05-01&include_content=true"),
|
|
2257
|
+
store,
|
|
2258
|
+
"",
|
|
2259
|
+
);
|
|
2260
|
+
const body = await res.json() as any[];
|
|
2261
|
+
// "on-boundary" excluded because `< to` is half-open by design.
|
|
2262
|
+
expect(body.map((n) => n.content)).toEqual(["inside"]);
|
|
2263
|
+
});
|
|
2264
|
+
|
|
2265
|
+
test("unsupported date-column operator (e.g. gt) rejects with a guiding error", async () => {
|
|
2266
|
+
const res = await handleNotes(
|
|
2267
|
+
mkReq("GET", "/notes?meta[created_at][gt]=2026-01-01"),
|
|
2268
|
+
store,
|
|
2269
|
+
"",
|
|
2270
|
+
);
|
|
2271
|
+
expect(res.status).toBe(400);
|
|
2272
|
+
const body = await res.json() as any;
|
|
2273
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2274
|
+
// Error must call out the supported ops so callers can self-correct.
|
|
2275
|
+
expect(body.error).toContain("gte");
|
|
2276
|
+
expect(body.error).toContain("lt");
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
test("`meta[created_at]=…` (shorthand, no operator) rejects with a guiding error", async () => {
|
|
2280
|
+
const res = await handleNotes(
|
|
2281
|
+
mkReq("GET", "/notes?meta[created_at]=2026-01-01"),
|
|
2282
|
+
store,
|
|
2283
|
+
"",
|
|
2284
|
+
);
|
|
2285
|
+
expect(res.status).toBe(400);
|
|
2286
|
+
const body = await res.json() as any;
|
|
2287
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2288
|
+
expect(body.error).toContain("operator");
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
// ---- Mutually-exclusive shapes that would silently corrupt input ----
|
|
2292
|
+
|
|
2293
|
+
test("bracket-date filter spanning created_at AND updated_at in one request rejects (vault#289 F1)", async () => {
|
|
2294
|
+
// Before this guard, the parser flattened both columns onto a single
|
|
2295
|
+
// `dateBucket.field`, so the second column silently won and the first
|
|
2296
|
+
// column's bound was applied against the wrong column.
|
|
2297
|
+
const res = await handleNotes(
|
|
2298
|
+
mkReq(
|
|
2299
|
+
"GET",
|
|
2300
|
+
"/notes?meta[created_at][gte]=2026-04-01&meta[updated_at][lt]=2026-06-01",
|
|
2301
|
+
),
|
|
2302
|
+
store,
|
|
2303
|
+
"",
|
|
2304
|
+
);
|
|
2305
|
+
expect(res.status).toBe(400);
|
|
2306
|
+
const body = await res.json() as any;
|
|
2307
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2308
|
+
expect(body.error).toContain("cannot span");
|
|
2309
|
+
expect(body.error).toContain("created_at");
|
|
2310
|
+
expect(body.error).toContain("updated_at");
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
test("two bracket-date params on the same column compose into a range (regression)", async () => {
|
|
2314
|
+
// The F1 guard must reject *different* columns only — same-column
|
|
2315
|
+
// gte+lt is the canonical range case and must keep working.
|
|
2316
|
+
await store.createNote("in-window", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2317
|
+
await store.createNote("after-window", { created_at: "2026-05-15T00:00:00.000Z" });
|
|
2318
|
+
await store.createNote("before-window", { created_at: "2026-03-15T00:00:00.000Z" });
|
|
2319
|
+
const res = await handleNotes(
|
|
2320
|
+
mkReq(
|
|
2321
|
+
"GET",
|
|
2322
|
+
"/notes?meta[created_at][gte]=2026-04-01&meta[created_at][lt]=2026-05-01&include_content=true",
|
|
2323
|
+
),
|
|
2324
|
+
store,
|
|
2325
|
+
"",
|
|
2326
|
+
);
|
|
2327
|
+
expect(res.status).toBe(200);
|
|
2328
|
+
const body = await res.json() as any[];
|
|
2329
|
+
expect(body.map((n) => n.content)).toEqual(["in-window"]);
|
|
2330
|
+
});
|
|
2331
|
+
|
|
2332
|
+
test("shorthand-then-operator on the same field rejects (vault#289 F2)", async () => {
|
|
2333
|
+
// `URLSearchParams` iteration is insertion-order. Before this guard,
|
|
2334
|
+
// shorthand wrote `metadata[field] = primitive`, then the operator
|
|
2335
|
+
// handler called `metaOpBucket` which overwrote it with a fresh op
|
|
2336
|
+
// object — the shorthand was silently dropped.
|
|
2337
|
+
await declareIndexed();
|
|
2338
|
+
const res = await handleNotes(
|
|
2339
|
+
mkReq("GET", "/notes?meta[priority]=5&meta[priority][gte]=3"),
|
|
2340
|
+
store,
|
|
2341
|
+
"",
|
|
2342
|
+
);
|
|
2343
|
+
expect(res.status).toBe(400);
|
|
2344
|
+
const body = await res.json() as any;
|
|
2345
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2346
|
+
expect(body.error).toContain("mix shorthand and operator");
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
test("operator-then-shorthand on the same field rejects (vault#289 F2, reverse order)", async () => {
|
|
2350
|
+
// Reverse insertion order. Before this guard, the operator was set
|
|
2351
|
+
// first, then the shorthand wrote `metadata[field] = primitive` and
|
|
2352
|
+
// clobbered the op bucket — operator silently dropped.
|
|
2353
|
+
await declareIndexed();
|
|
2354
|
+
const res = await handleNotes(
|
|
2355
|
+
mkReq("GET", "/notes?meta[priority][gte]=3&meta[priority]=5"),
|
|
2356
|
+
store,
|
|
2357
|
+
"",
|
|
2358
|
+
);
|
|
2359
|
+
expect(res.status).toBe(400);
|
|
2360
|
+
const body = await res.json() as any;
|
|
2361
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
2362
|
+
expect(body.error).toContain("mix shorthand and operator");
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
test("`[]` array form on a non-array operator rejects at the parser layer (vault#289 F4)", async () => {
|
|
2366
|
+
// `meta[field][eq][]=value` is a shape error — `eq` takes a scalar.
|
|
2367
|
+
// The engine would also catch this (the value would be an array
|
|
2368
|
+
// SQLite can't bind), but the parser-level error names the issue
|
|
2369
|
+
// more precisely: "use single-value form for `eq`."
|
|
2370
|
+
const res = await handleNotes(
|
|
2371
|
+
mkReq("GET", "/notes?meta[priority][eq][]=5"),
|
|
2372
|
+
store,
|
|
2373
|
+
"",
|
|
2374
|
+
);
|
|
2375
|
+
expect(res.status).toBe(400);
|
|
2376
|
+
const body = await res.json() as any;
|
|
2377
|
+
expect(body.code).toBe("INVALID_OPERATOR_VALUE");
|
|
2378
|
+
expect(body.error).toContain("array form");
|
|
2379
|
+
expect(body.error).toContain("in");
|
|
2380
|
+
expect(body.error).toContain("not_in");
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
// ---- Precedence on overlap ----
|
|
2384
|
+
test("when both flat and bracket date params overlap, bracket wins", async () => {
|
|
2385
|
+
await store.createNote("old", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
2386
|
+
await store.createNote("new", { created_at: "2026-04-15T00:00:00.000Z" });
|
|
2387
|
+
// Bracket says "from 2026-04-01"; flat says "from 2020-01-01". If
|
|
2388
|
+
// flat won, both notes would match. The bracket-wins precedence is
|
|
2389
|
+
// verified by getting back only the post-April note.
|
|
2390
|
+
const res = await handleNotes(
|
|
2391
|
+
mkReq(
|
|
2392
|
+
"GET",
|
|
2393
|
+
"/notes?meta[created_at][gte]=2026-04-01&date_field=created_at&date_from=2020-01-01&include_content=true",
|
|
2394
|
+
),
|
|
2395
|
+
store,
|
|
2396
|
+
"",
|
|
2397
|
+
);
|
|
2398
|
+
const body = await res.json() as any[];
|
|
2399
|
+
expect(body.map((n) => n.content)).toEqual(["new"]);
|
|
2400
|
+
});
|
|
2401
|
+
});
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2404
|
+
describe("HTTP GET /notes?format=graph", async () => {
|
|
2405
|
+
test("returns nodes and edges for linked notes", async () => {
|
|
2406
|
+
const a = await store.createNote("A", { id: "a", path: "People/Alice", tags: ["person"] });
|
|
2407
|
+
const b = await store.createNote("B", { id: "b", path: "People/Bob", tags: ["person"] });
|
|
2408
|
+
const c = await store.createNote("C", { id: "c", path: "Projects/X" });
|
|
2409
|
+
await store.createLink("a", "b", "knows");
|
|
2410
|
+
await store.createLink("a", "c", "works-on");
|
|
2411
|
+
|
|
2412
|
+
const res = await handleNotes(
|
|
2413
|
+
mkReq("GET", "/notes?format=graph&include_links=true"),
|
|
2414
|
+
store,
|
|
2415
|
+
"",
|
|
2416
|
+
);
|
|
2417
|
+
const body = await res.json() as any;
|
|
2418
|
+
expect(body.nodes).toHaveLength(3);
|
|
2419
|
+
expect(body.edges).toHaveLength(2);
|
|
2420
|
+
// Nodes have id, path, tags
|
|
2421
|
+
const alice = body.nodes.find((n: any) => n.id === "a");
|
|
2422
|
+
expect(alice.path).toBe("People/Alice");
|
|
2423
|
+
expect(alice.tags).toEqual(["person"]);
|
|
2424
|
+
// Edges have source, target, relationship
|
|
2425
|
+
expect(body.edges).toContainEqual({ source: "a", target: "b", relationship: "knows" });
|
|
2426
|
+
expect(body.edges).toContainEqual({ source: "a", target: "c", relationship: "works-on" });
|
|
2427
|
+
});
|
|
2428
|
+
|
|
2429
|
+
test("returns empty edges when include_links is not set", async () => {
|
|
2430
|
+
await store.createNote("A", { id: "a" });
|
|
2431
|
+
await store.createNote("B", { id: "b" });
|
|
2432
|
+
await store.createLink("a", "b", "ref");
|
|
2433
|
+
|
|
2434
|
+
const res = await handleNotes(
|
|
2435
|
+
mkReq("GET", "/notes?format=graph"),
|
|
2436
|
+
store,
|
|
2437
|
+
"",
|
|
2438
|
+
);
|
|
2439
|
+
const body = await res.json() as any;
|
|
2440
|
+
expect(body.nodes).toHaveLength(2);
|
|
2441
|
+
expect(body.edges).toHaveLength(0);
|
|
1610
2442
|
});
|
|
1611
2443
|
|
|
1612
2444
|
test("composes with near param for subgraph", async () => {
|
|
@@ -1918,6 +2750,180 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
1918
2750
|
// Source note unchanged
|
|
1919
2751
|
expect((await store.getNote("a"))!.path).toBe("alpha");
|
|
1920
2752
|
});
|
|
2753
|
+
|
|
2754
|
+
// ---- include_content response-shape opt-out (vault#285 friction point 2.response) ----
|
|
2755
|
+
test("PATCH defaults to returning the full Note (back-compat)", async () => {
|
|
2756
|
+
await store.createNote("body", { id: "x" });
|
|
2757
|
+
const res = await handleNotes(
|
|
2758
|
+
mkReq("PATCH", "/notes/x", { content: "updated", force: true }),
|
|
2759
|
+
store,
|
|
2760
|
+
"/x",
|
|
2761
|
+
);
|
|
2762
|
+
expect(res.status).toBe(200);
|
|
2763
|
+
const body = await res.json() as any;
|
|
2764
|
+
expect(body.content).toBe("updated");
|
|
2765
|
+
expect(body.byteSize).toBeUndefined();
|
|
2766
|
+
expect(body.preview).toBeUndefined();
|
|
2767
|
+
});
|
|
2768
|
+
|
|
2769
|
+
test("PATCH with include_content: false returns the lean NoteIndex shape", async () => {
|
|
2770
|
+
const longBody = "x".repeat(2_000);
|
|
2771
|
+
await store.createNote(longBody, { id: "big", path: "big" });
|
|
2772
|
+
const res = await handleNotes(
|
|
2773
|
+
mkReq("PATCH", "/notes/big", { append: " edit", include_content: false }),
|
|
2774
|
+
store,
|
|
2775
|
+
"/big",
|
|
2776
|
+
);
|
|
2777
|
+
expect(res.status).toBe(200);
|
|
2778
|
+
const body = await res.json() as any;
|
|
2779
|
+
expect(body.content).toBeUndefined();
|
|
2780
|
+
expect(typeof body.byteSize).toBe("number");
|
|
2781
|
+
expect(body.byteSize).toBe(2_000 + 5);
|
|
2782
|
+
expect(typeof body.preview).toBe("string");
|
|
2783
|
+
expect(body.id).toBe("big");
|
|
2784
|
+
expect(body.path).toBe("big");
|
|
2785
|
+
});
|
|
2786
|
+
|
|
2787
|
+
// vault#287 — HTTP must match MCP on validation_status attachment.
|
|
2788
|
+
// Pre-#287 fix: MCP `update-note` attached validation_status; HTTP
|
|
2789
|
+
// PATCH didn't. HTTP consumers using schema-validated vaults had no
|
|
2790
|
+
// way to see schema warnings without re-reading + replaying validation
|
|
2791
|
+
// client-side. These tests pin the symmetry on both response shapes
|
|
2792
|
+
// (`include_content: true` and `false`) and confirm the no-schema
|
|
2793
|
+
// case still returns no validation_status (advisory only — never
|
|
2794
|
+
// forced onto vaults that don't declare fields).
|
|
2795
|
+
|
|
2796
|
+
test("PATCH attaches validation_status with enum_mismatch warning when tag schema is violated", async () => {
|
|
2797
|
+
await store.upsertTagSchema("task287patch", {
|
|
2798
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2799
|
+
});
|
|
2800
|
+
const note = await store.createNote("body", {
|
|
2801
|
+
id: "p287a",
|
|
2802
|
+
tags: ["task287patch"],
|
|
2803
|
+
metadata: { priority: "high" },
|
|
2804
|
+
});
|
|
2805
|
+
const res = await handleNotes(
|
|
2806
|
+
mkReq("PATCH", "/notes/p287a", {
|
|
2807
|
+
metadata: { priority: "ULTRA" },
|
|
2808
|
+
if_updated_at: note.updatedAt,
|
|
2809
|
+
}),
|
|
2810
|
+
store,
|
|
2811
|
+
"/p287a",
|
|
2812
|
+
);
|
|
2813
|
+
expect(res.status).toBe(200);
|
|
2814
|
+
const body = await res.json() as any;
|
|
2815
|
+
// The write still lands — validation is advisory.
|
|
2816
|
+
expect(body.metadata.priority).toBe("ULTRA");
|
|
2817
|
+
// …but the response carries the warning so the HTTP caller knows.
|
|
2818
|
+
expect(body.validation_status).toBeTruthy();
|
|
2819
|
+
expect(body.validation_status.schemas).toContain("task287patch");
|
|
2820
|
+
expect(body.validation_status.warnings.length).toBeGreaterThan(0);
|
|
2821
|
+
expect(body.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
2822
|
+
expect(body.validation_status.warnings[0].field).toBe("priority");
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
test("PATCH preserves validation_status on the lean (include_content: false) response", async () => {
|
|
2826
|
+
await store.upsertTagSchema("task287lean", {
|
|
2827
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2828
|
+
});
|
|
2829
|
+
const note = await store.createNote("body", {
|
|
2830
|
+
id: "p287b",
|
|
2831
|
+
tags: ["task287lean"],
|
|
2832
|
+
metadata: { priority: "high" },
|
|
2833
|
+
});
|
|
2834
|
+
const res = await handleNotes(
|
|
2835
|
+
mkReq("PATCH", "/notes/p287b", {
|
|
2836
|
+
metadata: { priority: "ULTRA" },
|
|
2837
|
+
include_content: false,
|
|
2838
|
+
if_updated_at: note.updatedAt,
|
|
2839
|
+
}),
|
|
2840
|
+
store,
|
|
2841
|
+
"/p287b",
|
|
2842
|
+
);
|
|
2843
|
+
expect(res.status).toBe(200);
|
|
2844
|
+
const body = await res.json() as any;
|
|
2845
|
+
// Lean shape: no `content`, has `byteSize` + `preview`.
|
|
2846
|
+
expect(body.content).toBeUndefined();
|
|
2847
|
+
expect(typeof body.byteSize).toBe("number");
|
|
2848
|
+
// …and validation_status survives the lean conversion.
|
|
2849
|
+
expect(body.validation_status).toBeTruthy();
|
|
2850
|
+
expect(body.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
2851
|
+
});
|
|
2852
|
+
|
|
2853
|
+
test("PATCH omits validation_status when no tag on the note declares fields", async () => {
|
|
2854
|
+
// No tag schemas configured for this note — the response should look
|
|
2855
|
+
// exactly like the pre-#287 shape (no validation_status). The behavior-
|
|
2856
|
+
// unchanged guarantee for callers that don't use tag schemas.
|
|
2857
|
+
await store.createNote("body", { id: "p287c", tags: ["plain"] });
|
|
2858
|
+
const res = await handleNotes(
|
|
2859
|
+
mkReq("PATCH", "/notes/p287c", { content: "updated", force: true }),
|
|
2860
|
+
store,
|
|
2861
|
+
"/p287c",
|
|
2862
|
+
);
|
|
2863
|
+
expect(res.status).toBe(200);
|
|
2864
|
+
const body = await res.json() as any;
|
|
2865
|
+
expect(body.content).toBe("updated");
|
|
2866
|
+
expect(body.validation_status).toBeUndefined();
|
|
2867
|
+
});
|
|
2868
|
+
});
|
|
2869
|
+
|
|
2870
|
+
describe("HTTP POST /notes — validation_status attachment (vault#287)", async () => {
|
|
2871
|
+
// Mirror of the PATCH cases for create. The MCP create-note path
|
|
2872
|
+
// attaches validation_status; HTTP POST must match (vault#287).
|
|
2873
|
+
|
|
2874
|
+
test("POST attaches validation_status with type_mismatch warning", async () => {
|
|
2875
|
+
await store.upsertTagSchema("task287post", {
|
|
2876
|
+
fields: { done: { type: "boolean" } },
|
|
2877
|
+
});
|
|
2878
|
+
const res = await handleNotes(
|
|
2879
|
+
mkReq("POST", "/notes", {
|
|
2880
|
+
content: "x",
|
|
2881
|
+
tags: ["task287post"],
|
|
2882
|
+
metadata: { done: "yes" }, // wrong type
|
|
2883
|
+
}),
|
|
2884
|
+
store,
|
|
2885
|
+
"",
|
|
2886
|
+
);
|
|
2887
|
+
expect(res.status).toBe(201);
|
|
2888
|
+
const body = await res.json() as any;
|
|
2889
|
+
expect(body.id).toBeTruthy();
|
|
2890
|
+
expect(body.validation_status).toBeTruthy();
|
|
2891
|
+
expect(body.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
2892
|
+
expect(body.validation_status.warnings[0].field).toBe("done");
|
|
2893
|
+
});
|
|
2894
|
+
|
|
2895
|
+
test("POST batch attaches validation_status per-note", async () => {
|
|
2896
|
+
await store.upsertTagSchema("task287batch", {
|
|
2897
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
2898
|
+
});
|
|
2899
|
+
const res = await handleNotes(
|
|
2900
|
+
mkReq("POST", "/notes", {
|
|
2901
|
+
notes: [
|
|
2902
|
+
{ content: "good", tags: ["task287batch"], metadata: { priority: "high" } },
|
|
2903
|
+
{ content: "bad", tags: ["task287batch"], metadata: { priority: "ULTRA" } },
|
|
2904
|
+
],
|
|
2905
|
+
}),
|
|
2906
|
+
store,
|
|
2907
|
+
"",
|
|
2908
|
+
);
|
|
2909
|
+
expect(res.status).toBe(201);
|
|
2910
|
+
const body = await res.json() as any[];
|
|
2911
|
+
expect(body).toHaveLength(2);
|
|
2912
|
+
expect(body[0].validation_status.warnings).toEqual([]);
|
|
2913
|
+
expect(body[1].validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
2914
|
+
});
|
|
2915
|
+
|
|
2916
|
+
test("POST omits validation_status when no tag declares fields (back-compat)", async () => {
|
|
2917
|
+
const res = await handleNotes(
|
|
2918
|
+
mkReq("POST", "/notes", { content: "no schema here", tags: ["plain287"] }),
|
|
2919
|
+
store,
|
|
2920
|
+
"",
|
|
2921
|
+
);
|
|
2922
|
+
expect(res.status).toBe(201);
|
|
2923
|
+
const body = await res.json() as any;
|
|
2924
|
+
expect(body.id).toBeTruthy();
|
|
2925
|
+
expect(body.validation_status).toBeUndefined();
|
|
2926
|
+
});
|
|
1921
2927
|
});
|
|
1922
2928
|
|
|
1923
2929
|
describe("HTTP /tags", async () => {
|
|
@@ -1986,7 +2992,7 @@ describe("HTTP /tags", async () => {
|
|
|
1986
2992
|
);
|
|
1987
2993
|
expect(res.status).toBe(200);
|
|
1988
2994
|
const body = await res.json() as any;
|
|
1989
|
-
expect(body).
|
|
2995
|
+
expect(body).toMatchObject({ renamed: 2, sub_tags_renamed: 0 });
|
|
1990
2996
|
expect((await store.getNote(n1.id))!.tags).toEqual(["memo"]);
|
|
1991
2997
|
expect((await store.getNote(n2.id))!.tags?.sort()).toEqual(["keeper", "memo"]);
|
|
1992
2998
|
});
|
|
@@ -2086,293 +3092,6 @@ describe("HTTP /tags", async () => {
|
|
|
2086
3092
|
});
|
|
2087
3093
|
});
|
|
2088
3094
|
|
|
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
3095
|
|
|
2377
3096
|
describe("HTTP /find-path", async () => {
|
|
2378
3097
|
test("finds path between two notes", async () => {
|