@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.4
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/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/link-count.test.ts +29 -0
- package/core/src/links.ts +43 -0
- package/core/src/mcp-manifest.ts +10 -6
- package/core/src/mcp.ts +129 -44
- package/core/src/notes.ts +51 -2
- package/core/src/schema-v28-unresolved-wikilinks.test.ts +103 -0
- package/core/src/schema.ts +28 -1
- package/core/src/store.ts +180 -57
- package/core/src/txn.test.ts +33 -1
- package/core/src/txn.ts +80 -7
- package/core/src/types.ts +11 -0
- package/core/src/vault-projection.ts +8 -1
- package/core/src/wikilinks.ts +873 -44
- package/package.json +2 -2
- package/src/aggregate-routes.test.ts +76 -0
- package/src/config.ts +8 -2
- package/src/mcp-http.test.ts +51 -1
- package/src/mcp-tools.ts +106 -5
- package/src/mirror-routes.test.ts +47 -0
- package/src/release-plan.test.ts +90 -1
- package/src/routes.ts +280 -66
- package/src/routing.test.ts +67 -0
- package/src/tag-scope-query-tag.test.ts +374 -0
- package/src/tag-scope.ts +116 -0
- package/src/test-support/vault-714-find-path.json +58 -0
- package/src/test-support/vault-714-graph.json +27 -0
- package/src/test-support/vault-714-has_links.json +96 -0
- package/src/test-support/vault-714-include_broken_links.json +118 -0
- package/src/test-support/vault-714-include_link_count.json +348 -0
- package/src/test-support/vault-714-include_links.json +86 -0
- package/src/test-support/vault-714-near.json +78 -0
- package/src/test-support/vault-714-unresolved-wikilinks.json +6 -0
- package/src/vault.test.ts +928 -1
- package/src/write-warnings-scope.test.ts +344 -0
- package/src/ws-server.ts +16 -3
- package/src/ws-subscribe.test.ts +76 -2
package/src/routes.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* and the Request, and returns a Response.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { Database } from "bun:sqlite";
|
|
14
15
|
import type { Store, Note, QueryOpts, AggregateSpec } from "../core/src/types.ts";
|
|
15
16
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
17
|
import {
|
|
@@ -30,7 +31,18 @@ import {
|
|
|
30
31
|
resolveStructuredLinkNote,
|
|
31
32
|
getUnresolvedLinksForNote,
|
|
32
33
|
getUnresolvedLinksForNotes,
|
|
34
|
+
listZeroVisibleAmbiguousAsUnresolved,
|
|
35
|
+
listResolvedToInvisibleAsUnresolved,
|
|
36
|
+
narrowByVisibleBrokenness,
|
|
37
|
+
sqlHasBrokenLinks,
|
|
38
|
+
getAmbiguousLinksForNote,
|
|
39
|
+
getAmbiguousLinksForNotes,
|
|
40
|
+
narrowByVisibleAmbiguity,
|
|
41
|
+
sqlHasAmbiguousLinks,
|
|
33
42
|
getContentWikilinkWarnings,
|
|
43
|
+
ambiguousLinkWarning,
|
|
44
|
+
unresolvedLinkWarning,
|
|
45
|
+
narrowLinkWarningsForVisibility,
|
|
34
46
|
} from "../core/src/wikilinks.ts";
|
|
35
47
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
36
48
|
import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, validatePath, PathValidationError, getVaultMap } from "../core/src/notes.ts";
|
|
@@ -67,6 +79,7 @@ import {
|
|
|
67
79
|
filterHydratedLinksByTagScope,
|
|
68
80
|
filterNotesByTagScope,
|
|
69
81
|
noteWithinTagScope,
|
|
82
|
+
scopeQueryTags,
|
|
70
83
|
scrubIndexedFieldConflictError,
|
|
71
84
|
scrubNotesTagsByScope,
|
|
72
85
|
scrubNoteTagsByScope,
|
|
@@ -901,8 +914,66 @@ function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response }
|
|
|
901
914
|
* Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
|
|
902
915
|
* as the inline code did. `hasSearch` is surfaced from the raw `search` param
|
|
903
916
|
* (this helper does not itself build a search query — the caller routes that).
|
|
917
|
+
*
|
|
918
|
+
* `tagScope` (vault#675): out-of-scope `tag=` / `exclude_tag=` inputs are
|
|
919
|
+
* rewritten so they match nothing, exactly as a nonexistent tag would — see
|
|
920
|
+
* `scopeQueryTags`. Applied here so every branch that lowers a query string
|
|
921
|
+
* (structured, `search`, `semantic`, `aggregate`, `near`, cursor) is covered
|
|
922
|
+
* by one call, the same way this helper already gives subscribe/search
|
|
923
|
+
* predicate parity. Default `NO_TAG_SCOPE` → no-op, so the WS route's
|
|
924
|
+
* upgrade-time parse (which runs before the socket authenticates) is
|
|
925
|
+
* unchanged; `ws-server.ts` applies the scoping once auth is known.
|
|
926
|
+
*/
|
|
927
|
+
/**
|
|
928
|
+
* The `visible` predicate the vault#581 ambiguous-links surface consults for
|
|
929
|
+
* a TAG-SCOPED reader — `undefined` (and therefore a complete no-op) for an
|
|
930
|
+
* unscoped one. Identical shape and rule to the MCP door's `ambiguityVisible`
|
|
931
|
+
* (`src/mcp-tools.ts`) and to `nearTraversable`: look a CANDIDATE note's tags
|
|
932
|
+
* up by id and apply the same `noteWithinTagScope` test every other read path
|
|
933
|
+
* uses, so the two doors cannot drift on who counts as a visible candidate.
|
|
934
|
+
*
|
|
935
|
+
* Needed because `candidate_count` is derived vault-wide: without it a
|
|
936
|
+
* `work`-scoped reader learns from `candidate_count: 2` on its own `[[Dup]]`
|
|
937
|
+
* that a second `Dup` exists in a scope it cannot see.
|
|
938
|
+
*
|
|
939
|
+
* vault#239 reuses the SAME predicate for the BROKEN-links surface — "which
|
|
940
|
+
* of this target's candidates can the reader see" is one question, and the
|
|
941
|
+
* ambiguous (>=2 visible) and broken (0 visible) answers are two faces of it.
|
|
942
|
+
*/
|
|
943
|
+
function ambiguityVisibilityFor(
|
|
944
|
+
db: Database,
|
|
945
|
+
tagScope: TagScopeCtx,
|
|
946
|
+
): ((noteId: string) => boolean) | undefined {
|
|
947
|
+
if (tagScope.raw === null) return undefined;
|
|
948
|
+
return (noteId: string) =>
|
|
949
|
+
noteWithinTagScope(
|
|
950
|
+
{ id: noteId, tags: getNoteTags(db, noteId) } as Note,
|
|
951
|
+
tagScope.allowed,
|
|
952
|
+
tagScope.raw,
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Re-decide a WRITE response's `ambiguous_link` warnings against the caller's
|
|
958
|
+
* own sub-vault. `create-note`/`update-note` echo per-note `warnings`, and an
|
|
959
|
+
* `ambiguous_link` entry carries a `candidate_count` derived VAULT-WIDE — so
|
|
960
|
+
* without this a `work`-scoped writer saving `[[Dup]]` learns from
|
|
961
|
+
* `candidate_count: 2` that a second `Dup` exists in a scope it cannot see.
|
|
962
|
+
* That is exactly the vault#707 read-side oracle, reachable through the write
|
|
963
|
+
* door. Same predicate ({@link ambiguityVisibilityFor}) and same core decision
|
|
964
|
+
* function as the read surfaces, so the doors cannot drift. Unscoped: the
|
|
965
|
+
* predicate is `undefined` and the array is returned unchanged.
|
|
904
966
|
*/
|
|
905
|
-
|
|
967
|
+
function narrowWriteWarnings(
|
|
968
|
+
db: Database,
|
|
969
|
+
warnings: QueryWarning[],
|
|
970
|
+
tagScope: TagScopeCtx,
|
|
971
|
+
): QueryWarning[] {
|
|
972
|
+
const visible = ambiguityVisibilityFor(db, tagScope);
|
|
973
|
+
return visible ? narrowLinkWarningsForVisibility(db, warnings, visible) : warnings;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
export function parseNotesQueryOpts(url: URL, tagScope: TagScopeCtx = NO_TAG_SCOPE): {
|
|
906
977
|
queryOpts?: QueryOpts;
|
|
907
978
|
hasSearch: boolean;
|
|
908
979
|
hasNear: boolean;
|
|
@@ -956,10 +1027,29 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
956
1027
|
expand,
|
|
957
1028
|
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
958
1029
|
hasTags: parseBoolOrUndef(parseQuery(url, "has_tags")),
|
|
959
|
-
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
1030
|
+
hasLinks: linkOps.sqlHasLinks(parseBoolOrUndef(parseQuery(url, "has_links")), tagScope.raw !== null),
|
|
960
1031
|
// Presence filter on dangling outbound wikilinks/structured links
|
|
961
1032
|
// (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
|
|
962
|
-
|
|
1033
|
+
// vault#239: for a TAG-SCOPED reader NEITHER polarity is safe to push
|
|
1034
|
+
// into SQL — a note can be broken in that reader's sub-vault while
|
|
1035
|
+
// carrying no `unresolved_wikilinks` row at all (its target's only
|
|
1036
|
+
// candidates are invisible, so the row sits in `ambiguous_wikilinks`
|
|
1037
|
+
// instead). Both are lifted here and re-decided per note by
|
|
1038
|
+
// `narrowByVisibleBrokenness`. Identity for an unscoped reader.
|
|
1039
|
+
hasBrokenLinks: sqlHasBrokenLinks(
|
|
1040
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1041
|
+
tagScope.raw !== null,
|
|
1042
|
+
),
|
|
1043
|
+
// Presence filter on AMBIGUOUS outbound links — a target that matched ≥2
|
|
1044
|
+
// notes (vault#581) — see core/src/types.ts QueryOpts.hasAmbiguousLinks.
|
|
1045
|
+
// vault#581 auth review: for a TAG-SCOPED reader only the `true` polarity
|
|
1046
|
+
// is safe to push into SQL (it's a superset of the right answer);
|
|
1047
|
+
// `false` is lifted here and both are re-decided per note by
|
|
1048
|
+
// `narrowByVisibleAmbiguity` on the reader's own visible sub-vault.
|
|
1049
|
+
hasAmbiguousLinks: sqlHasAmbiguousLinks(
|
|
1050
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
1051
|
+
tagScope.raw !== null,
|
|
1052
|
+
),
|
|
963
1053
|
path: parseQuery(url, "path") ?? undefined,
|
|
964
1054
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
965
1055
|
excludePathPrefix: parseQueryList(url, "exclude_path_prefix"),
|
|
@@ -986,7 +1076,12 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
986
1076
|
cursor: cursorParam ?? undefined,
|
|
987
1077
|
};
|
|
988
1078
|
|
|
989
|
-
return {
|
|
1079
|
+
return {
|
|
1080
|
+
queryOpts: scopeQueryTags(queryOpts, tagScope.allowed, tagScope.raw),
|
|
1081
|
+
hasSearch,
|
|
1082
|
+
hasNear,
|
|
1083
|
+
hasCursor,
|
|
1084
|
+
};
|
|
990
1085
|
}
|
|
991
1086
|
|
|
992
1087
|
/**
|
|
@@ -1264,8 +1359,17 @@ async function handleNotesInner(
|
|
|
1264
1359
|
// No tag-scope filtering needed (unlike include_links above): a
|
|
1265
1360
|
// broken-link `target` never resolved to a note, so there's no
|
|
1266
1361
|
// neighbor identity/content to leak — just the string this note's
|
|
1267
|
-
// own [[wikilink]]/structured link already named.
|
|
1268
|
-
|
|
1362
|
+
// own [[wikilink]]/structured link already named. The predicate is
|
|
1363
|
+
// not a filter but an ADDITION (vault#239): it makes a reference
|
|
1364
|
+
// whose only candidates are invisible read as broken here, instead
|
|
1365
|
+
// of only once the last of them is deleted.
|
|
1366
|
+
result.broken_links = getUnresolvedLinksForNote(db, note.id, ambiguityVisibilityFor(db, tagScope));
|
|
1367
|
+
}
|
|
1368
|
+
if (parseBool(parseQuery(url, "include_ambiguous_links"), false)) {
|
|
1369
|
+
// Same no-tag-scope-needed reasoning as broken_links above: an
|
|
1370
|
+
// ambiguous `target` never became a link, so no neighbor identity
|
|
1371
|
+
// is exposed — only the string this note's own reference named.
|
|
1372
|
+
result.ambiguous_links = getAmbiguousLinksForNote(db, note.id, ambiguityVisibilityFor(db, tagScope));
|
|
1269
1373
|
}
|
|
1270
1374
|
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
1271
1375
|
result.attachments = await store.getAttachments(note.id);
|
|
@@ -1273,7 +1377,7 @@ async function handleNotesInner(
|
|
|
1273
1377
|
// linkCount injected after filterMetadata on purpose — same as
|
|
1274
1378
|
// links/attachments above; filterMetadata only touches `metadata`.
|
|
1275
1379
|
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
1276
|
-
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
|
|
1380
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
|
|
1277
1381
|
}
|
|
1278
1382
|
return json(result);
|
|
1279
1383
|
}
|
|
@@ -1338,7 +1442,7 @@ async function handleNotesInner(
|
|
|
1338
1442
|
400,
|
|
1339
1443
|
);
|
|
1340
1444
|
}
|
|
1341
|
-
const parsed = parseNotesQueryOpts(url);
|
|
1445
|
+
const parsed = parseNotesQueryOpts(url, tagScope);
|
|
1342
1446
|
if (parsed.error) return parsed.error;
|
|
1343
1447
|
try {
|
|
1344
1448
|
const semanticResult = await store.semanticSearch(nearText, { ...parsed.queryOpts, cursor: undefined });
|
|
@@ -1348,7 +1452,26 @@ async function handleNotesInner(
|
|
|
1348
1452
|
embeddingsPendingWarning(semanticResult.pendingCount, semanticResult.totalCandidates),
|
|
1349
1453
|
);
|
|
1350
1454
|
}
|
|
1351
|
-
|
|
1455
|
+
let filtered = filterNotesByTagScope(semanticResult.notes, tagScope.allowed, tagScope.raw);
|
|
1456
|
+
// Apply the same scoped presence filters as the structured list.
|
|
1457
|
+
filtered = narrowByVisibleAmbiguity(
|
|
1458
|
+
db,
|
|
1459
|
+
filtered,
|
|
1460
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
1461
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1462
|
+
);
|
|
1463
|
+
filtered = narrowByVisibleBrokenness(
|
|
1464
|
+
db,
|
|
1465
|
+
filtered,
|
|
1466
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1467
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1468
|
+
);
|
|
1469
|
+
filtered = linkOps.narrowByVisibleLinks(
|
|
1470
|
+
db,
|
|
1471
|
+
filtered,
|
|
1472
|
+
parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
1473
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1474
|
+
);
|
|
1352
1475
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
1353
1476
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1354
1477
|
if (contentRange.error) return contentRange.error;
|
|
@@ -1374,7 +1497,7 @@ async function handleNotesInner(
|
|
|
1374
1497
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
1375
1498
|
}
|
|
1376
1499
|
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
1377
|
-
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url));
|
|
1500
|
+
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope));
|
|
1378
1501
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1379
1502
|
}
|
|
1380
1503
|
return jsonWithWarnings(output, semanticWarnings);
|
|
@@ -1456,7 +1579,7 @@ async function handleNotesInner(
|
|
|
1456
1579
|
// were silently dropped — a well-formed result set answering a
|
|
1457
1580
|
// different question. `search_mode` is still parsed here because it
|
|
1458
1581
|
// is search-specific (the helper does not know about it).
|
|
1459
|
-
const parsed = parseNotesQueryOpts(url);
|
|
1582
|
+
const parsed = parseNotesQueryOpts(url, tagScope);
|
|
1460
1583
|
if (parsed.error) return parsed.error;
|
|
1461
1584
|
const searchModeParsed = parseSearchModeParam(url);
|
|
1462
1585
|
if (searchModeParsed.error) return searchModeParsed.error;
|
|
@@ -1543,7 +1666,26 @@ async function handleNotesInner(
|
|
|
1543
1666
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
1544
1667
|
// happens after the store query so an empty post-filter list still
|
|
1545
1668
|
// returns 200 [] (consistent with "no matches"), not 403.
|
|
1546
|
-
|
|
1669
|
+
let results = filterNotesByTagScope(rawResults, tagScope.allowed, tagScope.raw);
|
|
1670
|
+
// Apply the same scoped presence filters as the structured list.
|
|
1671
|
+
results = narrowByVisibleAmbiguity(
|
|
1672
|
+
db,
|
|
1673
|
+
results,
|
|
1674
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
1675
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1676
|
+
);
|
|
1677
|
+
results = narrowByVisibleBrokenness(
|
|
1678
|
+
db,
|
|
1679
|
+
results,
|
|
1680
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1681
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1682
|
+
);
|
|
1683
|
+
results = linkOps.narrowByVisibleLinks(
|
|
1684
|
+
db,
|
|
1685
|
+
results,
|
|
1686
|
+
parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
1687
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1688
|
+
);
|
|
1547
1689
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
1548
1690
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1549
1691
|
if (contentRange.error) return contentRange.error;
|
|
@@ -1577,6 +1719,7 @@ async function handleNotesInner(
|
|
|
1577
1719
|
db,
|
|
1578
1720
|
output.map((n: any) => n.id),
|
|
1579
1721
|
parseLinkCountDirection(url),
|
|
1722
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1580
1723
|
);
|
|
1581
1724
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1582
1725
|
}
|
|
@@ -1608,7 +1751,7 @@ async function handleNotesInner(
|
|
|
1608
1751
|
// Structured-query parsing is shared with the live `/subscribe` route
|
|
1609
1752
|
// (see `parseNotesQueryOpts`) so both endpoints lower an identical query
|
|
1610
1753
|
// string to the same `QueryOpts` — predicate parity by construction.
|
|
1611
|
-
const parsed = parseNotesQueryOpts(url);
|
|
1754
|
+
const parsed = parseNotesQueryOpts(url, tagScope);
|
|
1612
1755
|
if (parsed.error) return parsed.error;
|
|
1613
1756
|
const queryOpts = parsed.queryOpts!;
|
|
1614
1757
|
const cursorParam = parseQuery(url, "cursor");
|
|
@@ -1685,7 +1828,31 @@ async function handleNotesInner(
|
|
|
1685
1828
|
// path applies, THEN aggregate over just that visible id set
|
|
1686
1829
|
// (reusing the `ids` filter `near` already pushes into SQL).
|
|
1687
1830
|
const allMatches = await store.queryNotes({ ...queryOpts, limit: 1000000, offset: 0 });
|
|
1688
|
-
|
|
1831
|
+
let visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
|
|
1832
|
+
// vault#581 auth review: a rollup over `has_ambiguous_links` is
|
|
1833
|
+
// the same oracle as the note-level filter — a non-zero count
|
|
1834
|
+
// still reveals the collision — so re-decide each note's
|
|
1835
|
+
// ambiguity on the visible sub-vault before aggregating.
|
|
1836
|
+
visible = narrowByVisibleAmbiguity(
|
|
1837
|
+
db,
|
|
1838
|
+
visible,
|
|
1839
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
1840
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1841
|
+
);
|
|
1842
|
+
// vault#239: the `has_broken_links` rollup is the same oracle for
|
|
1843
|
+
// the same reason — re-decide brokenness on the sub-vault too.
|
|
1844
|
+
visible = narrowByVisibleBrokenness(
|
|
1845
|
+
db,
|
|
1846
|
+
visible,
|
|
1847
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1848
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1849
|
+
);
|
|
1850
|
+
visible = linkOps.narrowByVisibleLinks(
|
|
1851
|
+
db,
|
|
1852
|
+
visible,
|
|
1853
|
+
parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
1854
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1855
|
+
);
|
|
1689
1856
|
// Always run the rollup, even on an empty visible set: ungrouped
|
|
1690
1857
|
// count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
|
|
1691
1858
|
rows = await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
|
|
@@ -1839,12 +2006,39 @@ async function handleNotesInner(
|
|
|
1839
2006
|
// output. Same semantics as the search path — empty result is 200 [],
|
|
1840
2007
|
// not 403.
|
|
1841
2008
|
results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
|
|
2009
|
+
// vault#581 auth review — `has_ambiguous_links` must answer on the
|
|
2010
|
+
// reader's OWN sub-vault: the SQL filter counts a persisted row even
|
|
2011
|
+
// when the notes that collided are invisible, which would let a scoped
|
|
2012
|
+
// token sweep its corpus for cross-scope naming collisions. No-op
|
|
2013
|
+
// unscoped and when the filter wasn't asked for. Mirrors the MCP door.
|
|
2014
|
+
results = narrowByVisibleAmbiguity(
|
|
2015
|
+
db,
|
|
2016
|
+
results,
|
|
2017
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
2018
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
2019
|
+
);
|
|
2020
|
+
// vault#239 — same rule for `has_broken_links`: the SQL filter was
|
|
2021
|
+
// lifted for a scoped reader, so the real predicate is applied here on
|
|
2022
|
+
// that reader's own sub-vault. No-op unscoped. Mirrors the MCP door.
|
|
2023
|
+
results = narrowByVisibleBrokenness(
|
|
2024
|
+
db,
|
|
2025
|
+
results,
|
|
2026
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
2027
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
2028
|
+
);
|
|
2029
|
+
results = linkOps.narrowByVisibleLinks(
|
|
2030
|
+
db,
|
|
2031
|
+
results,
|
|
2032
|
+
parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
2033
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
2034
|
+
);
|
|
1842
2035
|
|
|
1843
2036
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
1844
2037
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1845
2038
|
if (contentRange.error) return contentRange.error;
|
|
1846
2039
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
1847
2040
|
const includeBrokenLinks = parseBool(parseQuery(url, "include_broken_links"), false);
|
|
2041
|
+
const includeAmbiguousLinks = parseBool(parseQuery(url, "include_ambiguous_links"), false);
|
|
1848
2042
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
1849
2043
|
const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
|
|
1850
2044
|
const inclMeta = parseIncludeMetadata(url);
|
|
@@ -1917,6 +2111,7 @@ async function handleNotesInner(
|
|
|
1917
2111
|
db,
|
|
1918
2112
|
output.map((n: any) => n.id),
|
|
1919
2113
|
parseLinkCountDirection(url),
|
|
2114
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1920
2115
|
);
|
|
1921
2116
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
1922
2117
|
}
|
|
@@ -1945,7 +2140,7 @@ async function handleNotesInner(
|
|
|
1945
2140
|
);
|
|
1946
2141
|
}
|
|
1947
2142
|
|
|
1948
|
-
if (includeLinks || includeBrokenLinks || includeAttachments) {
|
|
2143
|
+
if (includeLinks || includeBrokenLinks || includeAmbiguousLinks || includeAttachments) {
|
|
1949
2144
|
// Whole-page link hydration in a constant number of queries — the
|
|
1950
2145
|
// per-note variant cost (1 link query + 1 summary query + N tag
|
|
1951
2146
|
// queries) × page size. 2026-06-10 perf measurements.
|
|
@@ -1956,7 +2151,11 @@ async function handleNotesInner(
|
|
|
1956
2151
|
// filtering needed — a broken-link `target` never resolved to a
|
|
1957
2152
|
// note, so there's no neighbor identity to leak.
|
|
1958
2153
|
const brokenLinksByNote = includeBrokenLinks
|
|
1959
|
-
? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id))
|
|
2154
|
+
? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id), ambiguityVisibilityFor(db, tagScope))
|
|
2155
|
+
: null;
|
|
2156
|
+
// Same again for the ambiguity twin (vault#581), same non-leak logic.
|
|
2157
|
+
const ambiguousLinksByNote = includeAmbiguousLinks
|
|
2158
|
+
? getAmbiguousLinksForNotes(db, output.map((n: any) => n.id), ambiguityVisibilityFor(db, tagScope))
|
|
1960
2159
|
: null;
|
|
1961
2160
|
const enrichedOut: any[] = [];
|
|
1962
2161
|
for (const n of output) {
|
|
@@ -1970,6 +2169,7 @@ async function handleNotesInner(
|
|
|
1970
2169
|
);
|
|
1971
2170
|
}
|
|
1972
2171
|
if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
|
|
2172
|
+
if (ambiguousLinksByNote) enriched.ambiguous_links = ambiguousLinksByNote.get(n.id) ?? [];
|
|
1973
2173
|
if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
|
|
1974
2174
|
enrichedOut.push(enriched);
|
|
1975
2175
|
}
|
|
@@ -2282,20 +2482,9 @@ async function handleNotesInner(
|
|
|
2282
2482
|
if (outcome.status === "resolved") {
|
|
2283
2483
|
await store.createLink(sourceId, outcome.note_id, link.relationship);
|
|
2284
2484
|
} else if (outcome.status === "ambiguous") {
|
|
2285
|
-
pushLinkWarning(sourceId,
|
|
2286
|
-
code: "ambiguous_link",
|
|
2287
|
-
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.`,
|
|
2288
|
-
target: link.target,
|
|
2289
|
-
relationship: link.relationship,
|
|
2290
|
-
candidate_count: outcome.candidates.length,
|
|
2291
|
-
});
|
|
2485
|
+
pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
2292
2486
|
} else {
|
|
2293
|
-
pushLinkWarning(sourceId,
|
|
2294
|
-
code: "unresolved_link",
|
|
2295
|
-
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.`,
|
|
2296
|
-
target: link.target,
|
|
2297
|
-
relationship: link.relationship,
|
|
2298
|
-
});
|
|
2487
|
+
pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
|
|
2299
2488
|
}
|
|
2300
2489
|
}
|
|
2301
2490
|
}
|
|
@@ -2389,10 +2578,10 @@ async function handleNotesInner(
|
|
|
2389
2578
|
// default "error" path's response shape is untouched.
|
|
2390
2579
|
const final = refreshed.map((n) => {
|
|
2391
2580
|
const validated = attachValidationStatus(store, db, n);
|
|
2392
|
-
const warnings = linkWarningsByNote.get(n.id);
|
|
2581
|
+
const warnings = narrowWriteWarnings(db, linkWarningsByNote.get(n.id) ?? [], tagScope);
|
|
2393
2582
|
const existed = existedMap.get(n.id);
|
|
2394
2583
|
let out: any = validated;
|
|
2395
|
-
if (warnings
|
|
2584
|
+
if (warnings.length > 0) out = { ...out, warnings };
|
|
2396
2585
|
if (existed !== undefined) out = { ...out, existed };
|
|
2397
2586
|
// Tag-scope (vault#568): a create response echoes the STORED note,
|
|
2398
2587
|
// and under `if_exists: ignore|update|replace` that's a note that
|
|
@@ -2690,7 +2879,7 @@ async function handleNotesInner(
|
|
|
2690
2879
|
result.attachments = await store.getAttachments(note.id);
|
|
2691
2880
|
}
|
|
2692
2881
|
if (parseBool(parseQuery(url, "include_link_count"), false)) {
|
|
2693
|
-
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url)).get(note.id) ?? 0;
|
|
2882
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], parseLinkCountDirection(url), ambiguityVisibilityFor(db, tagScope)).get(note.id) ?? 0;
|
|
2694
2883
|
}
|
|
2695
2884
|
return json(result);
|
|
2696
2885
|
}
|
|
@@ -2780,20 +2969,9 @@ async function handleNotesInner(
|
|
|
2780
2969
|
if (outcome.status === "resolved") {
|
|
2781
2970
|
await store.createLink(created.id, outcome.note_id, link.relationship, link.metadata);
|
|
2782
2971
|
} else if (outcome.status === "ambiguous") {
|
|
2783
|
-
createWarnings.push(
|
|
2784
|
-
code: "ambiguous_link",
|
|
2785
|
-
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.`,
|
|
2786
|
-
target: link.target,
|
|
2787
|
-
relationship: link.relationship,
|
|
2788
|
-
candidate_count: outcome.candidates.length,
|
|
2789
|
-
});
|
|
2972
|
+
createWarnings.push(ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
2790
2973
|
} else {
|
|
2791
|
-
createWarnings.push(
|
|
2792
|
-
code: "unresolved_link",
|
|
2793
|
-
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.`,
|
|
2794
|
-
target: link.target,
|
|
2795
|
-
relationship: link.relationship,
|
|
2796
|
-
});
|
|
2974
|
+
createWarnings.push(unresolvedLinkWarning(link.target, link.relationship));
|
|
2797
2975
|
}
|
|
2798
2976
|
}
|
|
2799
2977
|
}
|
|
@@ -2804,10 +2982,11 @@ async function handleNotesInner(
|
|
|
2804
2982
|
if (content) {
|
|
2805
2983
|
createWarnings.push(...getContentWikilinkWarnings(db, created.id, content));
|
|
2806
2984
|
}
|
|
2985
|
+
const scopedCreateWarnings = narrowWriteWarnings(db, createWarnings, tagScope);
|
|
2807
2986
|
const final = await store.getNote(created.id);
|
|
2808
2987
|
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
2809
2988
|
let validated: any = attachValidationStatus(store, db, final);
|
|
2810
|
-
if (
|
|
2989
|
+
if (scopedCreateWarnings.length > 0) validated.warnings = scopedCreateWarnings;
|
|
2811
2990
|
// Tag-scope (vault#568): scrub the echoed note so create-then-read
|
|
2812
2991
|
// returns the SAME shape. A scoped token may legitimately attach an
|
|
2813
2992
|
// out-of-scope co-tag on write (`tagsWithinScope` only requires ONE
|
|
@@ -2825,7 +3004,7 @@ async function handleNotesInner(
|
|
|
2825
3004
|
const lean: any = toNoteIndex(validated);
|
|
2826
3005
|
const vs = (validated as any).validation_status;
|
|
2827
3006
|
if (vs !== undefined) lean.validation_status = vs;
|
|
2828
|
-
if (
|
|
3007
|
+
if (scopedCreateWarnings.length > 0) lean.warnings = scopedCreateWarnings;
|
|
2829
3008
|
lean.created = true;
|
|
2830
3009
|
return json(lean);
|
|
2831
3010
|
}
|
|
@@ -3104,20 +3283,9 @@ async function handleNotesInner(
|
|
|
3104
3283
|
if (outcome.status === "resolved") {
|
|
3105
3284
|
await store.createLink(note.id, outcome.note_id, link.relationship, link.metadata);
|
|
3106
3285
|
} else if (outcome.status === "ambiguous") {
|
|
3107
|
-
linkWarnings.push(
|
|
3108
|
-
code: "ambiguous_link",
|
|
3109
|
-
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.`,
|
|
3110
|
-
target: link.target,
|
|
3111
|
-
relationship: link.relationship,
|
|
3112
|
-
candidate_count: outcome.candidates.length,
|
|
3113
|
-
});
|
|
3286
|
+
linkWarnings.push(ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
3114
3287
|
} else {
|
|
3115
|
-
linkWarnings.push(
|
|
3116
|
-
code: "unresolved_link",
|
|
3117
|
-
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.`,
|
|
3118
|
-
target: link.target,
|
|
3119
|
-
relationship: link.relationship,
|
|
3120
|
-
});
|
|
3288
|
+
linkWarnings.push(unresolvedLinkWarning(link.target, link.relationship));
|
|
3121
3289
|
}
|
|
3122
3290
|
}
|
|
3123
3291
|
}
|
|
@@ -3171,7 +3339,8 @@ async function handleNotesInner(
|
|
|
3171
3339
|
tagScope.raw,
|
|
3172
3340
|
);
|
|
3173
3341
|
}
|
|
3174
|
-
|
|
3342
|
+
const scopedLinkWarnings = narrowWriteWarnings(db, linkWarnings, tagScope);
|
|
3343
|
+
if (scopedLinkWarnings.length > 0) validated.warnings = scopedLinkWarnings;
|
|
3175
3344
|
const includeContentResp = body.include_content !== false;
|
|
3176
3345
|
// `created: false` is appended to every update-path response so
|
|
3177
3346
|
// sync-loop callers using `if_missing: "create"` can distinguish
|
|
@@ -3184,7 +3353,7 @@ async function handleNotesInner(
|
|
|
3184
3353
|
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
3185
3354
|
// drops unknown fields, same as the `validation_status` recipe above.
|
|
3186
3355
|
if (validated.links !== undefined) lean.links = validated.links;
|
|
3187
|
-
if (
|
|
3356
|
+
if (scopedLinkWarnings.length > 0) lean.warnings = scopedLinkWarnings;
|
|
3188
3357
|
lean.created = false;
|
|
3189
3358
|
return json(lean);
|
|
3190
3359
|
} catch (e: any) {
|
|
@@ -3924,7 +4093,7 @@ export async function handleFindPath(
|
|
|
3924
4093
|
|
|
3925
4094
|
type VaultConfigLike = {
|
|
3926
4095
|
name: string;
|
|
3927
|
-
description?: string;
|
|
4096
|
+
description?: string | null;
|
|
3928
4097
|
audio_retention?: "keep" | "until_transcribed" | "never";
|
|
3929
4098
|
auto_transcribe?: { enabled?: boolean };
|
|
3930
4099
|
};
|
|
@@ -4024,12 +4193,33 @@ export async function handleVault(
|
|
|
4024
4193
|
const parsedBody = await parseJsonBody(req);
|
|
4025
4194
|
if (!parsedBody.ok) return parsedBody.response;
|
|
4026
4195
|
const body = parsedBody.body as {
|
|
4027
|
-
description?:
|
|
4196
|
+
description?: unknown;
|
|
4028
4197
|
config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
|
|
4029
4198
|
};
|
|
4030
4199
|
let dirty = false;
|
|
4031
4200
|
|
|
4032
4201
|
if (body.description !== undefined) {
|
|
4202
|
+
// vault#669: runtime type guard mirroring the audio_retention /
|
|
4203
|
+
// auto_transcribe validators below — the `description?: string`
|
|
4204
|
+
// annotation was a compile-time claim guarding nothing (cast retyped
|
|
4205
|
+
// to `unknown` so the check is mandatory), so a write/admin caller
|
|
4206
|
+
// could persist a non-string and poison the next MCP `initialize`
|
|
4207
|
+
// (cloud#87). Reject non-string/non-null with the same 400 body
|
|
4208
|
+
// shape the sibling validators in this handler emit. `null` stays
|
|
4209
|
+
// legal: it clears the description. Cross-door parity with cloud#263.
|
|
4210
|
+
if (body.description !== null && typeof body.description !== "string") {
|
|
4211
|
+
return json(
|
|
4212
|
+
{
|
|
4213
|
+
error: "invalid_description",
|
|
4214
|
+
error_type: "invalid_description",
|
|
4215
|
+
field: "description",
|
|
4216
|
+
got: body.description,
|
|
4217
|
+
message: "description must be a string or null",
|
|
4218
|
+
hint: "pass a string, or null to clear the description",
|
|
4219
|
+
},
|
|
4220
|
+
400,
|
|
4221
|
+
);
|
|
4222
|
+
}
|
|
4033
4223
|
vaultConfig.description = body.description;
|
|
4034
4224
|
dirty = true;
|
|
4035
4225
|
}
|
|
@@ -4111,11 +4301,35 @@ export function handleUnresolvedWikilinks(
|
|
|
4111
4301
|
// and the wikilink target strings those notes contain. Filter the page and
|
|
4112
4302
|
// recompute `count` from the filtered set so the aggregate total of
|
|
4113
4303
|
// out-of-scope rows doesn't leak either.
|
|
4114
|
-
const
|
|
4304
|
+
const inScope = (row: { source_id: string }): boolean => {
|
|
4115
4305
|
const note = getNote(db, row.source_id);
|
|
4116
4306
|
return note !== null && noteWithinTagScope(note, tagScope.allowed, tagScope.raw);
|
|
4307
|
+
};
|
|
4308
|
+
const filtered = result.unresolved.filter(inScope);
|
|
4309
|
+
|
|
4310
|
+
// vault#239: a reference whose only candidates are OUT OF SCOPE is broken
|
|
4311
|
+
// in this reader's sub-vault, but its row lives in `ambiguous_wikilinks`
|
|
4312
|
+
// until the last of those candidates is deleted. Reporting it only after
|
|
4313
|
+
// that deletion makes this listing a timing oracle for a cross-scope naming
|
|
4314
|
+
// collision — the same one `getAmbiguousLinksForNotes` narrows away on the
|
|
4315
|
+
// note surfaces. Fold those rows in so the answer is stable across the
|
|
4316
|
+
// delete. Deduped against the base listing by (source, relationship,
|
|
4317
|
+
// target); the two tables are disjoint by construction, so this only
|
|
4318
|
+
// guards against a stale row.
|
|
4319
|
+
const seen = new Set(filtered.map((r) => `${r.source_id}\u0000${r.relationship}\u0000${r.target_path.toLowerCase()}`));
|
|
4320
|
+
const visible = ambiguityVisibilityFor(db, tagScope)!;
|
|
4321
|
+
const collapsed = [
|
|
4322
|
+
...listZeroVisibleAmbiguousAsUnresolved(db, visible, limit),
|
|
4323
|
+
...listResolvedToInvisibleAsUnresolved(db, visible, limit),
|
|
4324
|
+
].filter((row) => {
|
|
4325
|
+
const key = `${row.source_id}\u0000${row.relationship}\u0000${row.target_path.toLowerCase()}`;
|
|
4326
|
+
if (!inScope(row) || seen.has(key)) return false;
|
|
4327
|
+
seen.add(key);
|
|
4328
|
+
return true;
|
|
4117
4329
|
});
|
|
4118
|
-
|
|
4330
|
+
|
|
4331
|
+
const merged = [...filtered, ...collapsed].slice(0, limit);
|
|
4332
|
+
return Response.json({ unresolved: merged, count: merged.length });
|
|
4119
4333
|
}
|
|
4120
4334
|
|
|
4121
4335
|
// ---------------------------------------------------------------------------
|
package/src/routing.test.ts
CHANGED
|
@@ -1880,6 +1880,73 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1880
1880
|
expect(res.status).toBe(401);
|
|
1881
1881
|
});
|
|
1882
1882
|
|
|
1883
|
+
// ----- vault#669: PATCH /api/vault description type guard (cloud#87) ------
|
|
1884
|
+
//
|
|
1885
|
+
// The REST door's `description` write had no runtime type check — the
|
|
1886
|
+
// `description?: string` body annotation was a compile-time claim guarding
|
|
1887
|
+
// nothing, so a write/admin caller could persist a non-string and poison
|
|
1888
|
+
// the next MCP `initialize` (serverInstruction's `description.trim()`).
|
|
1889
|
+
// The guard rejects non-string/non-null with the same 400 shape the
|
|
1890
|
+
// sibling audio_retention / auto_transcribe validators emit. `null` still
|
|
1891
|
+
// clears the description.
|
|
1892
|
+
describe("PATCH /api/vault description type guard (vault#669)", () => {
|
|
1893
|
+
async function patchDescription(vault: string, token: string, description: unknown): Promise<Response> {
|
|
1894
|
+
const path = `/vault/${vault}/api/vault`;
|
|
1895
|
+
return route(
|
|
1896
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1897
|
+
method: "PATCH",
|
|
1898
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1899
|
+
body: JSON.stringify({ description }),
|
|
1900
|
+
}),
|
|
1901
|
+
path,
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
for (const [label, value] of [
|
|
1906
|
+
["a number", 123],
|
|
1907
|
+
["an object", { text: "nope" }],
|
|
1908
|
+
["an array", ["a", "b"]],
|
|
1909
|
+
["a boolean", true],
|
|
1910
|
+
] as const) {
|
|
1911
|
+
test(`${label} description → 400 invalid_description, description unchanged`, async () => {
|
|
1912
|
+
createVault("journal", "original");
|
|
1913
|
+
const token = await mintToken("journal", { permission: "full", scopes: ["vault:write", "vault:admin"] });
|
|
1914
|
+
|
|
1915
|
+
const res = await patchDescription("journal", token, value);
|
|
1916
|
+
expect(res.status).toBe(400);
|
|
1917
|
+
const body = (await res.json()) as { error_type?: string; field?: string };
|
|
1918
|
+
expect(body.error_type).toBe("invalid_description");
|
|
1919
|
+
expect(body.field).toBe("description");
|
|
1920
|
+
|
|
1921
|
+
// Nothing persisted — no poison state introduced.
|
|
1922
|
+
const getRes = await route(authed(token, "GET", "/vault/journal/api/vault"), "/vault/journal/api/vault");
|
|
1923
|
+
expect(((await getRes.json()) as { description?: unknown }).description).toBe("original");
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
test("null description → 200, clears it", async () => {
|
|
1928
|
+
createVault("journal", "to be cleared");
|
|
1929
|
+
const token = await mintToken("journal", { permission: "full", scopes: ["vault:write", "vault:admin"] });
|
|
1930
|
+
|
|
1931
|
+
const res = await patchDescription("journal", token, null);
|
|
1932
|
+
expect(res.status).toBe(200);
|
|
1933
|
+
|
|
1934
|
+
const getRes = await route(authed(token, "GET", "/vault/journal/api/vault"), "/vault/journal/api/vault");
|
|
1935
|
+
expect(((await getRes.json()) as { description?: unknown }).description).toBeNull();
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
test("a valid string description → 200, persists un-mangled", async () => {
|
|
1939
|
+
createVault("journal", "before");
|
|
1940
|
+
const token = await mintToken("journal", { permission: "full", scopes: ["vault:write", "vault:admin"] });
|
|
1941
|
+
|
|
1942
|
+
const res = await patchDescription("journal", token, "after the edit");
|
|
1943
|
+
expect(res.status).toBe(200);
|
|
1944
|
+
|
|
1945
|
+
const getRes = await route(authed(token, "GET", "/vault/journal/api/vault"), "/vault/journal/api/vault");
|
|
1946
|
+
expect(((await getRes.json()) as { description?: unknown }).description).toBe("after the edit");
|
|
1947
|
+
});
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1883
1950
|
// ----- tag-scoped tokens (docs/contracts/tag-scoped-tokens.md) -----------------
|
|
1884
1951
|
|
|
1885
1952
|
/**
|