@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
package/src/mcp-tools.ts
CHANGED
|
@@ -22,8 +22,15 @@ import {
|
|
|
22
22
|
expandTokenTagScope,
|
|
23
23
|
filterHydratedLinksByTagScope,
|
|
24
24
|
noteWithinTagScope,
|
|
25
|
+
scrubIndexedFieldConflictError,
|
|
26
|
+
scrubParentCycleError,
|
|
27
|
+
scrubReferencingTagsByScope,
|
|
28
|
+
scrubTagFieldViolationsByScope,
|
|
25
29
|
tagsWithinScope,
|
|
26
30
|
} from "./tag-scope.ts";
|
|
31
|
+
import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
32
|
+
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
33
|
+
import { runDoctorScan } from "../core/src/doctor.ts";
|
|
27
34
|
import {
|
|
28
35
|
findTokensReferencingTag,
|
|
29
36
|
recordMcpMintLedger,
|
|
@@ -215,11 +222,12 @@ export function generateScopedMcpTools(
|
|
|
215
222
|
}
|
|
216
223
|
|
|
217
224
|
/**
|
|
218
|
-
* Tag-delete and
|
|
219
|
-
*
|
|
220
|
-
*
|
|
225
|
+
* Tag-delete and tag-merge always check for tag-scoped tokens referencing
|
|
226
|
+
* the doomed tag(s) — regardless of whether the CALLER is itself
|
|
227
|
+
* tag-scoped. A successful delete/merge that orphans an allowlist would
|
|
221
228
|
* silently widen surface area downstream. Mirrors the REST 409
|
|
222
|
-
* `tag_in_use_by_tokens` envelope.
|
|
229
|
+
* `tag_in_use_by_tokens` envelope (routes.ts's DELETE /tags/:name and
|
|
230
|
+
* POST /tags/merge run the identical check).
|
|
223
231
|
*/
|
|
224
232
|
function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void {
|
|
225
233
|
const store = getVaultStore(vaultName);
|
|
@@ -239,6 +247,29 @@ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void
|
|
|
239
247
|
}
|
|
240
248
|
return await orig(params);
|
|
241
249
|
});
|
|
250
|
+
// vault#552: merging consumes every source tag (same identity-drop as
|
|
251
|
+
// delete), so a source referenced by a tag-scoped token would orphan that
|
|
252
|
+
// token's allowlist exactly like a bare delete would. Aggregate matches
|
|
253
|
+
// across all sources into one 409-equivalent envelope, same shape REST's
|
|
254
|
+
// POST /tags/merge returns.
|
|
255
|
+
wrapReadTool(tools, "merge-tags", async (orig, params) => {
|
|
256
|
+
const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
|
|
257
|
+
const referenced: { source: string; tokens: { id: string; label: string }[] }[] = [];
|
|
258
|
+
for (const src of sources) {
|
|
259
|
+
if (typeof src !== "string") continue;
|
|
260
|
+
const tokens = findTokensReferencingTag(store.db, src);
|
|
261
|
+
if (tokens.length > 0) referenced.push({ source: src, tokens });
|
|
262
|
+
}
|
|
263
|
+
if (referenced.length > 0) {
|
|
264
|
+
return {
|
|
265
|
+
error: "TagInUseByTokens",
|
|
266
|
+
error_type: "tag_in_use_by_tokens",
|
|
267
|
+
message: `Cannot merge: ${referenced.length} source tag(s) referenced by tag-scoped token(s); revoke or re-mint them first.`,
|
|
268
|
+
referenced_by: referenced,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return await orig(params);
|
|
272
|
+
});
|
|
242
273
|
}
|
|
243
274
|
|
|
244
275
|
/**
|
|
@@ -344,7 +375,7 @@ function applyTagScopeWrappers(
|
|
|
344
375
|
if (result && typeof result === "object" && "id" in result && "tags" in result) {
|
|
345
376
|
return noteWithinTagScope(result as any, allowed, rawTags)
|
|
346
377
|
? scrubNoteLinks(result)
|
|
347
|
-
: { error: "Note not found", id: (result as any).id };
|
|
378
|
+
: { error: "Note not found", error_type: "not_found", id: (result as any).id };
|
|
348
379
|
}
|
|
349
380
|
return result;
|
|
350
381
|
});
|
|
@@ -470,7 +501,7 @@ function applyTagScopeWrappers(
|
|
|
470
501
|
if (!id) continue;
|
|
471
502
|
const existing = await store.getNote(id as string);
|
|
472
503
|
if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
|
|
473
|
-
return { error: "Note not found", id };
|
|
504
|
+
return { error: "Note not found", error_type: "not_found", id };
|
|
474
505
|
}
|
|
475
506
|
const removed = new Set<string>((item as any).tags?.remove ?? []);
|
|
476
507
|
const projected = new Set<string>((existing.tags ?? []).filter((t) => !removed.has(t)));
|
|
@@ -489,7 +520,7 @@ function applyTagScopeWrappers(
|
|
|
489
520
|
if (id) {
|
|
490
521
|
const existing = await store.getNote(id as string);
|
|
491
522
|
if (!existing || !noteWithinTagScope(existing, allowed, rawTags)) {
|
|
492
|
-
return { error: "Note not found", id };
|
|
523
|
+
return { error: "Note not found", error_type: "not_found", id };
|
|
493
524
|
}
|
|
494
525
|
}
|
|
495
526
|
return await orig(params);
|
|
@@ -502,7 +533,45 @@ function applyTagScopeWrappers(
|
|
|
502
533
|
if (typeof tag === "string" && !allowed.has(tag)) {
|
|
503
534
|
return forbidden(`update-tag: tag "${tag}" is outside the token's allowlist`);
|
|
504
535
|
}
|
|
505
|
-
|
|
536
|
+
try {
|
|
537
|
+
return await orig(params);
|
|
538
|
+
} catch (err: any) {
|
|
539
|
+
// Tag-scope scrub for cross-tag field conflicts (vault#554
|
|
540
|
+
// auth-and-scope fold). Core's validation scans EVERY tag's schema
|
|
541
|
+
// (scope-unaware by architecture), so its TagFieldConflictError can
|
|
542
|
+
// name an OUT-OF-SCOPE tag and reveal its declared type/flag in both
|
|
543
|
+
// the violation entries and the error message. The write stays
|
|
544
|
+
// rejected — schema integrity is scope-independent — but re-throw
|
|
545
|
+
// with scrubbed violations (out-of-scope declarers generalized, no
|
|
546
|
+
// tag name / declared type, `other_tag` dropped; in-scope declarers
|
|
547
|
+
// keep full detail). Rebuilding via the constructor also rebuilds
|
|
548
|
+
// the top-level message from the scrubbed entries. Same pattern as
|
|
549
|
+
// the list-tags `did_you_mean` scrub above.
|
|
550
|
+
if (err && err.code === "TAG_FIELD_CONFLICT" && Array.isArray(err.violations)) {
|
|
551
|
+
throw new TagFieldConflictError(
|
|
552
|
+
err.tag ?? (tag as string),
|
|
553
|
+
scrubTagFieldViolationsByScope(err.violations, allowed),
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
// Same leak through the OTHER door (wire-review interaction): a
|
|
557
|
+
// both-indexed cross-tag type conflict deliberately bypasses the
|
|
558
|
+
// pre-check (preserving its declareField → invalid_indexed_field
|
|
559
|
+
// contract), and declareField's IndexedFieldError message names the
|
|
560
|
+
// other declarer tag(s). Generalize for scoped callers; solo
|
|
561
|
+
// own-field IndexedFieldErrors (no declarer_tags) pass untouched.
|
|
562
|
+
if (err instanceof IndexedFieldError) {
|
|
563
|
+
throw scrubIndexedFieldConflictError(err, allowed);
|
|
564
|
+
}
|
|
565
|
+
// parent_names cycle guard (vault#552) — the hierarchy `upsertTagRecord`
|
|
566
|
+
// validates against is vault-wide (scope-unaware by architecture), so
|
|
567
|
+
// the cycle path can name an out-of-scope tag. Same "write stays
|
|
568
|
+
// rejected, path gets generalized" posture as the field-conflict scrub
|
|
569
|
+
// above.
|
|
570
|
+
if (err instanceof ParentCycleError) {
|
|
571
|
+
throw scrubParentCycleError(err, allowed);
|
|
572
|
+
}
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
506
575
|
});
|
|
507
576
|
|
|
508
577
|
wrapReadTool(tools, "delete-tag", async (orig, params) => {
|
|
@@ -512,9 +581,59 @@ function applyTagScopeWrappers(
|
|
|
512
581
|
if (typeof tag === "string" && !allowed.has(tag)) {
|
|
513
582
|
return forbidden(`delete-tag: tag "${tag}" is outside the token's allowlist`);
|
|
514
583
|
}
|
|
584
|
+
const result = await orig(params);
|
|
585
|
+
// Referential-integrity refusal (vault#552) — scrub out-of-scope
|
|
586
|
+
// referencing tag names before returning. The delete stays refused
|
|
587
|
+
// either way; only the response's tag-name visibility changes.
|
|
588
|
+
if (result && typeof result === "object" && (result as any).error_type === "tag_referenced_as_parent") {
|
|
589
|
+
return {
|
|
590
|
+
...(result as Record<string, unknown>),
|
|
591
|
+
referencing_tags: scrubReferencingTagsByScope((result as any).referencing_tags ?? [], allowed),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return result;
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// rename-tag / merge-tags (vault#552 — MCP parity with the pre-existing
|
|
598
|
+
// REST engine). Same tag-scope posture as update-tag/delete-tag: every
|
|
599
|
+
// tag NAMED in the request (source(s) + target, or old/new name) must be
|
|
600
|
+
// inside the caller's allowlist — a rename/merge that pulls a tag out of
|
|
601
|
+
// scope (or in) is a privilege-boundary move, refuse the whole op before
|
|
602
|
+
// it reaches core.
|
|
603
|
+
wrapReadTool(tools, "rename-tag", async (orig, params) => {
|
|
604
|
+
const allowed = await getAllowed();
|
|
605
|
+
if (!allowed) return await orig(params);
|
|
606
|
+
const oldName = (params as any).old_name ?? (params as any).from ?? (params as any).tag;
|
|
607
|
+
const newName = (params as any).new_name ?? (params as any).to;
|
|
608
|
+
for (const t of [oldName, newName]) {
|
|
609
|
+
if (typeof t === "string" && !allowed.has(t)) {
|
|
610
|
+
return forbidden(`rename-tag: tag "${t}" is outside the token's allowlist`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return await orig(params);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
wrapReadTool(tools, "merge-tags", async (orig, params) => {
|
|
617
|
+
const allowed = await getAllowed();
|
|
618
|
+
if (!allowed) return await orig(params);
|
|
619
|
+
const sources = Array.isArray((params as any).sources) ? ((params as any).sources as unknown[]) : [];
|
|
620
|
+
const target = (params as any).target;
|
|
621
|
+
for (const t of [...sources, target]) {
|
|
622
|
+
if (typeof t === "string" && !allowed.has(t)) {
|
|
623
|
+
return forbidden(`merge-tags: tag "${t}" is outside the token's allowlist`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
515
626
|
return await orig(params);
|
|
516
627
|
});
|
|
517
628
|
|
|
629
|
+
// doctor (vault#552) — the scan itself is re-run with the caller's
|
|
630
|
+
// expanded allowlist rather than post-filtering the unscoped result, so
|
|
631
|
+
// aggregate counts (findings summary) never reflect out-of-scope data.
|
|
632
|
+
wrapReadTool(tools, "doctor", async (orig, params) => {
|
|
633
|
+
const allowed = await getAllowed();
|
|
634
|
+
if (!allowed) return await orig(params);
|
|
635
|
+
return runDoctorScan(store.db, { allowedTags: allowed });
|
|
636
|
+
});
|
|
518
637
|
}
|
|
519
638
|
|
|
520
639
|
function wrapReadTool(
|
|
@@ -542,6 +661,16 @@ function overrideVaultInfo(
|
|
|
542
661
|
if (!vaultInfo) return;
|
|
543
662
|
|
|
544
663
|
vaultInfo.execute = async (params) => {
|
|
664
|
+
// NOTE (vault#554): these two throws are deliberately left as plain,
|
|
665
|
+
// unstructured `Error`s — NOT given `error_type` — even though the rest
|
|
666
|
+
// of this file's sweep attaches one everywhere else. Attaching one here
|
|
667
|
+
// routes the throw through mcp-http.ts's structured-error mapping, which
|
|
668
|
+
// surfaces it as a JSON-RPC protocol-level error (`response.error`)
|
|
669
|
+
// instead of the in-band tool result the existing contract test asserts
|
|
670
|
+
// (`response.result.isError === true`, `response.result.content[0].text`
|
|
671
|
+
// containing the scope name) — see "tools/call of vault-info with
|
|
672
|
+
// description arg and vault:read scope is refused" in src/vault.test.ts.
|
|
673
|
+
// Changing that transport shape is out of scope for this wave.
|
|
545
674
|
const config = readVaultConfig(vaultName);
|
|
546
675
|
if (!config) throw new Error(`Vault "${vaultName}" not found`);
|
|
547
676
|
|
package/src/mirror-import.ts
CHANGED
|
@@ -518,6 +518,11 @@ function importResultFromStats(
|
|
|
518
518
|
`Orphaned sidecar ${ss.sidecar_id} (path=${ss.expected_path ?? "—"}): ${ss.reason}`,
|
|
519
519
|
);
|
|
520
520
|
}
|
|
521
|
+
for (const sp of stats.skipped_schema_parents) {
|
|
522
|
+
warnings.push(
|
|
523
|
+
`Dropped parent_names on tag "${sp.tag}": ${sp.reason}`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
521
526
|
const result: ImportResult = {
|
|
522
527
|
notes_imported: stats.notes_created + stats.notes_updated,
|
|
523
528
|
tags_imported: stats.schemas_restored,
|