@openparachute/vault 0.7.9-rc.2 → 0.7.9-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/core.test.ts +546 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/mcp-manifest.ts +6 -2
- package/core/src/mcp.ts +113 -38
- 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 +806 -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/routes.ts +215 -61
- 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/vault.test.ts +685 -0
- 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,17 @@ import {
|
|
|
30
31
|
resolveStructuredLinkNote,
|
|
31
32
|
getUnresolvedLinksForNote,
|
|
32
33
|
getUnresolvedLinksForNotes,
|
|
34
|
+
listZeroVisibleAmbiguousAsUnresolved,
|
|
35
|
+
narrowByVisibleBrokenness,
|
|
36
|
+
sqlHasBrokenLinks,
|
|
37
|
+
getAmbiguousLinksForNote,
|
|
38
|
+
getAmbiguousLinksForNotes,
|
|
39
|
+
narrowByVisibleAmbiguity,
|
|
40
|
+
sqlHasAmbiguousLinks,
|
|
33
41
|
getContentWikilinkWarnings,
|
|
42
|
+
ambiguousLinkWarning,
|
|
43
|
+
unresolvedLinkWarning,
|
|
44
|
+
narrowLinkWarningsForVisibility,
|
|
34
45
|
} from "../core/src/wikilinks.ts";
|
|
35
46
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
36
47
|
import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, validatePath, PathValidationError, getVaultMap } from "../core/src/notes.ts";
|
|
@@ -67,6 +78,7 @@ import {
|
|
|
67
78
|
filterHydratedLinksByTagScope,
|
|
68
79
|
filterNotesByTagScope,
|
|
69
80
|
noteWithinTagScope,
|
|
81
|
+
scopeQueryTags,
|
|
70
82
|
scrubIndexedFieldConflictError,
|
|
71
83
|
scrubNotesTagsByScope,
|
|
72
84
|
scrubNoteTagsByScope,
|
|
@@ -901,8 +913,66 @@ function parseSearchModeParam(url: URL): { mode?: SearchMode; error?: Response }
|
|
|
901
913
|
* Returns `{ error }` (a 400 Response) on a malformed metadata filter, exactly
|
|
902
914
|
* as the inline code did. `hasSearch` is surfaced from the raw `search` param
|
|
903
915
|
* (this helper does not itself build a search query — the caller routes that).
|
|
916
|
+
*
|
|
917
|
+
* `tagScope` (vault#675): out-of-scope `tag=` / `exclude_tag=` inputs are
|
|
918
|
+
* rewritten so they match nothing, exactly as a nonexistent tag would — see
|
|
919
|
+
* `scopeQueryTags`. Applied here so every branch that lowers a query string
|
|
920
|
+
* (structured, `search`, `semantic`, `aggregate`, `near`, cursor) is covered
|
|
921
|
+
* by one call, the same way this helper already gives subscribe/search
|
|
922
|
+
* predicate parity. Default `NO_TAG_SCOPE` → no-op, so the WS route's
|
|
923
|
+
* upgrade-time parse (which runs before the socket authenticates) is
|
|
924
|
+
* unchanged; `ws-server.ts` applies the scoping once auth is known.
|
|
925
|
+
*/
|
|
926
|
+
/**
|
|
927
|
+
* The `visible` predicate the vault#581 ambiguous-links surface consults for
|
|
928
|
+
* a TAG-SCOPED reader — `undefined` (and therefore a complete no-op) for an
|
|
929
|
+
* unscoped one. Identical shape and rule to the MCP door's `ambiguityVisible`
|
|
930
|
+
* (`src/mcp-tools.ts`) and to `nearTraversable`: look a CANDIDATE note's tags
|
|
931
|
+
* up by id and apply the same `noteWithinTagScope` test every other read path
|
|
932
|
+
* uses, so the two doors cannot drift on who counts as a visible candidate.
|
|
933
|
+
*
|
|
934
|
+
* Needed because `candidate_count` is derived vault-wide: without it a
|
|
935
|
+
* `work`-scoped reader learns from `candidate_count: 2` on its own `[[Dup]]`
|
|
936
|
+
* that a second `Dup` exists in a scope it cannot see.
|
|
937
|
+
*
|
|
938
|
+
* vault#239 reuses the SAME predicate for the BROKEN-links surface — "which
|
|
939
|
+
* of this target's candidates can the reader see" is one question, and the
|
|
940
|
+
* ambiguous (>=2 visible) and broken (0 visible) answers are two faces of it.
|
|
941
|
+
*/
|
|
942
|
+
function ambiguityVisibilityFor(
|
|
943
|
+
db: Database,
|
|
944
|
+
tagScope: TagScopeCtx,
|
|
945
|
+
): ((noteId: string) => boolean) | undefined {
|
|
946
|
+
if (tagScope.raw === null) return undefined;
|
|
947
|
+
return (noteId: string) =>
|
|
948
|
+
noteWithinTagScope(
|
|
949
|
+
{ id: noteId, tags: getNoteTags(db, noteId) } as Note,
|
|
950
|
+
tagScope.allowed,
|
|
951
|
+
tagScope.raw,
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Re-decide a WRITE response's `ambiguous_link` warnings against the caller's
|
|
957
|
+
* own sub-vault. `create-note`/`update-note` echo per-note `warnings`, and an
|
|
958
|
+
* `ambiguous_link` entry carries a `candidate_count` derived VAULT-WIDE — so
|
|
959
|
+
* without this a `work`-scoped writer saving `[[Dup]]` learns from
|
|
960
|
+
* `candidate_count: 2` that a second `Dup` exists in a scope it cannot see.
|
|
961
|
+
* That is exactly the vault#707 read-side oracle, reachable through the write
|
|
962
|
+
* door. Same predicate ({@link ambiguityVisibilityFor}) and same core decision
|
|
963
|
+
* function as the read surfaces, so the doors cannot drift. Unscoped: the
|
|
964
|
+
* predicate is `undefined` and the array is returned unchanged.
|
|
904
965
|
*/
|
|
905
|
-
|
|
966
|
+
function narrowWriteWarnings(
|
|
967
|
+
db: Database,
|
|
968
|
+
warnings: QueryWarning[],
|
|
969
|
+
tagScope: TagScopeCtx,
|
|
970
|
+
): QueryWarning[] {
|
|
971
|
+
const visible = ambiguityVisibilityFor(db, tagScope);
|
|
972
|
+
return visible ? narrowLinkWarningsForVisibility(db, warnings, visible) : warnings;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function parseNotesQueryOpts(url: URL, tagScope: TagScopeCtx = NO_TAG_SCOPE): {
|
|
906
976
|
queryOpts?: QueryOpts;
|
|
907
977
|
hasSearch: boolean;
|
|
908
978
|
hasNear: boolean;
|
|
@@ -959,7 +1029,26 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
959
1029
|
hasLinks: parseBoolOrUndef(parseQuery(url, "has_links")),
|
|
960
1030
|
// Presence filter on dangling outbound wikilinks/structured links
|
|
961
1031
|
// (vault#555) — see core/src/types.ts QueryOpts.hasBrokenLinks.
|
|
962
|
-
|
|
1032
|
+
// vault#239: for a TAG-SCOPED reader NEITHER polarity is safe to push
|
|
1033
|
+
// into SQL — a note can be broken in that reader's sub-vault while
|
|
1034
|
+
// carrying no `unresolved_wikilinks` row at all (its target's only
|
|
1035
|
+
// candidates are invisible, so the row sits in `ambiguous_wikilinks`
|
|
1036
|
+
// instead). Both are lifted here and re-decided per note by
|
|
1037
|
+
// `narrowByVisibleBrokenness`. Identity for an unscoped reader.
|
|
1038
|
+
hasBrokenLinks: sqlHasBrokenLinks(
|
|
1039
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1040
|
+
tagScope.raw !== null,
|
|
1041
|
+
),
|
|
1042
|
+
// Presence filter on AMBIGUOUS outbound links — a target that matched ≥2
|
|
1043
|
+
// notes (vault#581) — see core/src/types.ts QueryOpts.hasAmbiguousLinks.
|
|
1044
|
+
// vault#581 auth review: for a TAG-SCOPED reader only the `true` polarity
|
|
1045
|
+
// is safe to push into SQL (it's a superset of the right answer);
|
|
1046
|
+
// `false` is lifted here and both are re-decided per note by
|
|
1047
|
+
// `narrowByVisibleAmbiguity` on the reader's own visible sub-vault.
|
|
1048
|
+
hasAmbiguousLinks: sqlHasAmbiguousLinks(
|
|
1049
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
1050
|
+
tagScope.raw !== null,
|
|
1051
|
+
),
|
|
963
1052
|
path: parseQuery(url, "path") ?? undefined,
|
|
964
1053
|
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
965
1054
|
excludePathPrefix: parseQueryList(url, "exclude_path_prefix"),
|
|
@@ -986,7 +1075,12 @@ export function parseNotesQueryOpts(url: URL): {
|
|
|
986
1075
|
cursor: cursorParam ?? undefined,
|
|
987
1076
|
};
|
|
988
1077
|
|
|
989
|
-
return {
|
|
1078
|
+
return {
|
|
1079
|
+
queryOpts: scopeQueryTags(queryOpts, tagScope.allowed, tagScope.raw),
|
|
1080
|
+
hasSearch,
|
|
1081
|
+
hasNear,
|
|
1082
|
+
hasCursor,
|
|
1083
|
+
};
|
|
990
1084
|
}
|
|
991
1085
|
|
|
992
1086
|
/**
|
|
@@ -1264,8 +1358,17 @@ async function handleNotesInner(
|
|
|
1264
1358
|
// No tag-scope filtering needed (unlike include_links above): a
|
|
1265
1359
|
// broken-link `target` never resolved to a note, so there's no
|
|
1266
1360
|
// neighbor identity/content to leak — just the string this note's
|
|
1267
|
-
// own [[wikilink]]/structured link already named.
|
|
1268
|
-
|
|
1361
|
+
// own [[wikilink]]/structured link already named. The predicate is
|
|
1362
|
+
// not a filter but an ADDITION (vault#239): it makes a reference
|
|
1363
|
+
// whose only candidates are invisible read as broken here, instead
|
|
1364
|
+
// of only once the last of them is deleted.
|
|
1365
|
+
result.broken_links = getUnresolvedLinksForNote(db, note.id, ambiguityVisibilityFor(db, tagScope));
|
|
1366
|
+
}
|
|
1367
|
+
if (parseBool(parseQuery(url, "include_ambiguous_links"), false)) {
|
|
1368
|
+
// Same no-tag-scope-needed reasoning as broken_links above: an
|
|
1369
|
+
// ambiguous `target` never became a link, so no neighbor identity
|
|
1370
|
+
// is exposed — only the string this note's own reference named.
|
|
1371
|
+
result.ambiguous_links = getAmbiguousLinksForNote(db, note.id, ambiguityVisibilityFor(db, tagScope));
|
|
1269
1372
|
}
|
|
1270
1373
|
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
1271
1374
|
result.attachments = await store.getAttachments(note.id);
|
|
@@ -1338,7 +1441,7 @@ async function handleNotesInner(
|
|
|
1338
1441
|
400,
|
|
1339
1442
|
);
|
|
1340
1443
|
}
|
|
1341
|
-
const parsed = parseNotesQueryOpts(url);
|
|
1444
|
+
const parsed = parseNotesQueryOpts(url, tagScope);
|
|
1342
1445
|
if (parsed.error) return parsed.error;
|
|
1343
1446
|
try {
|
|
1344
1447
|
const semanticResult = await store.semanticSearch(nearText, { ...parsed.queryOpts, cursor: undefined });
|
|
@@ -1456,7 +1559,7 @@ async function handleNotesInner(
|
|
|
1456
1559
|
// were silently dropped — a well-formed result set answering a
|
|
1457
1560
|
// different question. `search_mode` is still parsed here because it
|
|
1458
1561
|
// is search-specific (the helper does not know about it).
|
|
1459
|
-
const parsed = parseNotesQueryOpts(url);
|
|
1562
|
+
const parsed = parseNotesQueryOpts(url, tagScope);
|
|
1460
1563
|
if (parsed.error) return parsed.error;
|
|
1461
1564
|
const searchModeParsed = parseSearchModeParam(url);
|
|
1462
1565
|
if (searchModeParsed.error) return searchModeParsed.error;
|
|
@@ -1608,7 +1711,7 @@ async function handleNotesInner(
|
|
|
1608
1711
|
// Structured-query parsing is shared with the live `/subscribe` route
|
|
1609
1712
|
// (see `parseNotesQueryOpts`) so both endpoints lower an identical query
|
|
1610
1713
|
// string to the same `QueryOpts` — predicate parity by construction.
|
|
1611
|
-
const parsed = parseNotesQueryOpts(url);
|
|
1714
|
+
const parsed = parseNotesQueryOpts(url, tagScope);
|
|
1612
1715
|
if (parsed.error) return parsed.error;
|
|
1613
1716
|
const queryOpts = parsed.queryOpts!;
|
|
1614
1717
|
const cursorParam = parseQuery(url, "cursor");
|
|
@@ -1685,7 +1788,25 @@ async function handleNotesInner(
|
|
|
1685
1788
|
// path applies, THEN aggregate over just that visible id set
|
|
1686
1789
|
// (reusing the `ids` filter `near` already pushes into SQL).
|
|
1687
1790
|
const allMatches = await store.queryNotes({ ...queryOpts, limit: 1000000, offset: 0 });
|
|
1688
|
-
|
|
1791
|
+
let visible = filterNotesByTagScope(allMatches, tagScope.allowed, tagScope.raw);
|
|
1792
|
+
// vault#581 auth review: a rollup over `has_ambiguous_links` is
|
|
1793
|
+
// the same oracle as the note-level filter — a non-zero count
|
|
1794
|
+
// still reveals the collision — so re-decide each note's
|
|
1795
|
+
// ambiguity on the visible sub-vault before aggregating.
|
|
1796
|
+
visible = narrowByVisibleAmbiguity(
|
|
1797
|
+
db,
|
|
1798
|
+
visible,
|
|
1799
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
1800
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1801
|
+
);
|
|
1802
|
+
// vault#239: the `has_broken_links` rollup is the same oracle for
|
|
1803
|
+
// the same reason — re-decide brokenness on the sub-vault too.
|
|
1804
|
+
visible = narrowByVisibleBrokenness(
|
|
1805
|
+
db,
|
|
1806
|
+
visible,
|
|
1807
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1808
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1809
|
+
);
|
|
1689
1810
|
// Always run the rollup, even on an empty visible set: ungrouped
|
|
1690
1811
|
// count (vault#626) must return `[{group:null,value:0}]`, not `[]`.
|
|
1691
1812
|
rows = await store.aggregateNotes({ ids: visible.map((n) => n.id), aggregate: aggregateParsed.aggregate });
|
|
@@ -1839,12 +1960,33 @@ async function handleNotesInner(
|
|
|
1839
1960
|
// output. Same semantics as the search path — empty result is 200 [],
|
|
1840
1961
|
// not 403.
|
|
1841
1962
|
results = filterNotesByTagScope(results, tagScope.allowed, tagScope.raw);
|
|
1963
|
+
// vault#581 auth review — `has_ambiguous_links` must answer on the
|
|
1964
|
+
// reader's OWN sub-vault: the SQL filter counts a persisted row even
|
|
1965
|
+
// when the notes that collided are invisible, which would let a scoped
|
|
1966
|
+
// token sweep its corpus for cross-scope naming collisions. No-op
|
|
1967
|
+
// unscoped and when the filter wasn't asked for. Mirrors the MCP door.
|
|
1968
|
+
results = narrowByVisibleAmbiguity(
|
|
1969
|
+
db,
|
|
1970
|
+
results,
|
|
1971
|
+
parseBoolOrUndef(parseQuery(url, "has_ambiguous_links")),
|
|
1972
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1973
|
+
);
|
|
1974
|
+
// vault#239 — same rule for `has_broken_links`: the SQL filter was
|
|
1975
|
+
// lifted for a scoped reader, so the real predicate is applied here on
|
|
1976
|
+
// that reader's own sub-vault. No-op unscoped. Mirrors the MCP door.
|
|
1977
|
+
results = narrowByVisibleBrokenness(
|
|
1978
|
+
db,
|
|
1979
|
+
results,
|
|
1980
|
+
parseBoolOrUndef(parseQuery(url, "has_broken_links")),
|
|
1981
|
+
ambiguityVisibilityFor(db, tagScope),
|
|
1982
|
+
);
|
|
1842
1983
|
|
|
1843
1984
|
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
1844
1985
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1845
1986
|
if (contentRange.error) return contentRange.error;
|
|
1846
1987
|
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
1847
1988
|
const includeBrokenLinks = parseBool(parseQuery(url, "include_broken_links"), false);
|
|
1989
|
+
const includeAmbiguousLinks = parseBool(parseQuery(url, "include_ambiguous_links"), false);
|
|
1848
1990
|
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
1849
1991
|
const includeLinkCount = parseBool(parseQuery(url, "include_link_count"), false);
|
|
1850
1992
|
const inclMeta = parseIncludeMetadata(url);
|
|
@@ -1945,7 +2087,7 @@ async function handleNotesInner(
|
|
|
1945
2087
|
);
|
|
1946
2088
|
}
|
|
1947
2089
|
|
|
1948
|
-
if (includeLinks || includeBrokenLinks || includeAttachments) {
|
|
2090
|
+
if (includeLinks || includeBrokenLinks || includeAmbiguousLinks || includeAttachments) {
|
|
1949
2091
|
// Whole-page link hydration in a constant number of queries — the
|
|
1950
2092
|
// per-note variant cost (1 link query + 1 summary query + N tag
|
|
1951
2093
|
// queries) × page size. 2026-06-10 perf measurements.
|
|
@@ -1956,7 +2098,11 @@ async function handleNotesInner(
|
|
|
1956
2098
|
// filtering needed — a broken-link `target` never resolved to a
|
|
1957
2099
|
// note, so there's no neighbor identity to leak.
|
|
1958
2100
|
const brokenLinksByNote = includeBrokenLinks
|
|
1959
|
-
? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id))
|
|
2101
|
+
? getUnresolvedLinksForNotes(db, output.map((n: any) => n.id), ambiguityVisibilityFor(db, tagScope))
|
|
2102
|
+
: null;
|
|
2103
|
+
// Same again for the ambiguity twin (vault#581), same non-leak logic.
|
|
2104
|
+
const ambiguousLinksByNote = includeAmbiguousLinks
|
|
2105
|
+
? getAmbiguousLinksForNotes(db, output.map((n: any) => n.id), ambiguityVisibilityFor(db, tagScope))
|
|
1960
2106
|
: null;
|
|
1961
2107
|
const enrichedOut: any[] = [];
|
|
1962
2108
|
for (const n of output) {
|
|
@@ -1970,6 +2116,7 @@ async function handleNotesInner(
|
|
|
1970
2116
|
);
|
|
1971
2117
|
}
|
|
1972
2118
|
if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
|
|
2119
|
+
if (ambiguousLinksByNote) enriched.ambiguous_links = ambiguousLinksByNote.get(n.id) ?? [];
|
|
1973
2120
|
if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
|
|
1974
2121
|
enrichedOut.push(enriched);
|
|
1975
2122
|
}
|
|
@@ -2282,20 +2429,9 @@ async function handleNotesInner(
|
|
|
2282
2429
|
if (outcome.status === "resolved") {
|
|
2283
2430
|
await store.createLink(sourceId, outcome.note_id, link.relationship);
|
|
2284
2431
|
} 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
|
-
});
|
|
2432
|
+
pushLinkWarning(sourceId, ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
2292
2433
|
} 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
|
-
});
|
|
2434
|
+
pushLinkWarning(sourceId, unresolvedLinkWarning(link.target, link.relationship));
|
|
2299
2435
|
}
|
|
2300
2436
|
}
|
|
2301
2437
|
}
|
|
@@ -2389,10 +2525,10 @@ async function handleNotesInner(
|
|
|
2389
2525
|
// default "error" path's response shape is untouched.
|
|
2390
2526
|
const final = refreshed.map((n) => {
|
|
2391
2527
|
const validated = attachValidationStatus(store, db, n);
|
|
2392
|
-
const warnings = linkWarningsByNote.get(n.id);
|
|
2528
|
+
const warnings = narrowWriteWarnings(db, linkWarningsByNote.get(n.id) ?? [], tagScope);
|
|
2393
2529
|
const existed = existedMap.get(n.id);
|
|
2394
2530
|
let out: any = validated;
|
|
2395
|
-
if (warnings
|
|
2531
|
+
if (warnings.length > 0) out = { ...out, warnings };
|
|
2396
2532
|
if (existed !== undefined) out = { ...out, existed };
|
|
2397
2533
|
// Tag-scope (vault#568): a create response echoes the STORED note,
|
|
2398
2534
|
// and under `if_exists: ignore|update|replace` that's a note that
|
|
@@ -2780,20 +2916,9 @@ async function handleNotesInner(
|
|
|
2780
2916
|
if (outcome.status === "resolved") {
|
|
2781
2917
|
await store.createLink(created.id, outcome.note_id, link.relationship, link.metadata);
|
|
2782
2918
|
} 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
|
-
});
|
|
2919
|
+
createWarnings.push(ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
2790
2920
|
} 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
|
-
});
|
|
2921
|
+
createWarnings.push(unresolvedLinkWarning(link.target, link.relationship));
|
|
2797
2922
|
}
|
|
2798
2923
|
}
|
|
2799
2924
|
}
|
|
@@ -2804,10 +2929,11 @@ async function handleNotesInner(
|
|
|
2804
2929
|
if (content) {
|
|
2805
2930
|
createWarnings.push(...getContentWikilinkWarnings(db, created.id, content));
|
|
2806
2931
|
}
|
|
2932
|
+
const scopedCreateWarnings = narrowWriteWarnings(db, createWarnings, tagScope);
|
|
2807
2933
|
const final = await store.getNote(created.id);
|
|
2808
2934
|
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
2809
2935
|
let validated: any = attachValidationStatus(store, db, final);
|
|
2810
|
-
if (
|
|
2936
|
+
if (scopedCreateWarnings.length > 0) validated.warnings = scopedCreateWarnings;
|
|
2811
2937
|
// Tag-scope (vault#568): scrub the echoed note so create-then-read
|
|
2812
2938
|
// returns the SAME shape. A scoped token may legitimately attach an
|
|
2813
2939
|
// out-of-scope co-tag on write (`tagsWithinScope` only requires ONE
|
|
@@ -2825,7 +2951,7 @@ async function handleNotesInner(
|
|
|
2825
2951
|
const lean: any = toNoteIndex(validated);
|
|
2826
2952
|
const vs = (validated as any).validation_status;
|
|
2827
2953
|
if (vs !== undefined) lean.validation_status = vs;
|
|
2828
|
-
if (
|
|
2954
|
+
if (scopedCreateWarnings.length > 0) lean.warnings = scopedCreateWarnings;
|
|
2829
2955
|
lean.created = true;
|
|
2830
2956
|
return json(lean);
|
|
2831
2957
|
}
|
|
@@ -3104,20 +3230,9 @@ async function handleNotesInner(
|
|
|
3104
3230
|
if (outcome.status === "resolved") {
|
|
3105
3231
|
await store.createLink(note.id, outcome.note_id, link.relationship, link.metadata);
|
|
3106
3232
|
} 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
|
-
});
|
|
3233
|
+
linkWarnings.push(ambiguousLinkWarning(link.target, link.relationship, outcome.candidates.length));
|
|
3114
3234
|
} 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
|
-
});
|
|
3235
|
+
linkWarnings.push(unresolvedLinkWarning(link.target, link.relationship));
|
|
3121
3236
|
}
|
|
3122
3237
|
}
|
|
3123
3238
|
}
|
|
@@ -3171,7 +3286,8 @@ async function handleNotesInner(
|
|
|
3171
3286
|
tagScope.raw,
|
|
3172
3287
|
);
|
|
3173
3288
|
}
|
|
3174
|
-
|
|
3289
|
+
const scopedLinkWarnings = narrowWriteWarnings(db, linkWarnings, tagScope);
|
|
3290
|
+
if (scopedLinkWarnings.length > 0) validated.warnings = scopedLinkWarnings;
|
|
3175
3291
|
const includeContentResp = body.include_content !== false;
|
|
3176
3292
|
// `created: false` is appended to every update-path response so
|
|
3177
3293
|
// sync-loop callers using `if_missing: "create"` can distinguish
|
|
@@ -3184,7 +3300,7 @@ async function handleNotesInner(
|
|
|
3184
3300
|
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
3185
3301
|
// drops unknown fields, same as the `validation_status` recipe above.
|
|
3186
3302
|
if (validated.links !== undefined) lean.links = validated.links;
|
|
3187
|
-
if (
|
|
3303
|
+
if (scopedLinkWarnings.length > 0) lean.warnings = scopedLinkWarnings;
|
|
3188
3304
|
lean.created = false;
|
|
3189
3305
|
return json(lean);
|
|
3190
3306
|
} catch (e: any) {
|
|
@@ -3924,7 +4040,7 @@ export async function handleFindPath(
|
|
|
3924
4040
|
|
|
3925
4041
|
type VaultConfigLike = {
|
|
3926
4042
|
name: string;
|
|
3927
|
-
description?: string;
|
|
4043
|
+
description?: string | null;
|
|
3928
4044
|
audio_retention?: "keep" | "until_transcribed" | "never";
|
|
3929
4045
|
auto_transcribe?: { enabled?: boolean };
|
|
3930
4046
|
};
|
|
@@ -4024,12 +4140,33 @@ export async function handleVault(
|
|
|
4024
4140
|
const parsedBody = await parseJsonBody(req);
|
|
4025
4141
|
if (!parsedBody.ok) return parsedBody.response;
|
|
4026
4142
|
const body = parsedBody.body as {
|
|
4027
|
-
description?:
|
|
4143
|
+
description?: unknown;
|
|
4028
4144
|
config?: { audio_retention?: string; auto_transcribe?: { enabled?: unknown } };
|
|
4029
4145
|
};
|
|
4030
4146
|
let dirty = false;
|
|
4031
4147
|
|
|
4032
4148
|
if (body.description !== undefined) {
|
|
4149
|
+
// vault#669: runtime type guard mirroring the audio_retention /
|
|
4150
|
+
// auto_transcribe validators below — the `description?: string`
|
|
4151
|
+
// annotation was a compile-time claim guarding nothing (cast retyped
|
|
4152
|
+
// to `unknown` so the check is mandatory), so a write/admin caller
|
|
4153
|
+
// could persist a non-string and poison the next MCP `initialize`
|
|
4154
|
+
// (cloud#87). Reject non-string/non-null with the same 400 body
|
|
4155
|
+
// shape the sibling validators in this handler emit. `null` stays
|
|
4156
|
+
// legal: it clears the description. Cross-door parity with cloud#263.
|
|
4157
|
+
if (body.description !== null && typeof body.description !== "string") {
|
|
4158
|
+
return json(
|
|
4159
|
+
{
|
|
4160
|
+
error: "invalid_description",
|
|
4161
|
+
error_type: "invalid_description",
|
|
4162
|
+
field: "description",
|
|
4163
|
+
got: body.description,
|
|
4164
|
+
message: "description must be a string or null",
|
|
4165
|
+
hint: "pass a string, or null to clear the description",
|
|
4166
|
+
},
|
|
4167
|
+
400,
|
|
4168
|
+
);
|
|
4169
|
+
}
|
|
4033
4170
|
vaultConfig.description = body.description;
|
|
4034
4171
|
dirty = true;
|
|
4035
4172
|
}
|
|
@@ -4111,11 +4248,28 @@ export function handleUnresolvedWikilinks(
|
|
|
4111
4248
|
// and the wikilink target strings those notes contain. Filter the page and
|
|
4112
4249
|
// recompute `count` from the filtered set so the aggregate total of
|
|
4113
4250
|
// out-of-scope rows doesn't leak either.
|
|
4114
|
-
const
|
|
4251
|
+
const inScope = (row: { source_id: string }): boolean => {
|
|
4115
4252
|
const note = getNote(db, row.source_id);
|
|
4116
4253
|
return note !== null && noteWithinTagScope(note, tagScope.allowed, tagScope.raw);
|
|
4117
|
-
}
|
|
4118
|
-
|
|
4254
|
+
};
|
|
4255
|
+
const filtered = result.unresolved.filter(inScope);
|
|
4256
|
+
|
|
4257
|
+
// vault#239: a reference whose only candidates are OUT OF SCOPE is broken
|
|
4258
|
+
// in this reader's sub-vault, but its row lives in `ambiguous_wikilinks`
|
|
4259
|
+
// until the last of those candidates is deleted. Reporting it only after
|
|
4260
|
+
// that deletion makes this listing a timing oracle for a cross-scope naming
|
|
4261
|
+
// collision — the same one `getAmbiguousLinksForNotes` narrows away on the
|
|
4262
|
+
// note surfaces. Fold those rows in so the answer is stable across the
|
|
4263
|
+
// delete. Deduped against the base listing by (source, relationship,
|
|
4264
|
+
// target); the two tables are disjoint by construction, so this only
|
|
4265
|
+
// guards against a stale row.
|
|
4266
|
+
const seen = new Set(filtered.map((r) => `${r.source_id}\u0000${r.relationship}\u0000${r.target_path.toLowerCase()}`));
|
|
4267
|
+
const collapsed = listZeroVisibleAmbiguousAsUnresolved(db, ambiguityVisibilityFor(db, tagScope)!, limit)
|
|
4268
|
+
.filter((row) => inScope(row)
|
|
4269
|
+
&& !seen.has(`${row.source_id}\u0000${row.relationship}\u0000${row.target_path.toLowerCase()}`));
|
|
4270
|
+
|
|
4271
|
+
const merged = [...filtered, ...collapsed].slice(0, limit);
|
|
4272
|
+
return Response.json({ unresolved: merged, count: merged.length });
|
|
4119
4273
|
}
|
|
4120
4274
|
|
|
4121
4275
|
// ---------------------------------------------------------------------------
|
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
|
/**
|