@openparachute/vault 0.7.0-rc.9 → 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 +669 -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 +310 -51
- package/core/src/notes.ts +344 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.ts +19 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +89 -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 +200 -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 +76 -17
- package/src/routes.ts +272 -23
- 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 +469 -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
|
|
@@ -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
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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`
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
// 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.
|
|
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
|
|
713
|
-
// for THIS vault
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
|
|
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:
|
|
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
|
package/src/routes.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* and the Request, and returns a Response.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
14
|
+
import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
|
|
15
15
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
16
|
import {
|
|
17
17
|
collectUnknownTagWarnings,
|
|
@@ -28,9 +28,10 @@ import {
|
|
|
28
28
|
resolveStructuredLinkNote,
|
|
29
29
|
getUnresolvedLinksForNote,
|
|
30
30
|
getUnresolvedLinksForNotes,
|
|
31
|
+
getContentWikilinkWarnings,
|
|
31
32
|
} from "../core/src/wikilinks.ts";
|
|
32
33
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
33
|
-
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError } from "../core/src/notes.ts";
|
|
34
|
+
import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, getVaultMap } from "../core/src/notes.ts";
|
|
34
35
|
import { normalizePath } from "../core/src/paths.ts";
|
|
35
36
|
import {
|
|
36
37
|
parseContentRange,
|
|
@@ -62,6 +63,7 @@ import {
|
|
|
62
63
|
scrubValidationStatusByScope,
|
|
63
64
|
tagScopeForbidden,
|
|
64
65
|
tagsWithinScope,
|
|
66
|
+
tagVisibleInScope,
|
|
65
67
|
} from "./tag-scope.ts";
|
|
66
68
|
import { ParentCycleError, InvalidFieldDefaultError, InvalidFieldTypeError } from "../core/src/tag-schemas.ts";
|
|
67
69
|
import { findTokensReferencingTag } from "./token-store.ts";
|
|
@@ -850,6 +852,58 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
850
852
|
return { queryOpts, hasSearch, hasNear, hasCursor };
|
|
851
853
|
}
|
|
852
854
|
|
|
855
|
+
/**
|
|
856
|
+
* Parse the `?aggregate[group_by]=…&aggregate[op]=…&aggregate[field]=…`
|
|
857
|
+
* aggregation params (bracket-style, consistent with `meta[field][op]=`
|
|
858
|
+
* above) into an `AggregateSpec`. Absent entirely (none of the three keys
|
|
859
|
+
* present) → `{}` — no aggregate intent, the caller falls through to a
|
|
860
|
+
* normal query. `group_by` + `op` are required together when ANY of the
|
|
861
|
+
* three is present; `field` is optional at the parser level (its
|
|
862
|
+
* requiredness depends on `op`, enforced by `aggregateNotes` itself). Value
|
|
863
|
+
* validity beyond shape (indexed field, numeric type, sum-requires-field)
|
|
864
|
+
* is ALSO enforced by `aggregateNotes` — same FIELD_NOT_INDEXED /
|
|
865
|
+
* INVALID_QUERY contract every other query surface uses.
|
|
866
|
+
*
|
|
867
|
+
* Returns `{ aggregate? }` or `{ error }` (a 400 Response) on a malformed
|
|
868
|
+
* shape (missing `group_by`/`op`, or an unrecognized `op`).
|
|
869
|
+
*/
|
|
870
|
+
function parseAggregateParam(url: URL): { aggregate?: AggregateSpec; error?: Response } {
|
|
871
|
+
const groupBy = parseQuery(url, "aggregate[group_by]");
|
|
872
|
+
const op = parseQuery(url, "aggregate[op]");
|
|
873
|
+
const field = parseQuery(url, "aggregate[field]");
|
|
874
|
+
if (groupBy === null && op === null && field === null) return {};
|
|
875
|
+
if (groupBy === null || op === null) {
|
|
876
|
+
return {
|
|
877
|
+
error: json(
|
|
878
|
+
{
|
|
879
|
+
error: `aggregate requires both aggregate[group_by] and aggregate[op] — group_by is an indexed metadata field or "tag"; op is "count" or "sum".`,
|
|
880
|
+
code: "INVALID_QUERY",
|
|
881
|
+
error_type: "invalid_query",
|
|
882
|
+
field: "aggregate",
|
|
883
|
+
hint: `pass ?aggregate[group_by]=<field|tag>&aggregate[op]=<count|sum>[&aggregate[field]=<numeric field>]`,
|
|
884
|
+
},
|
|
885
|
+
400,
|
|
886
|
+
),
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
if (op !== "count" && op !== "sum") {
|
|
890
|
+
return {
|
|
891
|
+
error: json(
|
|
892
|
+
{
|
|
893
|
+
error: `invalid aggregate[op]: "${op}" — must be "count" or "sum"`,
|
|
894
|
+
code: "INVALID_QUERY",
|
|
895
|
+
error_type: "invalid_query",
|
|
896
|
+
field: "aggregate.op",
|
|
897
|
+
got: op,
|
|
898
|
+
hint: `pass "count" or "sum"`,
|
|
899
|
+
},
|
|
900
|
+
400,
|
|
901
|
+
),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
return { aggregate: { group_by: groupBy, op, field: field ?? undefined } };
|
|
905
|
+
}
|
|
906
|
+
|
|
853
907
|
/**
|
|
854
908
|
* Parse include_metadata query param.
|
|
855
909
|
* - absent/null → undefined (all metadata, default)
|
|
@@ -907,6 +961,18 @@ function parseExpandParams(
|
|
|
907
961
|
* differing only by extension (vault#330 S1). When the path is
|
|
908
962
|
* ambiguous and no extension hint is supplied, `getNoteByPath` throws
|
|
909
963
|
* `AmbiguousPathError` — REST handlers catch it and return 409.
|
|
964
|
+
*
|
|
965
|
+
* Title fallback (additive): when id AND path/extension both miss
|
|
966
|
+
* cleanly (no throw), tries an H1-title match via `getNoteByTitle` — the
|
|
967
|
+
* note whose first `# ` content line equals `idOrPath`, only when
|
|
968
|
+
* exactly one note has that title. Mirrors the MCP-layer `resolveNote`
|
|
969
|
+
* in core/src/mcp.ts and `[[wikilink]]` resolution (core/src/wikilinks.ts);
|
|
970
|
+
* exact id/path always wins first. Skipped (falls through to "not found")
|
|
971
|
+
* when `store.db` isn't available — the `Store` interface documents `db`
|
|
972
|
+
* as always present on the concrete class, but a handful of tests pass a
|
|
973
|
+
* minimal duck-typed stub (e.g. `handleViewNote`'s coverage in
|
|
974
|
+
* published.test.ts) that only implements `getNote`/`getNoteByPath`; this
|
|
975
|
+
* fallback must never turn that into a crash.
|
|
910
976
|
*/
|
|
911
977
|
async function resolveNote(store: Store, idOrPath: string): Promise<Note | null> {
|
|
912
978
|
const byId = await store.getNote(idOrPath);
|
|
@@ -916,7 +982,9 @@ async function resolveNote(store: Store, idOrPath: string): Promise<Note | null>
|
|
|
916
982
|
const explicit = await store.getNoteByPath(extMatch[1]!, extMatch[2]!);
|
|
917
983
|
if (explicit) return explicit;
|
|
918
984
|
}
|
|
919
|
-
|
|
985
|
+
const byPath = await store.getNoteByPath(idOrPath);
|
|
986
|
+
if (byPath) return byPath;
|
|
987
|
+
return store.db ? getNoteByTitle(store.db, idOrPath) : null;
|
|
920
988
|
}
|
|
921
989
|
|
|
922
990
|
async function requireNote(store: Store, idOrPath: string): Promise<Note> {
|
|
@@ -1241,6 +1309,87 @@ async function handleNotesInner(
|
|
|
1241
1309
|
400,
|
|
1242
1310
|
);
|
|
1243
1311
|
}
|
|
1312
|
+
|
|
1313
|
+
// Aggregation / rollup mode (top new-feature ask from a UX round).
|
|
1314
|
+
// Mutually exclusive with cursor/near — a rollup returns one row per
|
|
1315
|
+
// group, not a paginated/graph-scoped note list — so reject those
|
|
1316
|
+
// combos loudly before touching the DB, then short-circuit: none of
|
|
1317
|
+
// the normal-query output machinery below (expand, include_links,
|
|
1318
|
+
// metadata filtering, ...) applies to rollup rows.
|
|
1319
|
+
const aggregateParsed = parseAggregateParam(url);
|
|
1320
|
+
if (aggregateParsed.error) return aggregateParsed.error;
|
|
1321
|
+
if (aggregateParsed.aggregate) {
|
|
1322
|
+
if (cursorMode) {
|
|
1323
|
+
return json(
|
|
1324
|
+
{
|
|
1325
|
+
error: "aggregate is incompatible with cursor pagination — a rollup has no watermark to page through.",
|
|
1326
|
+
code: "INVALID_QUERY",
|
|
1327
|
+
error_type: "invalid_query",
|
|
1328
|
+
field: "aggregate",
|
|
1329
|
+
hint: "drop `cursor` when using `aggregate`",
|
|
1330
|
+
},
|
|
1331
|
+
400,
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
if (nearNoteIdEarly) {
|
|
1335
|
+
return json(
|
|
1336
|
+
{
|
|
1337
|
+
error: "aggregate is incompatible with near (graph neighborhood).",
|
|
1338
|
+
code: "INVALID_QUERY",
|
|
1339
|
+
error_type: "invalid_query",
|
|
1340
|
+
field: "aggregate",
|
|
1341
|
+
hint: "drop `near` when using `aggregate`",
|
|
1342
|
+
},
|
|
1343
|
+
400,
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
let rows;
|
|
1348
|
+
if (tagScope.raw === null) {
|
|
1349
|
+
rows = await store.aggregateNotes({ ...queryOpts, aggregate: aggregateParsed.aggregate });
|
|
1350
|
+
} else {
|
|
1351
|
+
// Tag-scoped: filter to visible notes FIRST — fetch every note
|
|
1352
|
+
// the OTHER filters match (unpaginated, same `limit: 1000000`
|
|
1353
|
+
// "get everything" convention core's `syncAllWikilinks` uses),
|
|
1354
|
+
// narrow via the SAME `filterNotesByTagScope` every other read
|
|
1355
|
+
// path applies, THEN aggregate over just that visible id set
|
|
1356
|
+
// (reusing the `ids` filter `near` already pushes into SQL).
|
|
1357
|
+
const allMatches = await store.queryNotes({ ...queryOpts, limit: 1000000, offset: 0 });
|
|
1358
|
+
const visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
|
|
1359
|
+
rows = visible.length === 0
|
|
1360
|
+
? []
|
|
1361
|
+
: await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
|
|
1362
|
+
// That note-level narrowing isn't sufficient on its own under
|
|
1363
|
+
// `group_by: "tag"`: a note can be in scope via one tag while
|
|
1364
|
+
// ALSO carrying an out-of-scope co-tag, and a tag rollup's
|
|
1365
|
+
// `group` values ARE tag names — the co-tag would otherwise
|
|
1366
|
+
// surface directly as a group. Scrub group NAMES too, mirroring
|
|
1367
|
+
// the MCP wrapper's identical fix (src/mcp-tools.ts).
|
|
1368
|
+
if (aggregateParsed.aggregate.group_by === "tag") {
|
|
1369
|
+
rows = rows.filter(
|
|
1370
|
+
(r: any) => typeof r?.group === "string" && tagVisibleInScope(r.group, tagScope.allowed, tagScope.raw),
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return json(rows);
|
|
1375
|
+
} catch (e: any) {
|
|
1376
|
+
if (e && e.name === "QueryError") {
|
|
1377
|
+
return json(
|
|
1378
|
+
{
|
|
1379
|
+
error: e.message,
|
|
1380
|
+
code: e.code ?? "INVALID_QUERY",
|
|
1381
|
+
error_type: e.error_type ?? "invalid_query",
|
|
1382
|
+
...(e.field !== undefined ? { field: e.field } : {}),
|
|
1383
|
+
...(e.got !== undefined ? { got: e.got } : {}),
|
|
1384
|
+
...(e.hint !== undefined ? { hint: e.hint } : {}),
|
|
1385
|
+
},
|
|
1386
|
+
400,
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
throw e;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1244
1393
|
// Warnings channel (vault#550). `unknown_tag` stays structured-query
|
|
1245
1394
|
// only (skipped entirely for a tag-scoped session:
|
|
1246
1395
|
// `collectUnknownTagWarnings` resolves `did_you_mean` against the
|
|
@@ -1537,6 +1686,17 @@ async function handleNotesInner(
|
|
|
1537
1686
|
// layers share `resolveOrQueueLink` from core/wikilinks.ts).
|
|
1538
1687
|
const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
|
|
1539
1688
|
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1689
|
+
const pushLinkWarning = (noteId: string, warning: QueryWarning): void => {
|
|
1690
|
+
const list = linkWarningsByNote.get(noteId) ?? [];
|
|
1691
|
+
list.push(warning);
|
|
1692
|
+
linkWarningsByNote.set(noteId, list);
|
|
1693
|
+
};
|
|
1694
|
+
// Content wikilinks whose note+content pair needs an `unresolved_link`/
|
|
1695
|
+
// `ambiguous_link` warning check (vault#570) — deferred to the same
|
|
1696
|
+
// second pass as `pendingLinks` for the same forward-ref-within-batch
|
|
1697
|
+
// reason (a content `[[wikilink]]` to a batch sibling created later
|
|
1698
|
+
// resolves via the pending-wikilink backfill by the time this runs).
|
|
1699
|
+
const contentWikilinkNotes: { noteId: string; content: string }[] = [];
|
|
1540
1700
|
// `if_exists` bookkeeping (vault#555) — mirrors core/src/mcp.ts's
|
|
1541
1701
|
// create-note tool exactly (same contract, independent REST-layer
|
|
1542
1702
|
// reimplementation sharing the same core primitives). See that file's
|
|
@@ -1610,6 +1770,14 @@ async function handleNotesInner(
|
|
|
1610
1770
|
});
|
|
1611
1771
|
}
|
|
1612
1772
|
|
|
1773
|
+
// Content-wikilink warnings (vault#570) — this branch's content
|
|
1774
|
+
// update (if any) already ran `syncWikilinks` inside
|
|
1775
|
+
// `store.updateNote` above; queue for the shared second-pass
|
|
1776
|
+
// classification below.
|
|
1777
|
+
if (updates.content !== undefined) {
|
|
1778
|
+
contentWikilinkNotes.push({ noteId: result.id, content: updates.content });
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1613
1781
|
if (incomingTags.length > 0) {
|
|
1614
1782
|
await store.tagNote(result.id, incomingTags);
|
|
1615
1783
|
// Redundant with the outer batch loop's applySchemaDefaults (this
|
|
@@ -1705,6 +1873,13 @@ async function handleNotesInner(
|
|
|
1705
1873
|
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
1706
1874
|
}
|
|
1707
1875
|
|
|
1876
|
+
// Content-wikilink warnings (vault#570) — `store.createNote` above
|
|
1877
|
+
// already ran `syncWikilinks` (gated on `content` truthy, same
|
|
1878
|
+
// condition here); queue for the shared second-pass classification.
|
|
1879
|
+
if (item.content) {
|
|
1880
|
+
contentWikilinkNotes.push({ noteId: note.id, content: item.content });
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1708
1883
|
created.push((await store.getNote(note.id)) ?? note);
|
|
1709
1884
|
}
|
|
1710
1885
|
|
|
@@ -1713,24 +1888,40 @@ async function handleNotesInner(
|
|
|
1713
1888
|
// that every sibling note in this batch exists. A target that still
|
|
1714
1889
|
// doesn't resolve is queued for lazy resolution (backfills
|
|
1715
1890
|
// automatically when a matching note is created later) and surfaces
|
|
1716
|
-
// an `unresolved_link` warning naming the target
|
|
1891
|
+
// an `unresolved_link` warning naming the target. A target that
|
|
1892
|
+
// matched ≥2 notes (vault#570) is neither linked nor queued —
|
|
1893
|
+
// surfaces a distinct `ambiguous_link` warning instead. Never silent.
|
|
1717
1894
|
for (const { sourceId, links } of pendingLinks) {
|
|
1718
1895
|
for (const link of links) {
|
|
1719
|
-
const
|
|
1720
|
-
if (
|
|
1721
|
-
await store.createLink(sourceId,
|
|
1896
|
+
const outcome = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1897
|
+
if (outcome.status === "resolved") {
|
|
1898
|
+
await store.createLink(sourceId, outcome.note_id, link.relationship);
|
|
1899
|
+
} else if (outcome.status === "ambiguous") {
|
|
1900
|
+
pushLinkWarning(sourceId, {
|
|
1901
|
+
code: "ambiguous_link",
|
|
1902
|
+
message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
1903
|
+
target: link.target,
|
|
1904
|
+
relationship: link.relationship,
|
|
1905
|
+
candidate_count: outcome.candidates.length,
|
|
1906
|
+
});
|
|
1722
1907
|
} else {
|
|
1723
|
-
|
|
1724
|
-
list.push({
|
|
1908
|
+
pushLinkWarning(sourceId, {
|
|
1725
1909
|
code: "unresolved_link",
|
|
1726
1910
|
message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
1727
1911
|
target: link.target,
|
|
1728
1912
|
relationship: link.relationship,
|
|
1729
1913
|
});
|
|
1730
|
-
linkWarningsByNote.set(sourceId, list);
|
|
1731
1914
|
}
|
|
1732
1915
|
}
|
|
1733
1916
|
}
|
|
1917
|
+
|
|
1918
|
+
// --- Content-wikilink warnings (vault#570) ---
|
|
1919
|
+
// Same forward-ref-aware timing as the structured-links pass above.
|
|
1920
|
+
for (const { noteId, content } of contentWikilinkNotes) {
|
|
1921
|
+
for (const warning of getContentWikilinkWarnings(db, noteId, content)) {
|
|
1922
|
+
pushLinkWarning(noteId, warning);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1734
1925
|
};
|
|
1735
1926
|
try {
|
|
1736
1927
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
@@ -2096,15 +2287,25 @@ async function handleNotesInner(
|
|
|
2096
2287
|
// fresh note).
|
|
2097
2288
|
// - Missing target notes skip silently (mirrors MCP).
|
|
2098
2289
|
const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
2099
|
-
// `unresolved_link` warnings (vault#555)
|
|
2100
|
-
// resolve is queued for lazy resolution
|
|
2101
|
-
// when a matching note is created later),
|
|
2290
|
+
// `unresolved_link` / `ambiguous_link` warnings (vault#555, vault#570)
|
|
2291
|
+
// — a target that doesn't resolve is queued for lazy resolution
|
|
2292
|
+
// (backfills automatically when a matching note is created later),
|
|
2293
|
+
// and a target matching ≥2 notes is neither linked nor queued.
|
|
2294
|
+
// Never silently dropped.
|
|
2102
2295
|
const createWarnings: QueryWarning[] = [];
|
|
2103
2296
|
if (linksAdd) {
|
|
2104
2297
|
for (const link of linksAdd) {
|
|
2105
|
-
const
|
|
2106
|
-
if (
|
|
2107
|
-
await store.createLink(created.id,
|
|
2298
|
+
const outcome = resolveOrQueueLink(db, created.id, link.target, link.relationship);
|
|
2299
|
+
if (outcome.status === "resolved") {
|
|
2300
|
+
await store.createLink(created.id, outcome.note_id, link.relationship, link.metadata);
|
|
2301
|
+
} else if (outcome.status === "ambiguous") {
|
|
2302
|
+
createWarnings.push({
|
|
2303
|
+
code: "ambiguous_link",
|
|
2304
|
+
message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
2305
|
+
target: link.target,
|
|
2306
|
+
relationship: link.relationship,
|
|
2307
|
+
candidate_count: outcome.candidates.length,
|
|
2308
|
+
});
|
|
2108
2309
|
} else {
|
|
2109
2310
|
createWarnings.push({
|
|
2110
2311
|
code: "unresolved_link",
|
|
@@ -2115,6 +2316,13 @@ async function handleNotesInner(
|
|
|
2115
2316
|
}
|
|
2116
2317
|
}
|
|
2117
2318
|
}
|
|
2319
|
+
// Content-wikilink warnings (vault#570) — `store.createNote` above
|
|
2320
|
+
// already ran `syncWikilinks`; this is the single-note (non-batch)
|
|
2321
|
+
// create path, so there's no batch-sibling forward-ref to wait for
|
|
2322
|
+
// — compute right away against the committed content.
|
|
2323
|
+
if (content) {
|
|
2324
|
+
createWarnings.push(...getContentWikilinkWarnings(db, created.id, content));
|
|
2325
|
+
}
|
|
2118
2326
|
const final = await store.getNote(created.id);
|
|
2119
2327
|
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
2120
2328
|
const validated: any = attachValidationStatus(store, db, final);
|
|
@@ -2352,6 +2560,14 @@ async function handleNotesInner(
|
|
|
2352
2560
|
// moved despite a real tags/note_tags or links change.
|
|
2353
2561
|
const hasTagMutation = (body.tags?.add?.length ?? 0) > 0 || (body.tags?.remove?.length ?? 0) > 0;
|
|
2354
2562
|
const hasLinkMutation = body.links?.add !== undefined || body.links?.remove !== undefined;
|
|
2563
|
+
// Content-wikilink warnings (vault#570) gate on the SAME condition
|
|
2564
|
+
// `store.updateNote`/`syncWikilinks` use to decide whether to re-sync
|
|
2565
|
+
// content wikilinks at all — a tags/links-only PATCH must not
|
|
2566
|
+
// spuriously re-warn about pre-existing broken links this call never
|
|
2567
|
+
// touched.
|
|
2568
|
+
const contentChanged = updates.content !== undefined
|
|
2569
|
+
|| updates.append !== undefined
|
|
2570
|
+
|| updates.prepend !== undefined;
|
|
2355
2571
|
if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
|
|
2356
2572
|
// Write-attribution (vault#298) — REST update. Stamp the most-recent-
|
|
2357
2573
|
// write columns on the same UPDATE that bumps updated_at. `updates`
|
|
@@ -2378,16 +2594,25 @@ async function handleNotesInner(
|
|
|
2378
2594
|
await store.untagNote(note.id, body.tags.remove);
|
|
2379
2595
|
}
|
|
2380
2596
|
|
|
2381
|
-
// Add links. `unresolved_link` warnings (vault#555
|
|
2382
|
-
// doesn't resolve is queued for lazy
|
|
2383
|
-
// automatically when a matching note is created
|
|
2384
|
-
//
|
|
2597
|
+
// Add links. `unresolved_link` / `ambiguous_link` warnings (vault#555,
|
|
2598
|
+
// vault#570) — a target that doesn't resolve is queued for lazy
|
|
2599
|
+
// resolution (backfills automatically when a matching note is created
|
|
2600
|
+
// later); a target matching ≥2 notes is neither linked nor queued.
|
|
2601
|
+
// Never silently dropped.
|
|
2385
2602
|
const linkWarnings: QueryWarning[] = [];
|
|
2386
2603
|
if (body.links?.add) {
|
|
2387
2604
|
for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
|
|
2388
|
-
const
|
|
2389
|
-
if (
|
|
2390
|
-
await store.createLink(note.id,
|
|
2605
|
+
const outcome = resolveOrQueueLink(db, note.id, link.target, link.relationship);
|
|
2606
|
+
if (outcome.status === "resolved") {
|
|
2607
|
+
await store.createLink(note.id, outcome.note_id, link.relationship, link.metadata);
|
|
2608
|
+
} else if (outcome.status === "ambiguous") {
|
|
2609
|
+
linkWarnings.push({
|
|
2610
|
+
code: "ambiguous_link",
|
|
2611
|
+
message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
2612
|
+
target: link.target,
|
|
2613
|
+
relationship: link.relationship,
|
|
2614
|
+
candidate_count: outcome.candidates.length,
|
|
2615
|
+
});
|
|
2391
2616
|
} else {
|
|
2392
2617
|
linkWarnings.push({
|
|
2393
2618
|
code: "unresolved_link",
|
|
@@ -2409,6 +2634,13 @@ async function handleNotesInner(
|
|
|
2409
2634
|
// `toNoteIndex` drops unknown fields).
|
|
2410
2635
|
const updatedNote = await store.getNote(note.id);
|
|
2411
2636
|
if (updatedNote === null) return json({ error: "Note disappeared", error_type: "not_found" }, 404);
|
|
2637
|
+
// Content-wikilink warnings (vault#570) — `store.updateNote` above
|
|
2638
|
+
// already ran `syncWikilinks` when `contentChanged`; this PATCH
|
|
2639
|
+
// handles a single note (no batch-sibling forward-ref to wait for),
|
|
2640
|
+
// so compute right away against the committed content.
|
|
2641
|
+
if (contentChanged) {
|
|
2642
|
+
linkWarnings.push(...getContentWikilinkWarnings(db, note.id, updatedNote.content));
|
|
2643
|
+
}
|
|
2412
2644
|
const validated: any = attachValidationStatus(store, db, updatedNote);
|
|
2413
2645
|
// Echo hydrated links when a link mutation was part of this request,
|
|
2414
2646
|
// OR the caller explicitly asked for them via `?include_links=true`
|
|
@@ -3224,6 +3456,13 @@ export async function handleVault(
|
|
|
3224
3456
|
* configured AND available), which is what a surface gates its mic on.
|
|
3225
3457
|
*/
|
|
3226
3458
|
resolveCapability: () => Promise<TranscriptionCapability> = resolveTranscriptionCapability,
|
|
3459
|
+
/**
|
|
3460
|
+
* Tag-scope allowlist (front-door structural map). Threaded from
|
|
3461
|
+
* routing.ts the same way every other read handler gets it. Unscoped
|
|
3462
|
+
* (`NO_TAG_SCOPE`) by default so every pre-existing call site — none of
|
|
3463
|
+
* which pass this — is unaffected.
|
|
3464
|
+
*/
|
|
3465
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
3227
3466
|
): Promise<Response> {
|
|
3228
3467
|
const url = new URL(req.url);
|
|
3229
3468
|
|
|
@@ -3233,6 +3472,16 @@ export async function handleVault(
|
|
|
3233
3472
|
// and available. `minutes_remaining` is omitted (cloud/plan concern;
|
|
3234
3473
|
// self-host is unmetered). This is the field Notes gates the mic on.
|
|
3235
3474
|
result.transcription = await resolveCapability();
|
|
3475
|
+
// Front-door structural map — ALWAYS included (unlike `stats`, which is
|
|
3476
|
+
// include_stats-gated): three cheap grouped-COUNT queries, so a fresh
|
|
3477
|
+
// reader orients in one call. Scope-aware: a tag-scoped token's map
|
|
3478
|
+
// covers only notes reachable through an in-scope tag (`tagFilter`
|
|
3479
|
+
// restricts the underlying query rather than post-hoc filtering an
|
|
3480
|
+
// unscoped rollup — see `getVaultMap`'s doc comment for why).
|
|
3481
|
+
result.map =
|
|
3482
|
+
tagScope.raw === null
|
|
3483
|
+
? getVaultMap(store.db)
|
|
3484
|
+
: getVaultMap(store.db, { tagFilter: [...(tagScope.allowed ?? [])] });
|
|
3236
3485
|
if (parseBool(parseQuery(url, "include_stats"), false)) {
|
|
3237
3486
|
result.stats = await store.getVaultStats();
|
|
3238
3487
|
}
|