@openparachute/vault 0.7.0-rc.7 → 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 +168 -27
- package/core/src/notes.ts +12 -4
- package/core/src/store.ts +22 -15
- package/core/src/tag-schemas.ts +115 -19
- 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/mcp-http.test.ts +140 -0
- package/src/mcp-http.ts +73 -16
- package/src/mcp-tools.ts +11 -0
- package/src/routes.ts +168 -25
- 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/mcp-http.ts
CHANGED
|
@@ -45,6 +45,48 @@ function requiredVerbForTool(tool: { requiredVerb?: VaultVerb }): VaultVerb {
|
|
|
45
45
|
return tool.requiredVerb ?? "write";
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Matches the JSON-RPC prefix the MCP SDK's `McpError` constructor bakes
|
|
49
|
+
* into `.message` itself (not just `.toString()`) — e.g.
|
|
50
|
+
* `"MCP error -32602: the actual message"`. */
|
|
51
|
+
const MCP_ERROR_PREFIX_RE = /^MCP error -?\d+:\s*/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build a JSON-RPC `McpError` for a domain error, in ONE place (vault#555
|
|
55
|
+
* fix 6 — was 15 duplicated `new McpError(...)` call sites below, each a
|
|
56
|
+
* chance to drift). Two things this fixes over the old inline construction:
|
|
57
|
+
*
|
|
58
|
+
* 1. **No double-wrap.** The MCP SDK's `McpError` constructor bakes
|
|
59
|
+
* `"MCP error <code>: "` into `.message` ITSELF. If the message being
|
|
60
|
+
* wrapped ALREADY carries that prefix (a message forwarded verbatim
|
|
61
|
+
* from another MCP-speaking service, or a stale caller that read
|
|
62
|
+
* `err.message` off an already-formed `McpError` instead of re-throwing
|
|
63
|
+
* it — see the `err instanceof McpError` guard at the top of the catch
|
|
64
|
+
* block below, which is the PRIMARY fix for that exact case), naively
|
|
65
|
+
* constructing `new McpError(code, message, data)` doubles it:
|
|
66
|
+
* `"MCP error -32602: MCP error -32602: ..."`. This strips any
|
|
67
|
+
* pre-existing prefix before building the new one, so the message is
|
|
68
|
+
* single-prefixed no matter how many times a message string passes
|
|
69
|
+
* through this function.
|
|
70
|
+
* 2. **`error_type` in the human-readable message too** (optional polish,
|
|
71
|
+
* vault#555) — a string-reading human (not just a JSON-parsing agent
|
|
72
|
+
* keying off `data.error_type`) sees `[error_type] message` rather than
|
|
73
|
+
* a bare message with no clue which structured category it belongs to.
|
|
74
|
+
*
|
|
75
|
+
* `data.error_type` fidelity was never actually broken — it was always
|
|
76
|
+
* threaded correctly through the JSON-RPC `data` field; only the
|
|
77
|
+
* human-readable `message` string could double-prefix.
|
|
78
|
+
*/
|
|
79
|
+
export function mcpDomainError(
|
|
80
|
+
code: ErrorCode,
|
|
81
|
+
rawMessage: string,
|
|
82
|
+
data: Record<string, unknown>,
|
|
83
|
+
): McpError {
|
|
84
|
+
const cleanMessage = rawMessage.replace(MCP_ERROR_PREFIX_RE, "");
|
|
85
|
+
const errorType = typeof data.error_type === "string" ? data.error_type : undefined;
|
|
86
|
+
const humanMessage = errorType ? `[${errorType}] ${cleanMessage}` : cleanMessage;
|
|
87
|
+
return new McpError(code, humanMessage, data);
|
|
88
|
+
}
|
|
89
|
+
|
|
48
90
|
/**
|
|
49
91
|
* Handle scoped MCP at /vault/{name}/mcp (single vault).
|
|
50
92
|
*
|
|
@@ -75,7 +117,10 @@ export async function handleScopedMcp(
|
|
|
75
117
|
);
|
|
76
118
|
}
|
|
77
119
|
|
|
78
|
-
|
|
120
|
+
/** Exported for direct testing (vault#555 fix 6) — lets a test inject a
|
|
121
|
+
* synthetic failing tool to exercise the `err instanceof McpError` guard
|
|
122
|
+
* without needing a real domain error class to construct one naturally. */
|
|
123
|
+
export async function handleMcp(
|
|
79
124
|
req: Request,
|
|
80
125
|
getTools: () => McpToolDef[],
|
|
81
126
|
serverName: string,
|
|
@@ -139,6 +184,18 @@ async function handleMcp(
|
|
|
139
184
|
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
140
185
|
};
|
|
141
186
|
} catch (err) {
|
|
187
|
+
// vault#555 fix 6 — never re-wrap an already-formed McpError. Passing
|
|
188
|
+
// the SAME instance straight through is strictly correct (no
|
|
189
|
+
// information lost — `.message` AND `.data.error_type` are already
|
|
190
|
+
// exactly right) and is the primary fix for the double-prefix bug
|
|
191
|
+
// ("MCP error -32602: MCP error -32602: ..."): every branch below
|
|
192
|
+
// reads `err.message` and feeds it into a NEW McpError, which would
|
|
193
|
+
// double the SDK's baked-in "MCP error <code>: " prefix if `err` were
|
|
194
|
+
// already one. See `mcpDomainError`'s doc comment for the belt-and-
|
|
195
|
+
// suspenders half of this fix (stripping a pre-existing prefix from a
|
|
196
|
+
// plain message too).
|
|
197
|
+
if (err instanceof McpError) throw err;
|
|
198
|
+
|
|
142
199
|
// Domain errors from the core tools (conflict, missing precondition,
|
|
143
200
|
// path collisions, batch caps, cursor/query validation, tag-field
|
|
144
201
|
// conflicts, ...) get surfaced as JSON-RPC errors with a structured
|
|
@@ -182,7 +239,7 @@ async function handleMcp(
|
|
|
182
239
|
// existing unstructured `isError: true` fallback — only the NEW #550
|
|
183
240
|
// call sites that explicitly set `error_type` opt into this shape.
|
|
184
241
|
if (e?.error_type === "invalid_query") {
|
|
185
|
-
throw
|
|
242
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
186
243
|
error_type: "invalid_query",
|
|
187
244
|
field: e.field,
|
|
188
245
|
got: e.got,
|
|
@@ -195,7 +252,7 @@ async function handleMcp(
|
|
|
195
252
|
// caller can tell "your search_mode:\"advanced\" syntax is malformed"
|
|
196
253
|
// apart from "your query PARAMS are malformed."
|
|
197
254
|
if (e?.error_type === "invalid_search_syntax") {
|
|
198
|
-
throw
|
|
255
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
199
256
|
error_type: "invalid_search_syntax",
|
|
200
257
|
field: e.field,
|
|
201
258
|
got: e.got,
|
|
@@ -203,7 +260,7 @@ async function handleMcp(
|
|
|
203
260
|
});
|
|
204
261
|
}
|
|
205
262
|
if (e?.code === "CONFLICT") {
|
|
206
|
-
throw
|
|
263
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
207
264
|
error_type: "conflict",
|
|
208
265
|
current_updated_at: e.current_updated_at ?? null,
|
|
209
266
|
your_updated_at: e.expected_updated_at,
|
|
@@ -216,7 +273,7 @@ async function handleMcp(
|
|
|
216
273
|
// DISTINCT vocabulary from `conflict` (settled lead #3): the value
|
|
217
274
|
// didn't match, not the updated_at token.
|
|
218
275
|
if (e?.code === "TRANSITION_CONFLICT") {
|
|
219
|
-
throw
|
|
276
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
220
277
|
error_type: "transition_conflict",
|
|
221
278
|
note_id: e.note_id,
|
|
222
279
|
path: e.note_path ?? null,
|
|
@@ -230,14 +287,14 @@ async function handleMcp(
|
|
|
230
287
|
// Strict-schema rejection (vault#299 Part A) — one error carrying ALL
|
|
231
288
|
// per-field violations (settled lead #1).
|
|
232
289
|
if (e?.code === "SCHEMA_VALIDATION") {
|
|
233
|
-
throw
|
|
290
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
234
291
|
error_type: "schema_validation",
|
|
235
292
|
violations: e.violations ?? [],
|
|
236
293
|
hint: "fix every field listed in violations and retry — none of this write was applied",
|
|
237
294
|
});
|
|
238
295
|
}
|
|
239
296
|
if (e?.code === "PRECONDITION_REQUIRED") {
|
|
240
|
-
throw
|
|
297
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
241
298
|
error_type: "precondition_required",
|
|
242
299
|
note_id: e.note_id,
|
|
243
300
|
path: e.note_path ?? null,
|
|
@@ -252,7 +309,7 @@ async function handleMcp(
|
|
|
252
309
|
// REST already uses for this whole error class — so these no longer
|
|
253
310
|
// fall through to the unstructured `isError: true` text.
|
|
254
311
|
if (e?.name === "QueryError") {
|
|
255
|
-
throw
|
|
312
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
256
313
|
error_type: e.error_type ?? "invalid_query",
|
|
257
314
|
code: e.code,
|
|
258
315
|
field: e.field,
|
|
@@ -266,14 +323,14 @@ async function handleMcp(
|
|
|
266
323
|
// already the exact `error_type` vocabulary: "cursor_invalid" or
|
|
267
324
|
// "cursor_query_mismatch".
|
|
268
325
|
if (e?.name === "CursorError" && typeof e.code === "string") {
|
|
269
|
-
throw
|
|
326
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
270
327
|
error_type: e.code,
|
|
271
328
|
});
|
|
272
329
|
}
|
|
273
330
|
// Path-rename/create collision (vault#126, vault#554) — schema's
|
|
274
331
|
// UNIQUE(path) tripped. Mirrors REST's 409 `path_conflict` shape.
|
|
275
332
|
if (e?.code === "PATH_CONFLICT") {
|
|
276
|
-
throw
|
|
333
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
277
334
|
error_type: "path_conflict",
|
|
278
335
|
path: e.path,
|
|
279
336
|
});
|
|
@@ -282,7 +339,7 @@ async function handleMcp(
|
|
|
282
339
|
// mirrors REST's 409 `ambiguous_path` shape (candidates = the
|
|
283
340
|
// disambiguating extensions).
|
|
284
341
|
if (e?.code === "AMBIGUOUS_PATH") {
|
|
285
|
-
throw
|
|
342
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
286
343
|
error_type: "ambiguous_path",
|
|
287
344
|
path: e.path,
|
|
288
345
|
candidates: e.candidates,
|
|
@@ -291,7 +348,7 @@ async function handleMcp(
|
|
|
291
348
|
// Bad `extension` value (vault#328, vault#554) — mirrors REST's 400
|
|
292
349
|
// `invalid_extension` shape.
|
|
293
350
|
if (e?.code === "INVALID_EXTENSION") {
|
|
294
|
-
throw
|
|
351
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
295
352
|
error_type: "invalid_extension",
|
|
296
353
|
extension: e.extension,
|
|
297
354
|
reason: e.reason,
|
|
@@ -300,7 +357,7 @@ async function handleMcp(
|
|
|
300
357
|
// Batch cap exceeded (#213, vault#554) — mirrors REST's 413
|
|
301
358
|
// `batch_too_large` shape.
|
|
302
359
|
if (e?.code === "BATCH_TOO_LARGE") {
|
|
303
|
-
throw
|
|
360
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
304
361
|
error_type: "batch_too_large",
|
|
305
362
|
limit: e.limit,
|
|
306
363
|
got: e.got,
|
|
@@ -309,7 +366,7 @@ async function handleMcp(
|
|
|
309
366
|
// update-tag cross-tag field conflicts (vault#553/#554) — carries
|
|
310
367
|
// EVERY violation in one response (see `TagFieldConflictError`).
|
|
311
368
|
if (e?.code === "TAG_FIELD_CONFLICT") {
|
|
312
|
-
throw
|
|
369
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
313
370
|
error_type: "tag_field_conflict",
|
|
314
371
|
tag: e.tag,
|
|
315
372
|
violations: e.violations ?? [],
|
|
@@ -320,7 +377,7 @@ async function handleMcp(
|
|
|
320
377
|
// wrapper (src/mcp-tools.ts) scope-scrubs `cycle` before this throw
|
|
321
378
|
// for a tag-scoped caller, same as TAG_FIELD_CONFLICT above.
|
|
322
379
|
if (e?.code === "PARENT_CYCLE") {
|
|
323
|
-
throw
|
|
380
|
+
throw mcpDomainError(ErrorCode.InvalidRequest, message, {
|
|
324
381
|
error_type: "parent_cycle",
|
|
325
382
|
tag: e.tag,
|
|
326
383
|
cycle: e.cycle ?? [],
|
|
@@ -338,7 +395,7 @@ async function handleMcp(
|
|
|
338
395
|
// error" true by construction: only errors nobody tagged with
|
|
339
396
|
// `error_type` still hit the fallback below.
|
|
340
397
|
if (typeof e?.error_type === "string") {
|
|
341
|
-
throw
|
|
398
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
342
399
|
error_type: e.error_type,
|
|
343
400
|
field: e.field,
|
|
344
401
|
hint: e.hint,
|
package/src/mcp-tools.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
scrubParentCycleError,
|
|
27
27
|
scrubReferencingTagsByScope,
|
|
28
28
|
scrubTagFieldViolationsByScope,
|
|
29
|
+
scrubValidationStatusByScope,
|
|
29
30
|
tagsWithinScope,
|
|
30
31
|
} from "./tag-scope.ts";
|
|
31
32
|
import { TagFieldConflictError, ParentCycleError } from "../core/src/tag-schemas.ts";
|
|
@@ -332,6 +333,16 @@ function applyTagScopeWrappers(
|
|
|
332
333
|
if (n && Array.isArray(n.links)) {
|
|
333
334
|
n.links = filterHydratedLinksByTagScope(n.links, allowedHolder?.value ?? null, rawTags);
|
|
334
335
|
}
|
|
336
|
+
// vault#555 auth review — a note the caller can see may ALSO carry an
|
|
337
|
+
// out-of-scope co-tag whose schema `validation_status` would otherwise
|
|
338
|
+
// leak (field name / type / enum values, the #560 class). Scrub it with
|
|
339
|
+
// the same allowlist the link scrub uses. Reads the resolved holder for
|
|
340
|
+
// the same reason (see the ordering-invariant note above scrubNoteLinks).
|
|
341
|
+
if (n && n.validation_status) {
|
|
342
|
+
const scrubbed = scrubValidationStatusByScope(n.validation_status, allowedHolder?.value ?? null, rawTags);
|
|
343
|
+
if (scrubbed === undefined) delete n.validation_status;
|
|
344
|
+
else n.validation_status = scrubbed;
|
|
345
|
+
}
|
|
335
346
|
return n;
|
|
336
347
|
};
|
|
337
348
|
|
package/src/routes.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
type QueryWarning,
|
|
23
23
|
} from "../core/src/query-warnings.ts";
|
|
24
24
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "../core/src/search-query.ts";
|
|
25
|
-
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
25
|
+
import { listUnresolvedWikilinks, resolveOrQueueLink, resolveStructuredLinkNote } from "../core/src/wikilinks.ts";
|
|
26
26
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
27
27
|
import { getNote, getNotes, getNoteTags, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "../core/src/notes.ts";
|
|
28
28
|
import {
|
|
@@ -52,10 +52,11 @@ import {
|
|
|
52
52
|
scrubParentCycleError,
|
|
53
53
|
scrubReferencingTagsByScope,
|
|
54
54
|
scrubTagFieldViolationsByScope,
|
|
55
|
+
scrubValidationStatusByScope,
|
|
55
56
|
tagScopeForbidden,
|
|
56
57
|
tagsWithinScope,
|
|
57
58
|
} from "./tag-scope.ts";
|
|
58
|
-
import { ParentCycleError, InvalidFieldDefaultError } from "../core/src/tag-schemas.ts";
|
|
59
|
+
import { ParentCycleError, InvalidFieldDefaultError, InvalidFieldTypeError } from "../core/src/tag-schemas.ts";
|
|
59
60
|
import { findTokensReferencingTag } from "./token-store.ts";
|
|
60
61
|
|
|
61
62
|
/**
|
|
@@ -1352,6 +1353,34 @@ async function handleNotesInner(
|
|
|
1352
1353
|
if (contentRange.range && includeContent) {
|
|
1353
1354
|
for (const n of output) applyContentRange(n, contentRange.range);
|
|
1354
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
|
+
}
|
|
1355
1384
|
if (inclMeta !== undefined && inclMeta !== true) {
|
|
1356
1385
|
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
1357
1386
|
}
|
|
@@ -1475,6 +1504,14 @@ async function handleNotesInner(
|
|
|
1475
1504
|
}
|
|
1476
1505
|
|
|
1477
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[]>();
|
|
1478
1515
|
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
1479
1516
|
// failure (path conflict, etc.) rolls back every prior insert. Without
|
|
1480
1517
|
// this, callers got half-applied batches where the prefix landed and
|
|
@@ -1510,16 +1547,36 @@ async function handleNotesInner(
|
|
|
1510
1547
|
via: writeCtx.via,
|
|
1511
1548
|
});
|
|
1512
1549
|
|
|
1513
|
-
// Create explicit links
|
|
1514
1550
|
if (item.links) {
|
|
1515
|
-
|
|
1516
|
-
const target = await resolveNote(store, link.target);
|
|
1517
|
-
if (target) await store.createLink(note.id, target.id, link.relationship);
|
|
1518
|
-
}
|
|
1551
|
+
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
1519
1552
|
}
|
|
1520
1553
|
|
|
1521
1554
|
created.push((await store.getNote(note.id)) ?? note);
|
|
1522
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
|
+
}
|
|
1523
1580
|
};
|
|
1524
1581
|
try {
|
|
1525
1582
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
@@ -1584,8 +1641,14 @@ async function handleNotesInner(
|
|
|
1584
1641
|
// Attach `validation_status` so HTTP create-note matches the MCP
|
|
1585
1642
|
// surface (vault#287). `attachValidationStatus` returns the note
|
|
1586
1643
|
// unchanged when no tag declares fields, so vaults without any tag
|
|
1587
|
-
// schemas see no behavior change.
|
|
1588
|
-
|
|
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
|
+
});
|
|
1589
1652
|
return json(body.notes ? final : final[0], 201);
|
|
1590
1653
|
}
|
|
1591
1654
|
|
|
@@ -1741,6 +1804,24 @@ async function handleNotesInner(
|
|
|
1741
1804
|
const contentRange = parseContentRangeQuery(url, includeContent);
|
|
1742
1805
|
if (contentRange.error) return contentRange.error;
|
|
1743
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
|
+
}
|
|
1744
1825
|
const expand = parseExpandParams(url, db, tagScope);
|
|
1745
1826
|
if (expand && includeContent && typeof result.content === "string") {
|
|
1746
1827
|
expand.ctx.expanded.add(note.id);
|
|
@@ -1834,22 +1915,35 @@ async function handleNotesInner(
|
|
|
1834
1915
|
// fresh note).
|
|
1835
1916
|
// - Missing target notes skip silently (mirrors MCP).
|
|
1836
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[] = [];
|
|
1837
1922
|
if (linksAdd) {
|
|
1838
1923
|
for (const link of linksAdd) {
|
|
1839
|
-
const
|
|
1840
|
-
if (
|
|
1841
|
-
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
|
+
});
|
|
1842
1934
|
}
|
|
1843
1935
|
}
|
|
1844
1936
|
}
|
|
1845
1937
|
const final = await store.getNote(created.id);
|
|
1846
1938
|
if (!final) return json({ error: "Note disappeared", error_type: "internal_error" }, 500);
|
|
1847
|
-
const validated = attachValidationStatus(store, db, final);
|
|
1939
|
+
const validated: any = attachValidationStatus(store, db, final);
|
|
1940
|
+
if (createWarnings.length > 0) validated.warnings = createWarnings;
|
|
1848
1941
|
const includeContentResp = body.include_content !== false;
|
|
1849
1942
|
if (includeContentResp) return json({ ...validated, created: true });
|
|
1850
1943
|
const lean: any = toNoteIndex(validated);
|
|
1851
1944
|
const vs = (validated as any).validation_status;
|
|
1852
1945
|
if (vs !== undefined) lean.validation_status = vs;
|
|
1946
|
+
if (createWarnings.length > 0) lean.warnings = createWarnings;
|
|
1853
1947
|
lean.created = true;
|
|
1854
1948
|
return json(lean);
|
|
1855
1949
|
}
|
|
@@ -1992,7 +2086,7 @@ async function handleNotesInner(
|
|
|
1992
2086
|
const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
|
|
1993
2087
|
if (linksRemove) {
|
|
1994
2088
|
for (const link of linksRemove) {
|
|
1995
|
-
const target =
|
|
2089
|
+
const target = resolveStructuredLinkNote(db, link.target);
|
|
1996
2090
|
if (!target) continue;
|
|
1997
2091
|
resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
|
|
1998
2092
|
if (link.relationship === "wikilink" && target.path) {
|
|
@@ -2069,9 +2163,21 @@ async function handleNotesInner(
|
|
|
2069
2163
|
gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
2070
2164
|
}
|
|
2071
2165
|
|
|
2072
|
-
|
|
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) {
|
|
2073
2175
|
// Write-attribution (vault#298) — REST update. Stamp the most-recent-
|
|
2074
|
-
// 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.
|
|
2075
2181
|
updates.actor = writeCtx.actor;
|
|
2076
2182
|
updates.via = writeCtx.via;
|
|
2077
2183
|
await store.updateNote(note.id, updates);
|
|
@@ -2091,11 +2197,24 @@ async function handleNotesInner(
|
|
|
2091
2197
|
await store.untagNote(note.id, body.tags.remove);
|
|
2092
2198
|
}
|
|
2093
2199
|
|
|
2094
|
-
// 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[] = [];
|
|
2095
2205
|
if (body.links?.add) {
|
|
2096
2206
|
for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
|
|
2097
|
-
const
|
|
2098
|
-
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
|
+
}
|
|
2099
2218
|
}
|
|
2100
2219
|
}
|
|
2101
2220
|
|
|
@@ -2130,6 +2249,7 @@ async function handleNotesInner(
|
|
|
2130
2249
|
tagScope.raw,
|
|
2131
2250
|
);
|
|
2132
2251
|
}
|
|
2252
|
+
if (linkWarnings.length > 0) validated.warnings = linkWarnings;
|
|
2133
2253
|
const includeContentResp = body.include_content !== false;
|
|
2134
2254
|
// `created: false` is appended to every update-path response so
|
|
2135
2255
|
// sync-loop callers using `if_missing: "create"` can distinguish
|
|
@@ -2142,6 +2262,7 @@ async function handleNotesInner(
|
|
|
2142
2262
|
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
2143
2263
|
// drops unknown fields, same as the `validation_status` recipe above.
|
|
2144
2264
|
if (validated.links !== undefined) lean.links = validated.links;
|
|
2265
|
+
if (linkWarnings.length > 0) lean.warnings = linkWarnings;
|
|
2145
2266
|
lean.created = false;
|
|
2146
2267
|
return json(lean);
|
|
2147
2268
|
} catch (e: any) {
|
|
@@ -2642,11 +2763,22 @@ export async function handleTags(
|
|
|
2642
2763
|
// — silent 200s on main — is non-indexed type conflicts and indexed-flag
|
|
2643
2764
|
// conflicts. See `collectCrossTagFieldViolations`'s doc comment.
|
|
2644
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).
|
|
2645
2777
|
const fieldViolations = tagSchemaOps.collectCrossTagFieldViolations(
|
|
2646
2778
|
store.db,
|
|
2647
2779
|
putTagName,
|
|
2648
|
-
|
|
2649
|
-
);
|
|
2780
|
+
incomingFields,
|
|
2781
|
+
).concat(tagSchemaOps.collectOwnFieldDefaultAndTypeViolations(incomingFields));
|
|
2650
2782
|
if (fieldViolations.length > 0) {
|
|
2651
2783
|
// Tag-scope scrub (vault#554 auth-and-scope fold): the write is
|
|
2652
2784
|
// still rejected, but a violation whose conflicting declarer is
|
|
@@ -2694,15 +2826,26 @@ export async function handleTags(
|
|
|
2694
2826
|
if (err instanceof InvalidFieldDefaultError) {
|
|
2695
2827
|
// vault#553 Decision B — a declared `default` doesn't conform to its
|
|
2696
2828
|
// own field's type/enum. Own-field error (no cross-tag data), so no
|
|
2697
|
-
// scrub needed.
|
|
2698
|
-
//
|
|
2699
|
-
//
|
|
2700
|
-
// 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.
|
|
2701
2834
|
return json(
|
|
2702
2835
|
{ error: err.message, error_type: "invalid_field_default", field: err.field },
|
|
2703
2836
|
400,
|
|
2704
2837
|
);
|
|
2705
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
|
+
}
|
|
2706
2849
|
if (err instanceof ParentCycleError) {
|
|
2707
2850
|
// vault#552: parent_names would close a cycle. Nothing was
|
|
2708
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
|
});
|