@openparachute/vault 0.6.4-rc.2 → 0.6.4-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/README.md +38 -1
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +183 -4
- package/core/src/notes.ts +128 -4
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +2 -11
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/config.ts +11 -0
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +15 -3
- package/src/routes.ts +133 -3
- package/src/routing.ts +10 -2
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
|
@@ -60,6 +60,33 @@ export interface SchemaField {
|
|
|
60
60
|
type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
|
|
61
61
|
enum?: string[];
|
|
62
62
|
description?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Strict enforcement opt-in (vault#299, Part A). Default `false` — when
|
|
65
|
+
* unset/false, ALL constraints on this field are advisory: violations
|
|
66
|
+
* surface as `validation_status` warnings and the write succeeds (the
|
|
67
|
+
* historical byte-identical behavior). When `true`, ALL declared
|
|
68
|
+
* constraints on this field (type + enum + required + cardinality) flip
|
|
69
|
+
* to hard write rejections, all-or-nothing per field (the Gitcoin team's
|
|
70
|
+
* call — "enum + required together, not enum alone"). Free-form fields
|
|
71
|
+
* (`notes`/`description`) on an otherwise-strict tag stay advisory by
|
|
72
|
+
* simply leaving `strict` off — strictness is per-field, not per-tag.
|
|
73
|
+
*/
|
|
74
|
+
strict?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Whether the field MUST be present (and non-null) on a note carrying this
|
|
77
|
+
* tag. Advisory under `strict:false` (a `required` violation surfaces as a
|
|
78
|
+
* `missing_required` warning); a hard rejection only when `strict:true`.
|
|
79
|
+
*/
|
|
80
|
+
required?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Cardinality constraint for the field's value. `"one"` (the implicit
|
|
83
|
+
* default) means a single scalar — an array value is a `cardinality`
|
|
84
|
+
* violation. `"many"` means the value must be an array. Advisory under
|
|
85
|
+
* `strict:false`; a hard rejection when `strict:true`. Distinct from the
|
|
86
|
+
* relationship cardinality vocabulary (that governs typed links, not
|
|
87
|
+
* metadata fields).
|
|
88
|
+
*/
|
|
89
|
+
cardinality?: "one" | "many";
|
|
63
90
|
}
|
|
64
91
|
|
|
65
92
|
/**
|
|
@@ -83,19 +110,35 @@ export interface ValidationWarning {
|
|
|
83
110
|
/**
|
|
84
111
|
* `type_mismatch` — value's type contradicts the declared `type`.
|
|
85
112
|
* `enum_mismatch` — string value not in the declared `enum`.
|
|
113
|
+
* `missing_required` — a `required` field is absent or null (vault#299).
|
|
114
|
+
* `cardinality_mismatch` — value's shape (scalar vs array) contradicts the
|
|
115
|
+
* declared `cardinality` (vault#299).
|
|
86
116
|
* `schema_conflict` — two ancestors declared the same field with
|
|
87
117
|
* different specs; first-in-walk wins, the loser surfaces here so the
|
|
88
118
|
* operator can resolve the disagreement.
|
|
89
119
|
*/
|
|
90
|
-
reason:
|
|
120
|
+
reason:
|
|
121
|
+
| "type_mismatch"
|
|
122
|
+
| "enum_mismatch"
|
|
123
|
+
| "missing_required"
|
|
124
|
+
| "cardinality_mismatch"
|
|
125
|
+
| "schema_conflict";
|
|
91
126
|
message: string;
|
|
92
127
|
/**
|
|
93
128
|
* `schema_conflict` only — the tag whose declaration was overridden. Set
|
|
94
|
-
* when `reason === "schema_conflict"`; absent on
|
|
129
|
+
* when `reason === "schema_conflict"`; absent on other reasons.
|
|
95
130
|
* Surfaces structurally so agents don't have to regex `message` to find
|
|
96
131
|
* the loser.
|
|
97
132
|
*/
|
|
98
133
|
loser_schema?: string;
|
|
134
|
+
/**
|
|
135
|
+
* `true` when this violation comes from a `strict:true` field (vault#299).
|
|
136
|
+
* A strict violation is an ENFORCEMENT error — the write path rejects it.
|
|
137
|
+
* Absent/false for advisory warnings (the historical guidance behavior).
|
|
138
|
+
* Surfaced structurally so the write path can split "block" from "warn"
|
|
139
|
+
* without re-deriving the field's strict flag.
|
|
140
|
+
*/
|
|
141
|
+
strict?: boolean;
|
|
99
142
|
}
|
|
100
143
|
|
|
101
144
|
export interface ValidationStatus {
|
|
@@ -126,6 +169,9 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
|
|
|
126
169
|
if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
|
|
127
170
|
if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
|
|
128
171
|
if (typeof f.description === "string") field.description = f.description;
|
|
172
|
+
if (f.strict === true) field.strict = true;
|
|
173
|
+
if (f.required === true) field.required = true;
|
|
174
|
+
if (f.cardinality === "one" || f.cardinality === "many") field.cardinality = f.cardinality;
|
|
129
175
|
fields[k] = field;
|
|
130
176
|
}
|
|
131
177
|
return fields;
|
|
@@ -301,15 +347,23 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
|
|
|
301
347
|
* Validate a note's metadata against the merged schema. Returns null when no
|
|
302
348
|
* ancestor declares any fields (so the caller can omit `validation_status`
|
|
303
349
|
* entirely). Otherwise returns the status with conflict warnings prepended,
|
|
304
|
-
* followed by per-field
|
|
350
|
+
* followed by per-field violations.
|
|
305
351
|
*
|
|
306
352
|
* Rules per merged field:
|
|
353
|
+
* - `required` declared and value absent/null → `missing_required`
|
|
307
354
|
* - Present and `type` declared and value's type doesn't match → `type_mismatch`
|
|
308
355
|
* - Present and `enum` declared and value not in enum → `enum_mismatch`
|
|
356
|
+
* - Present and `cardinality` declared and shape (scalar vs array) wrong
|
|
357
|
+
* → `cardinality_mismatch`
|
|
358
|
+
*
|
|
359
|
+
* Every violation carries `strict: true` iff its field declared `strict:true`
|
|
360
|
+
* (vault#299). The list itself is the SAME whether or not a field is strict —
|
|
361
|
+
* the difference is only the per-warning `strict` flag, which the write path
|
|
362
|
+
* uses to decide block-vs-warn. Under `strict:false` this is byte-identical to
|
|
363
|
+
* the historical advisory behavior PLUS the new `required`/`cardinality`
|
|
364
|
+
* advisory reasons (which fire for any field declaring those, strict or not).
|
|
309
365
|
*
|
|
310
|
-
* Fields not declared by any ancestor's schema are ignored entirely
|
|
311
|
-
* isn't a "strict" validator — it's a guide). There is no `required` concept
|
|
312
|
-
* (post-v17); declarations are advisory only.
|
|
366
|
+
* Fields not declared by any ancestor's schema are ignored entirely.
|
|
313
367
|
*/
|
|
314
368
|
export function validateNote(
|
|
315
369
|
resolved: ResolvedSchemas,
|
|
@@ -322,9 +376,25 @@ export function validateNote(
|
|
|
322
376
|
const warnings: ValidationWarning[] = [...resolution.conflicts];
|
|
323
377
|
|
|
324
378
|
for (const [fieldName, { spec, sourceTag }] of resolution.mergedFields) {
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
379
|
+
const strictFlag = spec.strict === true ? { strict: true } : {};
|
|
380
|
+
const present = fieldName in m;
|
|
381
|
+
const value = present ? m[fieldName] : undefined;
|
|
382
|
+
const absent = !present || value === undefined || value === null;
|
|
383
|
+
|
|
384
|
+
if (spec.required === true && absent) {
|
|
385
|
+
warnings.push({
|
|
386
|
+
field: fieldName,
|
|
387
|
+
schema: sourceTag,
|
|
388
|
+
reason: "missing_required",
|
|
389
|
+
message: `'${fieldName}' is required (tag '${sourceTag}')`,
|
|
390
|
+
...strictFlag,
|
|
391
|
+
});
|
|
392
|
+
// A required field that's absent has no value to type/enum/cardinality
|
|
393
|
+
// check — the missing_required violation stands alone.
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (absent) continue;
|
|
328
398
|
|
|
329
399
|
if (spec.type && !valueMatchesType(value, spec.type)) {
|
|
330
400
|
warnings.push({
|
|
@@ -332,6 +402,7 @@ export function validateNote(
|
|
|
332
402
|
schema: sourceTag,
|
|
333
403
|
reason: "type_mismatch",
|
|
334
404
|
message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
|
|
405
|
+
...strictFlag,
|
|
335
406
|
});
|
|
336
407
|
}
|
|
337
408
|
|
|
@@ -341,9 +412,91 @@ export function validateNote(
|
|
|
341
412
|
schema: sourceTag,
|
|
342
413
|
reason: "enum_mismatch",
|
|
343
414
|
message: `'${fieldName}' must be one of [${spec.enum.join(", ")}] (tag '${sourceTag}')`,
|
|
415
|
+
...strictFlag,
|
|
344
416
|
});
|
|
345
417
|
}
|
|
418
|
+
|
|
419
|
+
if (spec.cardinality) {
|
|
420
|
+
const isArray = Array.isArray(value);
|
|
421
|
+
const wantMany = spec.cardinality === "many";
|
|
422
|
+
if (wantMany !== isArray) {
|
|
423
|
+
warnings.push({
|
|
424
|
+
field: fieldName,
|
|
425
|
+
schema: sourceTag,
|
|
426
|
+
reason: "cardinality_mismatch",
|
|
427
|
+
message: wantMany
|
|
428
|
+
? `'${fieldName}' must be an array (cardinality 'many', tag '${sourceTag}')`
|
|
429
|
+
: `'${fieldName}' must be a single value, not an array (cardinality 'one', tag '${sourceTag}')`,
|
|
430
|
+
...strictFlag,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
346
434
|
}
|
|
347
435
|
|
|
348
436
|
return { schemas: resolution.effectiveTags, warnings };
|
|
349
437
|
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Strict enforcement (vault#299)
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Thrown by the write path when a note violates one or more `strict:true`
|
|
445
|
+
* field constraints. Carries ALL per-field violations in a single error (the
|
|
446
|
+
* settled design lead — one `SchemaValidationError`, not per-axis errors), so
|
|
447
|
+
* an agent sees the whole contract it broke in one response and can fix every
|
|
448
|
+
* field before retrying.
|
|
449
|
+
*
|
|
450
|
+
* Each entry is a `ValidationWarning` with `strict: true`. `code` is stable
|
|
451
|
+
* (`SCHEMA_VALIDATION`) for transport mapping (MCP error / HTTP 422).
|
|
452
|
+
*/
|
|
453
|
+
export class SchemaValidationError extends Error {
|
|
454
|
+
code = "SCHEMA_VALIDATION" as const;
|
|
455
|
+
violations: ValidationWarning[];
|
|
456
|
+
|
|
457
|
+
constructor(violations: ValidationWarning[]) {
|
|
458
|
+
const summary = violations
|
|
459
|
+
.map((v) => `${v.field}: ${v.reason}`)
|
|
460
|
+
.join("; ");
|
|
461
|
+
super(`schema_validation: ${violations.length} strict field violation(s) — ${summary}`);
|
|
462
|
+
this.name = "SchemaValidationError";
|
|
463
|
+
this.violations = violations;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Extract the enforcement-level (strict) subset of a validation status —
|
|
469
|
+
* the violations the write path must reject. Conflict warnings are advisory
|
|
470
|
+
* by nature (they describe operator schema disagreements, not note data) and
|
|
471
|
+
* are never enforced even when a field is strict. Returns `[]` when nothing
|
|
472
|
+
* strict was violated (the common case — the caller then proceeds with the
|
|
473
|
+
* write).
|
|
474
|
+
*/
|
|
475
|
+
export function strictViolations(status: ValidationStatus | null): ValidationWarning[] {
|
|
476
|
+
if (!status) return [];
|
|
477
|
+
return status.warnings.filter((w) => w.strict === true && w.reason !== "schema_conflict");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Validate-and-enforce: run `validateNote`, and if any `strict:true` field is
|
|
482
|
+
* violated, throw a single `SchemaValidationError` carrying every violation.
|
|
483
|
+
* Returns the full advisory `ValidationStatus` (or null) on success so the
|
|
484
|
+
* caller can still surface advisory warnings on the response. `bypass: true`
|
|
485
|
+
* skips the throw entirely (migration-bypass scope) — the caller is
|
|
486
|
+
* responsible for logging the bypass.
|
|
487
|
+
*
|
|
488
|
+
* The write path calls this BEFORE persisting so a rejection leaves the note
|
|
489
|
+
* untouched.
|
|
490
|
+
*/
|
|
491
|
+
export function enforceStrictSchema(
|
|
492
|
+
resolved: ResolvedSchemas,
|
|
493
|
+
note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
|
|
494
|
+
opts?: { bypass?: boolean },
|
|
495
|
+
): { status: ValidationStatus | null; violations: ValidationWarning[] } {
|
|
496
|
+
const status = validateNote(resolved, note);
|
|
497
|
+
const violations = strictViolations(status);
|
|
498
|
+
if (violations.length > 0 && opts?.bypass !== true) {
|
|
499
|
+
throw new SchemaValidationError(violations);
|
|
500
|
+
}
|
|
501
|
+
return { status, violations };
|
|
502
|
+
}
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -30,6 +30,18 @@ export interface TagFieldSchema {
|
|
|
30
30
|
// across declarers — all tags declaring this field must agree on both
|
|
31
31
|
// `type` and `indexed`. See core/src/indexed-fields.ts for lifecycle.
|
|
32
32
|
indexed?: boolean;
|
|
33
|
+
// Strict-enforcement opt-in (vault#299). Default false (advisory). When
|
|
34
|
+
// true, ALL declared constraints on this field (type + enum + required +
|
|
35
|
+
// cardinality) flip from validation_status warnings to hard write
|
|
36
|
+
// rejections — all-or-nothing per field. Stored verbatim in the `fields`
|
|
37
|
+
// JSON column; the resolver (schema-defaults.ts) interprets it.
|
|
38
|
+
strict?: boolean;
|
|
39
|
+
// The field must be present + non-null on a note carrying this tag.
|
|
40
|
+
// Advisory unless `strict:true`. vault#299.
|
|
41
|
+
required?: boolean;
|
|
42
|
+
// "one" (scalar, default) or "many" (array). Advisory unless `strict:true`.
|
|
43
|
+
// Distinct from relationship cardinality. vault#299.
|
|
44
|
+
cardinality?: "one" | "many";
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
/**
|
|
@@ -27,6 +27,12 @@ export interface StoredTriggerWhen {
|
|
|
27
27
|
has_content?: boolean;
|
|
28
28
|
missing_metadata?: string[];
|
|
29
29
|
has_metadata?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Value-matched metadata predicate (vault#299 Part B). field →
|
|
32
|
+
* operator-object, evaluated with the shared query-operators engine. See
|
|
33
|
+
* src/config.ts:TriggerWhen.metadata.
|
|
34
|
+
*/
|
|
35
|
+
metadata?: Record<string, Record<string, unknown>>;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/** Webhook auth — only the bearer-JWT path for now. */
|
package/core/src/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite";
|
|
|
2
2
|
import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
3
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
4
4
|
import type { TagExpandMode } from "./tag-hierarchy.js";
|
|
5
|
+
import type { ValidationStatus } from "./schema-defaults.js";
|
|
5
6
|
|
|
6
7
|
// ---- Re-exports ----
|
|
7
8
|
|
|
@@ -420,17 +421,7 @@ export interface Store {
|
|
|
420
421
|
// `_default` is the implicit universal parent. Returns null when no
|
|
421
422
|
// ancestor declares any fields. The underlying resolver is in-memory after
|
|
422
423
|
// the first lazy load.
|
|
423
|
-
validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }):
|
|
424
|
-
schemas: string[];
|
|
425
|
-
warnings: {
|
|
426
|
-
field: string;
|
|
427
|
-
schema: string;
|
|
428
|
-
reason: "type_mismatch" | "enum_mismatch" | "schema_conflict";
|
|
429
|
-
message: string;
|
|
430
|
-
/** Set only on `schema_conflict` — the tag whose declaration was overridden. */
|
|
431
|
-
loser_schema?: string;
|
|
432
|
-
}[];
|
|
433
|
-
} | null;
|
|
424
|
+
validateNoteAgainstSchemas(note: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> }): ValidationStatus | null;
|
|
434
425
|
|
|
435
426
|
// Attachments
|
|
436
427
|
addAttachment(noteId: string, path: string, mimeType: string, metadata?: Record<string, unknown>): Promise<Attachment>;
|
|
@@ -369,6 +369,25 @@ export function projectionToMarkdown(args: {
|
|
|
369
369
|
lines.push(`- No indexed metadata fields.`);
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
// Strict fields (vault#299): surface the enforced contract so an agent knows
|
|
373
|
+
// BEFORE writing which fields hard-reject on violation (not just warn).
|
|
374
|
+
// Derived from each schema tag's effective fields carrying `strict: true`.
|
|
375
|
+
const strictLines: string[] = [];
|
|
376
|
+
for (const t of projection.tags) {
|
|
377
|
+
const strictNames = Object.entries(t.effective_fields)
|
|
378
|
+
.filter(([, spec]) => (spec as { strict?: boolean }).strict === true)
|
|
379
|
+
.map(([name]) => name);
|
|
380
|
+
if (strictNames.length > 0) {
|
|
381
|
+
strictLines.push(` - #${t.name}: ${strictNames.join(", ")}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (strictLines.length > 0) {
|
|
385
|
+
lines.push(
|
|
386
|
+
`- Strict fields (writes that violate these are REJECTED, not just warned — enum/type/required/cardinality enforced):`,
|
|
387
|
+
);
|
|
388
|
+
lines.push(...strictLines);
|
|
389
|
+
}
|
|
390
|
+
|
|
372
391
|
lines.push("");
|
|
373
392
|
lines.push("## Querying");
|
|
374
393
|
lines.push("");
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -215,6 +215,17 @@ export interface TriggerWhen {
|
|
|
215
215
|
missing_metadata?: string[];
|
|
216
216
|
/** Note.metadata must have ALL of these keys set (non-null). */
|
|
217
217
|
has_metadata?: string[];
|
|
218
|
+
/**
|
|
219
|
+
* Value-matched metadata predicate (vault#299 Part B). A map of
|
|
220
|
+
* field → operator-object, evaluated against the note's live metadata with
|
|
221
|
+
* the SAME operators as `query-notes` (eq/ne/gt/gte/lt/lte/in/not_in/exists)
|
|
222
|
+
* via the shared `matchesOperator` engine. ALL entries must match (AND).
|
|
223
|
+
* Lets a trigger fire only on a specific transition — e.g.
|
|
224
|
+
* `metadata: { state: { eq: "published" } }` fires when a note reaches
|
|
225
|
+
* `published`, not on every edit. Combines with the presence/tag/content
|
|
226
|
+
* filters above (all must hold).
|
|
227
|
+
*/
|
|
228
|
+
metadata?: Record<string, Record<string, unknown>>;
|
|
218
229
|
}
|
|
219
230
|
|
|
220
231
|
/**
|
package/src/mcp-http.ts
CHANGED
|
@@ -151,6 +151,11 @@ async function handleMcp(
|
|
|
151
151
|
note_path?: string | null;
|
|
152
152
|
current_updated_at?: string | null;
|
|
153
153
|
expected_updated_at?: string;
|
|
154
|
+
field?: string;
|
|
155
|
+
expected_from?: unknown;
|
|
156
|
+
to?: unknown;
|
|
157
|
+
current?: unknown;
|
|
158
|
+
violations?: unknown;
|
|
154
159
|
};
|
|
155
160
|
if (e?.code === "CONFLICT") {
|
|
156
161
|
throw new McpError(ErrorCode.InvalidRequest, message, {
|
|
@@ -161,6 +166,28 @@ async function handleMcp(
|
|
|
161
166
|
note_id: e.note_id,
|
|
162
167
|
});
|
|
163
168
|
}
|
|
169
|
+
// State-transition compare-and-set conflict (vault#299 Part B) — a
|
|
170
|
+
// DISTINCT vocabulary from `conflict` (settled lead #3): the value
|
|
171
|
+
// didn't match, not the updated_at token.
|
|
172
|
+
if (e?.code === "TRANSITION_CONFLICT") {
|
|
173
|
+
throw new McpError(ErrorCode.InvalidRequest, message, {
|
|
174
|
+
error_type: "transition_conflict",
|
|
175
|
+
note_id: e.note_id,
|
|
176
|
+
path: e.note_path ?? null,
|
|
177
|
+
field: e.field,
|
|
178
|
+
expected_from: e.expected_from,
|
|
179
|
+
to: e.to,
|
|
180
|
+
current: e.current ?? null,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// Strict-schema rejection (vault#299 Part A) — one error carrying ALL
|
|
184
|
+
// per-field violations (settled lead #1).
|
|
185
|
+
if (e?.code === "SCHEMA_VALIDATION") {
|
|
186
|
+
throw new McpError(ErrorCode.InvalidParams, message, {
|
|
187
|
+
error_type: "schema_validation",
|
|
188
|
+
violations: e.violations ?? [],
|
|
189
|
+
});
|
|
190
|
+
}
|
|
164
191
|
if (e?.code === "PRECONDITION_REQUIRED") {
|
|
165
192
|
throw new McpError(ErrorCode.InvalidParams, message, {
|
|
166
193
|
error_type: "precondition_required",
|
package/src/mcp-tools.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
9
|
-
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
9
|
+
import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
|
|
10
10
|
import { getNoteTags } from "../core/src/notes.ts";
|
|
11
11
|
import type { Note } from "../core/src/types.ts";
|
|
12
12
|
import {
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "../core/src/vault-projection.ts";
|
|
17
17
|
import { readVaultConfig, writeVaultConfig } from "./config.ts";
|
|
18
18
|
import { getVaultStore } from "./vault-store.ts";
|
|
19
|
-
import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts";
|
|
19
|
+
import { hasScopeForVault, hasMigrateScopeForVault, parseScopes, validateMintedScopes, logStrictBypass } from "./scopes.ts";
|
|
20
20
|
import type { AuthResult } from "./auth.ts";
|
|
21
21
|
import {
|
|
22
22
|
expandTokenTagScope,
|
|
@@ -177,13 +177,25 @@ export function generateScopedMcpTools(
|
|
|
177
177
|
? { actor: auth.actor, via: auth.via === "operator" ? "operator" : "mcp" }
|
|
178
178
|
: undefined;
|
|
179
179
|
|
|
180
|
+
// Migration-bypass (vault#299): a `vault:migrate`-scoped MCP session skips
|
|
181
|
+
// strict-schema enforcement and logs every bypassed write. Orthogonal to
|
|
182
|
+
// read/write/admin — an admin token does NOT bypass unless it also holds
|
|
183
|
+
// `migrate`. `onStrictBypass` writes the same structured log line the REST
|
|
184
|
+
// path uses (the audit-log table, #300, is deferred).
|
|
185
|
+
const strictBypass = auth ? hasMigrateScopeForVault(auth.scopes, vaultName) : false;
|
|
186
|
+
const onStrictBypass: GenerateMcpToolsOpts["onStrictBypass"] = strictBypass
|
|
187
|
+
? (info) => logStrictBypass(info)
|
|
188
|
+
: undefined;
|
|
189
|
+
|
|
180
190
|
const tools = generateMcpTools(
|
|
181
191
|
store,
|
|
182
|
-
expandVisibility || nearTraversable || writeContext
|
|
192
|
+
expandVisibility || nearTraversable || writeContext || strictBypass
|
|
183
193
|
? {
|
|
184
194
|
...(expandVisibility ? { expandVisibility } : {}),
|
|
185
195
|
...(nearTraversable ? { nearTraversable } : {}),
|
|
186
196
|
...(writeContext ? { writeContext } : {}),
|
|
197
|
+
...(strictBypass ? { strictBypass } : {}),
|
|
198
|
+
...(onStrictBypass ? { onStrictBypass } : {}),
|
|
187
199
|
}
|
|
188
200
|
: undefined,
|
|
189
201
|
);
|
package/src/routes.ts
CHANGED
|
@@ -21,7 +21,9 @@ import {
|
|
|
21
21
|
contentRangeRequiresContent,
|
|
22
22
|
type ContentRange,
|
|
23
23
|
} from "../core/src/content-range.ts";
|
|
24
|
-
import { attachValidationStatus } from "../core/src/mcp.ts";
|
|
24
|
+
import { attachValidationStatus, enforceStrictWrite } from "../core/src/mcp.ts";
|
|
25
|
+
import type { ValidationWarning } from "../core/src/schema-defaults.ts";
|
|
26
|
+
import { logStrictBypass } from "./scopes.ts";
|
|
25
27
|
import * as linkOps from "../core/src/links.ts";
|
|
26
28
|
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
27
29
|
import {
|
|
@@ -51,9 +53,45 @@ const NO_TAG_SCOPE: TagScopeCtx = { allowed: null, raw: null };
|
|
|
51
53
|
* `store.createNote` / `store.updateNote`. Both null for paths without an auth
|
|
52
54
|
* context (the no-op default). See `WriteContext` in core/src/notes.ts.
|
|
53
55
|
*/
|
|
54
|
-
export type WriteCtx = {
|
|
56
|
+
export type WriteCtx = {
|
|
57
|
+
actor: string | null;
|
|
58
|
+
via: string | null;
|
|
59
|
+
/**
|
|
60
|
+
* Migration-bypass (vault#299). True when the caller holds `vault:migrate`
|
|
61
|
+
* — strict-schema enforcement is skipped and the bypass is logged. Defaults
|
|
62
|
+
* to false (full enforcement) for every normal write.
|
|
63
|
+
*/
|
|
64
|
+
bypassStrict?: boolean;
|
|
65
|
+
};
|
|
55
66
|
|
|
56
67
|
const NO_WRITE_CTX: WriteCtx = { actor: null, via: null };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run the shared strict-schema gate for a REST write (vault#299). Mirrors the
|
|
71
|
+
* MCP path's `enforceStrict` closure: enforce unless the caller holds the
|
|
72
|
+
* migration-bypass scope, and log every bypassed write to the daemon's
|
|
73
|
+
* structured log (the audit-log table, #300, is deferred). Throws
|
|
74
|
+
* `SchemaValidationError` (caught by the route's catch → 422) when not bypassed.
|
|
75
|
+
*/
|
|
76
|
+
function gateStrictWrite(
|
|
77
|
+
store: Store,
|
|
78
|
+
writeCtx: WriteCtx,
|
|
79
|
+
shape: { path?: string | null; tags?: string[]; metadata?: Record<string, unknown> },
|
|
80
|
+
): void {
|
|
81
|
+
enforceStrictWrite(store, shape, {
|
|
82
|
+
bypass: writeCtx.bypassStrict === true,
|
|
83
|
+
onBypass: (violations: ValidationWarning[]) => {
|
|
84
|
+
logStrictBypass({
|
|
85
|
+
actor: writeCtx.actor,
|
|
86
|
+
via: writeCtx.via,
|
|
87
|
+
path: shape.path ?? null,
|
|
88
|
+
tags: shape.tags,
|
|
89
|
+
violations,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
57
95
|
import {
|
|
58
96
|
expandContent,
|
|
59
97
|
DEFAULT_EXPAND_DEPTH,
|
|
@@ -1121,6 +1159,13 @@ async function handleNotesInner(
|
|
|
1121
1159
|
const extension = item.extension !== undefined
|
|
1122
1160
|
? validateExtension(item.extension)
|
|
1123
1161
|
: undefined;
|
|
1162
|
+
// Strict-schema gate (vault#299) — reject before any write so a
|
|
1163
|
+
// mid-batch violation rolls back via the outer BEGIN/ROLLBACK.
|
|
1164
|
+
gateStrictWrite(store, writeCtx, {
|
|
1165
|
+
path: item.path,
|
|
1166
|
+
tags: item.tags,
|
|
1167
|
+
metadata: item.metadata,
|
|
1168
|
+
});
|
|
1124
1169
|
const note = await store.createNote(item.content ?? "", {
|
|
1125
1170
|
id: item.id,
|
|
1126
1171
|
path: item.path,
|
|
@@ -1153,6 +1198,13 @@ async function handleNotesInner(
|
|
|
1153
1198
|
409,
|
|
1154
1199
|
);
|
|
1155
1200
|
}
|
|
1201
|
+
// Strict-schema rejection (vault#299 Part A) on create — 422.
|
|
1202
|
+
if (e && e.code === "SCHEMA_VALIDATION") {
|
|
1203
|
+
return json(
|
|
1204
|
+
{ error_type: "schema_validation", error: "schema_validation", violations: e.violations ?? [], message: e.message },
|
|
1205
|
+
422,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1156
1208
|
if (e && e.code === "INVALID_EXTENSION") {
|
|
1157
1209
|
return json(
|
|
1158
1210
|
{ error_type: "invalid_extension", error: "invalid_extension", extension: e.extension, reason: e.reason, message: e.message },
|
|
@@ -1410,6 +1462,12 @@ async function handleNotesInner(
|
|
|
1410
1462
|
via: writeCtx.via,
|
|
1411
1463
|
};
|
|
1412
1464
|
const content = (body.content as string | undefined) ?? "";
|
|
1465
|
+
// Strict-schema gate (vault#299) — if_missing:"create" is a create.
|
|
1466
|
+
gateStrictWrite(store, writeCtx, {
|
|
1467
|
+
path: createOpts.path ?? undefined,
|
|
1468
|
+
tags: createOpts.tags,
|
|
1469
|
+
metadata: createOpts.metadata,
|
|
1470
|
+
});
|
|
1413
1471
|
const created = await store.createNote(content, createOpts);
|
|
1414
1472
|
if (tagsArr.length > 0) {
|
|
1415
1473
|
await applySchemaDefaults(store, db, [created.id], tagsArr);
|
|
@@ -1499,7 +1557,19 @@ async function handleNotesInner(
|
|
|
1499
1557
|
&& body.createdAt === undefined
|
|
1500
1558
|
&& body.tags === undefined
|
|
1501
1559
|
&& body.links === undefined;
|
|
1502
|
-
|
|
1560
|
+
// A state_transition is itself a compare-and-set precondition (vault#299
|
|
1561
|
+
// Part B) — a transition-only PATCH needs no if_updated_at/force.
|
|
1562
|
+
const isTransitionOnly = body.state_transition !== undefined
|
|
1563
|
+
&& !hasContent
|
|
1564
|
+
&& !hasAppendPrepend
|
|
1565
|
+
&& !hasContentEdit
|
|
1566
|
+
&& body.path === undefined
|
|
1567
|
+
&& body.metadata === undefined
|
|
1568
|
+
&& body.created_at === undefined
|
|
1569
|
+
&& body.createdAt === undefined
|
|
1570
|
+
&& body.tags === undefined
|
|
1571
|
+
&& body.links === undefined;
|
|
1572
|
+
if (!isAppendOnly && !isTransitionOnly && body.if_updated_at === undefined && body.force !== true) {
|
|
1503
1573
|
return json(
|
|
1504
1574
|
{
|
|
1505
1575
|
error_type: "precondition_required",
|
|
@@ -1591,6 +1661,33 @@ async function handleNotesInner(
|
|
|
1591
1661
|
if (body.if_updated_at !== undefined) {
|
|
1592
1662
|
updates.if_updated_at = body.if_updated_at;
|
|
1593
1663
|
}
|
|
1664
|
+
// Compare-and-set state transition (vault#299 Part B). Combinable with
|
|
1665
|
+
// other field updates; folds into the same atomic UPDATE.
|
|
1666
|
+
const stBody = body.state_transition as { field?: unknown; from?: unknown; to?: unknown } | undefined;
|
|
1667
|
+
if (stBody !== undefined) {
|
|
1668
|
+
if (typeof stBody.field !== "string" || stBody.field.length === 0) {
|
|
1669
|
+
return json(
|
|
1670
|
+
{ error: "bad_request", message: "`state_transition.field` must be a non-empty string." },
|
|
1671
|
+
400,
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
updates.state_transition = { field: stBody.field, from: stBody.from, to: stBody.to };
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// --- Strict-schema gate (vault#299 Part A) ---
|
|
1678
|
+
// Validate the PROSPECTIVE shape (final tags + merged metadata, incl. a
|
|
1679
|
+
// state_transition's `to`) before the write so a rejection leaves the
|
|
1680
|
+
// note untouched. Throws SchemaValidationError → 422 in the catch.
|
|
1681
|
+
{
|
|
1682
|
+
const removeSet = new Set<string>((body.tags?.remove as string[] | undefined) ?? []);
|
|
1683
|
+
const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
|
|
1684
|
+
for (const t of (body.tags?.add as string[] | undefined) ?? []) projectedTags.add(t);
|
|
1685
|
+
const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
|
|
1686
|
+
const projectedMeta = stBody !== undefined
|
|
1687
|
+
? { ...baseMeta, [stBody.field as string]: stBody.to }
|
|
1688
|
+
: baseMeta;
|
|
1689
|
+
gateStrictWrite(store, writeCtx, { path: note.path, tags: [...projectedTags], metadata: projectedMeta });
|
|
1690
|
+
}
|
|
1594
1691
|
|
|
1595
1692
|
if (Object.keys(updates).length > 0) {
|
|
1596
1693
|
// Write-attribution (vault#298) — REST update. Stamp the most-recent-
|
|
@@ -1690,6 +1787,39 @@ async function handleNotesInner(
|
|
|
1690
1787
|
409,
|
|
1691
1788
|
);
|
|
1692
1789
|
}
|
|
1790
|
+
// State-transition compare-and-set conflict (vault#299 Part B). A
|
|
1791
|
+
// DISTINCT error vocabulary from `conflict` (settled lead #3): the
|
|
1792
|
+
// field VALUE didn't match `from`, not the updated_at token. 409.
|
|
1793
|
+
if (e && e.code === "TRANSITION_CONFLICT") {
|
|
1794
|
+
return json(
|
|
1795
|
+
{
|
|
1796
|
+
error_type: "transition_conflict",
|
|
1797
|
+
error: "transition_conflict",
|
|
1798
|
+
note_id: e.note_id,
|
|
1799
|
+
path: e.note_path ?? null,
|
|
1800
|
+
field: e.field,
|
|
1801
|
+
expected_from: e.expected_from,
|
|
1802
|
+
to: e.to,
|
|
1803
|
+
current: e.current ?? null,
|
|
1804
|
+
message: e.message,
|
|
1805
|
+
},
|
|
1806
|
+
409,
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
// Strict-schema rejection (vault#299 Part A). One error carrying ALL
|
|
1810
|
+
// per-field violations (settled lead #1). 422 Unprocessable Entity —
|
|
1811
|
+
// the note exists / request is well-formed but violates the contract.
|
|
1812
|
+
if (e && e.code === "SCHEMA_VALIDATION") {
|
|
1813
|
+
return json(
|
|
1814
|
+
{
|
|
1815
|
+
error_type: "schema_validation",
|
|
1816
|
+
error: "schema_validation",
|
|
1817
|
+
violations: e.violations ?? [],
|
|
1818
|
+
message: e.message,
|
|
1819
|
+
},
|
|
1820
|
+
422,
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1693
1823
|
// Path-rename collision — schema's UNIQUE(path) tripped. Issue #126.
|
|
1694
1824
|
if (e && e.code === "PATH_CONFLICT") {
|
|
1695
1825
|
return json(
|
package/src/routing.ts
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
authenticateGlobalRequest,
|
|
53
53
|
extractApiKey,
|
|
54
54
|
} from "./auth.ts";
|
|
55
|
-
import { hasScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
55
|
+
import { hasScopeForVault, hasMigrateScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
56
56
|
import { getVaultStore } from "./vault-store.ts";
|
|
57
57
|
import { handleScopedMcp } from "./mcp-http.ts";
|
|
58
58
|
import {
|
|
@@ -818,7 +818,15 @@ export async function route(
|
|
|
818
818
|
// `operator` for the env-var bearer). The REST surface IS the `api` channel,
|
|
819
819
|
// so no refinement is needed here — the base via stands (the MCP handler is
|
|
820
820
|
// the one that refines to `mcp`). Threaded only into the write handler.
|
|
821
|
-
|
|
821
|
+
// Migration-bypass (vault#299): a `vault:migrate`-scoped caller may write
|
|
822
|
+
// notes that violate `strict:true` field constraints (for backfill /
|
|
823
|
+
// migration). Every bypassed write is logged. Orthogonal to read/write/admin
|
|
824
|
+
// — an admin token does NOT bypass unless it also holds `migrate`.
|
|
825
|
+
const writeCtx: WriteCtx = {
|
|
826
|
+
actor: auth.actor,
|
|
827
|
+
via: auth.via,
|
|
828
|
+
bypassStrict: hasMigrateScopeForVault(auth.scopes, vaultName),
|
|
829
|
+
};
|
|
822
830
|
|
|
823
831
|
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
|
|
824
832
|
// Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
|