@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.
Files changed (38) hide show
  1. package/core/src/aggregate.test.ts +260 -0
  2. package/core/src/contract-taxonomy.test.ts +26 -2
  3. package/core/src/contract-typed-index.test.ts +4 -2
  4. package/core/src/core.test.ts +1151 -0
  5. package/core/src/cursor.ts +1 -0
  6. package/core/src/doctor.ts +23 -1
  7. package/core/src/indexed-fields.test.ts +4 -1
  8. package/core/src/indexed-fields.ts +6 -1
  9. package/core/src/links.ts +22 -0
  10. package/core/src/mcp.ts +587 -79
  11. package/core/src/notes.ts +371 -18
  12. package/core/src/query-warnings.ts +60 -12
  13. package/core/src/schema-defaults.ts +7 -1
  14. package/core/src/seed-packs.test.ts +14 -0
  15. package/core/src/seed-packs.ts +42 -5
  16. package/core/src/store.ts +129 -5
  17. package/core/src/tag-schemas.ts +20 -7
  18. package/core/src/types.ts +98 -0
  19. package/core/src/ulid.test.ts +56 -0
  20. package/core/src/ulid.ts +116 -0
  21. package/core/src/vault-projection.ts +19 -1
  22. package/core/src/wikilinks.test.ts +205 -1
  23. package/core/src/wikilinks.ts +244 -35
  24. package/package.json +1 -1
  25. package/src/aggregate-routes.test.ts +230 -0
  26. package/src/contract-errors.test.ts +2 -1
  27. package/src/contract-search.test.ts +17 -0
  28. package/src/mcp-link-warnings-scope.test.ts +144 -0
  29. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  30. package/src/mcp-tools.ts +100 -19
  31. package/src/routes.ts +469 -39
  32. package/src/routing.test.ts +167 -0
  33. package/src/routing.ts +47 -11
  34. package/src/tag-integrity-mcp.test.ts +45 -6
  35. package/src/tag-scope.ts +10 -1
  36. package/src/vault.test.ts +923 -21
  37. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  38. 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:write.
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
- * Write-tool gating happens in handleScopedMcp at the verb-scope layer
290
- * AND inside each tool's wrapper here (so a tag-scoped `vault:write`
291
- * token can't write outside its allowlist). See applyTagScopeWriteGuards.
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`) is enforced at the dispatch layer
476
- // in handleScopedMcp. These wrappers add the second axis: a scoped
477
- // `vault:write` token can only mutate within its tag-allowlist, never
478
- // outside it. Tag operations (`update-tag`, `delete-tag`) gate on the
479
- // tag name itself; note operations gate on the prospective tag set.
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 write
691
- // for THIS vault. Without this, a vault:read token could bypass the
692
- // outer gate by passing `description` to a tool the outer gate
693
- // considers read-only.
694
- if (!auth || !hasScopeForVault(auth.scopes, vaultName, "write")) {
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:write' scope (or 'vault:${vaultName}:write'). Granted scopes: ${auth?.scopes.join(" ") || "(none)"}.`,
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 }\n" +
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",