@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.
@@ -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
@@ -20,6 +20,12 @@
20
20
  */
21
21
 
22
22
  import type { Store, Note, HydratedLink, NoteSummary } from "../core/src/types.ts";
23
+ import type { TagFieldViolation } from "../core/src/tag-schemas.ts";
24
+ import { ParentCycleError } from "../core/src/tag-schemas.ts";
25
+ import { IndexedFieldError } from "../core/src/indexed-fields.ts";
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)";
23
29
 
24
30
  /**
25
31
  * Build the effective tag-allowlist for a token: union of `{root} ∪
@@ -167,6 +173,109 @@ export function filterHydratedLinksByTagScope(
167
173
  );
168
174
  }
169
175
 
176
+ /**
177
+ * Scrub `tag_field_conflict` violations for a tag-scoped caller
178
+ * (vault#554 auth-and-scope review fold). Core's cross-tag field
179
+ * validation (`collectCrossTagFieldViolations` / `collectTagFieldViolations`
180
+ * in core/src/tag-schemas.ts) scans EVERY tag's schema — scope-unaware by
181
+ * architecture — so a scoped caller updating its own in-scope tag can
182
+ * receive a violation whose `message` names an OUT-OF-SCOPE tag and
183
+ * reveals its declared type/indexed flag (proven live on both MCP and
184
+ * REST). Policy: the write is STILL rejected (schema integrity is
185
+ * scope-independent — do not weaken the check), but the violation entry
186
+ * for an out-of-scope conflicting declarer is generalized: no tag name,
187
+ * no declared type/flag, `other_tag` dropped. In-scope conflicting
188
+ * declarers keep full detail; solo own-field violations (no `other_tag`)
189
+ * pass through untouched; unscoped callers (`allowed === null`) keep full
190
+ * detail everywhere.
191
+ *
192
+ * Membership is `allowed.has(other_tag)` — the same convention the
193
+ * `did_you_mean` scrub and the update-tag/delete-tag wrappers use in
194
+ * src/mcp-tools.ts (no string-form root fallback: when in doubt, scrub —
195
+ * the fail-closed direction only costs detail, never correctness).
196
+ */
197
+ export function scrubTagFieldViolationsByScope(
198
+ violations: TagFieldViolation[],
199
+ allowed: Set<string> | null,
200
+ ): TagFieldViolation[] {
201
+ if (allowed === null) return violations;
202
+ return violations.map((v) => {
203
+ if (v.other_tag === undefined || allowed.has(v.other_tag)) return v;
204
+ const generalized =
205
+ v.reason === "type_conflict"
206
+ ? `field "${v.field}" type conflict: another tag (outside your token's tag scope) declares this field with a different type. Types must agree across all declarers.`
207
+ : `field "${v.field}" indexed-flag conflict: another tag (outside your token's tag scope) declares this field with a different indexed flag. Must match across all declarers.`;
208
+ const { other_tag: _dropped, ...rest } = v;
209
+ return { ...rest, message: generalized };
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Companion scrub for the OTHER door the same information can leak
215
+ * through (vault#554 auth-and-scope × wire-review interaction): a
216
+ * both-indexed cross-tag type conflict deliberately bypasses the
217
+ * `tag_field_conflict` pre-check (preserving its pre-existing
218
+ * `declareField` → 400 `invalid_indexed_field` contract — see
219
+ * `collectCrossTagFieldViolations`'s doc comment), and declareField's
220
+ * message names the other declarer tag(s) plus their sqlite type. For a
221
+ * tag-scoped caller with any out-of-scope declarer, return a REPLACEMENT
222
+ * `IndexedFieldError` with a generalized message (no tag names, no
223
+ * existing type) — same rejection, same 400 status, same `error_type`,
224
+ * just no leak. If every declarer is in scope (or the error carries no
225
+ * `declarer_tags` — the solo own-field throws), or the caller is unscoped
226
+ * (`allowed === null`), the original error is returned untouched.
227
+ */
228
+ export function scrubIndexedFieldConflictError(
229
+ err: IndexedFieldError & { field?: string; declarer_tags?: string[] },
230
+ allowed: Set<string> | null,
231
+ ): IndexedFieldError {
232
+ if (allowed === null) return err;
233
+ if (!Array.isArray(err.declarer_tags) || err.declarer_tags.length === 0) return err;
234
+ if (err.declarer_tags.every((t) => allowed.has(t))) return err;
235
+ return new IndexedFieldError(
236
+ `field "${err.field ?? "?"}" is already indexed by another tag (outside your token's tag scope) with a conflicting type. Types must agree across all declarers.`,
237
+ );
238
+ }
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
+
170
279
  /**
171
280
  * Standard 403 response shape for tag-scope rejections. Mirrors the
172
281
  * `insufficient_scope` 403 shape used elsewhere in the API so clients
package/src/vault.test.ts CHANGED
@@ -484,7 +484,7 @@ describe("deeper link queries", async () => {
484
484
  describe("MCP tools", async () => {
485
485
  test("generates the consolidated tool set", () => {
486
486
  const tools = generateMcpTools(store);
487
- expect(tools.length).toBe(10);
487
+ expect(tools.length).toBe(13);
488
488
 
489
489
  const names = tools.map((t) => t.name);
490
490
  expect(names).toContain("query-notes");
@@ -498,6 +498,12 @@ describe("MCP tools", async () => {
498
498
  expect(names).toContain("vault-info");
499
499
  // prune-schema (admin) — drops orphaned indexed-field columns.
500
500
  expect(names).toContain("prune-schema");
501
+ // rename-tag / merge-tags (vault#552) — MCP parity with the pre-existing
502
+ // REST engine.
503
+ expect(names).toContain("rename-tag");
504
+ expect(names).toContain("merge-tags");
505
+ // doctor (admin, vault#552) — read-only taxonomy/metadata integrity scan.
506
+ expect(names).toContain("doctor");
501
507
  // Six note-schema MCP tools (list/update/delete-note-schema +
502
508
  // list/set/delete-schema-mapping) retired in v17 — vault#267.
503
509
  expect(names).not.toContain("list-note-schemas");
@@ -5414,8 +5420,11 @@ describe("stateless MCP transport", async () => {
5414
5420
  expect(toolNames).not.toContain("delete-note");
5415
5421
  expect(toolNames).not.toContain("update-tag");
5416
5422
  expect(toolNames).not.toContain("delete-tag");
5423
+ expect(toolNames).not.toContain("rename-tag");
5424
+ expect(toolNames).not.toContain("merge-tags");
5417
5425
  // Admin tools (vault#376) are hidden too
5418
5426
  expect(toolNames).not.toContain("manage-token");
5427
+ expect(toolNames).not.toContain("doctor");
5419
5428
  // Read tier is exactly 4 tools.
5420
5429
  expect(toolNames.length).toBe(4);
5421
5430
 
@@ -5658,7 +5667,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
5658
5667
  expect(names.length).toBe(4);
5659
5668
  });
5660
5669
 
5661
- test("vault:read + vault:write sees the 9 read+write tools", async () => {
5670
+ test("vault:read + vault:write sees the 11 read+write tools", async () => {
5662
5671
  const names = await listToolNames(["vault:read", "vault:write"]);
5663
5672
  expect(new Set(names)).toEqual(
5664
5673
  new Set([
@@ -5671,24 +5680,31 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
5671
5680
  "delete-note",
5672
5681
  "update-tag",
5673
5682
  "delete-tag",
5683
+ // vault#552: MCP parity with the pre-existing REST rename/merge engine.
5684
+ "rename-tag",
5685
+ "merge-tags",
5674
5686
  ]),
5675
5687
  );
5676
- expect(names.length).toBe(9);
5688
+ expect(names.length).toBe(11);
5677
5689
  expect(names).not.toContain("manage-token");
5690
+ expect(names).not.toContain("doctor");
5678
5691
  // Aaron 2026-05-27: delete-* are write-tier (same destructive verb as
5679
- // update). Only manage-token is admin-gated.
5692
+ // update). Only manage-token/prune-schema/doctor are admin-gated.
5680
5693
  expect(names).toContain("delete-note");
5681
5694
  expect(names).toContain("delete-tag");
5682
5695
  });
5683
5696
 
5684
- test("vault:admin sees all 11 tools including manage-token + prune-schema", async () => {
5697
+ test("vault:admin sees all 14 tools including manage-token + prune-schema + doctor", async () => {
5685
5698
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
5686
5699
  expect(names).toContain("manage-token");
5687
5700
  expect(names).toContain("prune-schema");
5688
- expect(names.length).toBe(11);
5701
+ expect(names).toContain("doctor");
5702
+ expect(names).toContain("rename-tag");
5703
+ expect(names).toContain("merge-tags");
5704
+ expect(names.length).toBe(14);
5689
5705
  });
5690
5706
 
5691
- test("legacy-derived full token sees all 11 tools (back-compat)", async () => {
5707
+ test("legacy-derived full token sees all 14 tools (back-compat)", async () => {
5692
5708
  const { handleScopedMcp } = await import("./mcp-http.ts");
5693
5709
  const { writeVaultConfig } = await import("./config.ts");
5694
5710
  const { closeAllStores } = await import("./vault-store.ts");
@@ -5721,9 +5737,10 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
5721
5737
  } as any);
5722
5738
  const body = await res.json() as any;
5723
5739
  const names: string[] = body.result.tools.map((t: any) => t.name);
5724
- expect(names.length).toBe(11);
5740
+ expect(names.length).toBe(14);
5725
5741
  expect(names).toContain("manage-token");
5726
5742
  expect(names).toContain("prune-schema");
5743
+ expect(names).toContain("doctor");
5727
5744
  closeAllStores();
5728
5745
  });
5729
5746