@openparachute/vault 0.7.0-rc.3 → 0.7.0-rc.5

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/routing.ts CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  handleUnresolvedWikilinks,
71
71
  handleStorage,
72
72
  handleViewNote,
73
+ handleDoctor,
73
74
  type TagScopeCtx,
74
75
  type WriteCtx,
75
76
  } from "./routes.ts";
@@ -825,6 +826,34 @@ export async function route(
825
826
  }
826
827
  }
827
828
 
829
+ // /api/doctor — read-only taxonomy/metadata integrity scan (vault#552).
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`).
834
+ if (apiMatch[1] === "/doctor") {
835
+ if (req.method !== "GET") {
836
+ return Response.json({ error: "Method not allowed", error_type: "method_not_allowed" }, { status: 405 });
837
+ }
838
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
839
+ return Response.json(
840
+ {
841
+ error: "Forbidden",
842
+ 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,
845
+ granted_scopes: auth.scopes,
846
+ },
847
+ { status: 403 },
848
+ );
849
+ }
850
+ const doctorTagScope: TagScopeCtx = {
851
+ allowed: await expandTokenTagScope(store, auth.scoped_tags),
852
+ raw: auth.scoped_tags,
853
+ };
854
+ return handleDoctor(req, store, doctorTagScope);
855
+ }
856
+
828
857
  // REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
829
858
  // POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
830
859
  // and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Tag-scope scrub on cross-tag field-conflict errors (vault#554
3
+ * auth-and-scope review fold, refined by the wire review).
4
+ *
5
+ * Core's cross-tag field validation (`collectCrossTagFieldViolations` /
6
+ * `collectTagFieldViolations`, core/src/tag-schemas.ts) scans EVERY tag's
7
+ * schema — scope-unaware by architecture — so a tag-scoped caller updating
8
+ * its own in-scope tag used to receive violations NAMING an out-of-scope
9
+ * tag and revealing its declared type/indexed flag (proven live on both MCP
10
+ * and REST during review). The fix keeps the rejection (schema integrity is
11
+ * scope-independent) but generalizes any violation whose conflicting
12
+ * declarer is outside the caller's allowlist: no tag name, no declared
13
+ * type/flag, `other_tag` dropped (`scrubTagFieldViolationsByScope`,
14
+ * src/tag-scope.ts). In-scope declarers keep full detail; unscoped callers
15
+ * are untouched.
16
+ *
17
+ * The same information leaks through a SECOND door (wire-review
18
+ * interaction): a BOTH-indexed cross-tag type conflict deliberately
19
+ * bypasses the 422 pre-check to preserve its pre-existing `declareField` →
20
+ * 400 `invalid_indexed_field` contract, and declareField's message names
21
+ * the other declarer tag(s) + their sqlite type. `scrubIndexedFieldConflictError`
22
+ * generalizes that message for scoped callers — same 400, same error_type,
23
+ * no leak. Covered on both surfaces below.
24
+ *
25
+ * Fixture: PARACHUTE_HOME temp home + hand-built AuthResult, same pattern
26
+ * as mcp-list-tags-scope.test.ts.
27
+ */
28
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
29
+ import { mkdirSync, rmSync, existsSync } from "fs";
30
+ import { join } from "path";
31
+ import { tmpdir } from "os";
32
+ import { writeVaultConfig } from "./config.ts";
33
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
34
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
35
+ import { handleTags } from "./routes.ts";
36
+ import type { AuthResult } from "./auth.ts";
37
+ import { TagFieldConflictError } from "../core/src/tag-schemas.ts";
38
+ import { IndexedFieldError } from "../core/src/indexed-fields.ts";
39
+
40
+ let tmpHome: string;
41
+ let prevHome: string | undefined;
42
+
43
+ beforeEach(() => {
44
+ tmpHome = join(tmpdir(), `vault-tagconflict-scope-${Date.now()}-${Math.random().toString(36).slice(2)}`);
45
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
46
+ prevHome = process.env.PARACHUTE_HOME;
47
+ process.env.PARACHUTE_HOME = tmpHome;
48
+ clearVaultStoreCache();
49
+ });
50
+
51
+ afterEach(() => {
52
+ clearVaultStoreCache();
53
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
54
+ else process.env.PARACHUTE_HOME = prevHome;
55
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
56
+ });
57
+
58
+ function seedVault(name: string): void {
59
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
60
+ getVaultStore(name);
61
+ }
62
+
63
+ function authFor(vaultName: string, scopedTags: string[] | null): AuthResult {
64
+ return {
65
+ permission: "full",
66
+ scopes: [`vault:${vaultName}:read`, `vault:${vaultName}:write`],
67
+ legacyDerived: false,
68
+ scoped_tags: scopedTags,
69
+ vault_name: null,
70
+ caller_jti: null,
71
+ actor: "test-user",
72
+ via: "api",
73
+ };
74
+ }
75
+
76
+ async function updateTagTool(vaultName: string, scopedTags: string[] | null) {
77
+ const tools = generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags));
78
+ return tools.find((t) => t.name === "update-tag")!;
79
+ }
80
+
81
+ /** Everything an agent-facing serialization of the error could carry. */
82
+ function serializeError(err: TagFieldConflictError): string {
83
+ return JSON.stringify({ message: err.message, tag: err.tag, violations: err.violations });
84
+ }
85
+
86
+ describe("tag_field_conflict × tag scope — out-of-scope declarers scrubbed (vault#554 fold)", () => {
87
+ test("MCP: scoped caller + out-of-scope NON-indexed type conflict → still rejected (422 class), but the out-of-scope tag's name and schema appear NOWHERE in the error", async () => {
88
+ seedVault("journal");
89
+ const store = getVaultStore("journal");
90
+ // Out-of-scope declarer with a distinctive name + type (non-indexed —
91
+ // the both-indexed variant routes through the 400 path, tested below).
92
+ await store.upsertTagRecord("project-manhattan", {
93
+ fields: { x: { type: "string" } },
94
+ });
95
+ // Caller's own in-scope tag.
96
+ await store.upsertTagRecord("mine", { description: "in scope" });
97
+
98
+ const tool = await updateTagTool("journal", ["mine"]);
99
+ let caught: unknown;
100
+ try {
101
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer" } } });
102
+ } catch (e) {
103
+ caught = e;
104
+ }
105
+
106
+ // The write is STILL rejected — integrity is scope-independent.
107
+ expect(caught).toBeInstanceOf(TagFieldConflictError);
108
+ const err = caught as TagFieldConflictError;
109
+ expect(err.violations).toHaveLength(1);
110
+ expect(err.violations[0]!.field).toBe("x");
111
+ expect(err.violations[0]!.reason).toBe("type_conflict");
112
+ expect(err.message).toContain("no changes were applied");
113
+
114
+ // The scrub: no tag name, no declared type, no other_tag — anywhere.
115
+ expect(err.violations[0]!.other_tag).toBeUndefined();
116
+ const serialized = serializeError(err);
117
+ expect(serialized).not.toContain("project-manhattan");
118
+ expect(serialized).not.toContain('declares "string"');
119
+
120
+ // Nothing persisted.
121
+ const mine = await store.getTagRecord("mine");
122
+ expect(mine?.fields ?? null).toBeFalsy();
123
+ });
124
+
125
+ test("MCP: scoped caller + IN-scope conflict keeps full detail (tag name + declared type + other_tag)", async () => {
126
+ seedVault("journal");
127
+ const store = getVaultStore("journal");
128
+ await store.upsertTagRecord("mine-too", {
129
+ fields: { x: { type: "string" } },
130
+ });
131
+ await store.upsertTagRecord("mine", { description: "in scope" });
132
+
133
+ const tool = await updateTagTool("journal", ["mine", "mine-too"]);
134
+ let caught: unknown;
135
+ try {
136
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer" } } });
137
+ } catch (e) {
138
+ caught = e;
139
+ }
140
+
141
+ expect(caught).toBeInstanceOf(TagFieldConflictError);
142
+ const err = caught as TagFieldConflictError;
143
+ expect(err.violations).toHaveLength(1);
144
+ expect(err.violations[0]!.other_tag).toBe("mine-too");
145
+ expect(err.violations[0]!.message).toContain("mine-too");
146
+ expect(err.violations[0]!.message).toContain('declares "string"');
147
+ });
148
+
149
+ test("REST: scoped caller + out-of-scope NON-indexed type conflict → 422 with generalized violation, out-of-scope tag name nowhere in the body", async () => {
150
+ seedVault("journal");
151
+ const store = getVaultStore("journal");
152
+ await store.upsertTagRecord("project-manhattan", {
153
+ fields: { x: { type: "string" } },
154
+ });
155
+ await store.upsertTagRecord("mine", { description: "in scope" });
156
+
157
+ const req = new Request("http://localhost/api/tags/mine", {
158
+ method: "PUT",
159
+ body: JSON.stringify({ fields: { x: { type: "integer" } } }),
160
+ });
161
+ const res = await handleTags(req, store, "/mine", {
162
+ allowed: new Set(["mine"]),
163
+ raw: ["mine"],
164
+ });
165
+
166
+ expect(res.status).toBe(422);
167
+ const body: any = await res.json();
168
+ expect(body.error_type).toBe("tag_field_conflict");
169
+ expect(body.violations).toHaveLength(1);
170
+ expect(body.violations[0].field).toBe("x");
171
+ expect(body.violations[0].reason).toBe("type_conflict");
172
+ expect(body.violations[0].other_tag).toBeUndefined();
173
+ expect(body.message).toContain("no changes were applied");
174
+
175
+ const serialized = JSON.stringify(body);
176
+ expect(serialized).not.toContain("project-manhattan");
177
+ expect(serialized).not.toContain('declares "string"');
178
+
179
+ const mine = await store.getTagRecord("mine");
180
+ expect(mine?.fields ?? null).toBeFalsy();
181
+ });
182
+
183
+ test("REST: scoped caller + IN-scope conflict keeps full detail", async () => {
184
+ seedVault("journal");
185
+ const store = getVaultStore("journal");
186
+ await store.upsertTagRecord("mine-too", {
187
+ fields: { x: { type: "string" } },
188
+ });
189
+ await store.upsertTagRecord("mine", { description: "in scope" });
190
+
191
+ const req = new Request("http://localhost/api/tags/mine", {
192
+ method: "PUT",
193
+ body: JSON.stringify({ fields: { x: { type: "integer" } } }),
194
+ });
195
+ const res = await handleTags(req, store, "/mine", {
196
+ allowed: new Set(["mine", "mine-too"]),
197
+ raw: ["mine", "mine-too"],
198
+ });
199
+
200
+ expect(res.status).toBe(422);
201
+ const body: any = await res.json();
202
+ expect(body.violations).toHaveLength(1);
203
+ expect(body.violations[0].other_tag).toBe("mine-too");
204
+ expect(body.violations[0].message).toContain("mine-too");
205
+ expect(body.violations[0].message).toContain('declares "string"');
206
+ });
207
+
208
+ test("unscoped callers keep full detail everywhere (MCP + REST controls)", async () => {
209
+ seedVault("journal");
210
+ const store = getVaultStore("journal");
211
+ await store.upsertTagRecord("project-manhattan", {
212
+ fields: { x: { type: "string" } },
213
+ });
214
+
215
+ // MCP — unscoped session: applyTagScopeWrappers never installs, core's
216
+ // full-detail error passes through untouched.
217
+ const tool = await updateTagTool("journal", null);
218
+ let caught: unknown;
219
+ try {
220
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer" } } });
221
+ } catch (e) {
222
+ caught = e;
223
+ }
224
+ const err = caught as TagFieldConflictError;
225
+ expect(err.violations[0]!.other_tag).toBe("project-manhattan");
226
+ expect(err.violations[0]!.message).toContain("project-manhattan");
227
+
228
+ // REST — unscoped ctx (allowed === null): scrub is a no-op.
229
+ const req = new Request("http://localhost/api/tags/mine", {
230
+ method: "PUT",
231
+ body: JSON.stringify({ fields: { x: { type: "integer" } } }),
232
+ });
233
+ const res = await handleTags(req, store, "/mine");
234
+ expect(res.status).toBe(422);
235
+ const body: any = await res.json();
236
+ expect(body.violations[0].other_tag).toBe("project-manhattan");
237
+ expect(body.violations[0].message).toContain("project-manhattan");
238
+ });
239
+ });
240
+
241
+ describe("invalid_indexed_field × tag scope — the 400 door is scrubbed too (wire-review interaction)", () => {
242
+ test("REST: scoped caller + BOTH-indexed type conflict vs out-of-scope tag → 400 invalid_indexed_field (status preserved) with a generalized message", async () => {
243
+ seedVault("journal");
244
+ const store = getVaultStore("journal");
245
+ await store.upsertTagRecord("project-manhattan", {
246
+ fields: { x: { type: "string", indexed: true } },
247
+ });
248
+ await store.upsertTagRecord("mine", { description: "in scope" });
249
+
250
+ const req = new Request("http://localhost/api/tags/mine", {
251
+ method: "PUT",
252
+ body: JSON.stringify({ fields: { x: { type: "integer", indexed: true } } }),
253
+ });
254
+ const res = await handleTags(req, store, "/mine", {
255
+ allowed: new Set(["mine"]),
256
+ raw: ["mine"],
257
+ });
258
+
259
+ // Status + error_type are the PRESERVED pre-existing contract; only
260
+ // the message prose is generalized for the scoped caller.
261
+ expect(res.status).toBe(400);
262
+ const body: any = await res.json();
263
+ expect(body.error_type).toBe("invalid_indexed_field");
264
+ expect(body.error).toContain('field "x"');
265
+ expect(body.error).toContain("outside your token's tag scope");
266
+ const serialized = JSON.stringify(body);
267
+ expect(serialized).not.toContain("project-manhattan");
268
+ expect(serialized).not.toContain("TEXT"); // the existing sqlite type stays hidden too
269
+
270
+ // Still rejected — nothing persisted.
271
+ const mine = await store.getTagRecord("mine");
272
+ expect(mine?.fields ?? null).toBeFalsy();
273
+ });
274
+
275
+ test("MCP: scoped caller + BOTH-indexed type conflict vs out-of-scope tag → IndexedFieldError with a generalized message", async () => {
276
+ seedVault("journal");
277
+ const store = getVaultStore("journal");
278
+ await store.upsertTagRecord("project-manhattan", {
279
+ fields: { x: { type: "string", indexed: true } },
280
+ });
281
+ await store.upsertTagRecord("mine", { description: "in scope" });
282
+
283
+ const tool = await updateTagTool("journal", ["mine"]);
284
+ let caught: any;
285
+ try {
286
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer", indexed: true } } });
287
+ } catch (e) {
288
+ caught = e;
289
+ }
290
+
291
+ expect(caught).toBeInstanceOf(IndexedFieldError);
292
+ expect(caught.error_type).toBe("invalid_indexed_field");
293
+ expect(caught.message).toContain("outside your token's tag scope");
294
+ expect(caught.message).not.toContain("project-manhattan");
295
+ expect(caught.message).not.toContain("TEXT");
296
+ // The structured declarer context is dropped from the scrubbed error.
297
+ expect(caught.declarer_tags).toBeUndefined();
298
+ });
299
+
300
+ test("both-indexed conflict vs an IN-scope tag keeps declareField's full message on both surfaces", async () => {
301
+ seedVault("journal");
302
+ const store = getVaultStore("journal");
303
+ await store.upsertTagRecord("mine-too", {
304
+ fields: { x: { type: "string", indexed: true } },
305
+ });
306
+ await store.upsertTagRecord("mine", { description: "in scope" });
307
+
308
+ // REST
309
+ const req = new Request("http://localhost/api/tags/mine", {
310
+ method: "PUT",
311
+ body: JSON.stringify({ fields: { x: { type: "integer", indexed: true } } }),
312
+ });
313
+ const res = await handleTags(req, store, "/mine", {
314
+ allowed: new Set(["mine", "mine-too"]),
315
+ raw: ["mine", "mine-too"],
316
+ });
317
+ expect(res.status).toBe(400);
318
+ const body: any = await res.json();
319
+ expect(body.error_type).toBe("invalid_indexed_field");
320
+ expect(body.error).toContain("mine-too");
321
+
322
+ // MCP
323
+ const tool = await updateTagTool("journal", ["mine", "mine-too"]);
324
+ let caught: any;
325
+ try {
326
+ await tool.execute({ tag: "mine", fields: { x: { type: "integer", indexed: true } } });
327
+ } catch (e) {
328
+ caught = e;
329
+ }
330
+ expect(caught).toBeInstanceOf(IndexedFieldError);
331
+ expect(caught.message).toContain("mine-too");
332
+ });
333
+ });
@@ -0,0 +1,288 @@
1
+ /**
2
+ * End-to-end coverage for the vault#552 MCP + REST surface: rename-tag and
3
+ * merge-tags tool wiring (verb + tag-scope gating), delete-tag's
4
+ * cascade/detach flags flowing through the MCP wrapper, and the `doctor`
5
+ * tool/endpoint's admin-gate + tag-scope filtering. Fixture pattern mirrors
6
+ * tag-field-conflict-scope.test.ts (PARACHUTE_HOME temp home + hand-built
7
+ * AuthResult).
8
+ */
9
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
10
+ import { mkdirSync, rmSync, existsSync } from "fs";
11
+ import { join } from "path";
12
+ import { tmpdir } from "os";
13
+ import { writeVaultConfig } from "./config.ts";
14
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
15
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
16
+ import { handleTags, handleDoctor, type TagScopeCtx } from "./routes.ts";
17
+ import type { AuthResult } from "./auth.ts";
18
+
19
+ let tmpHome: string;
20
+ let prevHome: string | undefined;
21
+
22
+ beforeEach(() => {
23
+ tmpHome = join(tmpdir(), `vault-tag-integrity-mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
24
+ mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
25
+ prevHome = process.env.PARACHUTE_HOME;
26
+ process.env.PARACHUTE_HOME = tmpHome;
27
+ clearVaultStoreCache();
28
+ });
29
+
30
+ afterEach(() => {
31
+ clearVaultStoreCache();
32
+ if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
33
+ else process.env.PARACHUTE_HOME = prevHome;
34
+ if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
35
+ });
36
+
37
+ function seedVault(name: string): void {
38
+ writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
39
+ getVaultStore(name);
40
+ }
41
+
42
+ function authFor(vaultName: string, scopedTags: string[] | null, verb: "read" | "write" | "admin" = "write"): AuthResult {
43
+ const verbs = verb === "admin" ? ["read", "write", "admin"] : verb === "write" ? ["read", "write"] : ["read"];
44
+ return {
45
+ permission: "full",
46
+ scopes: verbs.map((v) => `vault:${vaultName}:${v}`),
47
+ legacyDerived: false,
48
+ scoped_tags: scopedTags,
49
+ vault_name: null,
50
+ caller_jti: null,
51
+ actor: "test-user",
52
+ via: "api",
53
+ };
54
+ }
55
+
56
+ async function toolsFor(vaultName: string, scopedTags: string[] | null, verb: "read" | "write" | "admin" = "write") {
57
+ return generateScopedMcpTools(vaultName, authFor(vaultName, scopedTags, verb));
58
+ }
59
+
60
+ describe("rename-tag / merge-tags — tag-scope gating (vault#552)", () => {
61
+ test("rename-tag: old_name or new_name outside the allowlist is refused before reaching core", async () => {
62
+ seedVault("journal");
63
+ const store = getVaultStore("journal");
64
+ await store.upsertTagRecord("mine", {});
65
+ await store.upsertTagRecord("theirs", {});
66
+
67
+ const tools = await toolsFor("journal", ["mine"]);
68
+ const renameTag = tools.find((t) => t.name === "rename-tag")!;
69
+
70
+ const result = await renameTag.execute({ old_name: "theirs", new_name: "mine2" }) as any;
71
+ expect(result.error_type).toBe("tag_scope_violation");
72
+
73
+ // Untouched.
74
+ expect(await store.getTagRecord("theirs")).not.toBeNull();
75
+ });
76
+
77
+ test("rename-tag: both names in-scope reaches core (wrapper doesn't spuriously block), which reports target_exists for an already-live target", async () => {
78
+ seedVault("journal");
79
+ const store = getVaultStore("journal");
80
+ await store.upsertTagRecord("mine", {});
81
+ await store.upsertTagRecord("mine-other", {});
82
+
83
+ // Both names are directly scoped (each tag is trivially a member of its
84
+ // own descendant set), so the wrapper's scope check passes and the call
85
+ // reaches core — which correctly refuses because "mine-other" already
86
+ // exists (rename never silently overwrites; that's what merge-tags is
87
+ // for). Proves the wrapper isn't the thing blocking this, core is.
88
+ const tools = await toolsFor("journal", ["mine", "mine-other"]);
89
+ const renameTag = tools.find((t) => t.name === "rename-tag")!;
90
+ let caught: any;
91
+ try {
92
+ await renameTag.execute({ old_name: "mine", new_name: "mine-other" });
93
+ } catch (e) { caught = e; }
94
+ expect(caught?.error_type).toBe("target_exists");
95
+ });
96
+
97
+ test("merge-tags: a source outside the allowlist is refused before reaching core", async () => {
98
+ seedVault("journal");
99
+ const store = getVaultStore("journal");
100
+ await store.upsertTagRecord("mine", {});
101
+ await store.upsertTagRecord("theirs", {});
102
+
103
+ const tools = await toolsFor("journal", ["mine"]);
104
+ const mergeTags = tools.find((t) => t.name === "merge-tags")!;
105
+ const result = await mergeTags.execute({ sources: ["theirs"], target: "mine" }) as any;
106
+ expect(result.error_type).toBe("tag_scope_violation");
107
+ expect(await store.getTagRecord("theirs")).not.toBeNull();
108
+ });
109
+
110
+ test("merge-tags: a source referenced by a tag-scoped token is refused (409-equivalent), independent of the CALLER's own scope", async () => {
111
+ seedVault("journal");
112
+ const store = getVaultStore("journal");
113
+ await store.upsertTagRecord("mine", {});
114
+ await store.upsertTagRecord("locked", {});
115
+ // A DIFFERENT (vestigial) tag-scoped token row references "locked" —
116
+ // merging it away would orphan that token's allowlist. Raw INSERT, same
117
+ // fixture pattern as vault.test.ts's "MCP delete-tag returns
118
+ // tag_in_use_by_tokens..." — vault no longer mints these post-0.5.0, but
119
+ // findTokensReferencingTag still guards against leftover rows.
120
+ store.db
121
+ .prepare(
122
+ "INSERT INTO tokens (token_hash, label, permission, scoped_tags, created_at) VALUES (?, ?, ?, ?, ?)",
123
+ )
124
+ .run(
125
+ `sha256:locked-claw-${Math.random().toString(36).slice(2)}`,
126
+ "locked-claw",
127
+ "read",
128
+ JSON.stringify(["locked"]),
129
+ new Date().toISOString(),
130
+ );
131
+
132
+ const tools = await toolsFor("journal", null); // unscoped caller — still blocked by the token-reference guard
133
+ const mergeTags = tools.find((t) => t.name === "merge-tags")!;
134
+ const result = await mergeTags.execute({ sources: ["locked"], target: "mine" }) as any;
135
+ expect(result.error_type).toBe("tag_in_use_by_tokens");
136
+ expect(await store.getTagRecord("locked")).not.toBeNull();
137
+ });
138
+ });
139
+
140
+ describe("delete-tag cascade/detach — MCP wrapper pass-through (vault#552)", () => {
141
+ test("cascade:true flows through the tag-scope wrapper to the underlying store call", async () => {
142
+ seedVault("journal");
143
+ const store = getVaultStore("journal");
144
+ await store.upsertTagRecord("mine", { description: "root" });
145
+ await store.upsertTagRecord("mine-child", { parent_names: ["mine"] });
146
+
147
+ const tools = await toolsFor("journal", ["mine"]); // "mine-child" is a descendant, also in-scope
148
+ const deleteTag = tools.find((t) => t.name === "delete-tag")!;
149
+
150
+ const refused = await deleteTag.execute({ tag: "mine" }) as any;
151
+ expect(refused.error).toBe("tag_referenced_as_parent");
152
+
153
+ const cascaded = await deleteTag.execute({ tag: "mine", cascade: true }) as any;
154
+ expect(cascaded.deleted).toBe(true);
155
+ expect(await store.getTagRecord("mine")).toBeNull();
156
+ });
157
+ });
158
+
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 () => {
161
+ // `generateScopedMcpTools` (the `toolsFor` helper) always returns the
162
+ // FULL tool set — the `requiredVerb` visibility filter lives one layer
163
+ // up, in `handleScopedMcp`'s tools/list dispatch (mcp-http.ts). Drive
164
+ // through that layer, same pattern as vault.test.ts's `listToolNames`.
165
+ seedVault("journal");
166
+ const { handleScopedMcp } = await import("./mcp-http.ts");
167
+ const listReq = new Request("http://localhost:1940/vault/journal/mcp", {
168
+ method: "POST",
169
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
170
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
171
+ });
172
+ const listRes = await handleScopedMcp(listReq, "journal", authFor("journal", null, "write"));
173
+ const listBody = await listRes.json() as any;
174
+ const names: string[] = listBody.result.tools.map((t: any) => t.name);
175
+ expect(names).not.toContain("doctor");
176
+
177
+ // Explicitly calling it anyway is refused, not silently executed.
178
+ const callReq = new Request("http://localhost:1940/vault/journal/mcp", {
179
+ method: "POST",
180
+ headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
181
+ body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "doctor", arguments: {} } }),
182
+ });
183
+ const callRes = await handleScopedMcp(callReq, "journal", authFor("journal", null, "write"));
184
+ const callBody = await callRes.json() as any;
185
+ expect(callBody.result?.isError).toBe(true);
186
+ });
187
+
188
+ test("MCP: doctor is visible + callable for an admin session, and a scoped admin session only reports in-scope findings", async () => {
189
+ seedVault("journal");
190
+ const store = getVaultStore("journal");
191
+ // In-scope dangling parent_names.
192
+ await store.upsertTagRecord("mine-broken", { parent_names: ["mine-ghost-parent"] });
193
+ // Out-of-scope dangling parent_names — must not appear in a scoped report.
194
+ await store.upsertTagRecord("theirs-broken", { parent_names: ["theirs-ghost-parent"] });
195
+
196
+ const unscopedTools = await toolsFor("journal", null, "admin");
197
+ const unscopedDoctor = unscopedTools.find((t) => t.name === "doctor")!;
198
+ const unscopedReport = await unscopedDoctor.execute({}) as any;
199
+ const unscopedSubjects = unscopedReport.findings.map((f: any) => f.subject);
200
+ expect(unscopedSubjects).toContain("mine-broken");
201
+ expect(unscopedSubjects).toContain("theirs-broken");
202
+
203
+ const scopedTools = await toolsFor("journal", ["mine-broken"], "admin");
204
+ const scopedDoctor = scopedTools.find((t) => t.name === "doctor")!;
205
+ const scopedReport = await scopedDoctor.execute({}) as any;
206
+ const scopedSubjects = scopedReport.findings.map((f: any) => f.subject);
207
+ expect(scopedSubjects).toContain("mine-broken");
208
+ expect(scopedSubjects).not.toContain("theirs-broken");
209
+ });
210
+
211
+ test("REST: GET /api/doctor reports findings filtered to the tag-scoped caller's allowlist", async () => {
212
+ seedVault("journal");
213
+ const store = getVaultStore("journal");
214
+ await store.upsertTagRecord("mine-broken", { parent_names: ["mine-ghost-parent"] });
215
+ await store.upsertTagRecord("theirs-broken", { parent_names: ["theirs-ghost-parent"] });
216
+
217
+ const tagScope: TagScopeCtx = { allowed: new Set(["mine-broken"]), raw: ["mine-broken"] };
218
+ const res = await handleDoctor(new Request("http://localhost/api/doctor"), store, tagScope);
219
+ expect(res.status).toBe(200);
220
+ const body: any = await res.json();
221
+ const subjects = body.findings.map((f: any) => f.subject);
222
+ expect(subjects).toContain("mine-broken");
223
+ expect(subjects).not.toContain("theirs-broken");
224
+ });
225
+ });
226
+
227
+ describe("parent_cycle — REST PUT /api/tags/:name surfaces the 409 (vault#552)", () => {
228
+ test("A→B then B→A is rejected with error_type parent_cycle, naming the cycle", async () => {
229
+ seedVault("journal");
230
+ const store = getVaultStore("journal");
231
+ await store.upsertTagRecord("b", {});
232
+ await store.upsertTagRecord("a", { parent_names: ["b"] });
233
+
234
+ const req = new Request("http://localhost/api/tags/b", {
235
+ method: "PUT",
236
+ body: JSON.stringify({ parent_names: ["a"] }),
237
+ });
238
+ const res = await handleTags(req, store, "/b");
239
+ expect(res.status).toBe(409);
240
+ const body: any = await res.json();
241
+ expect(body.error_type).toBe("parent_cycle");
242
+ expect(body.tag).toBe("b");
243
+ expect(body.cycle).toContain("a");
244
+ expect(body.cycle).toContain("b");
245
+ // MUST-FIX 5 (wire review): a `message` key is load-bearing — the shared
246
+ // parachute-surface VaultClient 409 handler reads `body.message` (else it
247
+ // shows a hardcoded "Note was edited elsewhere"). `error` is the short
248
+ // code, `message` the human sentence — same split every sibling 409 uses.
249
+ expect(typeof body.message).toBe("string");
250
+ expect(body.message.length).toBeGreaterThan(0);
251
+ expect(body.message).toMatch(/cycle/i);
252
+ expect(body.error).toBe("ParentCycle");
253
+ expect(body.error).not.toContain(" "); // short code, not the full sentence
254
+
255
+ // Nothing persisted.
256
+ expect((await store.getTagRecord("b"))?.parent_names ?? null).toBeFalsy();
257
+ });
258
+ });
259
+
260
+ describe("tag_referenced_as_parent — REST DELETE /api/tags/:name surfaces the 409 + flags (vault#552)", () => {
261
+ test("refused by default, cascade query param proceeds", async () => {
262
+ seedVault("journal");
263
+ const store = getVaultStore("journal");
264
+ await store.upsertTagRecord("root", {});
265
+ await store.upsertTagRecord("child", { parent_names: ["root"] });
266
+
267
+ const refusedRes = await handleTags(new Request("http://localhost/api/tags/root", { method: "DELETE" }), store, "/root");
268
+ expect(refusedRes.status).toBe(409);
269
+ const refusedBody: any = await refusedRes.json();
270
+ expect(refusedBody.error_type).toBe("tag_referenced_as_parent");
271
+ expect(refusedBody.referencing_tags).toEqual(["child"]);
272
+ // Same short-code-`error` + human-`message` split as every sibling 409
273
+ // (wire consistency — parachute-surface reads `body.message`).
274
+ expect(refusedBody.error).toBe("TagReferencedAsParent");
275
+ expect(typeof refusedBody.message).toBe("string");
276
+ expect(refusedBody.message.length).toBeGreaterThan(0);
277
+
278
+ const cascadedRes = await handleTags(
279
+ new Request("http://localhost/api/tags/root?cascade=true", { method: "DELETE" }),
280
+ store,
281
+ "/root",
282
+ );
283
+ expect(cascadedRes.status).toBe(200);
284
+ const cascadedBody: any = await cascadedRes.json();
285
+ expect(cascadedBody.deleted).toBe(true);
286
+ expect(cascadedBody.parent_refs_detached).toBe(1);
287
+ });
288
+ });