@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.3

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 (47) 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 +737 -2
  5. package/core/src/cursor-keyset-ms.test.ts +537 -0
  6. package/core/src/cursor.ts +76 -6
  7. package/core/src/doctor.ts +23 -1
  8. package/core/src/hooks.ts +9 -0
  9. package/core/src/indexed-fields.test.ts +4 -1
  10. package/core/src/indexed-fields.ts +6 -1
  11. package/core/src/links.ts +22 -0
  12. package/core/src/mcp.ts +322 -53
  13. package/core/src/notes.ts +518 -67
  14. package/core/src/paths.ts +4 -0
  15. package/core/src/portable-md.test.ts +161 -0
  16. package/core/src/portable-md.ts +140 -9
  17. package/core/src/query-warnings.ts +60 -12
  18. package/core/src/schema-defaults.ts +7 -1
  19. package/core/src/schema.ts +128 -2
  20. package/core/src/seed-packs.ts +55 -15
  21. package/core/src/store.ts +141 -7
  22. package/core/src/tag-schemas.ts +20 -7
  23. package/core/src/txn.test.ts +100 -4
  24. package/core/src/txn.ts +119 -24
  25. package/core/src/types.ts +89 -0
  26. package/core/src/ulid.test.ts +56 -0
  27. package/core/src/ulid.ts +116 -0
  28. package/core/src/vault-projection.ts +19 -1
  29. package/core/src/wikilinks.test.ts +205 -1
  30. package/core/src/wikilinks.ts +200 -35
  31. package/package.json +1 -1
  32. package/src/aggregate-routes.test.ts +230 -0
  33. package/src/cli.ts +6 -0
  34. package/src/contract-errors.test.ts +2 -1
  35. package/src/contract-search.test.ts +17 -0
  36. package/src/mcp-link-warnings-scope.test.ts +144 -0
  37. package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
  38. package/src/mcp-tools.ts +76 -17
  39. package/src/mirror-import.ts +5 -0
  40. package/src/routes.ts +298 -24
  41. package/src/routing.test.ts +167 -0
  42. package/src/routing.ts +47 -11
  43. package/src/tag-integrity-mcp.test.ts +45 -6
  44. package/src/tag-scope.ts +10 -1
  45. package/src/vault.test.ts +557 -21
  46. package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
  47. 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
@@ -211,11 +227,12 @@ export function generateScopedMcpTools(
211
227
 
212
228
  const tools = generateMcpTools(
213
229
  store,
214
- expandVisibility || nearTraversable || ifExistsVisible || writeContext || strictBypass
230
+ expandVisibility || nearTraversable || ifExistsVisible || aggregateVisibility || writeContext || strictBypass
215
231
  ? {
216
232
  ...(expandVisibility ? { expandVisibility } : {}),
217
233
  ...(nearTraversable ? { nearTraversable } : {}),
218
234
  ...(ifExistsVisible ? { ifExistsVisible } : {}),
235
+ ...(aggregateVisibility ? { aggregateVisibility } : {}),
219
236
  ...(writeContext ? { writeContext } : {}),
220
237
  ...(strictBypass ? { strictBypass } : {}),
221
238
  ...(onStrictBypass ? { onStrictBypass } : {}),
@@ -301,9 +318,11 @@ function applyTagDependencyGuards(tools: McpToolDef[], vaultName: string): void
301
318
  * - vault-info: filter projection.tags + projection.indexed_fields
302
319
  * to entries an in-scope tag contributes to
303
320
  *
304
- * Write-tool gating happens in handleScopedMcp at the verb-scope layer
305
- * AND inside each tool's wrapper here (so a tag-scoped `vault:write`
306
- * 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.
307
326
  */
308
327
  function applyTagScopeWrappers(
309
328
  tools: McpToolDef[],
@@ -365,6 +384,29 @@ function applyTagScopeWrappers(
365
384
  const allowed = await getAllowed();
366
385
  const result = await orig(params);
367
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
+ }
368
410
  // Possible response shapes (vault#550 added the `warnings` variants):
369
411
  // - Array (legacy list, no cursor, no warnings)
370
412
  // - `{notes, next_cursor}` (cursor mode, vault#313)
@@ -478,20 +520,34 @@ function applyTagScopeWrappers(
478
520
  tags: Array.isArray(r.tags) ? r.tags : [],
479
521
  indexed_fields: Array.isArray(r.indexed_fields) ? r.indexed_fields : [],
480
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 },
481
527
  };
482
528
  const filtered = filterProjectionByScope(partial, allowed);
483
529
  r.tags = filtered.tags;
484
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
+ }
485
539
  return r;
486
540
  });
487
541
 
488
542
  // ---- Write-side guards ----
489
543
  //
490
- // The verb-scope check (`vault:write`) is enforced at the dispatch layer
491
- // in handleScopedMcp. These wrappers add the second axis: a scoped
492
- // `vault:write` token can only mutate within its tag-allowlist, never
493
- // outside it. Tag operations (`update-tag`, `delete-tag`) gate on the
494
- // 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.
495
551
 
496
552
  const forbidden = (msg: string): unknown => ({
497
553
  error: "Forbidden",
@@ -709,13 +765,15 @@ function overrideVaultInfo(
709
765
 
710
766
  if (params.description !== undefined) {
711
767
  // Secondary scope check: vault-info is read-gated so read-only callers
712
- // can fetch stats, but mutating the vault description requires write
713
- // for THIS vault. Without this, a vault:read token could bypass the
714
- // outer gate by passing `description` to a tool the outer gate
715
- // considers read-only.
716
- 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")) {
717
775
  throw new Error(
718
- `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)"}.`,
719
777
  );
720
778
  }
721
779
  config.description = params.description as string;
@@ -747,6 +805,7 @@ function overrideVaultInfo(
747
805
  tags: projection.tags,
748
806
  indexed_fields: projection.indexed_fields,
749
807
  query_hints: projection.query_hints,
808
+ map: projection.map,
750
809
  };
751
810
 
752
811
  // A2: surface a pointer (path, not body) to the seeded onboarding guide so
@@ -523,6 +523,11 @@ function importResultFromStats(
523
523
  `Dropped parent_names on tag "${sp.tag}": ${sp.reason}`,
524
524
  );
525
525
  }
526
+ for (const sd of stats.skipped_duplicate_ids) {
527
+ warnings.push(
528
+ `Skipped file (id "${sd.id}", path=${sd.path ?? "—"}): ${sd.reason}`,
529
+ );
530
+ }
526
531
  const result: ImportResult = {
527
532
  notes_imported: stats.notes_created + stats.notes_updated,
528
533
  tags_imported: stats.schemas_restored,