@openparachute/vault 0.7.0-rc.8 → 0.7.1
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/aggregate.test.ts +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +1151 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/doctor.ts +23 -1
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +587 -79
- package/core/src/notes.ts +371 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +42 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +98 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +244 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +100 -19
- package/src/routes.ts +469 -39
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +923 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/src/mcp-tools.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
9
9
|
import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
|
|
10
|
-
import { getNoteTags } from "../core/src/notes.ts";
|
|
10
|
+
import { getNoteTags, getVaultMap } from "../core/src/notes.ts";
|
|
11
11
|
import type { Note } from "../core/src/types.ts";
|
|
12
12
|
import {
|
|
13
13
|
buildVaultProjection,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
scrubTagFieldViolationsByScope,
|
|
29
29
|
scrubValidationStatusByScope,
|
|
30
30
|
tagsWithinScope,
|
|
31
|
+
tagVisibleInScope,
|
|
31
32
|
} from "./tag-scope.ts";
|
|
32
33
|
import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
33
34
|
import { IndexedFieldError } from "../core/src/indexed-fields.ts";
|
|
@@ -131,7 +132,9 @@ function resolveVaultCoordinates(): { hubOrigin: string; hubOriginKnown: boolean
|
|
|
131
132
|
* `auth` is the resolved token for the caller and is captured by vault-info's
|
|
132
133
|
* execute closure so the description-update branch can perform a secondary
|
|
133
134
|
* scope check: the tool itself is gated at vault:read (so read-only callers
|
|
134
|
-
* can fetch stats), but writing a new description requires vault:
|
|
135
|
+
* can fetch stats), but writing a new description requires vault:admin
|
|
136
|
+
* (tightened from vault:write by the write/admin re-tier — description is
|
|
137
|
+
* vault curation, not content).
|
|
135
138
|
*
|
|
136
139
|
* When omitted (internal callers that only inspect the tool list — no execute
|
|
137
140
|
* path exercised), the description-update branch is disabled entirely.
|
|
@@ -158,6 +161,19 @@ export function generateScopedMcpTools(
|
|
|
158
161
|
? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
|
|
159
162
|
: undefined;
|
|
160
163
|
|
|
164
|
+
// Tag-scope for `query-notes`'s `aggregate` mode (top new-feature ask
|
|
165
|
+
// from a UX round): a rollup has no per-note shape to post-filter (the
|
|
166
|
+
// response is `[{group, value}]`, not notes), so scope has to be applied
|
|
167
|
+
// BEFORE core aggregates, not after. Same shared `allowedHolder` /
|
|
168
|
+
// `getAllowed()`-before-`orig()` ordering invariant as `expandVisibility`
|
|
169
|
+
// above — reads the resolved allowlist safely because the query-notes
|
|
170
|
+
// wrapper always awaits `getAllowed()` before invoking core's (synchronous
|
|
171
|
+
// use of this) execute. Unscoped sessions install no predicate → the
|
|
172
|
+
// aggregate runs core's fast direct-SQL path.
|
|
173
|
+
const aggregateVisibility = scoped
|
|
174
|
+
? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
|
|
175
|
+
: undefined;
|
|
176
|
+
|
|
161
177
|
// Tag-scope hop-guard for `near[]` (vault#439): a per-note predicate the
|
|
162
178
|
// core BFS consults so it refuses to traverse THROUGH out-of-scope notes —
|
|
163
179
|
// symmetric with find-path. Reads from the SAME shared `allowedHolder` the
|
|
@@ -174,6 +190,20 @@ export function generateScopedMcpTools(
|
|
|
174
190
|
)
|
|
175
191
|
: undefined;
|
|
176
192
|
|
|
193
|
+
// Tag-scope guard for `create-note` `if_exists` (vault#555 auth-review
|
|
194
|
+
// must-fix): the core `if_exists` upsert resolves the target path VAULT-WIDE
|
|
195
|
+
// and returns/updates/replaces the found note, so a scoped caller could
|
|
196
|
+
// read/overwrite an out-of-scope note by naming its path. Inject a
|
|
197
|
+
// visibility predicate the core `applyExistingNote` consults on the RESOLVED
|
|
198
|
+
// existing note — covering BOTH the proactive check AND the concurrent-INSERT
|
|
199
|
+
// race backstop (a wrapper-only pre-check misses the latter). Reads from the
|
|
200
|
+
// SAME shared `allowedHolder` the create-note wrapper's `getAllowed()`
|
|
201
|
+
// populates before core's execute runs. Unscoped sessions install no
|
|
202
|
+
// predicate (unchanged). Same closure as `expandVisibility`.
|
|
203
|
+
const ifExistsVisible = scoped
|
|
204
|
+
? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
|
|
205
|
+
: undefined;
|
|
206
|
+
|
|
177
207
|
// Write-attribution (vault#298). Every write through an MCP session arrives
|
|
178
208
|
// on the `mcp` channel — so we REFINE the auth's base `via` (the generic
|
|
179
209
|
// credential class) to `mcp` here, where the path/channel is known. The
|
|
@@ -197,10 +227,12 @@ export function generateScopedMcpTools(
|
|
|
197
227
|
|
|
198
228
|
const tools = generateMcpTools(
|
|
199
229
|
store,
|
|
200
|
-
expandVisibility || nearTraversable || writeContext || strictBypass
|
|
230
|
+
expandVisibility || nearTraversable || ifExistsVisible || aggregateVisibility || writeContext || strictBypass
|
|
201
231
|
? {
|
|
202
232
|
...(expandVisibility ? { expandVisibility } : {}),
|
|
203
233
|
...(nearTraversable ? { nearTraversable } : {}),
|
|
234
|
+
...(ifExistsVisible ? { ifExistsVisible } : {}),
|
|
235
|
+
...(aggregateVisibility ? { aggregateVisibility } : {}),
|
|
204
236
|
...(writeContext ? { writeContext } : {}),
|
|
205
237
|
...(strictBypass ? { strictBypass } : {}),
|
|
206
238
|
...(onStrictBypass ? { onStrictBypass } : {}),
|
|
@@ -286,9 +318,11 @@ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void
|
|
|
286
318
|
* - vault-info: filter projection.tags + projection.indexed_fields
|
|
287
319
|
* to entries an in-scope tag contributes to
|
|
288
320
|
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
321
|
+
* Mutating-tool gating happens in handleScopedMcp at the verb-scope layer
|
|
322
|
+
* (`vault:write` for create/update/delete-note, `vault:admin` for the
|
|
323
|
+
* tag-schema/taxonomy tools since the write/admin re-tier) AND inside each
|
|
324
|
+
* tool's wrapper here, so a tag-scoped token — whatever verb it holds —
|
|
325
|
+
* can't mutate outside its allowlist. See applyTagScopeWriteGuards.
|
|
292
326
|
*/
|
|
293
327
|
function applyTagScopeWrappers(
|
|
294
328
|
tools: McpToolDef[],
|
|
@@ -350,6 +384,29 @@ function applyTagScopeWrappers(
|
|
|
350
384
|
const allowed = await getAllowed();
|
|
351
385
|
const result = await orig(params);
|
|
352
386
|
if (!allowed) return result;
|
|
387
|
+
// `aggregate` mode returns `[{group, value}]` rollup rows — no `.tags`
|
|
388
|
+
// to post-filter (and `noteWithinTagScope` would wrongly drop every
|
|
389
|
+
// row, since an absent `.tags` reads as "out of scope"). WHICH NOTES
|
|
390
|
+
// count was already narrowed INSIDE core via the `aggregateVisibility`
|
|
391
|
+
// predicate (see `generateScopedMcpTools`): fetch the full filtered
|
|
392
|
+
// set, keep only allowlisted notes, THEN aggregate.
|
|
393
|
+
//
|
|
394
|
+
// That note-level narrowing is NOT sufficient on its own under
|
|
395
|
+
// `group_by: "tag"`, though: a note can be in scope via one tag while
|
|
396
|
+
// ALSO carrying an out-of-scope co-tag (the same class of leak
|
|
397
|
+
// `scrubValidationStatusByScope` closes for `validation_status`), and a
|
|
398
|
+
// tag rollup's `group` values ARE tag names — the co-tag would surface
|
|
399
|
+
// directly as a group. Scrub group NAMES here, the same way every other
|
|
400
|
+
// tag-shaped output on this wrapper is scrubbed.
|
|
401
|
+
if ((params as any).aggregate) {
|
|
402
|
+
const groupBy = (params as any).aggregate?.group_by;
|
|
403
|
+
if (groupBy === "tag" && Array.isArray(result)) {
|
|
404
|
+
return result.filter(
|
|
405
|
+
(r: any) => typeof r?.group === "string" && tagVisibleInScope(r.group, allowed, rawTags),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
353
410
|
// Possible response shapes (vault#550 added the `warnings` variants):
|
|
354
411
|
// - Array (legacy list, no cursor, no warnings)
|
|
355
412
|
// - `{notes, next_cursor}` (cursor mode, vault#313)
|
|
@@ -463,20 +520,34 @@ function applyTagScopeWrappers(
|
|
|
463
520
|
tags: Array.isArray(r.tags) ? r.tags : [],
|
|
464
521
|
indexed_fields: Array.isArray(r.indexed_fields) ? r.indexed_fields : [],
|
|
465
522
|
query_hints: Array.isArray(r.query_hints) ? r.query_hints : [],
|
|
523
|
+
// Unused by filterProjectionByScope (map is handled separately below,
|
|
524
|
+
// via a live recompute) — a placeholder to satisfy VaultProjection's
|
|
525
|
+
// shape.
|
|
526
|
+
map: { total_notes: 0, tags: [], path_buckets: [], unfiled_notes: 0 },
|
|
466
527
|
};
|
|
467
528
|
const filtered = filterProjectionByScope(partial, allowed);
|
|
468
529
|
r.tags = filtered.tags;
|
|
469
530
|
r.indexed_fields = filtered.indexed_fields;
|
|
531
|
+
// `map` (front-door structural rollup): post-hoc filtering the unscoped
|
|
532
|
+
// aggregate isn't possible for path-bucket counts (they need per-note
|
|
533
|
+
// tag membership, not just a tag-name allowlist over a precomputed
|
|
534
|
+
// rollup) — so re-run the cheap grouped-count query restricted to the
|
|
535
|
+
// resolved allowlist instead of filtering `orig`'s unscoped result.
|
|
536
|
+
if (r.map) {
|
|
537
|
+
r.map = getVaultMap(store.db, { tagFilter: [...allowed] });
|
|
538
|
+
}
|
|
470
539
|
return r;
|
|
471
540
|
});
|
|
472
541
|
|
|
473
542
|
// ---- Write-side guards ----
|
|
474
543
|
//
|
|
475
|
-
// The verb-scope check (`vault:write`
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
// tag
|
|
544
|
+
// The verb-scope check (`vault:write` for note ops, `vault:admin` for the
|
|
545
|
+
// tag-schema/taxonomy ops post-re-tier) is enforced at the dispatch layer
|
|
546
|
+
// in handleScopedMcp. These wrappers add the second axis: a scoped token
|
|
547
|
+
// can only mutate within its tag-allowlist, never outside it. Tag
|
|
548
|
+
// operations (`update-tag`, `delete-tag`, `rename-tag`, `merge-tags`) gate
|
|
549
|
+
// on the tag name(s) themselves; note operations gate on the prospective
|
|
550
|
+
// tag set.
|
|
480
551
|
|
|
481
552
|
const forbidden = (msg: string): unknown => ({
|
|
482
553
|
error: "Forbidden",
|
|
@@ -498,6 +569,13 @@ function applyTagScopeWrappers(
|
|
|
498
569
|
return forbidden("create-note: every note must carry at least one tag in the token's allowlist");
|
|
499
570
|
}
|
|
500
571
|
}
|
|
572
|
+
// `if_exists` scope enforcement (vault#555 auth-review must-fix) is NOT
|
|
573
|
+
// done here as a wrapper pre-check — that would miss core's concurrent-
|
|
574
|
+
// INSERT race backstop. Instead the `ifExistsVisible` predicate wired into
|
|
575
|
+
// generateMcpTools above fires INSIDE core's `applyExistingNote`, covering
|
|
576
|
+
// both the proactive site and the race-backstop site with one guard. The
|
|
577
|
+
// `await getAllowed()` at the top of this wrapper populates the shared
|
|
578
|
+
// `allowedHolder` the predicate reads, before core's execute runs.
|
|
501
579
|
return await orig(params);
|
|
502
580
|
});
|
|
503
581
|
|
|
@@ -687,13 +765,15 @@ function overrideVaultInfo(
|
|
|
687
765
|
|
|
688
766
|
if (params.description !== undefined) {
|
|
689
767
|
// Secondary scope check: vault-info is read-gated so read-only callers
|
|
690
|
-
// can fetch stats, but mutating the vault description requires
|
|
691
|
-
// for THIS vault
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
768
|
+
// can fetch stats, but mutating the vault description requires admin
|
|
769
|
+
// for THIS vault (was `write` — tightened by the write/admin re-tier:
|
|
770
|
+
// writing the vault's own description/config is curation, same class
|
|
771
|
+
// as update-tag et al, not content authorship). Without this, a
|
|
772
|
+
// vault:read OR vault:write token could bypass the outer gate by
|
|
773
|
+
// passing `description` to a tool the outer gate considers read-only.
|
|
774
|
+
if (!auth || !hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
695
775
|
throw new Error(
|
|
696
|
-
`Forbidden: updating the vault description requires the 'vault:
|
|
776
|
+
`Forbidden: updating the vault description requires the 'vault:admin' scope (or 'vault:${vaultName}:admin'). Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
|
|
697
777
|
);
|
|
698
778
|
}
|
|
699
779
|
config.description = params.description as string;
|
|
@@ -725,6 +805,7 @@ function overrideVaultInfo(
|
|
|
725
805
|
tags: projection.tags,
|
|
726
806
|
indexed_fields: projection.indexed_fields,
|
|
727
807
|
query_hints: projection.query_hints,
|
|
808
|
+
map: projection.map,
|
|
728
809
|
};
|
|
729
810
|
|
|
730
811
|
// A2: surface a pointer (path, not body) to the seeded onboarding guide so
|
|
@@ -825,8 +906,8 @@ function buildManageTokenTool(
|
|
|
825
906
|
":admin'. List + revoke are scoped to tokens this session minted; " +
|
|
826
907
|
"CLI/REST-minted tokens are not surfaced here.\n\n" +
|
|
827
908
|
"Actions (discriminator: `action`):\n" +
|
|
828
|
-
"- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at }\n" +
|
|
829
|
-
"- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean }
|
|
909
|
+
"- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at, scopes, scoped_tags, vault_name } (vault#555: scopes/scoped_tags/vault_name were previously undocumented here)\n" +
|
|
910
|
+
"- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean, already_revoked?: boolean } — idempotent; a jti not in this session's ledger, or already revoked, still returns ok:true. A genuine failure additionally carries error/message (and, for a hub-side rejection, hub_status).\n" +
|
|
830
911
|
"- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
|
|
831
912
|
inputSchema: {
|
|
832
913
|
type: "object",
|