@openparachute/vault 0.7.0-rc.4 → 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/routes.ts CHANGED
@@ -42,10 +42,13 @@ import {
42
42
  filterNotesByTagScope,
43
43
  noteWithinTagScope,
44
44
  scrubIndexedFieldConflictError,
45
+ scrubParentCycleError,
46
+ scrubReferencingTagsByScope,
45
47
  scrubTagFieldViolationsByScope,
46
48
  tagScopeForbidden,
47
49
  tagsWithinScope,
48
50
  } from "./tag-scope.ts";
51
+ import { ParentCycleError } from "../core/src/tag-schemas.ts";
49
52
  import { findTokensReferencingTag } from "./token-store.ts";
50
53
 
51
54
  /**
@@ -2665,6 +2668,29 @@ export async function handleTags(
2665
2668
  400,
2666
2669
  );
2667
2670
  }
2671
+ if (err instanceof ParentCycleError) {
2672
+ // vault#552: parent_names would close a cycle. Nothing was
2673
+ // persisted. Scope-scrub the cycle path for a tag-scoped caller —
2674
+ // the write stays rejected either way. `error` is a SHORT code and
2675
+ // `message` the human sentence — the same split every sibling 409 in
2676
+ // this file uses (target_exists, tag_in_use_by_tokens,
2677
+ // tag_referenced_as_parent). parachute-surface's shared VaultClient
2678
+ // 409 handler reads `body.message` (falling back to a hardcoded
2679
+ // "Note was edited elsewhere"), so the message key is load-bearing:
2680
+ // without it the Tags parent_names editor would show the wrong
2681
+ // conflict text.
2682
+ const scrubbed = scrubParentCycleError(err, tagScope.allowed);
2683
+ return json(
2684
+ {
2685
+ error: "ParentCycle",
2686
+ error_type: "parent_cycle",
2687
+ tag: scrubbed.tag,
2688
+ cycle: scrubbed.cycle,
2689
+ message: scrubbed.message,
2690
+ },
2691
+ 409,
2692
+ );
2693
+ }
2668
2694
  throw err;
2669
2695
  }
2670
2696
  return json(result);
@@ -2692,7 +2718,33 @@ export async function handleTags(
2692
2718
  409,
2693
2719
  );
2694
2720
  }
2695
- return json(await store.deleteTag(tagName));
2721
+ // `?cascade=true` / `?detach=true` (synonyms — see DeleteTagOpts):
2722
+ // required to delete a tag another tag's parent_names still references
2723
+ // (vault#552). Query params, not a DELETE body, so the flag is visible
2724
+ // in access logs and works with any HTTP client without body support.
2725
+ const cascade = parseBool(parseQuery(url, "cascade"), false);
2726
+ const detach = parseBool(parseQuery(url, "detach"), false);
2727
+ const result = await store.deleteTag(tagName, { cascade, detach });
2728
+ if ("error" in result) {
2729
+ // Referential-integrity refusal (vault#552). Scope-scrub the
2730
+ // referencing tag names for a tag-scoped caller — the delete is
2731
+ // still refused (integrity is scope-independent), but an
2732
+ // out-of-scope referrer's name must not leak. Same posture as
2733
+ // `tag_field_conflict`'s scrub.
2734
+ const referencing_tags = scrubReferencingTagsByScope(result.referencing_tags, tagScope.allowed);
2735
+ return json(
2736
+ {
2737
+ error: "TagReferencedAsParent",
2738
+ error_type: "tag_referenced_as_parent",
2739
+ tag: tagName,
2740
+ referencing_tags,
2741
+ message: `Tag "${tagName}" is referenced by ${referencing_tags.length} other tag(s)' parent_names; pass ?cascade=true or ?detach=true to also remove the reference, or update the referencing tag(s) first.`,
2742
+ hint: "pass ?cascade=true or ?detach=true, or fix the referencing tag(s)' parent_names first",
2743
+ },
2744
+ 409,
2745
+ );
2746
+ }
2747
+ return json(result);
2696
2748
  }
2697
2749
 
2698
2750
  return json({ error: "Method not allowed", error_type: "method_not_allowed" }, 405);
@@ -2924,6 +2976,27 @@ export function handleUnresolvedWikilinks(
2924
2976
  return Response.json({ unresolved: filtered, count: filtered.length });
2925
2977
  }
2926
2978
 
2979
+ // ---------------------------------------------------------------------------
2980
+ // Doctor — GET /api/doctor (vault#552, admin-gated in routing.ts)
2981
+ // ---------------------------------------------------------------------------
2982
+
2983
+ /**
2984
+ * `GET /vault/{name}/api/doctor` — read-only taxonomy/metadata integrity
2985
+ * scan. Method + `vault:admin` scope are already enforced by the caller
2986
+ * (`src/routing.ts`, dispatched before the generic read/write gate — same
2987
+ * shape as `/api/triggers`); this handler just runs the scan with the
2988
+ * caller's tag-scope allowlist. See `core/src/doctor.ts` for the finding
2989
+ * catalog.
2990
+ */
2991
+ export async function handleDoctor(
2992
+ _req: Request,
2993
+ store: Store,
2994
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
2995
+ ): Promise<Response> {
2996
+ const report = await store.doctor({ allowedTags: tagScope.allowed });
2997
+ return json(report);
2998
+ }
2999
+
2927
3000
  // ---------------------------------------------------------------------------
