@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.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.
Files changed (47) hide show
  1. package/core/src/aggregate.test.ts +260 -0
  2. package/core/src/contract-taxonomy.test.ts +26 -2
  3. package/core/src/contract-typed-index.test.ts +4 -2
  4. package/core/src/core.test.ts +737 -2
  5. package/core/src/cursor-keyset-ms.test.ts +537 -0
  6. package/core/src/cursor.ts +76 -6
  7. package/core/src/doctor.ts +23 -1
  8. package/core/src/hooks.ts +9 -0
  9. package/core/src/indexed-fields.test.ts +4 -1
  10. package/core/src/indexed-fields.ts +6 -1
  11. package/core/src/links.ts +22 -0
  12. package/core/src/mcp.ts +322 -53
  13. package/core/src/notes.ts +518 -67
  14. package/core/src/paths.ts +4 -0
  15. package/core/src/portable-md.test.ts +161 -0
  16. package/core/src/portable-md.ts +140 -9
  17. package/core/src/query-warnings.ts +60 -12
  18. package/core/src/schema-defaults.ts +7 -1
  19. package/core/src/schema.ts +128 -2
  20. package/core/src/seed-packs.ts +55 -15
  21. package/core/src/store.ts +141 -7
  22. package/core/src/tag-schemas.ts +20 -7
  23. package/core/src/txn.test.ts +100 -4
  24. package/core/src/txn.ts +119 -24
  25. package/core/src/types.ts +89 -0
  26. package/core/src/ulid.test.ts +56 -0
  27. package/core/src/ulid.ts +116 -0
  28. package/core/src/vault-projection.ts +19 -1
  29. package/core/src/wikilinks.test.ts +205 -1
  30. package/core/src/wikilinks.ts +200 -35
  31. package/package.json +1 -1
  32. package/src/aggregate-routes.test.ts +230 -0
  33. package/src/cli.ts +6 -0
  34. package/src/contract-errors.test.ts +2 -1
  35. package/src/contract-search.test.ts +17 -0
  36. package/src/mcp-link-warnings-scope.test.ts +144 -0
  37. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  38. package/src/mcp-tools.ts +76 -17
  39. package/src/mirror-import.ts +5 -0
  40. package/src/routes.ts +298 -24
  41. package/src/routing.test.ts +167 -0
  42. package/src/routing.ts +47 -11
  43. package/src/tag-integrity-mcp.test.ts +45 -6
  44. package/src/tag-scope.ts +10 -1
  45. package/src/vault.test.ts +557 -21
  46. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  47. package/web/ui/dist/index.html +1 -1
