@openparachute/vault 0.7.0-rc.9 → 0.7.1
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/core/src/aggregate.test.ts +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +669 -0
- package/core/src/doctor.ts +23 -1
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +310 -51
- package/core/src/notes.ts +344 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.ts +19 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +89 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +200 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +76 -17
- package/src/routes.ts +272 -23
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +469 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/src/vault.test.ts
CHANGED
|
@@ -675,6 +675,34 @@ describe("scoped MCP wrapper", async () => {
|
|
|
675
675
|
closeAllStores();
|
|
676
676
|
});
|
|
677
677
|
|
|
678
|
+
test("vault-info includes a compact structural map WITHOUT include_stats (front-door)", async () => {
|
|
679
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
680
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
681
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
682
|
+
|
|
683
|
+
const vaultName = `map-${Date.now()}`;
|
|
684
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
685
|
+
|
|
686
|
+
const vaultStore = getVaultStore(vaultName);
|
|
687
|
+
await vaultStore.createNote("a", { tags: ["person"], path: "People/Alice" });
|
|
688
|
+
await vaultStore.createNote("b", { tags: ["person"] }); // no path
|
|
689
|
+
|
|
690
|
+
const tools = generateScopedMcpTools(vaultName);
|
|
691
|
+
const vaultInfo = tools.find((t) => t.name === "vault-info")!;
|
|
692
|
+
|
|
693
|
+
// No `include_stats` flag — the map must still be present (that's the
|
|
694
|
+
// whole point: orient in ONE call, no flag needed).
|
|
695
|
+
const result = await vaultInfo.execute({}) as any;
|
|
696
|
+
expect(result.stats).toBeUndefined();
|
|
697
|
+
expect(result.map).toBeTruthy();
|
|
698
|
+
expect(result.map.total_notes).toBe(2);
|
|
699
|
+
expect(result.map.tags).toEqual([{ name: "person", count: 2 }]);
|
|
700
|
+
expect(result.map.path_buckets).toEqual([{ name: "People", count: 1 }]);
|
|
701
|
+
expect(result.map.unfiled_notes).toBe(1);
|
|
702
|
+
|
|
703
|
+
closeAllStores();
|
|
704
|
+
});
|
|
705
|
+
|
|
678
706
|
test("getServerInstruction renders projection markdown for a populated vault (vault#271)", async () => {
|
|
679
707
|
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
680
708
|
const { writeVaultConfig } = await import("./config.ts");
|
|
@@ -1267,6 +1295,51 @@ describe("scoped MCP wrapper", async () => {
|
|
|
1267
1295
|
closeAllStores();
|
|
1268
1296
|
});
|
|
1269
1297
|
|
|
1298
|
+
test("scoped vault-info's map covers only notes reachable through an in-scope tag (front-door)", async () => {
|
|
1299
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1300
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1301
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1302
|
+
|
|
1303
|
+
const vaultName = `tagscope-vault-info-map-${Date.now()}`;
|
|
1304
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1305
|
+
|
|
1306
|
+
const store0 = getVaultStore(vaultName);
|
|
1307
|
+
await store0.createNote("a", { tags: ["work"], path: "Work/One" });
|
|
1308
|
+
await store0.createNote("b", { tags: ["work"] }); // no path
|
|
1309
|
+
await store0.createNote("c", { tags: ["personal"], path: "Personal/Two" });
|
|
1310
|
+
|
|
1311
|
+
// Scoped to `work` only.
|
|
1312
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["work"]) as any);
|
|
1313
|
+
const result = await tools.find((t) => t.name === "vault-info")!.execute({}) as any;
|
|
1314
|
+
|
|
1315
|
+
expect(result.map.total_notes).toBe(2); // a, b — "c" (personal) excluded
|
|
1316
|
+
expect(result.map.tags).toEqual([{ name: "work", count: 2 }]);
|
|
1317
|
+
expect(result.map.path_buckets).toEqual([{ name: "Work", count: 1 }]);
|
|
1318
|
+
expect(result.map.unfiled_notes).toBe(1);
|
|
1319
|
+
|
|
1320
|
+
closeAllStores();
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
test("scoped vault-info with an allowlist matching nothing returns an all-zero map, not the full vault", async () => {
|
|
1324
|
+
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1325
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
1326
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
1327
|
+
|
|
1328
|
+
const vaultName = `tagscope-vault-info-map-empty-${Date.now()}`;
|
|
1329
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
1330
|
+
|
|
1331
|
+
const store0 = getVaultStore(vaultName);
|
|
1332
|
+
await store0.createNote("a", { tags: ["work"], path: "Work/One" });
|
|
1333
|
+
|
|
1334
|
+
// Scoped to a tag that doesn't exist in this vault at all.
|
|
1335
|
+
const tools = generateScopedMcpTools(vaultName, authForTags(["nonexistent"]) as any);
|
|
1336
|
+
const result = await tools.find((t) => t.name === "vault-info")!.execute({}) as any;
|
|
1337
|
+
|
|
1338
|
+
expect(result.map).toEqual({ total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 });
|
|
1339
|
+
|
|
1340
|
+
closeAllStores();
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1270
1343
|
test("scoped create-note rejects a note whose tags fall outside the allowlist", async () => {
|
|
1271
1344
|
const { generateScopedMcpTools } = await import("./mcp-tools.ts");
|
|
1272
1345
|
const { writeVaultConfig } = await import("./config.ts");
|
|
@@ -2333,6 +2406,37 @@ describe("HTTP /notes", async () => {
|
|
|
2333
2406
|
expect(body.content).toBe("hello");
|
|
2334
2407
|
});
|
|
2335
2408
|
|
|
2409
|
+
// ---- Title-fallback resolution (additive — id/path/basename still win first) ----
|
|
2410
|
+
|
|
2411
|
+
test("GET /notes/:idOrPath resolves via H1 title when id and path both miss", async () => {
|
|
2412
|
+
await store.createNote("# My Great Note\n\nBody.", { path: "Inbox/2026-07-10-xyz" });
|
|
2413
|
+
const enc = encodeURIComponent("My Great Note");
|
|
2414
|
+
const res = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
|
|
2415
|
+
expect(res.status).toBe(200);
|
|
2416
|
+
const body = await res.json() as any;
|
|
2417
|
+
expect(body.path).toBe("Inbox/2026-07-10-xyz");
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
test("GET /notes/:idOrPath exact path still wins over a same-named title on another note", async () => {
|
|
2421
|
+
const byPath = await store.createNote("Path note", { path: "My Great Note" });
|
|
2422
|
+
await store.createNote("# My Great Note\n\nOther body.", { path: "Inbox/other" });
|
|
2423
|
+
const enc = encodeURIComponent("My Great Note");
|
|
2424
|
+
const res = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
|
|
2425
|
+
const body = await res.json() as any;
|
|
2426
|
+
expect(body.id).toBe(byPath.id);
|
|
2427
|
+
expect(body.content).toBe("Path note");
|
|
2428
|
+
});
|
|
2429
|
+
|
|
2430
|
+
test("GET /notes/:idOrPath stays 404 when 2+ notes share the same H1 title", async () => {
|
|
2431
|
+
await store.createNote("# Dup Note\n\nA.", { path: "Inbox/dup-a" });
|
|
2432
|
+
await store.createNote("# Dup Note\n\nB.", { path: "Inbox/dup-b" });
|
|
2433
|
+
const enc = encodeURIComponent("Dup Note");
|
|
2434
|
+
const res = await handleNotes(mkReq("GET", `/notes/${enc}`), store, `/${enc}`);
|
|
2435
|
+
expect(res.status).toBe(404);
|
|
2436
|
+
const body = await res.json() as any;
|
|
2437
|
+
expect(body.error_type).toBe("not_found");
|
|
2438
|
+
});
|
|
2439
|
+
|
|
2336
2440
|
test("GET /notes/:id?include_content=false returns lean shape", async () => {
|
|
2337
2441
|
await store.createNote("hello", { id: "x" });
|
|
2338
2442
|
const res = await handleNotes(mkReq("GET", "/notes/x?include_content=false"), store, "/x");
|
|
@@ -4672,6 +4776,104 @@ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
|
4672
4776
|
expect(links[0]!.relationship).toBe("knows");
|
|
4673
4777
|
});
|
|
4674
4778
|
|
|
4779
|
+
// vault#570 — content-parsed [[wikilinks]] to a missing target used to
|
|
4780
|
+
// fire NO write-time warning over REST either, mirroring the MCP fix.
|
|
4781
|
+
test("POST /notes with a content [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
|
|
4782
|
+
const res = await handleNotes(
|
|
4783
|
+
mkReq("POST", "/notes", { content: "See [[Nowhere REST]] for details." }),
|
|
4784
|
+
store,
|
|
4785
|
+
"",
|
|
4786
|
+
);
|
|
4787
|
+
expect(res.status).toBe(201);
|
|
4788
|
+
const body = await res.json() as any;
|
|
4789
|
+
expect(body.warnings).toBeDefined();
|
|
4790
|
+
expect(body.warnings[0].code).toBe("unresolved_link");
|
|
4791
|
+
expect(body.warnings[0].target).toBe("Nowhere REST");
|
|
4792
|
+
expect(await store.getLinks(body.id, { direction: "outbound" })).toHaveLength(0);
|
|
4793
|
+
});
|
|
4794
|
+
|
|
4795
|
+
// vault#570 — a target matching ≥2 notes is a distinct situation from a
|
|
4796
|
+
// genuine miss: `ambiguous_link`, not `unresolved_link`, and no edge.
|
|
4797
|
+
test("POST /notes with a content [[wikilink]] to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
|
|
4798
|
+
await store.createNote("A", { path: "Folder1/RestDup" });
|
|
4799
|
+
await store.createNote("B", { path: "Folder2/RestDup" });
|
|
4800
|
+
const res = await handleNotes(
|
|
4801
|
+
mkReq("POST", "/notes", { content: "See [[RestDup]] for details." }),
|
|
4802
|
+
store,
|
|
4803
|
+
"",
|
|
4804
|
+
);
|
|
4805
|
+
expect(res.status).toBe(201);
|
|
4806
|
+
const body = await res.json() as any;
|
|
4807
|
+
expect(body.warnings).toBeDefined();
|
|
4808
|
+
expect(body.warnings[0].code).toBe("ambiguous_link");
|
|
4809
|
+
expect(body.warnings[0].target).toBe("RestDup");
|
|
4810
|
+
expect(body.warnings[0].candidate_count).toBe(2);
|
|
4811
|
+
expect(await store.getLinks(body.id, { direction: "outbound" })).toHaveLength(0);
|
|
4812
|
+
});
|
|
4813
|
+
|
|
4814
|
+
// vault#570 — a structured `links` entry against an ambiguous target gets
|
|
4815
|
+
// the same treatment over REST (shared core implementation).
|
|
4816
|
+
test("POST /notes with a structured link to an AMBIGUOUS target: ambiguous_link, no edge", async () => {
|
|
4817
|
+
await store.createNote("A", { path: "Folder1/RestDup2" });
|
|
4818
|
+
await store.createNote("B", { path: "Folder2/RestDup2" });
|
|
4819
|
+
const res = await handleNotes(
|
|
4820
|
+
mkReq("POST", "/notes", {
|
|
4821
|
+
content: "no wikilinks here",
|
|
4822
|
+
links: [{ target: "RestDup2", relationship: "knows" }],
|
|
4823
|
+
}),
|
|
4824
|
+
store,
|
|
4825
|
+
"",
|
|
4826
|
+
);
|
|
4827
|
+
expect(res.status).toBe(201);
|
|
4828
|
+
const body = await res.json() as any;
|
|
4829
|
+
expect(body.warnings).toBeDefined();
|
|
4830
|
+
expect(body.warnings[0].code).toBe("ambiguous_link");
|
|
4831
|
+
expect(body.warnings[0].candidate_count).toBe(2);
|
|
4832
|
+
expect(await store.getLinks(body.id, { direction: "outbound" })).toHaveLength(0);
|
|
4833
|
+
});
|
|
4834
|
+
|
|
4835
|
+
test("PATCH content update with a [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
|
|
4836
|
+
await store.createNote("plain", { id: "patchable", path: "PatchableRest" });
|
|
4837
|
+
const res = await handleNotes(
|
|
4838
|
+
mkReq("PATCH", "/notes/patchable", { content: "now references [[Not Yet Real REST]]", force: true }),
|
|
4839
|
+
store,
|
|
4840
|
+
"/patchable",
|
|
4841
|
+
);
|
|
4842
|
+
expect(res.status).toBe(200);
|
|
4843
|
+
const body = await res.json() as any;
|
|
4844
|
+
expect(body.warnings).toBeDefined();
|
|
4845
|
+
expect(body.warnings[0].code).toBe("unresolved_link");
|
|
4846
|
+
expect(body.warnings[0].target).toBe("Not Yet Real REST");
|
|
4847
|
+
|
|
4848
|
+
// A tags-only follow-up PATCH must not re-surface the warning.
|
|
4849
|
+
const tagOnly = await handleNotes(
|
|
4850
|
+
mkReq("PATCH", "/notes/patchable", { tags: { add: ["x"] }, force: true }),
|
|
4851
|
+
store,
|
|
4852
|
+
"/patchable",
|
|
4853
|
+
);
|
|
4854
|
+
const tagOnlyBody = await tagOnly.json() as any;
|
|
4855
|
+
expect(tagOnlyBody.warnings).toBeUndefined();
|
|
4856
|
+
});
|
|
4857
|
+
|
|
4858
|
+
// vault#570 — `if_missing: "create"` is a distinct create-shaped code
|
|
4859
|
+
// path in routes.ts (separate from POST /notes); it needs the same fix.
|
|
4860
|
+
test("PATCH if_missing:create with a [[wikilink]] to a missing target: warns (unresolved_link)", async () => {
|
|
4861
|
+
const res = await handleNotes(
|
|
4862
|
+
mkReq("PATCH", "/notes/brand-new-rest", {
|
|
4863
|
+
if_missing: "create",
|
|
4864
|
+
content: "See [[Nowhere Upsert REST]].",
|
|
4865
|
+
}),
|
|
4866
|
+
store,
|
|
4867
|
+
"/brand-new-rest",
|
|
4868
|
+
);
|
|
4869
|
+
expect(res.status).toBe(200);
|
|
4870
|
+
const body = await res.json() as any;
|
|
4871
|
+
expect(body.created).toBe(true);
|
|
4872
|
+
expect(body.warnings).toBeDefined();
|
|
4873
|
+
expect(body.warnings[0].code).toBe("unresolved_link");
|
|
4874
|
+
expect(body.warnings[0].target).toBe("Nowhere Upsert REST");
|
|
4875
|
+
});
|
|
4876
|
+
|
|
4675
4877
|
test("PATCH without a link mutation or flag does NOT include links", async () => {
|
|
4676
4878
|
await store.createNote("a", { id: "a" });
|
|
4677
4879
|
await store.createNote("b", { id: "b" });
|
|
@@ -6161,19 +6363,24 @@ describe("stateless MCP transport", async () => {
|
|
|
6161
6363
|
expect(toolNames).toContain("list-tags");
|
|
6162
6364
|
expect(toolNames).toContain("find-path");
|
|
6163
6365
|
expect(toolNames).toContain("vault-info");
|
|
6366
|
+
// `doctor` moved admin → read (re-tier): a read-only, tag-scope-
|
|
6367
|
+
// restricted diagnostic, visible to a plain vault:read session.
|
|
6368
|
+
expect(toolNames).toContain("doctor");
|
|
6164
6369
|
// Mutation tools are hidden — filter applied before advertising
|
|
6165
6370
|
expect(toolNames).not.toContain("create-note");
|
|
6166
6371
|
expect(toolNames).not.toContain("update-note");
|
|
6167
6372
|
expect(toolNames).not.toContain("delete-note");
|
|
6373
|
+
// Tag-schema/taxonomy tools moved write → admin (re-tier): hidden from
|
|
6374
|
+
// a vault:read (and, per the next describe block, a plain vault:write)
|
|
6375
|
+
// session — they now need vault:admin.
|
|
6168
6376
|
expect(toolNames).not.toContain("update-tag");
|
|
6169
6377
|
expect(toolNames).not.toContain("delete-tag");
|
|
6170
6378
|
expect(toolNames).not.toContain("rename-tag");
|
|
6171
6379
|
expect(toolNames).not.toContain("merge-tags");
|
|
6172
6380
|
// Admin tools (vault#376) are hidden too
|
|
6173
6381
|
expect(toolNames).not.toContain("manage-token");
|
|
6174
|
-
|
|
6175
|
-
|
|
6176
|
-
expect(toolNames.length).toBe(4);
|
|
6382
|
+
// Read tier is exactly 5 tools (doctor added by the re-tier).
|
|
6383
|
+
expect(toolNames.length).toBe(5);
|
|
6177
6384
|
|
|
6178
6385
|
closeAllStores();
|
|
6179
6386
|
});
|
|
@@ -6221,7 +6428,7 @@ describe("stateless MCP transport", async () => {
|
|
|
6221
6428
|
// the required scope — the inner guard fired even though the outer tool
|
|
6222
6429
|
// gate allowed read-only callers through for stats.
|
|
6223
6430
|
expect(body.result.isError).toBe(true);
|
|
6224
|
-
expect(body.result.content[0].text).toContain("vault:
|
|
6431
|
+
expect(body.result.content[0].text).toContain("vault:admin");
|
|
6225
6432
|
|
|
6226
6433
|
// And critically: the vault description must NOT have been mutated.
|
|
6227
6434
|
const cfg = readVaultConfig(vaultName);
|
|
@@ -6230,7 +6437,7 @@ describe("stateless MCP transport", async () => {
|
|
|
6230
6437
|
closeAllStores();
|
|
6231
6438
|
});
|
|
6232
6439
|
|
|
6233
|
-
test("tools/call of vault-info with description arg and vault:write scope is
|
|
6440
|
+
test("tools/call of vault-info with description arg and vault:write scope is refused (re-tier: description-write now needs admin)", async () => {
|
|
6234
6441
|
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
6235
6442
|
const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
|
|
6236
6443
|
const { closeAllStores } = await import("./vault-store.ts");
|
|
@@ -6267,10 +6474,58 @@ describe("stateless MCP transport", async () => {
|
|
|
6267
6474
|
scoped_tags: null,
|
|
6268
6475
|
});
|
|
6269
6476
|
|
|
6477
|
+
expect(res.status).toBe(200);
|
|
6478
|
+
const body = await res.json() as any;
|
|
6479
|
+
// Was `isError: false` pre-re-tier — a plain vault:write session could
|
|
6480
|
+
// update the vault's own description. Now needs vault:admin.
|
|
6481
|
+
expect(body.result.isError).toBe(true);
|
|
6482
|
+
expect(body.result.content[0].text).toContain("vault:admin");
|
|
6483
|
+
expect(readVaultConfig(vaultName)?.description).toBe("original");
|
|
6484
|
+
|
|
6485
|
+
closeAllStores();
|
|
6486
|
+
});
|
|
6487
|
+
|
|
6488
|
+
test("tools/call of vault-info with description arg and vault:admin scope is allowed", async () => {
|
|
6489
|
+
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
6490
|
+
const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
|
|
6491
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
6492
|
+
|
|
6493
|
+
const vaultName = `scope-vault-info-admin-${Date.now()}`;
|
|
6494
|
+
writeVaultConfig({
|
|
6495
|
+
name: vaultName,
|
|
6496
|
+
api_keys: [],
|
|
6497
|
+
created_at: new Date().toISOString(),
|
|
6498
|
+
description: "original",
|
|
6499
|
+
});
|
|
6500
|
+
|
|
6501
|
+
const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
|
|
6502
|
+
method: "POST",
|
|
6503
|
+
headers: {
|
|
6504
|
+
"content-type": "application/json",
|
|
6505
|
+
"accept": "application/json, text/event-stream",
|
|
6506
|
+
},
|
|
6507
|
+
body: JSON.stringify({
|
|
6508
|
+
jsonrpc: "2.0",
|
|
6509
|
+
id: 1,
|
|
6510
|
+
method: "tools/call",
|
|
6511
|
+
params: {
|
|
6512
|
+
name: "vault-info",
|
|
6513
|
+
arguments: { description: "updated via admin scope" },
|
|
6514
|
+
},
|
|
6515
|
+
}),
|
|
6516
|
+
});
|
|
6517
|
+
|
|
6518
|
+
const res = await handleScopedMcp(req, vaultName, {
|
|
6519
|
+
permission: "full",
|
|
6520
|
+
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
6521
|
+
legacyDerived: false,
|
|
6522
|
+
scoped_tags: null,
|
|
6523
|
+
});
|
|
6524
|
+
|
|
6270
6525
|
expect(res.status).toBe(200);
|
|
6271
6526
|
const body = await res.json() as any;
|
|
6272
6527
|
expect(body.result.isError).toBeFalsy();
|
|
6273
|
-
expect(readVaultConfig(vaultName)?.description).toBe("updated via
|
|
6528
|
+
expect(readVaultConfig(vaultName)?.description).toBe("updated via admin scope");
|
|
6274
6529
|
|
|
6275
6530
|
closeAllStores();
|
|
6276
6531
|
});
|
|
@@ -6406,15 +6661,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6406
6661
|
return names;
|
|
6407
6662
|
}
|
|
6408
6663
|
|
|
6409
|
-
test("vault:read sees exactly the
|
|
6664
|
+
test("vault:read sees exactly the 5 read tools (doctor moved admin → read)", async () => {
|
|
6410
6665
|
const names = await listToolNames(["vault:read"]);
|
|
6411
6666
|
expect(new Set(names)).toEqual(
|
|
6412
|
-
new Set(["query-notes", "list-tags", "find-path", "vault-info"]),
|
|
6667
|
+
new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor"]),
|
|
6413
6668
|
);
|
|
6414
|
-
expect(names.length).toBe(
|
|
6669
|
+
expect(names.length).toBe(5);
|
|
6415
6670
|
});
|
|
6416
6671
|
|
|
6417
|
-
test("vault:read + vault:write sees the
|
|
6672
|
+
test("vault:read + vault:write sees the 8 read+write tools (tag-schema tools moved write → admin)", async () => {
|
|
6418
6673
|
const names = await listToolNames(["vault:read", "vault:write"]);
|
|
6419
6674
|
expect(new Set(names)).toEqual(
|
|
6420
6675
|
new Set([
|
|
@@ -6422,30 +6677,31 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6422
6677
|
"list-tags",
|
|
6423
6678
|
"find-path",
|
|
6424
6679
|
"vault-info",
|
|
6680
|
+
"doctor",
|
|
6425
6681
|
"create-note",
|
|
6426
6682
|
"update-note",
|
|
6427
6683
|
"delete-note",
|
|
6428
|
-
"update-tag",
|
|
6429
|
-
"delete-tag",
|
|
6430
|
-
// vault#552: MCP parity with the pre-existing REST rename/merge engine.
|
|
6431
|
-
"rename-tag",
|
|
6432
|
-
"merge-tags",
|
|
6433
6684
|
]),
|
|
6434
6685
|
);
|
|
6435
|
-
expect(names.length).toBe(
|
|
6686
|
+
expect(names.length).toBe(8);
|
|
6436
6687
|
expect(names).not.toContain("manage-token");
|
|
6437
|
-
|
|
6438
|
-
//
|
|
6439
|
-
//
|
|
6688
|
+
// Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
|
|
6689
|
+
// admin-tier — structure/taxonomy curation, not content authorship.
|
|
6690
|
+
// Only delete-note (content) stays write-tier.
|
|
6691
|
+
expect(names).not.toContain("update-tag");
|
|
6692
|
+
expect(names).not.toContain("delete-tag");
|
|
6693
|
+
expect(names).not.toContain("rename-tag");
|
|
6694
|
+
expect(names).not.toContain("merge-tags");
|
|
6440
6695
|
expect(names).toContain("delete-note");
|
|
6441
|
-
expect(names).toContain("delete-tag");
|
|
6442
6696
|
});
|
|
6443
6697
|
|
|
6444
|
-
test("vault:admin sees all 14 tools including manage-token + prune-schema +
|
|
6698
|
+
test("vault:admin sees all 14 tools including manage-token + prune-schema + the tag-schema tools", async () => {
|
|
6445
6699
|
const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
|
|
6446
6700
|
expect(names).toContain("manage-token");
|
|
6447
6701
|
expect(names).toContain("prune-schema");
|
|
6448
6702
|
expect(names).toContain("doctor");
|
|
6703
|
+
expect(names).toContain("update-tag");
|
|
6704
|
+
expect(names).toContain("delete-tag");
|
|
6449
6705
|
expect(names).toContain("rename-tag");
|
|
6450
6706
|
expect(names).toContain("merge-tags");
|
|
6451
6707
|
expect(names.length).toBe(14);
|
|
@@ -6535,6 +6791,144 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6535
6791
|
});
|
|
6536
6792
|
});
|
|
6537
6793
|
|
|
6794
|
+
// ===========================================================================
|
|
6795
|
+
// Write/admin re-tier — schema/taxonomy-curation tools moved write → admin,
|
|
6796
|
+
// doctor moved admin → read. Content-authorship (write) is now separate from
|
|
6797
|
+
// structure/taxonomy/schema-curation (admin). No new scope — same
|
|
6798
|
+
// read/write/admin vocabulary, only which tier each tool requires moves.
|
|
6799
|
+
// ===========================================================================
|
|
6800
|
+
|
|
6801
|
+
describe("MCP write/admin re-tier — scope enforcement at the tools/call layer", () => {
|
|
6802
|
+
async function callTool(
|
|
6803
|
+
vaultName: string,
|
|
6804
|
+
scopes: string[],
|
|
6805
|
+
name: string,
|
|
6806
|
+
args: Record<string, unknown>,
|
|
6807
|
+
): Promise<any> {
|
|
6808
|
+
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
6809
|
+
const req = new Request(`http://localhost:1940/vault/${vaultName}/mcp`, {
|
|
6810
|
+
method: "POST",
|
|
6811
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
6812
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
6813
|
+
});
|
|
6814
|
+
const res = await handleScopedMcp(req, vaultName, {
|
|
6815
|
+
permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
|
|
6816
|
+
scopes,
|
|
6817
|
+
legacyDerived: false,
|
|
6818
|
+
scoped_tags: null,
|
|
6819
|
+
} as any);
|
|
6820
|
+
return res.json();
|
|
6821
|
+
}
|
|
6822
|
+
|
|
6823
|
+
test("vault:write CAN create-note but is DENIED rename-tag/merge-tags/delete-tag/update-tag (now admin-tier)", async () => {
|
|
6824
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
6825
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
6826
|
+
|
|
6827
|
+
const vaultName = `retier-write-${Date.now()}`;
|
|
6828
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
6829
|
+
const store = getVaultStore(vaultName);
|
|
6830
|
+
await store.createNote("seed", { tags: ["mine"] });
|
|
6831
|
+
|
|
6832
|
+
const WRITE = ["vault:read", "vault:write"];
|
|
6833
|
+
|
|
6834
|
+
// Allowed: content authorship.
|
|
6835
|
+
const create = await callTool(vaultName, WRITE, "create-note", { content: "hello", tags: ["mine"] });
|
|
6836
|
+
expect(create.result?.isError).toBeFalsy();
|
|
6837
|
+
|
|
6838
|
+
// Denied: each now requires vault:admin, not vault:write.
|
|
6839
|
+
const rename = await callTool(vaultName, WRITE, "rename-tag", { old_name: "mine", new_name: "mine2" });
|
|
6840
|
+
expect(rename.result?.isError).toBe(true);
|
|
6841
|
+
expect(rename.result.content[0].text).toContain("Unknown tool");
|
|
6842
|
+
|
|
6843
|
+
const merge = await callTool(vaultName, WRITE, "merge-tags", { sources: ["mine"], target: "mine2" });
|
|
6844
|
+
expect(merge.result?.isError).toBe(true);
|
|
6845
|
+
expect(merge.result.content[0].text).toContain("Unknown tool");
|
|
6846
|
+
|
|
6847
|
+
const del = await callTool(vaultName, WRITE, "delete-tag", { tag: "mine" });
|
|
6848
|
+
expect(del.result?.isError).toBe(true);
|
|
6849
|
+
expect(del.result.content[0].text).toContain("Unknown tool");
|
|
6850
|
+
|
|
6851
|
+
const upd = await callTool(vaultName, WRITE, "update-tag", { tag: "mine", description: "hijacked" });
|
|
6852
|
+
expect(upd.result?.isError).toBe(true);
|
|
6853
|
+
expect(upd.result.content[0].text).toContain("Unknown tool");
|
|
6854
|
+
|
|
6855
|
+
// Untouched — the denied calls above never reached core. "mine" already
|
|
6856
|
+
// has a bare identity row (auto-inserted when the seed note was tagged),
|
|
6857
|
+
// but its description is still unset — update-tag never ran.
|
|
6858
|
+
expect((await store.getTagRecord("mine"))?.description ?? null).toBeNull();
|
|
6859
|
+
const stillThere = await store.listTags();
|
|
6860
|
+
expect(stillThere.some((t) => t.name === "mine")).toBe(true);
|
|
6861
|
+
|
|
6862
|
+
closeAllStores();
|
|
6863
|
+
});
|
|
6864
|
+
|
|
6865
|
+
test("vault:read CAN run doctor (now read-tier)", async () => {
|
|
6866
|
+
const { writeVaultConfig } = await import("./config.ts");
|
|
6867
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
6868
|
+
|
|
6869
|
+
const vaultName = `retier-read-doctor-${Date.now()}`;
|
|
6870
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString() });
|
|
6871
|
+
|
|
6872
|
+
const result = await callTool(vaultName, ["vault:read"], "doctor", {});
|
|
6873
|
+
expect(result.result?.isError).toBeFalsy();
|
|
6874
|
+
const report = JSON.parse(result.result.content[0].text);
|
|
6875
|
+
expect(report.findings).toBeDefined();
|
|
6876
|
+
expect(report.summary).toBeDefined();
|
|
6877
|
+
|
|
6878
|
+
closeAllStores();
|
|
6879
|
+
});
|
|
6880
|
+
|
|
6881
|
+
test("vault:write is DENIED the vault-info description write (now admin-tier)", async () => {
|
|
6882
|
+
const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
|
|
6883
|
+
const { closeAllStores } = await import("./vault-store.ts");
|
|
6884
|
+
|
|
6885
|
+
const vaultName = `retier-write-vaultinfo-${Date.now()}`;
|
|
6886
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString(), description: "orig" });
|
|
6887
|
+
|
|
6888
|
+
const result = await callTool(vaultName, ["vault:read", "vault:write"], "vault-info", { description: "nope" });
|
|
6889
|
+
expect(result.result?.isError).toBe(true);
|
|
6890
|
+
expect(result.result.content[0].text).toContain("vault:admin");
|
|
6891
|
+
expect(readVaultConfig(vaultName)?.description).toBe("orig");
|
|
6892
|
+
|
|
6893
|
+
closeAllStores();
|
|
6894
|
+
});
|
|
6895
|
+
|
|
6896
|
+
test("vault:admin CAN do all of the above — create-note, rename/merge/delete/update-tag, doctor, and the vault-info description write", async () => {
|
|
6897
|
+
const { writeVaultConfig, readVaultConfig } = await import("./config.ts");
|
|
6898
|
+
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
6899
|
+
|
|
6900
|
+
const vaultName = `retier-admin-${Date.now()}`;
|
|
6901
|
+
writeVaultConfig({ name: vaultName, api_keys: [], created_at: new Date().toISOString(), description: "orig" });
|
|
6902
|
+
const store = getVaultStore(vaultName);
|
|
6903
|
+
await store.upsertTagRecord("a", {});
|
|
6904
|
+
await store.upsertTagRecord("b", {});
|
|
6905
|
+
await store.createNote("seed", { tags: ["a"] });
|
|
6906
|
+
|
|
6907
|
+
const ADMIN = ["vault:read", "vault:write", "vault:admin"];
|
|
6908
|
+
|
|
6909
|
+
const create = await callTool(vaultName, ADMIN, "create-note", { content: "hello", tags: ["a"] });
|
|
6910
|
+
expect(create.result?.isError).toBeFalsy();
|
|
6911
|
+
|
|
6912
|
+
const upd = await callTool(vaultName, ADMIN, "update-tag", { tag: "a", description: "updated" });
|
|
6913
|
+
expect(upd.result?.isError).toBeFalsy();
|
|
6914
|
+
|
|
6915
|
+
const doctor = await callTool(vaultName, ADMIN, "doctor", {});
|
|
6916
|
+
expect(doctor.result?.isError).toBeFalsy();
|
|
6917
|
+
|
|
6918
|
+
const vinfo = await callTool(vaultName, ADMIN, "vault-info", { description: "updated via admin" });
|
|
6919
|
+
expect(vinfo.result?.isError).toBeFalsy();
|
|
6920
|
+
expect(readVaultConfig(vaultName)?.description).toBe("updated via admin");
|
|
6921
|
+
|
|
6922
|
+
const merge = await callTool(vaultName, ADMIN, "merge-tags", { sources: ["b"], target: "a" });
|
|
6923
|
+
expect(merge.result?.isError).toBeFalsy();
|
|
6924
|
+
|
|
6925
|
+
const del = await callTool(vaultName, ADMIN, "delete-tag", { tag: "a" });
|
|
6926
|
+
expect(del.result?.isError).toBeFalsy();
|
|
6927
|
+
|
|
6928
|
+
closeAllStores();
|
|
6929
|
+
});
|
|
6930
|
+
});
|
|
6931
|
+
|
|
6538
6932
|
// ===========================================================================
|
|
6539
6933
|
// vault#376 — Change 2: manage-token mint/revoke/list
|
|
6540
6934
|
// ===========================================================================
|
|
@@ -7317,3 +7711,57 @@ describe("handleVault: transcription capability (scribe-fold Phase 1)", async ()
|
|
|
7317
7711
|
});
|
|
7318
7712
|
});
|
|
7319
7713
|
|
|
7714
|
+
describe("handleVault: front-door structural map", async () => {
|
|
7715
|
+
test("GET always includes `map` — no ?include_stats needed", async () => {
|
|
7716
|
+
await store.createNote("a", { tags: ["person"], path: "People/Alice" });
|
|
7717
|
+
await store.createNote("b", { tags: ["person"] }); // no path
|
|
7718
|
+
|
|
7719
|
+
const cfg = { name: "default" } as { name: string };
|
|
7720
|
+
const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
|
|
7721
|
+
expect(res.status).toBe(200);
|
|
7722
|
+
const body = await res.json() as any;
|
|
7723
|
+
expect(body.stats).toBeUndefined();
|
|
7724
|
+
expect(body.map).toBeTruthy();
|
|
7725
|
+
expect(body.map.total_notes).toBe(2);
|
|
7726
|
+
expect(body.map.tags).toEqual([{ name: "person", count: 2 }]);
|
|
7727
|
+
expect(body.map.path_buckets).toEqual([{ name: "People", count: 1 }]);
|
|
7728
|
+
expect(body.map.unfiled_notes).toBe(1);
|
|
7729
|
+
});
|
|
7730
|
+
|
|
7731
|
+
test("?include_stats=true adds stats ALONGSIDE map, not instead of it", async () => {
|
|
7732
|
+
await store.createNote("a", { tags: ["person"] });
|
|
7733
|
+
const cfg = { name: "default" } as { name: string };
|
|
7734
|
+
const res = await handleVault(mkReq("GET", "/vault?include_stats=true"), store, cfg as any);
|
|
7735
|
+
const body = await res.json() as any;
|
|
7736
|
+
expect(body.stats).toBeTruthy();
|
|
7737
|
+
expect(body.map).toBeTruthy();
|
|
7738
|
+
});
|
|
7739
|
+
|
|
7740
|
+
test("a tag-scoped caller's map covers only notes reachable through an in-scope tag", async () => {
|
|
7741
|
+
await store.createNote("a", { tags: ["work"], path: "Work/One" });
|
|
7742
|
+
await store.createNote("b", { tags: ["work"] }); // no path
|
|
7743
|
+
await store.createNote("c", { tags: ["personal"], path: "Personal/Two" });
|
|
7744
|
+
|
|
7745
|
+
const cfg = { name: "default" } as { name: string };
|
|
7746
|
+
const scope: TagScopeCtx = { allowed: await expandTokenTagScope(store, ["work"]), raw: ["work"] };
|
|
7747
|
+
const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any, undefined, undefined, scope);
|
|
7748
|
+
const body = await res.json() as any;
|
|
7749
|
+
|
|
7750
|
+
expect(body.map.total_notes).toBe(2); // a, b — "c" (personal) excluded
|
|
7751
|
+
expect(body.map.tags).toEqual([{ name: "work", count: 2 }]);
|
|
7752
|
+
expect(body.map.path_buckets).toEqual([{ name: "Work", count: 1 }]);
|
|
7753
|
+
expect(body.map.unfiled_notes).toBe(1);
|
|
7754
|
+
});
|
|
7755
|
+
|
|
7756
|
+
test("a tag-scoped caller whose allowlist matches nothing gets an all-zero map, not the full vault", async () => {
|
|
7757
|
+
await store.createNote("a", { tags: ["work"], path: "Work/One" });
|
|
7758
|
+
|
|
7759
|
+
const cfg = { name: "default" } as { name: string };
|
|
7760
|
+
const scope: TagScopeCtx = { allowed: await expandTokenTagScope(store, ["nonexistent"]), raw: ["nonexistent"] };
|
|
7761
|
+
const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any, undefined, undefined, scope);
|
|
7762
|
+
const body = await res.json() as any;
|
|
7763
|
+
|
|
7764
|
+
expect(body.map).toEqual({ total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 });
|
|
7765
|
+
});
|
|
7766
|
+
});
|
|
7767
|
+
|