@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.
- 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 +737 -2
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/doctor.ts +23 -1
- package/core/src/hooks.ts +9 -0
- 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 +322 -53
- package/core/src/notes.ts +518 -67
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +55 -15
- package/core/src/store.ts +141 -7
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- 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/cli.ts +6 -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/mirror-import.ts +5 -0
- package/src/routes.ts +298 -24
- 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 +557 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- 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
|
|
832
|
-
//
|
|
833
|
-
//
|
|
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, "
|
|
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 '${
|
|
844
|
-
required_scope:
|
|
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
|
-
|
|
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
|
|
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(
|
|
935
|
-
|
|
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
|
|
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 —
|
|
160
|
-
test("MCP: doctor is
|
|
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, "
|
|
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).
|
|
183
|
+
expect(names).toContain("doctor");
|
|
176
184
|
|
|
177
|
-
// Explicitly calling it
|
|
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,
|