2928
3001
  // Published notes — public, no-auth HTML rendering
2929
3002
  // ---------------------------------------------------------------------------
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,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
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Tag-scope scrub on the two vault#552 taxonomy-integrity guards:
3
+ * `tag_referenced_as_parent` (delete-tag's referential-integrity refusal)
4
+ * and `parent_cycle` (upsertTagRecord's write-time cycle guard). Same "in
5
+ * doubt, scrub" posture as `tag_field_conflict` (see
6
+ * tag-field-conflict-scope.test.ts) — the write stays rejected regardless
7
+ * of scope, but an out-of-scope tag name must never appear in the response.
8
+ *
9
+ * Reachability note: for BOTH guards, the offending tag(s) the scrub would
10
+ * redact are, by construction, always descendants of the tag the caller is
11
+ * already scoped to (deleteTag's `referencing_tags` are children of the
12
+ * doomed tag; `findParentCycle`'s conflicting parent + path are members of
13
+ * `getTagDescendants(hierarchy, tag)`, the SAME computation
14
+ * `expandTokenTagScope` uses to build the caller's allowlist for that same
15
+ * `tag`). So under the CURRENT hierarchy-based tag-scope model, a live
16
+ * end-to-end request can't actually construct an out-of-scope referrer —
17
+ * the scrub is defense-in-depth for a future change to either guard (e.g. if
18
+ * `deleteTag`'s search ever widens past direct parent_names children). These
19
+ * tests exercise the scrub functions directly against a synthetic
20
+ * out-of-scope input to pin the CONTRACT ("an out-of-scope name never
21
+ * survives"), independent of today's reachability.
22
+ */
23
+ import { describe, test, expect } from "bun:test";
24
+ import { scrubReferencingTagsByScope, scrubParentCycleError } from "./tag-scope.ts";
25
+ import { ParentCycleError } from "../core/src/tag-schemas.ts";
26
+
27
+ describe("scrubReferencingTagsByScope (vault#552)", () => {
28
+ test("unscoped caller (allowed === null): passthrough, no redaction", () => {
29
+ const result = scrubReferencingTagsByScope(["child-a", "secret-child"], null);
30
+ expect(result).toEqual(["child-a", "secret-child"]);
31
+ });
32
+
33
+ test("scoped caller: in-scope names kept, out-of-scope names redacted to a generic label — never the real name", () => {
34
+ const allowed = new Set(["child-a"]);
35
+ const result = scrubReferencingTagsByScope(["child-a", "secret-child"], allowed);
36
+ expect(result[0]).toBe("child-a");
37
+ expect(result[1]).not.toBe("secret-child");
38
+ expect(result.join(" ")).not.toContain("secret-child");
39
+ expect(result[1]).toContain("outside your token's tag scope");
40
+ });
41
+
42
+ test("every referencing tag out of scope: every entry redacted, count preserved", () => {
43
+ const allowed = new Set<string>(["unrelated"]);
44
+ const result = scrubReferencingTagsByScope(["secret-1", "secret-2"], allowed);
45
+ expect(result).toHaveLength(2);
46
+ for (const r of result) {
47
+ expect(r).not.toBe("secret-1");
48
+ expect(r).not.toBe("secret-2");
49
+ }
50
+ });
51
+
52
+ test("empty referencing list stays empty", () => {
53
+ expect(scrubReferencingTagsByScope([], new Set())).toEqual([]);
54
+ });
55
+ });
56
+
57
+ describe("scrubParentCycleError (vault#552)", () => {
58
+ test("unscoped caller (allowed === null): passthrough, same instance semantics (message/tag/cycle unchanged)", () => {
59
+ const err = new ParentCycleError("mine", ["mine", "secret-hop", "mine"]);
60
+ const scrubbed = scrubParentCycleError(err, null);
61
+ expect(scrubbed).toBe(err);
62
+ });
63
+
64
+ test("every hop in scope: passthrough, original error returned untouched", () => {
65
+ const err = new ParentCycleError("mine", ["mine", "child", "mine"]);
66
+ const allowed = new Set(["mine", "child"]);
67
+ const scrubbed = scrubParentCycleError(err, allowed);
68
+ expect(scrubbed).toBe(err);
69
+ expect(scrubbed.cycle).toEqual(["mine", "child", "mine"]);
70
+ });
71
+
72
+ test("an out-of-scope hop is generalized: the caller's own tag stays named, the out-of-scope hop does not, and the write is still reported rejected", () => {
73
+ const err = new ParentCycleError("mine", ["mine", "secret-hop", "mine"]);
74
+ const allowed = new Set(["mine"]); // "secret-hop" deliberately excluded
75
+ const scrubbed = scrubParentCycleError(err, allowed);
76
+
77
+ expect(scrubbed).not.toBe(err); // a REPLACEMENT error, not the original
78
+ expect(scrubbed.error_type).toBe("parent_cycle");
79
+ expect(scrubbed.tag).toBe("mine"); // the caller's own tag — always in-scope, never redacted
80
+ expect(scrubbed.cycle[0]).toBe("mine");
81
+ expect(scrubbed.cycle[2]).toBe("mine");
82
+ expect(scrubbed.cycle[1]).not.toBe("secret-hop");
83
+ expect(scrubbed.cycle.join(" ")).not.toContain("secret-hop");
84
+ expect(scrubbed.message).not.toContain("secret-hop");
85
+ expect(scrubbed.message).toContain("cycle");
86
+ });
87
+
88
+ test("multiple out-of-scope hops in a longer chain are each redacted independently", () => {
89
+ const err = new ParentCycleError("mine", ["mine", "secret-1", "secret-2", "mine"]);
90
+ const allowed = new Set(["mine"]);
91
+ const scrubbed = scrubParentCycleError(err, allowed);
92
+ expect(scrubbed.cycle[1]).not.toBe("secret-1");
93
+ expect(scrubbed.cycle[2]).not.toBe("secret-2");
94
+ expect(scrubbed.cycle.join(" ")).not.toContain("secret-1");
95
+ expect(scrubbed.cycle.join(" ")).not.toContain("secret-2");
96
+ });
97
+ });
package/src/tag-scope.ts CHANGED
@@ -21,8 +21,12 @@
21
21
 
22
22
  import type { Store, Note, HydratedLink, NoteSummary } from "../core/src/types.ts";
23
23
  import type { TagFieldViolation } from "../core/src/tag-schemas.ts";
24
+ import { ParentCycleError } from "../core/src/tag-schemas.ts";
24
25
  import { IndexedFieldError } from "../core/src/indexed-fields.ts";
25
26
 
27
+ /** Generic replacement for a redacted out-of-scope tag name — never the real name. */
28
+ const OUT_OF_SCOPE_LABEL = "(outside your token's tag scope)";
29
+
26
30
  /**
27
31
  * Build the effective tag-allowlist for a token: union of `{root} ∪
28
32
  * descendants(root)` for each root in `scoped_tags`. Returns null when the
@@ -233,6 +237,45 @@ export function scrubIndexedFieldConflictError(
233
237
  );
234
238
  }
235
239
 
240
+ /**
241
+ * Scrub the `referencing_tags` list on a `tag_referenced_as_parent` refusal
242
+ * (vault#552 — deleteTag's referential-integrity guard) for a tag-scoped
243
+ * caller. The delete is refused regardless of scope (referential integrity
244
+ * is scope-independent, same posture as `tag_field_conflict`), but a
245
+ * referencing tag OUTSIDE the caller's allowlist must not be named — same
246
+ * "in doubt, scrub" convention `scrubTagFieldViolationsByScope` uses.
247
+ * No-op for unscoped callers (`allowed === null`).
248
+ */
249
+ export function scrubReferencingTagsByScope(
250
+ referencing: string[],
251
+ allowed: Set<string> | null,
252
+ ): string[] {
253
+ if (allowed === null) return referencing;
254
+ return referencing.map((t) => (allowed.has(t) ? t : OUT_OF_SCOPE_LABEL));
255
+ }
256
+
257
+ /**
258
+ * Scrub a `ParentCycleError`'s `cycle` path (vault#552 — the `parent_names`
259
+ * cycle guard) for a tag-scoped caller. The write is still rejected — a
260
+ * cycle is a structural problem independent of who's asking — but the
261
+ * cycle path can walk through tags OUTSIDE the caller's allowlist (the
262
+ * hierarchy `upsertTagRecord` validates against is vault-wide, scope-unaware
263
+ * by architecture, same as the cross-tag field checks). Returns a
264
+ * REPLACEMENT error with out-of-scope hops in `cycle` generalized; the
265
+ * caller's own tag (`err.tag`) is always in-scope by the time this runs (the
266
+ * update-tag wrapper already gates on it) so it's never redacted. No-op for
267
+ * unscoped callers or a cycle with no out-of-scope hop.
268
+ */
269
+ export function scrubParentCycleError(
270
+ err: ParentCycleError,
271
+ allowed: Set<string> | null,
272
+ ): ParentCycleError {
273
+ if (allowed === null) return err;
274
+ if (err.cycle.every((t) => allowed.has(t))) return err;
275
+ const scrubbedCycle = err.cycle.map((t) => (allowed.has(t) ? t : OUT_OF_SCOPE_LABEL));
276
+ return new ParentCycleError(err.tag, scrubbedCycle);
277
+ }
278
+
236
279
  /**
237
280
  * Standard 403 response shape for tag-scope rejections. Mirrors the
238
281
  * `insufficient_scope` 403 shape used elsewhere in the API so clients