@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/core/src/contract-taxonomy.test.ts +193 -17
- package/core/src/contract-typed-index.test.ts +92 -3
- package/core/src/core.test.ts +29 -6
- package/core/src/doctor-scope.test.ts +117 -0
- package/core/src/doctor.ts +330 -0
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +190 -47
- package/core/src/notes.ts +107 -24
- package/core/src/portable-md.test.ts +107 -0
- package/core/src/portable-md.ts +51 -5
- package/core/src/store.ts +17 -3
- package/core/src/tag-hierarchy.ts +66 -0
- package/core/src/tag-schemas.ts +231 -4
- package/core/src/types.ts +23 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/mcp-http.ts +122 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +137 -8
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +350 -79
- package/src/routing.ts +29 -0
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-integrity-mcp.test.ts +288 -0
- package/src/tag-integrity-scope.test.ts +97 -0
- package/src/tag-scope.ts +109 -0
- package/src/vault.test.ts +25 -8
|
@@ -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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|