@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.
- 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 +737 -2
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/doctor.ts +23 -1
- package/core/src/hooks.ts +9 -0
- 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 +322 -53
- package/core/src/notes.ts +518 -67
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +55 -15
- package/core/src/store.ts +141 -7
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- 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/cli.ts +6 -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/mirror-import.ts +5 -0
- package/src/routes.ts +298 -24
- 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 +557 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- package/web/ui/dist/index.html +1 -1
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, validatePath, PathValidationError, 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
|
|
@@ -1643,6 +1811,9 @@ async function handleNotesInner(
|
|
|
1643
1811
|
const extension = item.extension !== undefined
|
|
1644
1812
|
? validateExtension(item.extension)
|
|
1645
1813
|
: undefined;
|
|
1814
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
1815
|
+
// Same batch-transaction throw semantics as extension validation.
|
|
1816
|
+
validatePath(item.path);
|
|
1646
1817
|
const effectiveExtension = extension ?? "md";
|
|
1647
1818
|
const ifExists: string = item.if_exists ?? "error";
|
|
1648
1819
|
const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
|
|
@@ -1705,6 +1876,13 @@ async function handleNotesInner(
|
|
|
1705
1876
|
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
1706
1877
|
}
|
|
1707
1878
|
|
|
1879
|
+
// Content-wikilink warnings (vault#570) — `store.createNote` above
|
|
1880
|
+
// already ran `syncWikilinks` (gated on `content` truthy, same
|
|
1881
|
+
// condition here); queue for the shared second-pass classification.
|
|
1882
|
+
if (item.content) {
|
|
1883
|
+
contentWikilinkNotes.push({ noteId: note.id, content: item.content });
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1708
1886
|
created.push((await store.getNote(note.id)) ?? note);
|
|
1709
1887
|
}
|
|
1710
1888
|
|
|
@@ -1713,24 +1891,40 @@ async function handleNotesInner(
|
|
|
1713
1891
|
// that every sibling note in this batch exists. A target that still
|
|
1714
1892
|
// doesn't resolve is queued for lazy resolution (backfills
|
|
1715
1893
|
// automatically when a matching note is created later) and surfaces
|
|
1716
|
-
// an `unresolved_link` warning naming the target
|
|
1894
|
+
// an `unresolved_link` warning naming the target. A target that
|
|
1895
|
+
// matched ≥2 notes (vault#570) is neither linked nor queued —
|
|
1896
|
+
// surfaces a distinct `ambiguous_link` warning instead. Never silent.
|
|
1717
1897
|
for (const { sourceId, links } of pendingLinks) {
|
|
1718
1898
|
for (const link of links) {
|
|
1719
|
-
const
|
|
1720
|
-
if (
|
|
1721
|
-
await store.createLink(sourceId,
|
|
1899
|
+
const outcome = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1900
|
+
if (outcome.status === "resolved") {
|
|
1901
|
+
await store.createLink(sourceId, outcome.note_id, link.relationship);
|
|
1902
|
+
} else if (outcome.status === "ambiguous") {
|
|
1903
|
+
pushLinkWarning(sourceId, {
|
|
1904
|
+
code: "ambiguous_link",
|
|
1905
|
+
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.`,
|
|
1906
|
+
target: link.target,
|
|
1907
|
+
relationship: link.relationship,
|
|
1908
|
+
candidate_count: outcome.candidates.length,
|
|
1909
|
+
});
|
|
1722
1910
|
} else {
|
|
1723
|
-
|
|
1724
|
-
list.push({
|
|
1911
|
+
pushLinkWarning(sourceId, {
|
|
1725
1912
|
code: "unresolved_link",
|
|
1726
1913
|
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
1914
|
target: link.target,
|
|
1728
1915
|
relationship: link.relationship,
|
|
1729
1916
|
});
|
|
1730
|
-
linkWarningsByNote.set(sourceId, list);
|
|
1731
1917
|
}
|
|
1732
1918
|
}
|
|
1733
1919
|
}
|
|
1920
|
+
|
|
1921
|
+
// --- Content-wikilink warnings (vault#570) ---
|
|
1922
|
+
// Same forward-ref-aware timing as the structured-links pass above.
|
|
1923
|
+
for (const { noteId, content } of contentWikilinkNotes) {
|
|
1924
|
+
for (const warning of getContentWikilinkWarnings(db, noteId, content)) {
|
|
1925
|
+
pushLinkWarning(noteId, warning);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1734
1928
|
};
|
|
1735
1929
|
try {
|
|
1736
1930
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
@@ -1767,6 +1961,13 @@ async function handleNotesInner(
|
|
|
1767
1961
|
400,
|
|
1768
1962
|
);
|
|
1769
1963
|
}
|
|
1964
|
+
// Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
|
|
1965
|
+
if (e && e.code === "INVALID_PATH") {
|
|
1966
|
+
return json(
|
|
1967
|
+
{ error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
|
|
1968
|
+
400,
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1770
1971
|
throw e;
|
|
1771
1972
|
}
|
|
1772
1973
|
|
|
@@ -2062,6 +2263,9 @@ async function handleNotesInner(
|
|
|
2062
2263
|
const createExt = body.extension !== undefined
|
|
2063
2264
|
? validateExtension(body.extension)
|
|
2064
2265
|
: undefined;
|
|
2266
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
2267
|
+
// Covers both the explicit `path` and the idOrPath-as-path shape.
|
|
2268
|
+
validatePath(explicitPath ?? (idLooksLikePath ? idOrPathStr : undefined));
|
|
2065
2269
|
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
2066
2270
|
...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
2067
2271
|
...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
|
|
@@ -2096,15 +2300,25 @@ async function handleNotesInner(
|
|
|
2096
2300
|
// fresh note).
|
|
2097
2301
|
// - Missing target notes skip silently (mirrors MCP).
|
|
2098
2302
|
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),
|
|
2303
|
+
// `unresolved_link` / `ambiguous_link` warnings (vault#555, vault#570)
|
|
2304
|
+
// — a target that doesn't resolve is queued for lazy resolution
|
|
2305
|
+
// (backfills automatically when a matching note is created later),
|
|
2306
|
+
// and a target matching ≥2 notes is neither linked nor queued.
|
|
2307
|
+
// Never silently dropped.
|
|
2102
2308
|
const createWarnings: QueryWarning[] = [];
|
|
2103
2309
|
if (linksAdd) {
|
|
2104
2310
|
for (const link of linksAdd) {
|
|
2105
|
-
const
|
|
2106
|
-
if (
|
|
2107
|
-
await store.createLink(created.id,
|
|
2311
|
+
const outcome = resolveOrQueueLink(db, created.id, link.target, link.relationship);
|
|
2312
|
+
if (outcome.status === "resolved") {
|
|
2313
|
+
await store.createLink(created.id, outcome.note_id, link.relationship, link.metadata);
|
|
2314
|
+
} else if (outcome.status === "ambiguous") {
|
|
2315
|
+
createWarnings.push({
|
|
2316
|
+
code: "ambiguous_link",
|
|
2317
|
+
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.`,
|
|
2318
|
+
target: link.target,
|
|
2319
|
+
relationship: link.relationship,
|
|
2320
|
+
candidate_count: outcome.candidates.length,
|
|
2321
|
+
});
|
|
2108
2322
|
} else {
|
|
2109
2323
|
createWarnings.push({
|
|
2110
2324
|
code: "unresolved_link",
|
|
@@ -2115,6 +2329,13 @@ async function handleNotesInner(
|
|
|
2115
2329
|
}
|
|
2116
2330
|
}
|
|
2117
2331
|
}
|
|
2332
|
+
// Content-wikilink warnings (vault#570) — `store.createNote` above
|
|
2333
|
+
// already ran `syncWikilinks`; this is the single-note (non-batch)
|
|
2334
|
+
// create path, so there's no batch-sibling forward-ref to wait for
|
|
2335
|
+
// — compute right away against the committed content.
|
|
2336
|
+
if (content) {
|
|
2337
|
+
createWarnings.push(...getContentWikilinkWarnings(db, created.id, content));
|
|
2338
|
+
}
|
|
2118
2339
|
const final = await store.getNote(created.id);
|
|
2119
2340
|
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
2120
2341
|
const validated: any = attachValidationStatus(store, db, final);
|
|
@@ -2291,7 +2512,12 @@ async function handleNotesInner(
|
|
|
2291
2512
|
if (body.append !== undefined) updates.append = body.append;
|
|
2292
2513
|
if (body.prepend !== undefined) updates.prepend = body.prepend;
|
|
2293
2514
|
}
|
|
2294
|
-
if (body.path !== undefined)
|
|
2515
|
+
if (body.path !== undefined) {
|
|
2516
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
2517
|
+
// Throws PathValidationError → 400 in the outer catch.
|
|
2518
|
+
validatePath(body.path);
|
|
2519
|
+
updates.path = body.path;
|
|
2520
|
+
}
|
|
2295
2521
|
if (body.extension !== undefined) {
|
|
2296
2522
|
// Validate up front (vault#328). Throws ExtensionValidationError
|
|
2297
2523
|
// which the outer catch converts to a 400.
|
|
@@ -2352,6 +2578,14 @@ async function handleNotesInner(
|
|
|
2352
2578
|
// moved despite a real tags/note_tags or links change.
|
|
2353
2579
|
const hasTagMutation = (body.tags?.add?.length ?? 0) > 0 || (body.tags?.remove?.length ?? 0) > 0;
|
|
2354
2580
|
const hasLinkMutation = body.links?.add !== undefined || body.links?.remove !== undefined;
|
|
2581
|
+
// Content-wikilink warnings (vault#570) gate on the SAME condition
|
|
2582
|
+
// `store.updateNote`/`syncWikilinks` use to decide whether to re-sync
|
|
2583
|
+
// content wikilinks at all — a tags/links-only PATCH must not
|
|
2584
|
+
// spuriously re-warn about pre-existing broken links this call never
|
|
2585
|
+
// touched.
|
|
2586
|
+
const contentChanged = updates.content !== undefined
|
|
2587
|
+
|| updates.append !== undefined
|
|
2588
|
+
|| updates.prepend !== undefined;
|
|
2355
2589
|
if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
|
|
2356
2590
|
// Write-attribution (vault#298) — REST update. Stamp the most-recent-
|
|
2357
2591
|
// write columns on the same UPDATE that bumps updated_at. `updates`
|
|
@@ -2378,16 +2612,25 @@ async function handleNotesInner(
|
|
|
2378
2612
|
await store.untagNote(note.id, body.tags.remove);
|
|
2379
2613
|
}
|
|
2380
2614
|
|
|
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
|
-
//
|
|
2615
|
+
// Add links. `unresolved_link` / `ambiguous_link` warnings (vault#555,
|
|
2616
|
+
// vault#570) — a target that doesn't resolve is queued for lazy
|
|
2617
|
+
// resolution (backfills automatically when a matching note is created
|
|
2618
|
+
// later); a target matching ≥2 notes is neither linked nor queued.
|
|
2619
|
+
// Never silently dropped.
|
|
2385
2620
|
const linkWarnings: QueryWarning[] = [];
|
|
2386
2621
|
if (body.links?.add) {
|
|
2387
2622
|
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,
|
|
2623
|
+
const outcome = resolveOrQueueLink(db, note.id, link.target, link.relationship);
|
|
2624
|
+
if (outcome.status === "resolved") {
|
|
2625
|
+
await store.createLink(note.id, outcome.note_id, link.relationship, link.metadata);
|
|
2626
|
+
} else if (outcome.status === "ambiguous") {
|
|
2627
|
+
linkWarnings.push({
|
|
2628
|
+
code: "ambiguous_link",
|
|
2629
|
+
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.`,
|
|
2630
|
+
target: link.target,
|
|
2631
|
+
relationship: link.relationship,
|
|
2632
|
+
candidate_count: outcome.candidates.length,
|
|
2633
|
+
});
|
|
2391
2634
|
} else {
|
|
2392
2635
|
linkWarnings.push({
|
|
2393
2636
|
code: "unresolved_link",
|
|
@@ -2409,6 +2652,13 @@ async function handleNotesInner(
|
|
|
2409
2652
|
// `toNoteIndex` drops unknown fields).
|
|
2410
2653
|
const updatedNote = await store.getNote(note.id);
|
|
2411
2654
|
if (updatedNote === null) return json({ error: "Note disappeared", error_type: "not_found" }, 404);
|
|
2655
|
+
// Content-wikilink warnings (vault#570) — `store.updateNote` above
|
|
2656
|
+
// already ran `syncWikilinks` when `contentChanged`; this PATCH
|
|
2657
|
+
// handles a single note (no batch-sibling forward-ref to wait for),
|
|
2658
|
+
// so compute right away against the committed content.
|
|
2659
|
+
if (contentChanged) {
|
|
2660
|
+
linkWarnings.push(...getContentWikilinkWarnings(db, note.id, updatedNote.content));
|
|
2661
|
+
}
|
|
2412
2662
|
const validated: any = attachValidationStatus(store, db, updatedNote);
|
|
2413
2663
|
// Echo hydrated links when a link mutation was part of this request,
|
|
2414
2664
|
// OR the caller explicitly asked for them via `?include_links=true`
|
|
@@ -2524,6 +2774,13 @@ async function handleNotesInner(
|
|
|
2524
2774
|
400,
|
|
2525
2775
|
);
|
|
2526
2776
|
}
|
|
2777
|
+
// Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
|
|
2778
|
+
if (e && e.code === "INVALID_PATH") {
|
|
2779
|
+
return json(
|
|
2780
|
+
{ error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
|
|
2781
|
+
400,
|
|
2782
|
+
);
|
|
2783
|
+
}
|
|
2527
2784
|
throw e;
|
|
2528
2785
|
}
|
|
2529
2786
|
}
|
|
@@ -3224,6 +3481,13 @@ export async function handleVault(
|
|
|
3224
3481
|
* configured AND available), which is what a surface gates its mic on.
|
|
3225
3482
|
*/
|
|
3226
3483
|
resolveCapability: () => Promise<TranscriptionCapability> = resolveTranscriptionCapability,
|
|
3484
|
+
/**
|
|
3485
|
+
* Tag-scope allowlist (front-door structural map). Threaded from
|
|
3486
|
+
* routing.ts the same way every other read handler gets it. Unscoped
|
|
3487
|
+
* (`NO_TAG_SCOPE`) by default so every pre-existing call site — none of
|
|
3488
|
+
* which pass this — is unaffected.
|
|
3489
|
+
*/
|
|
3490
|
+
tagScope: TagScopeCtx = NO_TAG_SCOPE,
|
|
3227
3491
|
): Promise<Response> {
|
|
3228
3492
|
const url = new URL(req.url);
|
|
3229
3493
|
|
|
@@ -3233,6 +3497,16 @@ export async function handleVault(
|
|
|
3233
3497
|
// and available. `minutes_remaining` is omitted (cloud/plan concern;
|
|
3234
3498
|
// self-host is unmetered). This is the field Notes gates the mic on.
|
|
3235
3499
|
result.transcription = await resolveCapability();
|
|
3500
|
+
// Front-door structural map — ALWAYS included (unlike `stats`, which is
|
|
3501
|
+
// include_stats-gated): three cheap grouped-COUNT queries, so a fresh
|
|
3502
|
+
// reader orients in one call. Scope-aware: a tag-scoped token's map
|
|
3503
|
+
// covers only notes reachable through an in-scope tag (`tagFilter`
|
|
3504
|
+
// restricts the underlying query rather than post-hoc filtering an
|
|
3505
|
+
// unscoped rollup — see `getVaultMap`'s doc comment for why).
|
|
3506
|
+
result.map =
|
|
3507
|
+
tagScope.raw === null
|
|
3508
|
+
? getVaultMap(store.db)
|
|
3509
|
+
: getVaultMap(store.db, { tagFilter: [...(tagScope.allowed ?? [])] });
|
|
3236
3510
|
if (parseBool(parseQuery(url, "include_stats"), false)) {
|
|
3237
3511
|
result.stats = await store.getVaultStats();
|
|
3238
3512
|
}
|
package/src/routing.test.ts
CHANGED
|
@@ -2168,6 +2168,173 @@ describe("scope enforcement on /api/*", () => {
|
|
|
2168
2168
|
);
|
|
2169
2169
|
expect(writeRes.status).toBe(403);
|
|
2170
2170
|
});
|
|
2171
|
+
|
|
2172
|
+
// ----- REST scope-tier completion (vault#570-adjacent) -------------------
|
|
2173
|
+
//
|
|
2174
|
+
// The MCP door re-tiered update-tag/delete-tag/rename-tag/merge-tags
|
|
2175
|
+
// write → admin, and doctor admin → read (core/src/mcp.ts). The REST door
|
|
2176
|
+
// originally only enforced the generic method→verb default (GET→read,
|
|
2177
|
+
// everything else→write) here, and hardcoded `/api/doctor` to admin
|
|
2178
|
+
// regardless — a gap between doors: a `vault:write` token could mutate
|
|
2179
|
+
// tag schemas over REST while MCP already refused it, and a `vault:read`
|
|
2180
|
+
// token could run the equivalent scan over MCP but not REST. These tests
|
|
2181
|
+
// pin BOTH doors now agreeing, mirroring the MCP suite in
|
|
2182
|
+
// `src/vault.test.ts`'s "MCP write/admin re-tier — scope enforcement at
|
|
2183
|
+
// the tools/call layer" describe block.
|
|
2184
|
+
|
|
2185
|
+
test("vault:write token: POST /api/notes succeeds but PUT/DELETE /api/tags/:name, POST /api/tags/merge, POST /api/tags/:name/rename are DENIED (now admin-tier — REST/MCP parity)", async () => {
|
|
2186
|
+
createVault("journal");
|
|
2187
|
+
const store = getVaultStore("journal");
|
|
2188
|
+
await store.createNote("seed", { tags: ["mine"] });
|
|
2189
|
+
const token = await mintToken("journal", { permission: "full", scopes: ["vault:write"] });
|
|
2190
|
+
|
|
2191
|
+
// Allowed: content authorship.
|
|
2192
|
+
const notesPath = "/vault/journal/api/notes";
|
|
2193
|
+
const createRes = await route(
|
|
2194
|
+
new Request(`http://localhost:1940${notesPath}`, {
|
|
2195
|
+
method: "POST",
|
|
2196
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2197
|
+
body: JSON.stringify({ content: "hello", tags: ["mine"] }),
|
|
2198
|
+
}),
|
|
2199
|
+
notesPath,
|
|
2200
|
+
);
|
|
2201
|
+
expect(createRes.status).toBeLessThan(400);
|
|
2202
|
+
|
|
2203
|
+
// Denied: each now requires vault:admin, not vault:write.
|
|
2204
|
+
const putPath = "/vault/journal/api/tags/mine";
|
|
2205
|
+
const putRes = await route(
|
|
2206
|
+
new Request(`http://localhost:1940${putPath}`, {
|
|
2207
|
+
method: "PUT",
|
|
2208
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2209
|
+
body: JSON.stringify({ description: "hijacked" }),
|
|
2210
|
+
}),
|
|
2211
|
+
putPath,
|
|
2212
|
+
);
|
|
2213
|
+
expect(putRes.status).toBe(403);
|
|
2214
|
+
const putBody = (await putRes.json()) as { error_type?: string; required_scope?: string };
|
|
2215
|
+
expect(putBody.error_type).toBe("insufficient_scope");
|
|
2216
|
+
expect(putBody.required_scope).toBe("vault:admin");
|
|
2217
|
+
|
|
2218
|
+
const deletePath = "/vault/journal/api/tags/mine";
|
|
2219
|
+
const deleteRes = await route(authed(token, "DELETE", deletePath), deletePath);
|
|
2220
|
+
expect(deleteRes.status).toBe(403);
|
|
2221
|
+
expect(((await deleteRes.json()) as { required_scope?: string }).required_scope).toBe("vault:admin");
|
|
2222
|
+
|
|
2223
|
+
const mergePath = "/vault/journal/api/tags/merge";
|
|
2224
|
+
const mergeRes = await route(
|
|
2225
|
+
new Request(`http://localhost:1940${mergePath}`, {
|
|
2226
|
+
method: "POST",
|
|
2227
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2228
|
+
body: JSON.stringify({ sources: ["mine"], target: "mine2" }),
|
|
2229
|
+
}),
|
|
2230
|
+
mergePath,
|
|
2231
|
+
);
|
|
2232
|
+
expect(mergeRes.status).toBe(403);
|
|
2233
|
+
expect(((await mergeRes.json()) as { required_scope?: string }).required_scope).toBe("vault:admin");
|
|
2234
|
+
|
|
2235
|
+
const renamePath = "/vault/journal/api/tags/mine/rename";
|
|
2236
|
+
const renameRes = await route(
|
|
2237
|
+
new Request(`http://localhost:1940${renamePath}`, {
|
|
2238
|
+
method: "POST",
|
|
2239
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2240
|
+
body: JSON.stringify({ new_name: "mine2" }),
|
|
2241
|
+
}),
|
|
2242
|
+
renamePath,
|
|
2243
|
+
);
|
|
2244
|
+
expect(renameRes.status).toBe(403);
|
|
2245
|
+
expect(((await renameRes.json()) as { required_scope?: string }).required_scope).toBe("vault:admin");
|
|
2246
|
+
|
|
2247
|
+
// Untouched — none of the denied calls above ever reached the store.
|
|
2248
|
+
const tags = await store.listTags();
|
|
2249
|
+
expect(tags.some((t) => t.name === "mine")).toBe(true);
|
|
2250
|
+
expect(tags.some((t) => t.name === "mine2")).toBe(false);
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
test("vault:read token: GET /api/doctor succeeds (now read-tier — REST/MCP parity)", async () => {
|
|
2254
|
+
createVault("journal");
|
|
2255
|
+
const token = await mintToken("journal", { permission: "read", scopes: ["vault:read"] });
|
|
2256
|
+
const path = "/vault/journal/api/doctor";
|
|
2257
|
+
const res = await route(authed(token, "GET", path), path);
|
|
2258
|
+
expect(res.status).toBe(200);
|
|
2259
|
+
const body = (await res.json()) as { findings?: unknown[]; summary?: unknown };
|
|
2260
|
+
expect(body.findings).toBeDefined();
|
|
2261
|
+
expect(body.summary).toBeDefined();
|
|
2262
|
+
});
|
|
2263
|
+
|
|
2264
|
+
test("vault:admin token: CAN PUT/DELETE /api/tags/:name, POST /api/tags/merge, POST /api/tags/:name/rename, and GET /api/doctor", async () => {
|
|
2265
|
+
createVault("journal");
|
|
2266
|
+
const store = getVaultStore("journal");
|
|
2267
|
+
await store.upsertTagRecord("a", {});
|
|
2268
|
+
await store.upsertTagRecord("b", {});
|
|
2269
|
+
await store.createNote("seed", { tags: ["a"] });
|
|
2270
|
+
const token = await mintToken("journal", { permission: "full", scopes: ["vault:admin"] });
|
|
2271
|
+
|
|
2272
|
+
const putPath = "/vault/journal/api/tags/a";
|
|
2273
|
+
const putRes = await route(
|
|
2274
|
+
new Request(`http://localhost:1940${putPath}`, {
|
|
2275
|
+
method: "PUT",
|
|
2276
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2277
|
+
body: JSON.stringify({ description: "updated" }),
|
|
2278
|
+
}),
|
|
2279
|
+
putPath,
|
|
2280
|
+
);
|
|
2281
|
+
expect(putRes.status).toBe(200);
|
|
2282
|
+
|
|
2283
|
+
const doctorPath = "/vault/journal/api/doctor";
|
|
2284
|
+
const doctorRes = await route(authed(token, "GET", doctorPath), doctorPath);
|
|
2285
|
+
expect(doctorRes.status).toBe(200);
|
|
2286
|
+
|
|
2287
|
+
const mergePath = "/vault/journal/api/tags/merge";
|
|
2288
|
+
const mergeRes = await route(
|
|
2289
|
+
new Request(`http://localhost:1940${mergePath}`, {
|
|
2290
|
+
method: "POST",
|
|
2291
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2292
|
+
body: JSON.stringify({ sources: ["b"], target: "a" }),
|
|
2293
|
+
}),
|
|
2294
|
+
mergePath,
|
|
2295
|
+
);
|
|
2296
|
+
expect(mergeRes.status).toBe(200);
|
|
2297
|
+
|
|
2298
|
+
const renamePath = "/vault/journal/api/tags/a/rename";
|
|
2299
|
+
const renameRes = await route(
|
|
2300
|
+
new Request(`http://localhost:1940${renamePath}`, {
|
|
2301
|
+
method: "POST",
|
|
2302
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2303
|
+
body: JSON.stringify({ new_name: "a2" }),
|
|
2304
|
+
}),
|
|
2305
|
+
renamePath,
|
|
2306
|
+
);
|
|
2307
|
+
expect(renameRes.status).toBe(200);
|
|
2308
|
+
|
|
2309
|
+
const deletePath = "/vault/journal/api/tags/a2";
|
|
2310
|
+
const deleteRes = await route(authed(token, "DELETE", deletePath), deletePath);
|
|
2311
|
+
expect(deleteRes.status).toBe(200);
|
|
2312
|
+
});
|
|
2313
|
+
|
|
2314
|
+
test("vault:write token: GET /api/doctor still succeeds (write ⊇ read inheritance, unaffected by the re-tier)", async () => {
|
|
2315
|
+
createVault("journal");
|
|
2316
|
+
const token = await mintToken("journal", { permission: "full", scopes: ["vault:write"] });
|
|
2317
|
+
const path = "/vault/journal/api/doctor";
|
|
2318
|
+
const res = await route(authed(token, "GET", path), path);
|
|
2319
|
+
expect(res.status).toBe(200);
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
test("vault:read token: PUT /api/tags/:name is DENIED (read does not imply admin)", async () => {
|
|
2323
|
+
createVault("journal");
|
|
2324
|
+
getVaultStore("journal").upsertTagRecord("mine", {});
|
|
2325
|
+
const token = await mintToken("journal", { permission: "read", scopes: ["vault:read"] });
|
|
2326
|
+
const path = "/vault/journal/api/tags/mine";
|
|
2327
|
+
const res = await route(
|
|
2328
|
+
new Request(`http://localhost:1940${path}`, {
|
|
2329
|
+
method: "PUT",
|
|
2330
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2331
|
+
body: JSON.stringify({ description: "denied" }),
|
|
2332
|
+
}),
|
|
2333
|
+
path,
|
|
2334
|
+
);
|
|
2335
|
+
expect(res.status).toBe(403);
|
|
2336
|
+
expect(((await res.json()) as { required_scope?: string }).required_scope).toBe("vault:admin");
|
|
2337
|
+
});
|
|
2171
2338
|
});
|
|
2172
2339
|
|
|
2173
2340
|
// ---------------------------------------------------------------------------
|