@openparachute/vault 0.7.0-rc.6 → 0.7.0-rc.8
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 +413 -0
- package/core/src/mcp.ts +194 -30
- package/core/src/notes.ts +97 -24
- package/core/src/query-warnings.ts +110 -0
- package/core/src/schema.ts +168 -9
- package/core/src/search-fts-v25.test.ts +362 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- package/core/src/types.ts +20 -0
- package/core/src/wikilinks.test.ts +113 -1
- package/core/src/wikilinks.ts +186 -21
- package/package.json +1 -1
- package/src/contract-errors.test.ts +73 -0
- package/src/contract-search.test.ts +168 -0
- package/src/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-query-notes-search-scope.test.ts +53 -0
- package/src/mcp-tools.ts +11 -0
- package/src/routes.ts +192 -26
- package/src/tag-field-conflict-scope.test.ts +44 -0
- package/src/tag-scope.ts +60 -0
- package/src/vault.test.ts +291 -4
package/src/routes.ts
CHANGED
|
@@ -13,9 +13,16 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Store, Note, QueryOpts } from "../core/src/types.ts";
|
|
15
15
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "../core/src/tag-hierarchy.ts";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
collectUnknownTagWarnings,
|
|
18
|
+
emptySearchWarning,
|
|
19
|
+
ignoredParamWarning,
|
|
20
|
+
computeSearchDidYouMean,
|
|
21
|
+
searchDidYouMeanWarning,
|
|
22
|
+
type QueryWarning,
|
|
23
|
+
} from "../core/src/query-warnings.ts";
|
|
17
24
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
|
|
18
|
-
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
25
|
+
import { listUnresolvedWikilinks, resolveOrQueueLink, resolveStructuredLinkNote } from "../core/src/wikilinks.ts";
|
|
19
26
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
20
27
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
21
28
|
import {
|
|
@@ -45,10 +52,11 @@ import {
|
|
|
45
52
|
scrubParentCycleError,
|
|
46
53
|
scrubReferencingTagsByScope,
|
|
47
54
|
scrubTagFieldViolationsByScope,
|
|
55
|
+
scrubValidationStatusByScope,
|
|
48
56
|
tagScopeForbidden,
|
|
49
57
|
tagsWithinScope,
|
|
50
58
|
} from "./tag-scope.ts";
|
|
51
|
-
import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
|
|
59
|
+
import { ParentCycleError, InvalidFieldDefaultError, InvalidFieldTypeError } from "../core/src/tag-schemas.ts";
|
|
52
60
|
import { findTokensReferencingTag } from "./token-store.ts";
|
|
53
61
|
|
|
54
62
|
/**
|
|
@@ -1103,6 +1111,22 @@ async function handleNotesInner(
|
|
|
1103
1111
|
}
|
|
1104
1112
|
throw e;
|
|
1105
1113
|
}
|
|
1114
|
+
// Zero-result `did_you_mean` (vault#551 WS2B) — cheap (a bounded
|
|
1115
|
+
// FTS5-vocabulary scan) and ONLY computed on the already-rare
|
|
1116
|
+
// empty-result path, mirroring `unknown_tag`'s did_you_mean.
|
|
1117
|
+
// Gated on `tagScope.allowed === null` (unscoped) — same stance
|
|
1118
|
+
// as `collectUnknownTagWarnings` below: the FTS5 vocabulary spans
|
|
1119
|
+
// the whole vault regardless of scope, so surfacing a suggestion
|
|
1120
|
+
// to a scoped token would leak out-of-scope content's existence
|
|
1121
|
+
// across the scope boundary. Unlike the MCP path, REST enforces
|
|
1122
|
+
// tag-scope inline (no post-hoc wrapper to strip it for us), so
|
|
1123
|
+
// the gate has to live here explicitly.
|
|
1124
|
+
if (rawResults.length === 0 && tagScope.allowed === null) {
|
|
1125
|
+
const suggestion = computeSearchDidYouMean(db, search);
|
|
1126
|
+
if (suggestion) {
|
|
1127
|
+
searchWarnings.push(searchDidYouMeanWarning(search, suggestion));
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1106
1130
|
}
|
|
1107
1131
|
// Tag-scope: drop any result the token isn't permitted to see. Filter
|
|
1108
1132
|
// happens after the store query so an empty post-filter list still
|
|
@@ -1329,6 +1353,34 @@ async function handleNotesInner(
|
|
|
1329
1353
|
if (contentRange.range && includeContent) {
|
|
1330
1354
|
for (const n of output) applyContentRange(n, contentRange.range);
|
|
1331
1355
|
}
|
|
1356
|
+
// vault#555 fix 3 — attach validation_status on reads too (mirrors
|
|
1357
|
+
// MCP query-notes). Additive, present only when a tag declares
|
|
1358
|
+
// `fields`. Runs BEFORE metadata filtering so it sees full metadata
|
|
1359
|
+
// regardless of `include_metadata`. Scrubbed for a tag-scoped caller
|
|
1360
|
+
// (vault#555 auth review) so an out-of-scope co-tag's schema shape
|
|
1361
|
+
// doesn't leak — `results` here is already scope-filtered, but a
|
|
1362
|
+
// surviving note may carry an out-of-scope co-tag whose schema would
|
|
1363
|
+
// otherwise appear in its validation_status. No-op when unscoped.
|
|
1364
|
+
{
|
|
1365
|
+
const statusById = new Map(
|
|
1366
|
+
results.map((n) => [
|
|
1367
|
+
n.id,
|
|
1368
|
+
scrubValidationStatusByScope(
|
|
1369
|
+
store.validateNoteAgainstSchemas({
|
|
1370
|
+
path: n.path,
|
|
1371
|
+
tags: n.tags,
|
|
1372
|
+
metadata: n.metadata as Record<string, unknown> | undefined,
|
|
1373
|
+
}),
|
|
1374
|
+
tagScope.allowed,
|
|
1375
|
+
tagScope.raw,
|
|
1376
|
+
),
|
|
1377
|
+
]),
|
|
1378
|
+
);
|
|
1379
|
+
for (const n of output as any[]) {
|
|
1380
|
+
const status = statusById.get(n.id);
|
|
1381
|
+
if (status) n.validation_status = status;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1332
1384
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
1333
1385
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
1334
1386
|
}
|
|
@@ -1452,6 +1504,14 @@ async function handleNotesInner(
|
|
|
1452
1504
|
}
|
|
1453
1505
|
|
|
1454
1506
|
const created: Note[] = [];
|
|
1507
|
+
// Structured `links` are resolved in a second pass, after every note
|
|
1508
|
+
// in this batch has been created (vault#555) — resolving inline (the
|
|
1509
|
+
// old behavior) meant a link from item 0 to item 1's path silently
|
|
1510
|
+
// dropped, since item 1 didn't exist yet at the moment item 0's links
|
|
1511
|
+
// were processed. Mirrors the MCP create-note fix exactly (both
|
|
1512
|
+
// layers share `resolveOrQueueLink` from core/wikilinks.ts).
|
|
1513
|
+
const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
|
|
1514
|
+
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1455
1515
|
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
1456
1516
|
// failure (path conflict, etc.) rolls back every prior insert. Without
|
|
1457
1517
|
// this, callers got half-applied batches where the prefix landed and
|
|
@@ -1487,16 +1547,36 @@ async function handleNotesInner(
|
|
|
1487
1547
|
via: writeCtx.via,
|
|
1488
1548
|
});
|
|
1489
1549
|
|
|
1490
|
-
// Create explicit links
|
|
1491
1550
|
if (item.links) {
|
|
1492
|
-
|
|
1493
|
-
const target = await resolveNote(store, link.target);
|
|
1494
|
-
if (target) await store.createLink(note.id, target.id, link.relationship);
|
|
1495
|
-
}
|
|
1551
|
+
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
1496
1552
|
}
|
|
1497
1553
|
|
|
1498
1554
|
created.push((await store.getNote(note.id)) ?? note);
|
|
1499
1555
|
}
|
|
1556
|
+
|
|
1557
|
+
// --- Resolve structured links (vault#555) ---
|
|
1558
|
+
// Same semantics as [[wikilinks]]: ID or path/title match, tried now
|
|
1559
|
+
// that every sibling note in this batch exists. A target that still
|
|
1560
|
+
// doesn't resolve is queued for lazy resolution (backfills
|
|
1561
|
+
// automatically when a matching note is created later) and surfaces
|
|
1562
|
+
// an `unresolved_link` warning naming the target — never silent.
|
|
1563
|
+
for (const { sourceId, links } of pendingLinks) {
|
|
1564
|
+
for (const link of links) {
|
|
1565
|
+
const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1566
|
+
if (targetId) {
|
|
1567
|
+
await store.createLink(sourceId, targetId, link.relationship);
|
|
1568
|
+
} else {
|
|
1569
|
+
const list = linkWarningsByNote.get(sourceId) ?? [];
|
|
1570
|
+
list.push({
|
|
1571
|
+
code: "unresolved_link",
|
|
1572
|
+
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.`,
|
|
1573
|
+
target: link.target,
|
|
1574
|
+
relationship: link.relationship,
|
|
1575
|
+
});
|
|
1576
|
+
linkWarningsByNote.set(sourceId, list);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1500
1580
|
};
|
|
1501
1581
|
try {
|
|
1502
1582
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
@@ -1561,8 +1641,14 @@ async function handleNotesInner(
|
|
|
1561
1641
|
// Attach `validation_status` so HTTP create-note matches the MCP
|
|
1562
1642
|
// surface (vault#287). `attachValidationStatus` returns the note
|
|
1563
1643
|
// unchanged when no tag declares fields, so vaults without any tag
|
|
1564
|
-
// schemas see no behavior change.
|
|
1565
|
-
|
|
1644
|
+
// schemas see no behavior change. Fold in any `unresolved_link`
|
|
1645
|
+
// warnings (vault#555) — additive, present only when this note's
|
|
1646
|
+
// `links` had a target that didn't resolve.
|
|
1647
|
+
const final = refreshed.map((n) => {
|
|
1648
|
+
const validated = attachValidationStatus(store, db, n);
|
|
1649
|
+
const warnings = linkWarningsByNote.get(n.id);
|
|
1650
|
+
return warnings && warnings.length > 0 ? { ...validated, warnings } : validated;
|
|
1651
|
+
});
|
|
1566
1652
|
return json(body.notes ? final : final[0], 201);
|
|
1567
1653
|
}
|
|
1568
1654
|
|
|
@@ -1718,6 +1804,24 @@ async function handleNotesInner(
|
|
|
1718
1804
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1719
1805
|
if (contentRange.error) return contentRange.error;
|
|
1720
1806
|
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
1807
|
+
// vault#555 fix 3 — mirror the MCP query-notes fix: attach
|
|
1808
|
+
// validation_status on reads too, not just on the one-time create/update
|
|
1809
|
+
// write response. See core/src/mcp.ts's query-notes handler for the
|
|
1810
|
+
// full rationale. Scrubbed for a tag-scoped caller (vault#555 auth
|
|
1811
|
+
// review) so an out-of-scope co-tag's schema shape doesn't leak — no-op
|
|
1812
|
+
// for unscoped callers (tagScope.raw === null).
|
|
1813
|
+
{
|
|
1814
|
+
const status = scrubValidationStatusByScope(
|
|
1815
|
+
store.validateNoteAgainstSchemas({
|
|
1816
|
+
path: note.path,
|
|
1817
|
+
tags: note.tags,
|
|
1818
|
+
metadata: note.metadata as Record<string, unknown> | undefined,
|
|
1819
|
+
}),
|
|
1820
|
+
tagScope.allowed,
|
|
1821
|
+
tagScope.raw,
|
|
1822
|
+
);
|
|
1823
|
+
if (status) result.validation_status = status;
|
|
1824
|
+
}
|
|
1721
1825
|
const expand = parseExpandParams(url, db, tagScope);
|
|
1722
1826
|
if (expand && includeContent && typeof result.content === "string") {
|
|
1723
1827
|
expand.ctx.expanded.add(note.id);
|
|
@@ -1811,22 +1915,35 @@ async function handleNotesInner(
|
|
|
1811
1915
|
// fresh note).
|
|
1812
1916
|
// - Missing target notes skip silently (mirrors MCP).
|
|
1813
1917
|
const linksAdd = (body.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
|
|
1918
|
+
// `unresolved_link` warnings (vault#555) — a target that doesn't
|
|
1919
|
+
// resolve is queued for lazy resolution (backfills automatically
|
|
1920
|
+
// when a matching note is created later), never silently dropped.
|
|
1921
|
+
const createWarnings: QueryWarning[] = [];
|
|
1814
1922
|
if (linksAdd) {
|
|
1815
1923
|
for (const link of linksAdd) {
|
|
1816
|
-
const
|
|
1817
|
-
if (
|
|
1818
|
-
await store.createLink(created.id,
|
|
1924
|
+
const targetId = resolveOrQueueLink(db, created.id, link.target, link.relationship);
|
|
1925
|
+
if (targetId) {
|
|
1926
|
+
await store.createLink(created.id, targetId, link.relationship, link.metadata);
|
|
1927
|
+
} else {
|
|
1928
|
+
createWarnings.push({
|
|
1929
|
+
code: "unresolved_link",
|
|
1930
|
+
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.`,
|
|
1931
|
+
target: link.target,
|
|
1932
|
+
relationship: link.relationship,
|
|
1933
|
+
});
|
|
1819
1934
|
}
|
|
1820
1935
|
}
|
|
1821
1936
|
}
|
|
1822
1937
|
const final = await store.getNote(created.id);
|
|
1823
1938
|
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
1824
|
-
const validated = attachValidationStatus(store, db, final);
|
|
1939
|
+
const validated: any = attachValidationStatus(store, db, final);
|
|
1940
|
+
if (createWarnings.length > 0) validated.warnings = createWarnings;
|
|
1825
1941
|
const includeContentResp = body.include_content !== false;
|
|
1826
1942
|
if (includeContentResp) return json({ ...validated, created: true });
|
|
1827
1943
|
const lean: any = toNoteIndex(validated);
|
|
1828
1944
|
const vs = (validated as any).validation_status;
|
|
1829
1945
|
if (vs !== undefined) lean.validation_status = vs;
|
|
1946
|
+
if (createWarnings.length > 0) lean.warnings = createWarnings;
|
|
1830
1947
|
lean.created = true;
|
|
1831
1948
|
return json(lean);
|
|
1832
1949
|
}
|
|
@@ -1969,7 +2086,7 @@ async function handleNotesInner(
|
|
|
1969
2086
|
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
1970
2087
|
if (linksRemove) {
|
|
1971
2088
|
for (const link of linksRemove) {
|
|
1972
|
-
const target =
|
|
2089
|
+
const target = resolveStructuredLinkNote(db, link.target);
|
|
1973
2090
|
if (!target) continue;
|
|
1974
2091
|
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
1975
2092
|
if (link.relationship === "wikilink" && target.path) {
|
|
@@ -2046,9 +2163,21 @@ async function handleNotesInner(
|
|
|
2046
2163
|
gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
2047
2164
|
}
|
|
2048
2165
|
|
|
2049
|
-
|
|
2166
|
+
// vault#555 fix 2 — tag and link mutations must bump `updated_at` too,
|
|
2167
|
+
// not just core-field (content/path/metadata) changes. Mirrors the MCP
|
|
2168
|
+
// `update-note` fix exactly: a tags-only or links-only PATCH with
|
|
2169
|
+
// `force: true` (no `if_updated_at`) left `updates` empty, so
|
|
2170
|
+
// `store.updateNote` was never even called and `updated_at` never
|
|
2171
|
+
// moved despite a real tags/note_tags or links change.
|
|
2172
|
+
const hasTagMutation = (body.tags?.add?.length ?? 0) > 0 || (body.tags?.remove?.length ?? 0) > 0;
|
|
2173
|
+
const hasLinkMutation = body.links?.add !== undefined || body.links?.remove !== undefined;
|
|
2174
|
+
if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
|
|
2050
2175
|
// Write-attribution (vault#298) — REST update. Stamp the most-recent-
|
|
2051
|
-
// write columns on the same UPDATE that bumps updated_at.
|
|
2176
|
+
// write columns on the same UPDATE that bumps updated_at. `updates`
|
|
2177
|
+
// may carry no core fields at all (a pure tag/link mutation) — that's
|
|
2178
|
+
// fine: `noteOps.updateNote` unconditionally SETs
|
|
2179
|
+
// `updated_at`/`last_updated_by`/`last_updated_via` whenever
|
|
2180
|
+
// `skipUpdatedAt` isn't set, so this still issues a real UPDATE.
|
|
2052
2181
|
updates.actor = writeCtx.actor;
|
|
2053
2182
|
updates.via = writeCtx.via;
|
|
2054
2183
|
await store.updateNote(note.id, updates);
|
|
@@ -2068,11 +2197,24 @@ async function handleNotesInner(
|
|
|
2068
2197
|
await store.untagNote(note.id, body.tags.remove);
|
|
2069
2198
|
}
|
|
2070
2199
|
|
|
2071
|
-
// Add links
|
|
2200
|
+
// Add links. `unresolved_link` warnings (vault#555) — a target that
|
|
2201
|
+
// doesn't resolve is queued for lazy resolution (backfills
|
|
2202
|
+
// automatically when a matching note is created later), never
|
|
2203
|
+
// silently dropped.
|
|
2204
|
+
const linkWarnings: QueryWarning[] = [];
|
|
2072
2205
|
if (body.links?.add) {
|
|
2073
2206
|
for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
|
|
2074
|
-
const
|
|
2075
|
-
if (
|
|
2207
|
+
const targetId = resolveOrQueueLink(db, note.id, link.target, link.relationship);
|
|
2208
|
+
if (targetId) {
|
|
2209
|
+
await store.createLink(note.id, targetId, link.relationship, link.metadata);
|
|
2210
|
+
} else {
|
|
2211
|
+
linkWarnings.push({
|
|
2212
|
+
code: "unresolved_link",
|
|
2213
|
+
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.`,
|
|
2214
|
+
target: link.target,
|
|
2215
|
+
relationship: link.relationship,
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2076
2218
|
}
|
|
2077
2219
|
}
|
|
2078
2220
|
|
|
@@ -2107,6 +2249,7 @@ async function handleNotesInner(
|
|
|
2107
2249
|
tagScope.raw,
|
|
2108
2250
|
);
|
|
2109
2251
|
}
|
|
2252
|
+
if (linkWarnings.length > 0) validated.warnings = linkWarnings;
|
|
2110
2253
|
const includeContentResp = body.include_content !== false;
|
|
2111
2254
|
// `created: false` is appended to every update-path response so
|
|
2112
2255
|
// sync-loop callers using `if_missing: "create"` can distinguish
|
|
@@ -2119,6 +2262,7 @@ async function handleNotesInner(
|
|
|
2119
2262
|
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
2120
2263
|
// drops unknown fields, same as the `validation_status` recipe above.
|
|
2121
2264
|
if (validated.links !== undefined) lean.links = validated.links;
|
|
2265
|
+
if (linkWarnings.length > 0) lean.warnings = linkWarnings;
|
|
2122
2266
|
lean.created = false;
|
|
2123
2267
|
return json(lean);
|
|
2124
2268
|
} catch (e: any) {
|
|
@@ -2619,11 +2763,22 @@ export async function handleTags(
|
|
|
2619
2763
|
// — silent 200s on main — is non-indexed type conflicts and indexed-flag
|
|
2620
2764
|
// conflicts. See `collectCrossTagFieldViolations`'s doc comment.
|
|
2621
2765
|
if (body.fields && typeof body.fields === "object" && !Array.isArray(body.fields)) {
|
|
2766
|
+
const incomingFields = body.fields as Record<string, tagSchemaOps.TagFieldSchema>;
|
|
2767
|
+
// vault#555 fix 5 — fold the own-field `default`/`type` checks into
|
|
2768
|
+
// the SAME bundled report as the cross-tag ones. Before this, a bad
|
|
2769
|
+
// `default` (and, since fix 4 added the check, an unrecognized
|
|
2770
|
+
// `type`) went through `store.upsertTagRecord`'s fail-fast pre-
|
|
2771
|
+
// validate below INSTEAD — a single-violation 400 that silently never
|
|
2772
|
+
// mentioned any OTHER bad field in the same call. `unsupported_
|
|
2773
|
+
// indexed_type`/`invalid_field_name` deliberately stay OUT of this
|
|
2774
|
+
// bundle — REST's established single-violation `400
|
|
2775
|
+
// invalid_indexed_field` contract for those two is unchanged
|
|
2776
|
+
// (vault#478; see `collectCrossTagFieldViolations`'s doc comment).
|
|
2622
2777
|
const fieldViolations = tagSchemaOps.collectCrossTagFieldViolations(
|
|
2623
2778
|
store.db,
|
|
2624
2779
|
putTagName,
|
|
2625
|
-
|
|
2626
|
-
);
|
|
2780
|
+
incomingFields,
|
|
2781
|
+
).concat(tagSchemaOps.collectOwnFieldDefaultAndTypeViolations(incomingFields));
|
|
2627
2782
|
if (fieldViolations.length > 0) {
|
|
2628
2783
|
// Tag-scope scrub (vault#554 auth-and-scope fold): the write is
|
|
2629
2784
|
// still rejected, but a violation whose conflicting declarer is
|
|
@@ -2671,15 +2826,26 @@ export async function handleTags(
|
|
|
2671
2826
|
if (err instanceof InvalidFieldDefaultError) {
|
|
2672
2827
|
// vault#553 Decision B — a declared `default` doesn't conform to its
|
|
2673
2828
|
// own field's type/enum. Own-field error (no cross-tag data), so no
|
|
2674
|
-
// scrub needed.
|
|
2675
|
-
//
|
|
2676
|
-
//
|
|
2677
|
-
// reports it bundled via `
|
|
2829
|
+
// scrub needed. DEFENSE-IN-DEPTH ONLY as of vault#555 fix 5 — the
|
|
2830
|
+
// pre-check above (`collectOwnFieldDefaultAndTypeViolations`) now
|
|
2831
|
+
// catches every `invalid_default` BEFORE reaching `store.upsertTagRecord`
|
|
2832
|
+
// and reports it bundled via `tag_field_conflict` 422 instead; this
|
|
2833
|
+
// branch only fires for a caller that somehow bypasses that pre-check.
|
|
2678
2834
|
return json(
|
|
2679
2835
|
{ error: err.message, error_type: "invalid_field_default", field: err.field },
|
|
2680
2836
|
400,
|
|
2681
2837
|
);
|
|
2682
2838
|
}
|
|
2839
|
+
if (err instanceof InvalidFieldTypeError) {
|
|
2840
|
+
// vault#555 fix 4 — a declared `type` isn't one of the six
|
|
2841
|
+
// recognized values. Same defense-in-depth posture as
|
|
2842
|
+
// InvalidFieldDefaultError above — the pre-check catches this in
|
|
2843
|
+
// the normal path and reports it bundled instead.
|
|
2844
|
+
return json(
|
|
2845
|
+
{ error: err.message, error_type: "invalid_field_type", field: err.field, type: err.type, valid_types: err.valid_types },
|
|
2846
|
+
400,
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2683
2849
|
if (err instanceof ParentCycleError) {
|
|
2684
2850
|
// vault#552: parent_names would close a cycle. Nothing was
|
|
2685
2851
|
// persisted. Scope-scrub the cycle path for a tag-scoped caller —
|
|
@@ -330,4 +330,48 @@ describe("invalid_indexed_field × tag scope — the 400 door is scrubbed too (w
|
|
|
330
330
|
expect(caught).toBeInstanceOf(IndexedFieldError);
|
|
331
331
|
expect(caught.message).toContain("mine-too");
|
|
332
332
|
});
|
|
333
|
+
|
|
334
|
+
// vault#555 fix 4/5 — `invalid_type` and `invalid_default` are own-field
|
|
335
|
+
// violations (no `other_tag`, no cross-tag data to leak) so
|
|
336
|
+
// `scrubTagFieldViolationsByScope` is a documented no-op for them by
|
|
337
|
+
// construction. Pin that a scoped caller still gets the FULL violation
|
|
338
|
+
// (field/reason/message) — nothing accidentally strips it.
|
|
339
|
+
test("invalid_type and invalid_default violations pass through the scope-scrub untouched (no other_tag to scrub)", async () => {
|
|
340
|
+
seedVault("journal");
|
|
341
|
+
getVaultStore("journal");
|
|
342
|
+
|
|
343
|
+
// REST
|
|
344
|
+
const res = await handleTags(
|
|
345
|
+
new Request("http://localhost/api/tags/mine", {
|
|
346
|
+
method: "PUT",
|
|
347
|
+
body: JSON.stringify({
|
|
348
|
+
fields: {
|
|
349
|
+
weird: { type: "frobnicator" },
|
|
350
|
+
bad_default: { type: "string", enum: ["a", "b"], default: "zzz" },
|
|
351
|
+
},
|
|
352
|
+
}),
|
|
353
|
+
}),
|
|
354
|
+
getVaultStore("journal"),
|
|
355
|
+
"/mine",
|
|
356
|
+
{ allowed: new Set(["mine"]), raw: ["mine"] },
|
|
357
|
+
);
|
|
358
|
+
expect(res.status).toBe(422);
|
|
359
|
+
const body: any = await res.json();
|
|
360
|
+
expect(body.violations).toHaveLength(2);
|
|
361
|
+
const byField = new Map(body.violations.map((v: any) => [v.field, v]));
|
|
362
|
+
expect(byField.get("weird").reason).toBe("invalid_type");
|
|
363
|
+
expect(byField.get("weird").other_tag).toBeUndefined();
|
|
364
|
+
expect(byField.get("bad_default").reason).toBe("invalid_default");
|
|
365
|
+
|
|
366
|
+
// MCP
|
|
367
|
+
const tool = await updateTagTool("journal", ["mine"]);
|
|
368
|
+
let caught: any;
|
|
369
|
+
try {
|
|
370
|
+
await tool.execute({ tag: "mine", fields: { weird: { type: "frobnicator" } } });
|
|
371
|
+
} catch (e) {
|
|
372
|
+
caught = e;
|
|
373
|
+
}
|
|
374
|
+
expect(caught.violations).toHaveLength(1);
|
|
375
|
+
expect(caught.violations[0].reason).toBe("invalid_type");
|
|
376
|
+
});
|
|
333
377
|
});
|
package/src/tag-scope.ts
CHANGED
|
@@ -83,6 +83,66 @@ export function filterNotesByTagScope<T extends Note>(
|
|
|
83
83
|
return notes.filter((n) => noteWithinTagScope(n, allowed, rawRoots));
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Is a SINGLE tag name visible to this token? The per-tag core of
|
|
88
|
+
* `noteWithinTagScope` — exact allowlist membership OR string-form root
|
|
89
|
+
* match — pulled out so the `validation_status` scrub below (which reasons
|
|
90
|
+
* about a warning's `schema`/`loser_schema` tag NAMES, not a note's whole
|
|
91
|
+
* tag set) uses the identical visibility rule. `rawRoots === null` (unscoped)
|
|
92
|
+
* → always visible.
|
|
93
|
+
*/
|
|
94
|
+
function tagVisibleInScope(
|
|
95
|
+
tag: string,
|
|
96
|
+
allowed: Set<string> | null,
|
|
97
|
+
rawRoots: string[] | null,
|
|
98
|
+
): boolean {
|
|
99
|
+
if (rawRoots === null) return true;
|
|
100
|
+
if (allowed && allowed.has(tag)) return true;
|
|
101
|
+
const root = tag.split("/")[0];
|
|
102
|
+
return !!root && rawRoots.includes(root);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Scrub a note's `validation_status` so a tag-scoped caller can't learn the
|
|
107
|
+
* SCHEMA SHAPE (field name, type, enum values) of an OUT-OF-SCOPE tag that
|
|
108
|
+
* happens to co-tag a note it can otherwise see (vault#555 auth review;
|
|
109
|
+
* the #560 leak class). Repro: a scoped caller reading a note tagged both
|
|
110
|
+
* `mine` (in scope) and `project-manhattan` (out of scope) received
|
|
111
|
+
* `validation_status.warnings: [{ schema: "project-manhattan", message:
|
|
112
|
+
* "'codeword' must be one of [fizzbuzz] ...", ... }]` and
|
|
113
|
+
* `schemas: ["project-manhattan"]` — leaking that tag's field/enum.
|
|
114
|
+
*
|
|
115
|
+
* Core produces the FULL status (scope-unaware by architecture, same as
|
|
116
|
+
* every other core surface); this server-layer scrub — mirroring
|
|
117
|
+
* `scrubTagFieldViolationsByScope` — drops any warning whose declaring
|
|
118
|
+
* tag (`schema`, and for a `schema_conflict` the overridden `loser_schema`)
|
|
119
|
+
* is out of scope, and filters the `schemas` array to visible tags. When
|
|
120
|
+
* nothing in-scope remains (the note's only schema-declaring tag was
|
|
121
|
+
* out-of-scope), returns `undefined` so the caller omits `validation_status`
|
|
122
|
+
* entirely — byte-identical to a note with no applicable schema. Unscoped
|
|
123
|
+
* callers (`rawRoots === null`) get the status untouched.
|
|
124
|
+
*
|
|
125
|
+
* Applied at every point a scoped caller receives `validation_status`: the
|
|
126
|
+
* MCP `query-notes` wrapper and the REST `GET /notes[/{id}]` read paths.
|
|
127
|
+
*/
|
|
128
|
+
export function scrubValidationStatusByScope<
|
|
129
|
+
S extends { schemas: string[]; warnings: Array<{ schema: string; loser_schema?: string }> },
|
|
130
|
+
>(
|
|
131
|
+
status: S | null | undefined,
|
|
132
|
+
allowed: Set<string> | null,
|
|
133
|
+
rawRoots: string[] | null,
|
|
134
|
+
): S | undefined {
|
|
135
|
+
if (!status || rawRoots === null) return status ?? undefined;
|
|
136
|
+
const schemas = status.schemas.filter((s) => tagVisibleInScope(s, allowed, rawRoots));
|
|
137
|
+
const warnings = status.warnings.filter(
|
|
138
|
+
(w) =>
|
|
139
|
+
tagVisibleInScope(w.schema, allowed, rawRoots) &&
|
|
140
|
+
(w.loser_schema === undefined || tagVisibleInScope(w.loser_schema, allowed, rawRoots)),
|
|
141
|
+
);
|
|
142
|
+
if (schemas.length === 0 && warnings.length === 0) return undefined;
|
|
143
|
+
return { ...status, schemas, warnings };
|
|
144
|
+
}
|
|
145
|
+
|
|
86
146
|
/**
|
|
87
147
|
* For write paths: a note being created/updated must end up carrying at
|
|
88
148
|
* least one tag inside the allowlist. `tags` is the post-write tag set
|