@openparachute/vault 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, handleNoteSchemas, handleFindPath, handleVault } from "./routes.ts";
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(16);
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,6 +2027,378 @@ describe("HTTP /notes", async () => {
1567
2027
  expect(body).toHaveLength(500);
1568
2028
  });
1569
2029
  });
2030
+
2031
+ // ---- Bracket-style metadata filter (vault#285 friction point 1.3) ----
2032
+ //
2033
+ // Exposes vault's engine-level metadata-value filtering to HTTP REST
2034
+ // callers (today: only MCP). Uses `meta[field][op]=value` (Stripe /
2035
+ // JSON:API / Strapi convention). The HTTP layer translates to the engine's
2036
+ // existing `metadata` filter; engine semantics + gates are unchanged.
2037
+ // Bridges `created_at` / `updated_at` through `dateFilter`.
2038
+ describe("bracket-style metadata filter", () => {
2039
+ async function declareIndexed() {
2040
+ const { declareField } = await import("../core/src/indexed-fields.ts");
2041
+ declareField(db, "priority", "INTEGER", "project");
2042
+ declareField(db, "status", "TEXT", "project");
2043
+ }
2044
+
2045
+ test("shorthand `?meta[field]=value` does exact equality (JSON scan, no indexed gate)", async () => {
2046
+ // Deliberately NOT calling declareIndexed — shorthand should work on
2047
+ // any field via the json_extract fallback at core/src/notes.ts:504-507.
2048
+ await store.createNote("matches", { metadata: { kind: "draft" } });
2049
+ await store.createNote("other", { metadata: { kind: "final" } });
2050
+ const res = await handleNotes(
2051
+ mkReq("GET", "/notes?meta[kind]=draft&include_content=true"),
2052
+ store,
2053
+ "",
2054
+ );
2055
+ expect(res.status).toBe(200);
2056
+ const body = await res.json() as any[];
2057
+ expect(body.map((n) => n.content)).toEqual(["matches"]);
2058
+ });
2059
+
2060
+ test("eq operator on indexed field", async () => {
2061
+ await declareIndexed();
2062
+ await store.createNote("p5", { metadata: { priority: 5 } });
2063
+ await store.createNote("p1", { metadata: { priority: 1 } });
2064
+ const res = await handleNotes(
2065
+ mkReq("GET", "/notes?meta[priority][eq]=5&include_content=true"),
2066
+ store,
2067
+ "",
2068
+ );
2069
+ const body = await res.json() as any[];
2070
+ expect(body.map((n) => n.content)).toEqual(["p5"]);
2071
+ });
2072
+
2073
+ test("ne operator returns non-matching rows plus rows without the field", async () => {
2074
+ await declareIndexed();
2075
+ await store.createNote("has-1", { metadata: { priority: 1 } });
2076
+ await store.createNote("has-2", { metadata: { priority: 2 } });
2077
+ await store.createNote("missing");
2078
+ const res = await handleNotes(
2079
+ mkReq("GET", "/notes?meta[priority][ne]=1&include_content=true"),
2080
+ store,
2081
+ "",
2082
+ );
2083
+ const body = await res.json() as any[];
2084
+ expect(body.map((n) => n.content).sort()).toEqual(["has-2", "missing"]);
2085
+ });
2086
+
2087
+ test("gt / gte / lt / lte compose into a range query on one field", async () => {
2088
+ await declareIndexed();
2089
+ for (const p of [1, 2, 3, 4, 5]) {
2090
+ await store.createNote(`p${p}`, { metadata: { priority: p } });
2091
+ }
2092
+ const res = await handleNotes(
2093
+ mkReq("GET", "/notes?meta[priority][gte]=2&meta[priority][lt]=5&include_content=true"),
2094
+ store,
2095
+ "",
2096
+ );
2097
+ const body = await res.json() as any[];
2098
+ expect(body.map((n) => n.content).sort()).toEqual(["p2", "p3", "p4"]);
2099
+ });
2100
+
2101
+ test("in array form: `?meta[field][in][]=v1&meta[field][in][]=v2`", async () => {
2102
+ await declareIndexed();
2103
+ await store.createNote("a", { metadata: { status: "active" } });
2104
+ await store.createNote("b", { metadata: { status: "exploring" } });
2105
+ await store.createNote("c", { metadata: { status: "done" } });
2106
+ const res = await handleNotes(
2107
+ mkReq("GET", "/notes?meta[status][in][]=active&meta[status][in][]=exploring&include_content=true"),
2108
+ store,
2109
+ "",
2110
+ );
2111
+ const body = await res.json() as any[];
2112
+ expect(body.map((n) => n.content).sort()).toEqual(["a", "b"]);
2113
+ });
2114
+
2115
+ test("in comma form: `?meta[field][in]=v1,v2`", async () => {
2116
+ await declareIndexed();
2117
+ await store.createNote("a", { metadata: { status: "active" } });
2118
+ await store.createNote("b", { metadata: { status: "exploring" } });
2119
+ await store.createNote("c", { metadata: { status: "done" } });
2120
+ const res = await handleNotes(
2121
+ mkReq("GET", "/notes?meta[status][in]=active,exploring&include_content=true"),
2122
+ store,
2123
+ "",
2124
+ );
2125
+ const body = await res.json() as any[];
2126
+ expect(body.map((n) => n.content).sort()).toEqual(["a", "b"]);
2127
+ });
2128
+
2129
+ test("not_in via comma form", async () => {
2130
+ await declareIndexed();
2131
+ await store.createNote("a", { metadata: { status: "active" } });
2132
+ await store.createNote("b", { metadata: { status: "done" } });
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
+ });
1570
2402
  });
1571
2403
 
1572
2404
  describe("HTTP GET /notes?format=graph", async () => {
@@ -1918,6 +2750,39 @@ 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
+ });
1921
2786
  });
1922
2787
 
1923
2788
  describe("HTTP /tags", async () => {
@@ -1986,7 +2851,7 @@ describe("HTTP /tags", async () => {
1986
2851
  );
1987
2852
  expect(res.status).toBe(200);
1988
2853
  const body = await res.json() as any;
1989
- expect(body).toEqual({ renamed: 2 });
2854
+ expect(body).toMatchObject({ renamed: 2, sub_tags_renamed: 0 });
1990
2855
  expect((await store.getNote(n1.id))!.tags).toEqual(["memo"]);
1991
2856
  expect((await store.getNote(n2.id))!.tags?.sort()).toEqual(["keeper", "memo"]);
1992
2857
  });
@@ -2086,293 +2951,6 @@ describe("HTTP /tags", async () => {
2086
2951
  });
2087
2952
  });
2088
2953
 
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
2954
 
2377
2955
  describe("HTTP /find-path", async () => {
2378
2956
  test("finds path between two notes", async () => {