@openparachute/vault 0.6.4-rc.2 → 0.6.4-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -1
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +183 -4
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- 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/cli.ts +164 -3
- 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
package/src/cli.ts
CHANGED
|
@@ -3345,11 +3345,17 @@ async function cmdExport(args: string[]) {
|
|
|
3345
3345
|
*/
|
|
3346
3346
|
async function cmdSchema(args: string[] = []) {
|
|
3347
3347
|
const sub = args[0];
|
|
3348
|
+
if (sub === "migrate-field") {
|
|
3349
|
+
await cmdSchemaMigrateField(args.slice(1));
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3348
3352
|
if (sub !== "prune") {
|
|
3349
|
-
console.error("Usage: parachute-vault schema prune
|
|
3353
|
+
console.error("Usage: parachute-vault schema <prune|migrate-field> ...");
|
|
3350
3354
|
console.error("\nSubcommands:");
|
|
3351
|
-
console.error(" prune
|
|
3352
|
-
console.error("
|
|
3355
|
+
console.error(" prune Drop orphaned indexed-field columns whose declaring tags are gone.");
|
|
3356
|
+
console.error(" Dry-run by default (--dry-run is an explicit alias); pass --apply (or --yes) to execute.");
|
|
3357
|
+
console.error(" migrate-field Evolve a tag-schema field across existing notes (rename/remap/set-default/retype).");
|
|
3358
|
+
console.error(" Dry-run by default; pass --apply (or --yes) to write. See `schema migrate-field --help`.");
|
|
3353
3359
|
process.exit(1);
|
|
3354
3360
|
}
|
|
3355
3361
|
|
|
@@ -3416,6 +3422,154 @@ async function cmdSchema(args: string[] = []) {
|
|
|
3416
3422
|
}
|
|
3417
3423
|
}
|
|
3418
3424
|
|
|
3425
|
+
/**
|
|
3426
|
+
* `parachute-vault schema migrate-field <tag> <field> --transform <spec>` —
|
|
3427
|
+
* evolve a tag-schema field's type / values across the existing notes that
|
|
3428
|
+
* carry the tag (vault#314). Dry-run by default — prints the count + a sample
|
|
3429
|
+
* of what WOULD change and writes nothing; `--apply` (alias `--yes`) executes.
|
|
3430
|
+
*
|
|
3431
|
+
* Transform specs (the built-in set — issue #314):
|
|
3432
|
+
* rename:<newkey> move the field's value to <newkey>, drop the old key
|
|
3433
|
+
* remap:<old>=<new>[,…] rewrite enum-style values (repeatable / comma-sep)
|
|
3434
|
+
* set-default:<value> fill the field where missing/null
|
|
3435
|
+
* set-default-invalid:<v> set <v> where missing/null OR currently schema-invalid
|
|
3436
|
+
* to_string | to_int | to_number | to_boolean retype the value
|
|
3437
|
+
*
|
|
3438
|
+
* The migration writes through `strict` enforcement (the migrate-bypass path,
|
|
3439
|
+
* vault#299) so a freshly-declared strict field that the back-catalog violates
|
|
3440
|
+
* doesn't block the rewrite, and every bypassed write is logged. Rewrites are
|
|
3441
|
+
* attributed `migrate` (last_updated_*); created_* is preserved.
|
|
3442
|
+
*
|
|
3443
|
+
* Atomic by default: if any note's value can't be converted (e.g. `to_int` on
|
|
3444
|
+
* "banana"), the apply aborts BEFORE writing anything and lists the offenders.
|
|
3445
|
+
* Pass `--continue-on-error` for best-effort (write the convertible, skip the rest).
|
|
3446
|
+
*/
|
|
3447
|
+
async function cmdSchemaMigrateField(args: string[]) {
|
|
3448
|
+
const usageLine =
|
|
3449
|
+
"Usage: parachute-vault schema migrate-field <tag> <field> --transform <spec> [--vault <name>] [--apply|--yes] [--continue-on-error]";
|
|
3450
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
3451
|
+
console.log(usageLine);
|
|
3452
|
+
console.log("\nTransform specs:");
|
|
3453
|
+
console.log(" rename:<newkey> Move the field's value to <newkey>, drop the old key.");
|
|
3454
|
+
console.log(" remap:<old>=<new>[,...] Rewrite enum-style values (repeatable, comma-separated).");
|
|
3455
|
+
console.log(" set-default:<value> Set <value> on notes where the field is missing/null.");
|
|
3456
|
+
console.log(" set-default-invalid:<v> Set <v> where missing/null OR the current value is schema-invalid.");
|
|
3457
|
+
console.log(" to_string | to_int | to_number | to_boolean Retype the field's value.");
|
|
3458
|
+
console.log("\nDry-run by default — prints the plan. Pass --apply (or --yes) to write.");
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
let vaultName = "default";
|
|
3463
|
+
let apply = false;
|
|
3464
|
+
let continueOnError = false;
|
|
3465
|
+
let transformSpec: string | undefined;
|
|
3466
|
+
const positionals: string[] = [];
|
|
3467
|
+
for (let i = 0; i < args.length; i++) {
|
|
3468
|
+
const arg = args[i]!;
|
|
3469
|
+
if (arg === "--vault") {
|
|
3470
|
+
const v = args[++i];
|
|
3471
|
+
if (!v) { console.error("--vault requires a value."); process.exit(1); }
|
|
3472
|
+
vaultName = v;
|
|
3473
|
+
} else if (arg === "--transform") {
|
|
3474
|
+
const v = args[++i];
|
|
3475
|
+
if (!v) { console.error("--transform requires a value."); process.exit(1); }
|
|
3476
|
+
transformSpec = v;
|
|
3477
|
+
} else if (arg === "--apply" || arg === "--yes") {
|
|
3478
|
+
apply = true;
|
|
3479
|
+
} else if (arg === "--dry-run") {
|
|
3480
|
+
// default; accept as an explicit affirmative
|
|
3481
|
+
} else if (arg === "--continue-on-error") {
|
|
3482
|
+
continueOnError = true;
|
|
3483
|
+
} else if (arg.startsWith("--")) {
|
|
3484
|
+
console.error(`Unknown flag for \`schema migrate-field\`: ${arg}`);
|
|
3485
|
+
process.exit(1);
|
|
3486
|
+
} else {
|
|
3487
|
+
positionals.push(arg);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
const [tag, field] = positionals;
|
|
3492
|
+
if (!tag || !field || !transformSpec) {
|
|
3493
|
+
console.error(usageLine);
|
|
3494
|
+
console.error("\n <tag> <field> and --transform <spec> are all required.");
|
|
3495
|
+
console.error(" Run `schema migrate-field --help` for the transform spec list.");
|
|
3496
|
+
process.exit(1);
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
const { parseTransformSpec } = await import("../core/src/migrate-tag-field.ts");
|
|
3500
|
+
let transform;
|
|
3501
|
+
try {
|
|
3502
|
+
transform = parseTransformSpec(transformSpec);
|
|
3503
|
+
} catch (err) {
|
|
3504
|
+
console.error(`Invalid --transform "${transformSpec}": ${(err as Error).message}`);
|
|
3505
|
+
process.exit(1);
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
const config = readVaultConfig(vaultName);
|
|
3509
|
+
if (!config) {
|
|
3510
|
+
console.error(`Vault "${vaultName}" not found.`);
|
|
3511
|
+
process.exit(1);
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
const { getVaultStore } = await import("./vault-store.ts");
|
|
3515
|
+
const { migrateTagField } = await import("../core/src/migrate-tag-field.ts");
|
|
3516
|
+
const { logStrictBypass } = await import("./scopes.ts");
|
|
3517
|
+
const store = getVaultStore(vaultName);
|
|
3518
|
+
|
|
3519
|
+
const result = await migrateTagField(store, {
|
|
3520
|
+
tag,
|
|
3521
|
+
field,
|
|
3522
|
+
transform: transform!,
|
|
3523
|
+
apply,
|
|
3524
|
+
continueOnError,
|
|
3525
|
+
// The CLI is the operator path — it bypasses strict enforcement by nature
|
|
3526
|
+
// (writes go through the store, not the scope-gated transport). Log every
|
|
3527
|
+
// waived violation so the bypass is auditable, exactly as MW2 intends.
|
|
3528
|
+
onStrictBypass: (info) => logStrictBypass(info),
|
|
3529
|
+
});
|
|
3530
|
+
|
|
3531
|
+
const verb = result.applied ? "Migrated" : "Would migrate";
|
|
3532
|
+
console.log(
|
|
3533
|
+
`${verb} field "${result.field}" on #${result.tag} in vault "${vaultName}"${result.applied ? "" : " (dry-run)"}:`,
|
|
3534
|
+
);
|
|
3535
|
+
console.log(
|
|
3536
|
+
` scanned ${result.scanned} · ${result.applied ? "changed" : "would change"} ${result.changed} · unchanged ${result.unchanged} · errored ${result.errored}`,
|
|
3537
|
+
);
|
|
3538
|
+
|
|
3539
|
+
if (result.sample.length > 0) {
|
|
3540
|
+
console.log(`\n ${result.applied ? "Changes" : "Sample of changes"} (${result.sample.length}${result.changed > result.sample.length ? ` of ${result.changed}` : ""}):`);
|
|
3541
|
+
for (const c of result.sample) {
|
|
3542
|
+
const ref = c.path ?? c.id;
|
|
3543
|
+
if (c.renamedTo) {
|
|
3544
|
+
console.log(` ${ref}: ${field} → ${c.renamedTo} = ${JSON.stringify(c.after)}`);
|
|
3545
|
+
} else {
|
|
3546
|
+
console.log(` ${ref}: ${JSON.stringify(c.before)} → ${JSON.stringify(c.after)}`);
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
if (result.errors.length > 0) {
|
|
3552
|
+
console.log(`\n Unconvertible (${result.errors.length}):`);
|
|
3553
|
+
for (const e of result.errors) {
|
|
3554
|
+
console.log(` ${e.path ?? e.id}: ${e.error}`);
|
|
3555
|
+
}
|
|
3556
|
+
if (apply && !result.applied) {
|
|
3557
|
+
console.log(
|
|
3558
|
+
"\n Aborted without writing — some notes can't be converted (atomic by default).",
|
|
3559
|
+
);
|
|
3560
|
+
console.log(" Fix the data, or re-run with --continue-on-error to migrate the rest.");
|
|
3561
|
+
process.exit(1);
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
if (!apply && result.changed > 0) {
|
|
3566
|
+
console.log("\n Re-run with --apply (or --yes) to write these changes.");
|
|
3567
|
+
}
|
|
3568
|
+
if (result.changed === 0 && result.errored === 0) {
|
|
3569
|
+
console.log("\n Nothing to migrate — the back-catalog already conforms.");
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3419
3573
|
// ---------------------------------------------------------------------------
|
|
3420
3574
|
// Export-watch glue. The git-shell + commit-message logic lives in
|
|
3421
3575
|
// `./export-watch.ts` for unit-testability; cli.ts just wires it in.
|
|
@@ -3961,6 +4115,13 @@ Schema maintenance:
|
|
|
3961
4115
|
parachute-vault schema prune --apply Execute the prune (alias: --yes). Co-declared
|
|
3962
4116
|
fields keep their column; a drop loses only
|
|
3963
4117
|
the index (data lives in notes.metadata).
|
|
4118
|
+
parachute-vault schema migrate-field <tag> <field> Evolve a tag-schema field across existing
|
|
4119
|
+
--transform <spec> notes (rename:<key>, remap:<old>=<new>,
|
|
4120
|
+
set-default:<v>, to_string/to_int/to_number/
|
|
4121
|
+
to_boolean). Dry-run by default — prints the
|
|
4122
|
+
plan; pass --apply (or --yes) to write.
|
|
4123
|
+
Writes through strict enforcement (migrate
|
|
4124
|
+
bypass). See \`schema migrate-field --help\`.
|
|
3964
4125
|
|
|
3965
4126
|
── Advanced / standalone ──────────────────────────────────────────────
|
|
3966
4127
|
|
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
|
package/src/scopes.test.ts
CHANGED
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
SCOPE_READ,
|
|
10
10
|
SCOPE_WRITE,
|
|
11
11
|
SCOPE_ADMIN,
|
|
12
|
+
SCOPE_MIGRATE,
|
|
12
13
|
parseScopes,
|
|
13
14
|
parseScopeFlags,
|
|
14
15
|
hasScope,
|
|
15
16
|
hasScopeForVault,
|
|
17
|
+
hasMigrateScopeForVault,
|
|
16
18
|
findBroadVaultScopes,
|
|
17
19
|
scopeForMethod,
|
|
18
20
|
verbForMethod,
|
|
@@ -20,6 +22,28 @@ import {
|
|
|
20
22
|
serializeScopes,
|
|
21
23
|
} from "./scopes.ts";
|
|
22
24
|
|
|
25
|
+
describe("hasMigrateScopeForVault — migration-bypass capability (vault#299)", () => {
|
|
26
|
+
test("broad vault:migrate satisfies any vault", () => {
|
|
27
|
+
expect(hasMigrateScopeForVault([SCOPE_MIGRATE], "default")).toBe(true);
|
|
28
|
+
expect(hasMigrateScopeForVault([SCOPE_MIGRATE], "anything")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("narrowed vault:<name>:migrate satisfies only that vault", () => {
|
|
32
|
+
expect(hasMigrateScopeForVault(["vault:gitcoin:migrate"], "gitcoin")).toBe(true);
|
|
33
|
+
expect(hasMigrateScopeForVault(["vault:gitcoin:migrate"], "default")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("admin does NOT imply migrate — orthogonal axis", () => {
|
|
37
|
+
expect(hasMigrateScopeForVault([SCOPE_ADMIN], "default")).toBe(false);
|
|
38
|
+
expect(hasMigrateScopeForVault(["vault:default:admin"], "default")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("no migrate scope → false", () => {
|
|
42
|
+
expect(hasMigrateScopeForVault([SCOPE_WRITE, SCOPE_READ], "default")).toBe(false);
|
|
43
|
+
expect(hasMigrateScopeForVault([], "default")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
23
47
|
describe("parseScopes", () => {
|
|
24
48
|
test("returns [] for null or empty input", () => {
|
|
25
49
|
expect(parseScopes(null)).toEqual([]);
|
package/src/scopes.ts
CHANGED
|
@@ -28,6 +28,19 @@ export const SCOPE_READ = "vault:read" as const;
|
|
|
28
28
|
export const SCOPE_WRITE = "vault:write" as const;
|
|
29
29
|
export const SCOPE_ADMIN = "vault:admin" as const;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Migration-bypass scope (vault#299). A SEPARATE capability axis — NOT part
|
|
33
|
+
* of the read ⊇ write ⊇ admin inheritance chain. A token holding
|
|
34
|
+
* `vault:migrate` (broad) or `vault:<name>:migrate` (narrowed) may skip
|
|
35
|
+
* `strict:true` schema enforcement so existing non-conforming notes can be
|
|
36
|
+
* migrated/backfilled. It is deliberately orthogonal: an `admin` token does
|
|
37
|
+
* NOT auto-bypass strict validation — bypass must be an explicit, audited
|
|
38
|
+
* grant. A bypass write still needs `write` to actually mutate (migrate is an
|
|
39
|
+
* ADD-ON flag, not a write grant on its own). Every bypassed write is logged
|
|
40
|
+
* (see the write path) since #300 (the audit-log table) is deferred.
|
|
41
|
+
*/
|
|
42
|
+
export const SCOPE_MIGRATE = "vault:migrate" as const;
|
|
43
|
+
|
|
31
44
|
/** All first-class vault scopes in inheritance order (lowest → highest). */
|
|
32
45
|
export const VAULT_SCOPES = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN] as const;
|
|
33
46
|
export type VaultScope = (typeof VAULT_SCOPES)[number];
|
|
@@ -137,6 +150,56 @@ export function hasScopeForVault(
|
|
|
137
150
|
return false;
|
|
138
151
|
}
|
|
139
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Migration-bypass check (vault#299): does `granted` hold the `migrate`
|
|
155
|
+
* capability for `vaultName`? Accepts broad `vault:migrate` (any vault) or
|
|
156
|
+
* narrowed `vault:<name>:migrate` (this vault only). Orthogonal to the
|
|
157
|
+
* read/write/admin verbs — a plain admin token returns `false` here, by
|
|
158
|
+
* design. The migrate scope does NOT live in `decomposeVaultScope` (it isn't a
|
|
159
|
+
* read/write/admin verb), so it's matched by exact shape here.
|
|
160
|
+
*/
|
|
161
|
+
export function hasMigrateScopeForVault(granted: string[], vaultName: string): boolean {
|
|
162
|
+
for (const s of granted) {
|
|
163
|
+
if (s === SCOPE_MIGRATE) return true; // broad — any vault
|
|
164
|
+
if (s === `vault:${vaultName}:migrate`) return true; // narrowed — this vault
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Structured log line for a migration-bypassed write (vault#299 settled lead
|
|
171
|
+
* #2). Records WHO bypassed (actor/via from MW1 attribution), WHAT note, and
|
|
172
|
+
* WHICH strict violations were waived — enough to reconstruct the bypass for
|
|
173
|
+
* later audit without the #300 audit-log table (deferred). One JSON line so
|
|
174
|
+
* it's grep/jq-able in the daemon log. Lives here (next to the migrate-scope
|
|
175
|
+
* check) so both write transports — REST (routes.ts) and MCP (mcp-tools.ts) —
|
|
176
|
+
* emit the identical line without importing each other.
|
|
177
|
+
*/
|
|
178
|
+
export function logStrictBypass(info: {
|
|
179
|
+
actor: string | null;
|
|
180
|
+
via: string | null;
|
|
181
|
+
path?: string | null;
|
|
182
|
+
tags?: string[];
|
|
183
|
+
violations: { field: string; reason: string; schema: string }[];
|
|
184
|
+
}): void {
|
|
185
|
+
console.warn(
|
|
186
|
+
"[schema-bypass] " +
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
event: "strict_schema_bypass",
|
|
189
|
+
actor: info.actor,
|
|
190
|
+
via: info.via,
|
|
191
|
+
path: info.path ?? null,
|
|
192
|
+
tags: info.tags ?? [],
|
|
193
|
+
violations: info.violations.map((v) => ({
|
|
194
|
+
field: v.field,
|
|
195
|
+
reason: v.reason,
|
|
196
|
+
schema: v.schema,
|
|
197
|
+
})),
|
|
198
|
+
at: new Date().toISOString(),
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
140
203
|
/**
|
|
141
204
|
* Pick the required scope for a given API request.
|
|
142
205
|
* - GET/HEAD/OPTIONS → read
|
package/src/triggers-api.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type TriggerInput,
|
|
31
31
|
} from "../core/src/triggers-store.ts";
|
|
32
32
|
import { registerVaultTrigger } from "./triggers.ts";
|
|
33
|
+
import { SUPPORTED_OPS } from "../core/src/query-operators.ts";
|
|
33
34
|
import { hasScopeForVault, SCOPE_ADMIN } from "./scopes.ts";
|
|
34
35
|
import type { AuthResult } from "./auth.ts";
|
|
35
36
|
|
|
@@ -135,6 +136,29 @@ function validateInput(body: unknown):
|
|
|
135
136
|
if (typeof b.when !== "object" || b.when === null || Array.isArray(b.when)) {
|
|
136
137
|
return { ok: false, message: "`when` is required and must be an object" };
|
|
137
138
|
}
|
|
139
|
+
// Value-matched metadata predicate (vault#299 Part B): when present, each
|
|
140
|
+
// field must map to an operator-object whose keys are all supported
|
|
141
|
+
// operators. Fail at registration so a typo'd operator (`{ state: { equals:
|
|
142
|
+
// ... } }`) is a 400, not a silently-never-firing trigger.
|
|
143
|
+
const whenMeta = (b.when as Record<string, unknown>).metadata;
|
|
144
|
+
if (whenMeta !== undefined) {
|
|
145
|
+
if (typeof whenMeta !== "object" || whenMeta === null || Array.isArray(whenMeta)) {
|
|
146
|
+
return { ok: false, message: "`when.metadata` must be an object mapping field → operator-object" };
|
|
147
|
+
}
|
|
148
|
+
for (const [field, opObj] of Object.entries(whenMeta as Record<string, unknown>)) {
|
|
149
|
+
if (typeof opObj !== "object" || opObj === null || Array.isArray(opObj)) {
|
|
150
|
+
return { ok: false, message: `\`when.metadata.${field}\` must be an operator-object, e.g. { eq: "published" }` };
|
|
151
|
+
}
|
|
152
|
+
for (const op of Object.keys(opObj as Record<string, unknown>)) {
|
|
153
|
+
if (!SUPPORTED_OPS.includes(op as (typeof SUPPORTED_OPS)[number])) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
message: `\`when.metadata.${field}\` has unknown operator "${op}". Supported: ${SUPPORTED_OPS.join(", ")}.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
138
162
|
|
|
139
163
|
if (typeof b.action !== "object" || b.action === null || Array.isArray(b.action)) {
|
|
140
164
|
return { ok: false, message: "`action` is required and must be an object" };
|