package/src/routing.ts CHANGED
@@ -828,20 +828,28 @@ export async function route(
828
828
 
829
829
  // /api/doctor — read-only taxonomy/metadata integrity scan (vault#552).
830
830
  // Same "dispatch before the generic read/write gate" shape as /triggers
831
- // above, because this is ADMIN-tier regardless of method (GET-only, but
832
- // admin not read since it's a whole-vault diagnostic, same tier as
833
- // the MCP `doctor` tool and `prune-schema`).
831
+ // above dispatched early because it's GET-only and doesn't otherwise
832
+ // fall under the generic verb→scope gate's method-based default.
833
+ //
834
+ // Tier: `read` (was `admin` — re-tiered to match the MCP `doctor` tool's
835
+ // own read/write/admin re-tier, vault#570-adjacent scope-completion pass:
836
+ // doctor never mutates and is ALREADY tag-scope-restricted via
837
+ // `doctorTagScope` below, so read-scoped monitoring/tending callers need
838
+ // to be able to run it without an admin credential over EITHER door. A
839
+ // `vault:read` token can now call `GET /api/doctor` (previously
840
+ // `Forbidden`) — matching MCP's `doctor` tool exactly, closing the
841
+ // REST/MCP divergence the scope re-tier PR left as a known gap.
834
842
  if (apiMatch[1] === "/doctor") {
835
843
  if (req.method !== "GET") {
836
844
  return Response.json({ error: "Method not allowed", error_type: "method_not_allowed" }, { status: 405 });
837
845
  }
838
- if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
846
+ if (!hasScopeForVault(auth.scopes, vaultName, "read")) {
839
847
  return Response.json(
840
848
  {
841
849
  error: "Forbidden",
842
850
  error_type: "insufficient_scope",
843
- message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
844
- required_scope: SCOPE_ADMIN,
851
+ message: `This endpoint requires the '${SCOPE_READ}' scope (or '${SCOPE_READ.replace("vault:", `vault:${vaultName}:`)}').`,
852
+ required_scope: SCOPE_READ,
845
853
  granted_scopes: auth.scopes,
846
854
  },
847
855
  { status: 403 },
@@ -867,9 +875,30 @@ export async function route(
867
875
  const apiSubpath = apiMatch[1] ?? "";
868
876
  const isReadOnlyPost =
869
877
  req.method === "POST" && /^\/tags\/[^/]+\/conformance$/.test(apiSubpath);
870
- const requiredVerb = isReadOnlyPost ? "read" : verbForMethod(req.method);
878
+ // Tag-schema/taxonomy mutation carve-out mirrors the MCP-side
879
+ // update-tag/delete-tag/rename-tag/merge-tags re-tier (write → admin):
880
+ // these tools define a tag's SCHEMA or restructure the tag graph across
881
+ // every note carrying it — structure, not content — the same distinction
882
+ // that keeps `create-note`/`update-note`/`delete-note` at `write` while
883
+ // moving these to `admin`. Before this fix, the REST door only required
884
+ // `vault:write` here — a gap between doors (a `vault:write` token could
885
+ // rename/merge/delete/update a tag over REST while MCP already refused
886
+ // it). Matches PUT/DELETE `/tags/:name` (update-tag / delete-tag), POST
887
+ // `/tags/merge` (merge-tags), and POST `/tags/:name/rename` (rename-tag).
888
+ // Deliberately does NOT match `/tags/:name/conformance` (2 path segments,
889
+ // not 1 — see `isReadOnlyPost` above for that endpoint's own, lower tier)
890
+ // or plain GET `/tags[/:name]` (read, unaffected).
891
+ const isTagSchemaMutation =
892
+ ((req.method === "PUT" || req.method === "DELETE") && /^\/tags\/[^/]+$/.test(apiSubpath)) ||
893
+ (req.method === "POST" &&
894
+ (apiSubpath === "/tags/merge" || /^\/tags\/[^/]+\/rename$/.test(apiSubpath)));
895
+ const requiredVerb = isReadOnlyPost ? "read" : isTagSchemaMutation ? "admin" : verbForMethod(req.method);
871
896
  if (!hasScopeForVault(auth.scopes, vaultName, requiredVerb)) {
872
- const requiredApiScope = isReadOnlyPost ? SCOPE_READ : scopeForMethod(req.method);
897
+ const requiredApiScope = isReadOnlyPost
898
+ ? SCOPE_READ
899
+ : isTagSchemaMutation
900
+ ? SCOPE_ADMIN
901
+ : scopeForMethod(req.method);
873
902
  return Response.json(
874
903
  {
875
904
  error: "Forbidden",
@@ -931,9 +960,16 @@ export async function route(
931
960
  if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
932
961
  if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
933
962
  if (apiPath === "/vault") {
934
- return handleVault(req, store, vaultConfig, () => {
935
- writeVaultConfig(vaultConfig);
936
- });
963
+ return handleVault(
964
+ req,
965
+ store,
966
+ vaultConfig,
967
+ () => {
968
+ writeVaultConfig(vaultConfig);
969
+ },
970
+ undefined, // resolveCapability — keep the production default
971
+ tagScope,
972
+ );
937
973
  }
938
974
  if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store, tagScope);
939
975
  if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName, store, tagScope);
@@ -2,9 +2,17 @@
2
2
  * End-to-end coverage for the vault#552 MCP + REST surface: rename-tag and
3
3
  * merge-tags tool wiring (verb + tag-scope gating), delete-tag's
4
4
  * cascade/detach flags flowing through the MCP wrapper, and the `doctor`
5
- * tool/endpoint's admin-gate + tag-scope filtering. Fixture pattern mirrors
5
+ * tool/endpoint's tag-scope filtering. Fixture pattern mirrors
6
6
  * tag-field-conflict-scope.test.ts (PARACHUTE_HOME temp home + hand-built
7
7
  * AuthResult).
8
+ *
9
+ * Write/admin re-tier (this PR): the MCP `doctor` tool moved admin → read
10
+ * (read-only, tag-scope-restricted diagnostic); `rename-tag`/`merge-tags`/
11
+ * `delete-tag`/`update-tag` moved write → admin (schema/taxonomy curation,
12
+ * not content). The REST `GET /api/doctor` endpoint is intentionally
13
+ * UNCHANGED — it stays admin-gated (a separate enforcement point in
14
+ * src/routing.ts) — so it and the MCP `doctor` tool now sit at different
15
+ * tiers for the same underlying scan; see CHANGELOG.
8
16
  */
9
17
  import { describe, test, expect, beforeEach, afterEach } from "bun:test";
10
18
  import { mkdirSync, rmSync, existsSync } from "fs";
@@ -156,8 +164,8 @@ describe("delete-tag cascade/detach — MCP wrapper pass-through (vault#552)", (
156
164
  });
157
165
  });
158
166
 
159
- describe("doctor — admin gate + tag-scope filtering (vault#552)", () => {
160
- test("MCP: doctor is invisible in tools/list to a write-only (non-admin) session, and excluded-tool-called-explicitly still refuses it", async () => {
167
+ describe("doctor — read gate (re-tier) + tag-scope filtering (vault#552)", () => {
168
+ test("MCP: doctor is visible + callable for a read-only session (re-tier: admin read)", async () => {
161
169
  // `generateScopedMcpTools` (the `toolsFor` helper) always returns the
162
170
  // FULL tool set — the `requiredVerb` visibility filter lives one layer
163
171
  // up, in `handleScopedMcp`'s tools/list dispatch (mcp-http.ts). Drive
@@ -169,17 +177,48 @@ describe("doctor — admin gate + tag-scope filtering (vault#552)", () => {
169
177
  headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
170
178
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
171
179
  });
172
- const listRes = await handleScopedMcp(listReq, "journal", authFor("journal", null, "write"));
180
+ const listRes = await handleScopedMcp(listReq, "journal", authFor("journal", null, "read"));
173
181
  const listBody = await listRes.json() as any;
174
182
  const names: string[] = listBody.result.tools.map((t: any) => t.name);
175
- expect(names).not.toContain("doctor");
183
+ expect(names).toContain("doctor");
176
184
 
177
- // Explicitly calling it anyway is refused, not silently executed.
185
+ // Explicitly calling it succeeds no admin credential needed anymore.
178
186
  const callReq = new Request("http://localhost:1940/vault/journal/mcp", {
179
187
  method: "POST",
180
188
  headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
181
189
  body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "doctor", arguments: {} } }),
182
190
  });
191
+ const callRes = await handleScopedMcp(callReq, "journal", authFor("journal", null, "read"));
192
+ const callBody = await callRes.json() as any;
193
+ expect(callBody.result?.isError).toBeFalsy();
194
+ const report = JSON.parse(callBody.result.content[0].text);
195
+ expect(report.findings).toBeDefined();
196
+ });
197
+
198
+ test("MCP: update-tag is invisible in tools/list to a write-only (non-admin) session, and excluded-tool-called-explicitly still refuses it (re-tier: write → admin)", async () => {
199
+ seedVault("journal");
200
+ const { handleScopedMcp } = await import("./mcp-http.ts");
201
+ const listReq = new Request("http://localhost:1940/vault/journal/mcp", {
202
+ method: "POST",
203
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
204
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
205
+ });
206
+ const listRes = await handleScopedMcp(listReq, "journal", authFor("journal", null, "write"));
207
+ const listBody = await listRes.json() as any;
208
+ const names: string[] = listBody.result.tools.map((t: any) => t.name);
209
+ expect(names).not.toContain("update-tag");
210
+ expect(names).not.toContain("delete-tag");
211
+ expect(names).not.toContain("rename-tag");
212
+ expect(names).not.toContain("merge-tags");
213
+ // doctor, by contrast, IS visible — it's read-tier now, and write ⊇ read.
214
+ expect(names).toContain("doctor");
215
+
216
+ // Explicitly calling update-tag anyway is refused, not silently executed.
217
+ const callReq = new Request("http://localhost:1940/vault/journal/mcp", {
218
+ method: "POST",
219
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
220
+ body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "update-tag", arguments: { tag: "mine" } } }),
221
+ });
183
222
  const callRes = await handleScopedMcp(callReq, "journal", authFor("journal", null, "write"));
184
223
  const callBody = await callRes.json() as any;
185
224
  expect(callBody.result?.isError).toBe(true);
package/src/tag-scope.ts CHANGED
@@ -90,8 +90,17 @@ export function filterNotesByTagScope<T extends Note>(
90
90
  * about a warning's `schema`/`loser_schema` tag NAMES, not a note's whole
91
91
  * tag set) uses the identical visibility rule. `rawRoots === null` (unscoped)
92
92
  * → always visible.
93
+ *
94
+ * Exported (vault aggregate/rollup feature) so `query-notes`'s `aggregate`
95
+ * mode under `group_by: "tag"` can scrub GROUP NAMES for a scoped caller —
96
+ * a co-tagged note that's itself in scope (visible via one tag) still
97
+ * carries out-of-scope tags, and a tag rollup's groups ARE tag names, so
98
+ * narrowing which NOTES count (the `aggregateVisibility` note-level
99
+ * predicate) isn't sufficient on its own to keep an out-of-scope tag name
100
+ * from surfacing as a group. See `src/mcp-tools.ts` / `src/routes.ts`'s
101
+ * aggregate branches.
93
102
  */
94
- function tagVisibleInScope(
103
+ export function tagVisibleInScope(
95
104
  tag: string,
96
105
  allowed: Set<string> | null,
97
106
  rawRoots: string[] | null,