@vxil/feature-configs 0.1.0

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/src/hooks.ts ADDED
@@ -0,0 +1,830 @@
1
+ // CMS lifecycle hooks — the SAFE expression engine (docs/cms-lifecycle-hooks-rung2-design.md, Lane A).
2
+ //
3
+ // A "code-based" lifecycle hook is a small EXPRESSION the tenant authors. Three kinds:
4
+ // - validate: the expression must evaluate truthy or the write is REJECTED (422)
5
+ // — or, on `beforeRead`, the ROW IS DROPPED from the response
6
+ // (a per-row visibility filter; never an error).
7
+ // - derive: the expression's value is written to a target field on the item.
8
+ // On `afterRead` the value is set on the OUTPUT COPY only — read
9
+ // derives are output shaping and are never persisted.
10
+ // - redact: (`afterRead` only) the target field is DELETED from the output
11
+ // copy when the expression is truthy.
12
+ // Write hooks run IN-TRANSACTION on the cms write path (before INSERT/UPDATE),
13
+ // so a validate failure rolls the whole write back atomically. Read hooks run
14
+ // per row over the PROJECTED page (computed fields + $expand results visible)
15
+ // under ONE shared eval-step budget for the whole request (see runReadHooks).
16
+ //
17
+ // CROSS-ROW AGGREGATION STAYS OUT — BY DESIGN. Read hooks are a pure function
18
+ // of ONE row (+ `now`): there are no new root variables, no array/aggregate
19
+ // functions, no access to sibling rows or other collections. Counts/sums/
20
+ // group-bys across rows are the §5 identity-fork anti-item (docs/features/
21
+ // cms.md §6.7) and must never enter this engine.
22
+ //
23
+ // SAFETY (the "not malicious" guarantee) is structural, not heuristic:
24
+ // * No JS is executed. `eval`/`Function`/the host runtime are never touched. The
25
+ // tenant's source is tokenized → parsed into a closed AST → walked by an
26
+ // interpreter that ONLY supports the node types and built-in functions below.
27
+ // * No I/O, no network, no DB, no host objects, no `this`, no globals — the
28
+ // grammar has no syntax for any of them.
29
+ // * No loops, no recursion, no user-defined functions, no assignment — the
30
+ // grammar has none, so every expression is total and terminates.
31
+ // * Deterministic: the only "ambient" input is `now`, injected as a fixed ISO
32
+ // string; there is no Date.now()/Math.random() reachable.
33
+ // * Prototype-pollution proof: member access reads OWN enumerable properties of
34
+ // plain objects only; `__proto__`/`constructor`/`prototype` are rejected at
35
+ // PARSE time and never traversed at eval time.
36
+ // * Resource-bounded (defense in depth on top of the closed grammar): source
37
+ // length, token count, AST node count, AST depth, eval step budget, string
38
+ // result length, and numeric magnitude are all capped. ReDoS is impossible —
39
+ // the engine never compiles a tenant regex.
40
+ // Validation runs at CONFIG-WRITE time (validateHookDef, called from
41
+ // validateFeatureConfig) so a malicious/oversized/ill-typed hook is rejected
42
+ // BEFORE it can be published to KV/runtime; the runtime budgets are the second line.
43
+
44
+ // ── limits ──────────────────────────────────────────────────────────────────
45
+ export const HOOK_LIMITS = {
46
+ maxSourceLen: 2000,
47
+ maxTokens: 600,
48
+ maxNodes: 250,
49
+ maxDepth: 40,
50
+ maxEvalSteps: 5000,
51
+ maxStringLen: 4096,
52
+ maxNumberMagnitude: 1e15,
53
+ // READ-path bounds (runReadHooks). The list path evaluates hooks PER ROW over
54
+ // a query page (≤500 rows), so the per-request cost is bounded by ONE shared
55
+ // eval-step budget across the whole page — not per expression. Typical hooks
56
+ // cost tens of steps/row, so 250k covers a max page with the full 10-hook
57
+ // complement many times over; a pathological page (maximal expressions ×
58
+ // maximal rows × maximal hooks) exhausts it and the request fails a clean
59
+ // 422 hook_error rather than pinning the isolate's CPU.
60
+ maxReadEvalStepsPerRequest: 250_000,
61
+ maxReadHooksPerCollection: 10,
62
+ } as const;
63
+
64
+ // ── allow-lists ─────────────────────────────────────────────────────────────
65
+ /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
66
+ * end-user principal (docs/end-user-principals-design.md §5.6) — read-only and
67
+ * populated ONLY on the READ path (runReadHooks); on the write path and in
68
+ * server-caller mode it is null, exactly like `before` on a create. It carries
69
+ * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
70
+ * ('end_user' | 'tenant'). It is the missing "caller/session context" a read
71
+ * hook keys on to redact/derive per-viewer. */
72
+ export const HOOK_ROOT_VARS = ['item', 'before', 'now', 'caller'] as const;
73
+ /** The ONLY callable functions. Each is pure + deterministic + bounded. */
74
+ export const HOOK_FUNCTIONS = [
75
+ // math
76
+ 'min', 'max', 'abs', 'round', 'floor', 'ceil', 'sqrt', 'pow', 'sign',
77
+ // string
78
+ 'len', 'lower', 'upper', 'trim', 'substr', 'contains', 'startsWith', 'endsWith', 'concat',
79
+ // logic / null
80
+ 'coalesce', 'ifNull', 'not', 'isNull',
81
+ // convert
82
+ 'number', 'string', 'bool',
83
+ // date (operate on ISO strings; deterministic)
84
+ 'daysBetween', 'yearsBetween',
85
+ ] as const;
86
+ const FN_SET = new Set<string>(HOOK_FUNCTIONS);
87
+ const ROOT_SET = new Set<string>(HOOK_ROOT_VARS);
88
+ const FORBIDDEN_PROPS = new Set(['__proto__', 'constructor', 'prototype']);
89
+ const PROP_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
90
+
91
+ // ── AST ─────────────────────────────────────────────────────────────────────
92
+ export type Node =
93
+ | { t: 'num'; v: number }
94
+ | { t: 'str'; v: string }
95
+ | { t: 'bool'; v: boolean }
96
+ | { t: 'null' }
97
+ | { t: 'var'; name: string }
98
+ | { t: 'member'; obj: Node; prop: string }
99
+ | { t: 'unary'; op: '!' | '-'; arg: Node }
100
+ | { t: 'bin'; op: BinOp; l: Node; r: Node }
101
+ | { t: 'tern'; c: Node; a: Node; b: Node }
102
+ | { t: 'call'; fn: string; args: Node[] };
103
+
104
+ type BinOp = '||' | '&&' | '==' | '!=' | '<' | '<=' | '>' | '>=' | '+' | '-' | '*' | '/' | '%';
105
+
106
+ export class HookParseError extends Error {}
107
+ export class HookEvalError extends Error {}
108
+ /** Thrown by a `validate` hook whose expression is falsy — maps to a clean 422. */
109
+ export class HookRejection extends Error {}
110
+
111
+ // ── tokenizer ───────────────────────────────────────────────────────────────
112
+ type Tok =
113
+ | { k: 'num'; v: number }
114
+ | { k: 'str'; v: string }
115
+ | { k: 'name'; v: string }
116
+ | { k: 'op'; v: string }
117
+ | { k: 'eof' };
118
+
119
+ const PUNCT = ['||', '&&', '==', '!=', '<=', '>=', '<', '>', '+', '-', '*', '/', '%', '!', '(', ')', ',', '.', '?', ':'];
120
+
121
+ function tokenize(src: string): Tok[] {
122
+ if (src.length > HOOK_LIMITS.maxSourceLen) {
123
+ throw new HookParseError(`expression exceeds ${HOOK_LIMITS.maxSourceLen} chars`);
124
+ }
125
+ const toks: Tok[] = [];
126
+ let i = 0;
127
+ const n = src.length;
128
+ while (i < n) {
129
+ const c = src[i]!;
130
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
131
+ // string literal — single or double quotes, backslash escapes for \ ' " n t
132
+ if (c === '"' || c === "'") {
133
+ const quote = c;
134
+ let s = '';
135
+ i++;
136
+ while (i < n && src[i] !== quote) {
137
+ let ch = src[i]!;
138
+ if (ch === '\\') {
139
+ const e = src[i + 1];
140
+ if (e === 'n') ch = '\n';
141
+ else if (e === 't') ch = '\t';
142
+ else if (e === '\\' || e === '"' || e === "'") ch = e;
143
+ else throw new HookParseError(`bad string escape \\${e ?? ''}`);
144
+ i += 2;
145
+ } else { i++; }
146
+ s += ch;
147
+ if (s.length > HOOK_LIMITS.maxStringLen) throw new HookParseError('string literal too long');
148
+ }
149
+ if (i >= n) throw new HookParseError('unterminated string');
150
+ i++; // closing quote
151
+ toks.push({ k: 'str', v: s });
152
+ } else if (c >= '0' && c <= '9') {
153
+ let j = i;
154
+ while (j < n && /[0-9]/.test(src[j]!)) j++;
155
+ if (src[j] === '.') { j++; while (j < n && /[0-9]/.test(src[j]!)) j++; }
156
+ const num = Number(src.slice(i, j));
157
+ if (!Number.isFinite(num)) throw new HookParseError(`bad number near ${src.slice(i, j)}`);
158
+ toks.push({ k: 'num', v: num });
159
+ i = j;
160
+ } else if (/[A-Za-z_]/.test(c)) {
161
+ let j = i;
162
+ while (j < n && /[A-Za-z0-9_]/.test(src[j]!)) j++;
163
+ toks.push({ k: 'name', v: src.slice(i, j) });
164
+ i = j;
165
+ } else {
166
+ const two = src.slice(i, i + 2);
167
+ const one = c;
168
+ const m = PUNCT.includes(two) ? two : PUNCT.includes(one) ? one : null;
169
+ if (!m) throw new HookParseError(`unexpected character '${c}'`);
170
+ toks.push({ k: 'op', v: m });
171
+ i += m.length;
172
+ }
173
+ if (toks.length > HOOK_LIMITS.maxTokens) throw new HookParseError('expression too long (token cap)');
174
+ }
175
+ toks.push({ k: 'eof' });
176
+ return toks;
177
+ }
178
+
179
+ // ── parser (Pratt) ──────────────────────────────────────────────────────────
180
+ // precedence: ternary < || < && < equality < comparison < add < mul < unary < postfix(member/call)
181
+ const BIN_PREC: Record<string, number> = {
182
+ '||': 1, '&&': 2, '==': 3, '!=': 3, '<': 4, '<=': 4, '>': 4, '>=': 4,
183
+ '+': 5, '-': 5, '*': 6, '/': 6, '%': 6,
184
+ };
185
+
186
+ class Parser {
187
+ private p = 0;
188
+ private nodes = 0;
189
+ constructor(private readonly toks: Tok[]) {}
190
+
191
+ private peek(): Tok { return this.toks[this.p]!; }
192
+ private next(): Tok { return this.toks[this.p++]!; }
193
+ private isOp(v: string): boolean { const t = this.peek(); return t.k === 'op' && t.v === v; }
194
+ private eatOp(v: string): void {
195
+ if (!this.isOp(v)) throw new HookParseError(`expected '${v}'`);
196
+ this.p++;
197
+ }
198
+ private node<T extends Node>(nd: T): T {
199
+ if (++this.nodes > HOOK_LIMITS.maxNodes) throw new HookParseError('expression too complex (node cap)');
200
+ return nd;
201
+ }
202
+
203
+ parse(): Node {
204
+ const e = this.ternary();
205
+ if (this.peek().k !== 'eof') throw new HookParseError('trailing tokens after expression');
206
+ return e;
207
+ }
208
+
209
+ private ternary(): Node {
210
+ const c = this.binary(0);
211
+ if (this.isOp('?')) {
212
+ this.next();
213
+ const a = this.ternary();
214
+ this.eatOp(':');
215
+ const b = this.ternary();
216
+ return this.node({ t: 'tern', c, a, b });
217
+ }
218
+ return c;
219
+ }
220
+
221
+ private binary(minPrec: number): Node {
222
+ let left = this.unary();
223
+ for (;;) {
224
+ const t = this.peek();
225
+ if (t.k !== 'op') break;
226
+ const prec = BIN_PREC[t.v];
227
+ if (prec === undefined || prec < minPrec) break;
228
+ this.next();
229
+ const right = this.binary(prec + 1); // left-assoc
230
+ left = this.node({ t: 'bin', op: t.v as BinOp, l: left, r: right });
231
+ }
232
+ return left;
233
+ }
234
+
235
+ private unary(): Node {
236
+ if (this.isOp('!')) { this.next(); return this.node({ t: 'unary', op: '!', arg: this.unary() }); }
237
+ if (this.isOp('-')) { this.next(); return this.node({ t: 'unary', op: '-', arg: this.unary() }); }
238
+ return this.postfix();
239
+ }
240
+
241
+ private postfix(): Node {
242
+ let e = this.primary();
243
+ for (;;) {
244
+ if (this.isOp('.')) {
245
+ this.next();
246
+ const t = this.next();
247
+ if (t.k !== 'name') throw new HookParseError('expected property name after .');
248
+ if (!PROP_RE.test(t.v) || FORBIDDEN_PROPS.has(t.v)) {
249
+ throw new HookParseError(`forbidden property '${t.v}'`);
250
+ }
251
+ e = this.node({ t: 'member', obj: e, prop: t.v });
252
+ } else break;
253
+ }
254
+ return e;
255
+ }
256
+
257
+ private primary(): Node {
258
+ const t = this.next();
259
+ if (t.k === 'num') return this.node({ t: 'num', v: t.v });
260
+ if (t.k === 'str') return this.node({ t: 'str', v: t.v });
261
+ if (t.k === 'op' && t.v === '(') {
262
+ const e = this.ternary();
263
+ this.eatOp(')');
264
+ return e;
265
+ }
266
+ if (t.k === 'name') {
267
+ if (t.v === 'true') return this.node({ t: 'bool', v: true });
268
+ if (t.v === 'false') return this.node({ t: 'bool', v: false });
269
+ if (t.v === 'null') return this.node({ t: 'null' });
270
+ // function call?
271
+ if (this.isOp('(')) {
272
+ this.next();
273
+ const args: Node[] = [];
274
+ if (!this.isOp(')')) {
275
+ args.push(this.ternary());
276
+ while (this.isOp(',')) { this.next(); args.push(this.ternary()); }
277
+ }
278
+ this.eatOp(')');
279
+ return this.node({ t: 'call', fn: t.v, args });
280
+ }
281
+ return this.node({ t: 'var', name: t.v });
282
+ }
283
+ throw new HookParseError('unexpected token');
284
+ }
285
+ }
286
+
287
+ /** Parse a hook expression into an AST. Throws HookParseError on any malformed input. */
288
+ export function parseExpr(src: string): Node {
289
+ return new Parser(tokenize(src)).parse();
290
+ }
291
+
292
+ // ── AST validator (the closed-allow-list "not malicious" gate) ──────────────
293
+ /** Walk the AST and reject anything outside the allow-list, plus depth bounds.
294
+ * Returns an array of human-readable errors (empty = safe). Pure, no eval. */
295
+ export function validateAst(root: Node): string[] {
296
+ const errors: string[] = [];
297
+ const walk = (nd: Node, depth: number): void => {
298
+ if (depth > HOOK_LIMITS.maxDepth) { errors.push('expression nests too deeply'); return; }
299
+ switch (nd.t) {
300
+ case 'num':
301
+ if (!Number.isFinite(nd.v) || Math.abs(nd.v) > HOOK_LIMITS.maxNumberMagnitude) errors.push('numeric literal out of range');
302
+ return;
303
+ case 'str':
304
+ if (nd.v.length > HOOK_LIMITS.maxStringLen) errors.push('string literal too long');
305
+ return;
306
+ case 'bool': case 'null': return;
307
+ case 'var':
308
+ if (!ROOT_SET.has(nd.name)) errors.push(`unknown variable '${nd.name}' (allowed: ${HOOK_ROOT_VARS.join(', ')})`);
309
+ return;
310
+ case 'member':
311
+ if (FORBIDDEN_PROPS.has(nd.prop) || !PROP_RE.test(nd.prop)) errors.push(`forbidden property '${nd.prop}'`);
312
+ walk(nd.obj, depth + 1);
313
+ return;
314
+ case 'unary': walk(nd.arg, depth + 1); return;
315
+ case 'bin': walk(nd.l, depth + 1); walk(nd.r, depth + 1); return;
316
+ case 'tern': walk(nd.c, depth + 1); walk(nd.a, depth + 1); walk(nd.b, depth + 1); return;
317
+ case 'call':
318
+ if (!FN_SET.has(nd.fn)) errors.push(`unknown function '${nd.fn}'`);
319
+ if (nd.args.length > 16) errors.push(`function '${nd.fn}' has too many arguments`);
320
+ for (const a of nd.args) walk(a, depth + 1);
321
+ return;
322
+ default: {
323
+ // exhaustiveness guard: an unknown node type is a bug/forbidden shape.
324
+ errors.push('unsupported expression node');
325
+ }
326
+ }
327
+ };
328
+ walk(root, 0);
329
+ return errors;
330
+ }
331
+
332
+ // ── evaluator (bounded, deterministic interpreter) ──────────────────────────
333
+ /** The VERIFIED caller/session context for read hooks (design §5.6). Read-only;
334
+ * present only on the read path in end-user mode. In server mode / write path
335
+ * the whole object is null. */
336
+ export interface HookCaller {
337
+ /** the verified end-user session sub, or null in server-caller mode. */
338
+ endUserId: string | null;
339
+ /** 'end_user' when a session was verified at the edge, else 'tenant'. */
340
+ principal: 'end_user' | 'tenant';
341
+ }
342
+
343
+ export interface HookContext {
344
+ item: Record<string, unknown>;
345
+ before: Record<string, unknown> | null;
346
+ now: string; // injected ISO timestamp — the only ambient input
347
+ /** verified caller/session context (read path only); null otherwise. */
348
+ caller?: HookCaller | null;
349
+ }
350
+
351
+ function num(x: unknown): number {
352
+ const v = typeof x === 'number' ? x : typeof x === 'string' || typeof x === 'boolean' ? Number(x) : NaN;
353
+ if (!Number.isFinite(v)) throw new HookEvalError('expected a finite number');
354
+ if (Math.abs(v) > HOOK_LIMITS.maxNumberMagnitude) throw new HookEvalError('number out of range');
355
+ return v;
356
+ }
357
+ function str(x: unknown): string {
358
+ let v: string;
359
+ if (x == null) v = '';
360
+ else if (typeof x === 'object') {
361
+ // Safety net: a circular/unserializable object must surface as a contained
362
+ // HookEvalError, never a raw TypeError that escapes the 422 contract.
363
+ try { v = JSON.stringify(x) ?? ''; } catch { throw new HookEvalError('value is not serializable'); }
364
+ } else v = String(x);
365
+ if (v.length > HOOK_LIMITS.maxStringLen) throw new HookEvalError('string too long');
366
+ return v;
367
+ }
368
+ function truthy(x: unknown): boolean {
369
+ if (typeof x === 'boolean') return x;
370
+ if (x == null) return false;
371
+ if (typeof x === 'number') return x !== 0 && !Number.isNaN(x);
372
+ if (typeof x === 'string') return x.length > 0;
373
+ return true;
374
+ }
375
+ function isoDays(iso: unknown): number {
376
+ const ms = Date.parse(str(iso));
377
+ if (!Number.isFinite(ms)) throw new HookEvalError('invalid date string');
378
+ return ms / 86_400_000;
379
+ }
380
+ function ownGet(obj: unknown, prop: string): unknown {
381
+ if (obj == null || typeof obj !== 'object') return undefined;
382
+ if (FORBIDDEN_PROPS.has(prop)) return undefined;
383
+ // OWN enumerable only — never walk the prototype chain.
384
+ return Object.prototype.hasOwnProperty.call(obj, prop) ? (obj as Record<string, unknown>)[prop] : undefined;
385
+ }
386
+
387
+ const FUNCS: Record<string, (a: unknown[]) => unknown> = {
388
+ min: (a) => { if (!a.length) throw new HookEvalError('min requires at least one argument'); return Math.min(...a.map(num)); },
389
+ max: (a) => { if (!a.length) throw new HookEvalError('max requires at least one argument'); return Math.max(...a.map(num)); },
390
+ abs: (a) => Math.abs(num(a[0])),
391
+ round: (a) => Math.round(num(a[0])),
392
+ floor: (a) => Math.floor(num(a[0])),
393
+ ceil: (a) => Math.ceil(num(a[0])),
394
+ sqrt: (a) => { const v = num(a[0]); if (v < 0) throw new HookEvalError('sqrt of negative'); return Math.sqrt(v); },
395
+ pow: (a) => { const r = Math.pow(num(a[0]), num(a[1])); if (!Number.isFinite(r) || Math.abs(r) > HOOK_LIMITS.maxNumberMagnitude) throw new HookEvalError('pow out of range'); return r; },
396
+ sign: (a) => Math.sign(num(a[0])),
397
+ len: (a) => str(a[0]).length,
398
+ lower: (a) => str(a[0]).toLowerCase(),
399
+ upper: (a) => str(a[0]).toUpperCase(),
400
+ trim: (a) => str(a[0]).trim(),
401
+ substr: (a) => { const s = str(a[0]); const start = Math.max(0, Math.trunc(num(a[1]))); const length = a[2] === undefined ? undefined : Math.max(0, Math.trunc(num(a[2]))); return s.substring(start, length === undefined ? undefined : start + length); },
402
+ contains: (a) => str(a[0]).includes(str(a[1])),
403
+ startsWith: (a) => str(a[0]).startsWith(str(a[1])),
404
+ endsWith: (a) => str(a[0]).endsWith(str(a[1])),
405
+ concat: (a) => { const out = a.map(str).join(''); if (out.length > HOOK_LIMITS.maxStringLen) throw new HookEvalError('concat result too long'); return out; },
406
+ coalesce: (a) => { for (const v of a) if (v != null) return v; return null; },
407
+ ifNull: (a) => (a[0] == null ? a[1] ?? null : a[0]),
408
+ isNull: (a) => a[0] == null,
409
+ not: (a) => !truthy(a[0]),
410
+ number: (a) => num(a[0]),
411
+ string: (a) => str(a[0]),
412
+ bool: (a) => truthy(a[0]),
413
+ // num() re-applies the finite + magnitude guard so date math obeys the same
414
+ // numeric invariant as every other numeric result (no unbounded escape).
415
+ daysBetween: (a) => num(isoDays(a[0]) - isoDays(a[1])),
416
+ yearsBetween: (a) => num((isoDays(a[0]) - isoDays(a[1])) / 365.2425),
417
+ };
418
+
419
+ /** A mutable step counter SHARED across many evalExpr calls (the read path's
420
+ * whole-page budget). Checked against maxReadEvalStepsPerRequest. */
421
+ export interface EvalBudget { steps: number }
422
+
423
+ /** Evaluate a validated AST against the context. Bounded by a per-expression
424
+ * eval-step budget, plus an optional SHARED budget spanning many evaluations
425
+ * (runReadHooks passes one per request so a page of rows shares one bound). */
426
+ export function evalExpr(root: Node, ctx: HookContext, shared?: EvalBudget): unknown {
427
+ let steps = 0;
428
+ const ev = (nd: Node): unknown => {
429
+ if (++steps > HOOK_LIMITS.maxEvalSteps) throw new HookEvalError('evaluation budget exceeded');
430
+ if (shared && ++shared.steps > HOOK_LIMITS.maxReadEvalStepsPerRequest) {
431
+ throw new HookEvalError('read evaluation budget exceeded for this request (simplify read hooks or lower the page size)');
432
+ }
433
+ switch (nd.t) {
434
+ case 'num': return nd.v;
435
+ case 'str': return nd.v;
436
+ case 'bool': return nd.v;
437
+ case 'null': return null;
438
+ case 'var':
439
+ if (nd.name === 'item') return ctx.item;
440
+ if (nd.name === 'before') return ctx.before;
441
+ if (nd.name === 'now') return ctx.now;
442
+ // caller (read path only) — null on the write path / server mode.
443
+ if (nd.name === 'caller') return ctx.caller ?? null;
444
+ throw new HookEvalError(`unknown variable '${nd.name}'`);
445
+ case 'member': return ownGet(ev(nd.obj), nd.prop);
446
+ case 'unary': {
447
+ const v = ev(nd.arg);
448
+ return nd.op === '!' ? !truthy(v) : -num(v);
449
+ }
450
+ case 'bin': return binOp(nd.op, nd.l, nd.r, ev);
451
+ case 'tern': return truthy(ev(nd.c)) ? ev(nd.a) : ev(nd.b);
452
+ case 'call': {
453
+ const fn = FUNCS[nd.fn];
454
+ if (!fn) throw new HookEvalError(`unknown function '${nd.fn}'`);
455
+ return fn(nd.args.map(ev));
456
+ }
457
+ default:
458
+ throw new HookEvalError('unsupported node');
459
+ }
460
+ };
461
+ return ev(root);
462
+ }
463
+
464
+ function binOp(op: BinOp, ln: Node, rn: Node, ev: (n: Node) => unknown): unknown {
465
+ // short-circuit booleans
466
+ if (op === '&&') return truthy(ev(ln)) ? truthy(ev(rn)) : false;
467
+ if (op === '||') return truthy(ev(ln)) ? true : truthy(ev(rn));
468
+ const l = ev(ln);
469
+ const r = ev(rn);
470
+ switch (op) {
471
+ case '==': return eq(l, r);
472
+ case '!=': return !eq(l, r);
473
+ case '<': case '<=': case '>': case '>=': return compare(op, l, r);
474
+ case '+': return num(l) + num(r);
475
+ case '-': return num(l) - num(r);
476
+ case '*': { const v = num(l) * num(r); if (Math.abs(v) > HOOK_LIMITS.maxNumberMagnitude) throw new HookEvalError('multiply overflow'); return v; }
477
+ case '/': { const d = num(r); if (d === 0) throw new HookEvalError('division by zero'); return num(l) / d; }
478
+ case '%': { const d = num(r); if (d === 0) throw new HookEvalError('modulo by zero'); return num(l) % d; }
479
+ default: throw new HookEvalError(`bad operator ${op as string}`);
480
+ }
481
+ }
482
+ function eq(l: unknown, r: unknown): boolean {
483
+ if (l == null || r == null) return l == null && r == null;
484
+ if (typeof l === 'object' || typeof r === 'object') return false; // no deep equality on objects
485
+ return l === r;
486
+ }
487
+ function compare(op: string, l: unknown, r: unknown): boolean {
488
+ // numbers compare numerically; strings lexically; mixed → coerce to number.
489
+ let a: number | string;
490
+ let b: number | string;
491
+ if (typeof l === 'string' && typeof r === 'string') { a = l; b = r; }
492
+ else { a = num(l); b = num(r); }
493
+ switch (op) {
494
+ case '<': return a < b;
495
+ case '<=': return a <= b;
496
+ case '>': return a > b;
497
+ case '>=': return a >= b;
498
+ default: return false;
499
+ }
500
+ }
501
+
502
+ // ── hook definitions + write-/read-time runners ─────────────────────────────
503
+ export interface HookDef {
504
+ collection: string;
505
+ event: 'beforeCreate' | 'beforeUpdate' | 'beforeWrite' | 'beforeRead' | 'afterRead';
506
+ kind: 'validate' | 'derive' | 'redact';
507
+ expr: string;
508
+ field?: string; // derive/redact target
509
+ message?: string; // validate rejection message
510
+ enabled?: boolean;
511
+ }
512
+
513
+ const READ_EVENTS = new Set<HookDef['event']>(['beforeRead', 'afterRead']);
514
+
515
+ /** Config-WRITE-time validation of one hook (the anti-malice gate). Returns
516
+ * errors (empty = safe to publish). Does NOT need the collection's field list. */
517
+ export function validateHookDef(id: string, def: HookDef): string[] {
518
+ const errs: string[] = [];
519
+ const where = `hooks.${id}`;
520
+ if (!def.collection || typeof def.collection !== 'string') errs.push(`${where}.collection is required`);
521
+ if (def.kind === 'derive' && !def.field) errs.push(`${where}.field is required for a derive hook`);
522
+ if (def.field !== undefined && !PROP_RE.test(def.field)) errs.push(`${where}.field '${def.field}' is not a valid field name`);
523
+ if (def.field !== undefined && FORBIDDEN_PROPS.has(def.field)) errs.push(`${where}.field '${def.field}' is forbidden`);
524
+ // kind ↔ event compatibility matrix:
525
+ // validate → any write event (rejects the write) or beforeRead (drops the row); never afterRead.
526
+ // derive → any write event (persists the field) or afterRead (output copy only); never beforeRead.
527
+ // redact → afterRead ONLY (deletes a field from the output copy); requires `field`.
528
+ if (def.kind === 'redact') {
529
+ if (def.event !== 'afterRead') errs.push(`${where}: a redact hook is only valid on afterRead (got '${def.event}')`);
530
+ if (!def.field) errs.push(`${where}.field is required for a redact hook`);
531
+ }
532
+ if (def.kind === 'validate' && def.event === 'afterRead') {
533
+ errs.push(`${where}: a validate hook cannot run on afterRead (use beforeRead to drop rows)`);
534
+ }
535
+ if (def.kind === 'derive' && def.event === 'beforeRead') {
536
+ errs.push(`${where}: a derive hook cannot run on beforeRead (use afterRead to shape the output)`);
537
+ }
538
+ let ast: Node | null = null;
539
+ try {
540
+ ast = parseExpr(def.expr ?? '');
541
+ } catch (e) {
542
+ errs.push(`${where}.expr: ${(e as Error).message}`);
543
+ }
544
+ if (ast) {
545
+ for (const e of validateAst(ast)) errs.push(`${where}.expr: ${e}`);
546
+ }
547
+ return errs;
548
+ }
549
+
550
+ /** Validate a whole hooks bag (CmsConfig.hooks) at config time. */
551
+ export function validateHooksConfig(hooks: Record<string, HookDef> | undefined): string[] {
552
+ if (!hooks) return [];
553
+ const errs: string[] = [];
554
+ const ids = Object.keys(hooks);
555
+ if (ids.length > 50) errs.push(`too many hooks (${ids.length}); cap is 50`);
556
+ for (const id of ids) errs.push(...validateHookDef(id, hooks[id]!));
557
+ // Read hooks run PER ROW over a query page, so their count is part of the
558
+ // per-request CPU bound (with the shared eval budget) — cap them per collection.
559
+ const readCounts = new Map<string, number>();
560
+ for (const id of ids) {
561
+ const h = hooks[id]!;
562
+ if (READ_EVENTS.has(h.event)) {
563
+ readCounts.set(h.collection, (readCounts.get(h.collection) ?? 0) + 1);
564
+ }
565
+ }
566
+ for (const [coll, n] of readCounts) {
567
+ if (n > HOOK_LIMITS.maxReadHooksPerCollection) {
568
+ errs.push(`too many read hooks on collection '${coll}' (${n}); cap is ${HOOK_LIMITS.maxReadHooksPerCollection}`);
569
+ }
570
+ }
571
+ return errs;
572
+ }
573
+
574
+ function eventMatches(event: HookDef['event'], phase: 'create' | 'update'): boolean {
575
+ if (READ_EVENTS.has(event)) return false; // a read hook NEVER fires on a write
576
+ if (event === 'beforeWrite') return true;
577
+ return phase === 'create' ? event === 'beforeCreate' : event === 'beforeUpdate';
578
+ }
579
+
580
+ export interface HookRunResult {
581
+ data: Record<string, unknown>;
582
+ derived: string[]; // fields a derive hook set
583
+ }
584
+
585
+ /** Run all matching write hooks for (collection, phase) over the candidate data.
586
+ * Mutates a CLONE: derive hooks set fields; a validate hook that returns falsy
587
+ * throws HookRejection (→ the caller maps to a 422 and the tx rolls back).
588
+ * Throws HookEvalError on a runtime fault (also a clean 422, never a 500). */
589
+ export function runWriteHooks(
590
+ hooks: Record<string, HookDef> | undefined,
591
+ collection: string,
592
+ phase: 'create' | 'update',
593
+ data: Record<string, unknown>,
594
+ before: Record<string, unknown> | null,
595
+ now: string,
596
+ ): HookRunResult {
597
+ const out: Record<string, unknown> = { ...data };
598
+ const derived: string[] = [];
599
+ if (!hooks) return { data: out, derived };
600
+ const ctx: HookContext = { item: out, before, now };
601
+ const entries = Object.entries(hooks).filter(
602
+ ([, h]) => h.collection === collection && h.enabled !== false && eventMatches(h.event, phase),
603
+ );
604
+ // pass 1: derives (so validates can read derived values); pass 2: validates.
605
+ for (const [id, h] of entries) {
606
+ if (h.kind !== 'derive') continue;
607
+ const field = h.field!;
608
+ // belt-and-braces: even though validateHookDef rejects these at config time,
609
+ // runWriteHooks is a public export a future caller could feed an unvetted bag.
610
+ if (FORBIDDEN_PROPS.has(field) || !PROP_RE.test(field)) {
611
+ throw new HookEvalError(`derive '${id}' targets a forbidden field`);
612
+ }
613
+ const ast = parseCached(id, h.expr);
614
+ const v = evalExpr(ast, ctx);
615
+ // A derive MUST produce a scalar. Returning an object/array (e.g. `item`,
616
+ // `before`, `item.sub`, `coalesce(item, …)`) would alias or circularize the
617
+ // persisted row by reference — a data leak + an unserializable structure.
618
+ if (v !== null && typeof v === 'object') {
619
+ throw new HookEvalError(`derive '${id}' must produce a scalar (got ${Array.isArray(v) ? 'array' : 'object'})`);
620
+ }
621
+ out[field] = v;
622
+ derived.push(field);
623
+ }
624
+ for (const [id, h] of entries) {
625
+ if (h.kind !== 'validate') continue;
626
+ const ast = parseCached(id, h.expr);
627
+ if (!truthy(evalExpr(ast, ctx))) {
628
+ throw new HookRejection(h.message || `validation hook '${id}' rejected the write`);
629
+ }
630
+ }
631
+ return { data: out, derived };
632
+ }
633
+
634
+ // ── read-time runner (beforeRead visibility + afterRead output shaping) ─────
635
+
636
+ export interface ReadHookResult {
637
+ /** Surviving rows in input order. Each is a SHALLOW COPY with a copied `data`
638
+ * bag — the caller's rows are never mutated and nothing here is persisted. */
639
+ rows: Record<string, unknown>[];
640
+ /** Rows removed by a falsy beforeRead validate (the visibility filter). */
641
+ dropped: number;
642
+ }
643
+
644
+ /** Run the tenant's READ hooks for a collection over a page of PROJECTED rows
645
+ * (each row an item envelope whose `data` already carries computed fields and
646
+ * $expand results — that is the cross-field surface an expression reads).
647
+ *
648
+ * Per row, in order:
649
+ * 1. beforeRead `validate` — a falsy expression DROPS the row (visibility
650
+ * filter; a dropped row is indistinguishable from a missing one).
651
+ * 2. afterRead `derive` — sets data[field] on the OUTPUT copy only
652
+ * (scalar-only, same guard as the write path; NEVER persisted).
653
+ * 3. afterRead `redact` — deletes data[field] when the expression is truthy.
654
+ *
655
+ * Context per row: { item: the projected data, before: null, now: ONE ISO
656
+ * string for the whole request } — deterministic across the page. The entire
657
+ * page shares ONE eval-step budget (HOOK_LIMITS.maxReadEvalStepsPerRequest),
658
+ * which is the list path's per-request CPU bound; exhausting it throws
659
+ * HookEvalError (→ a clean 422, never a pinned isolate). Write events never
660
+ * fire here, and read events never fire in runWriteHooks. */
661
+ export function runReadHooks(
662
+ hooks: Record<string, HookDef> | undefined,
663
+ collection: string,
664
+ rows: ReadonlyArray<Record<string, unknown>>,
665
+ now: string,
666
+ /** verified caller/session context (design §5.6) — read-only, exposed to
667
+ * expressions as `caller.endUserId` / `caller.principal`. Omitted ⇒ null
668
+ * (server-caller mode). */
669
+ caller?: HookCaller | null,
670
+ ): ReadHookResult {
671
+ if (!hooks) return { rows: [...rows], dropped: 0 };
672
+ const entries = Object.entries(hooks).filter(
673
+ ([, h]) => h.collection === collection && h.enabled !== false && READ_EVENTS.has(h.event),
674
+ );
675
+ if (entries.length === 0) return { rows: [...rows], dropped: 0 };
676
+ const visibility = entries.filter(([, h]) => h.event === 'beforeRead' && h.kind === 'validate');
677
+ const derives = entries.filter(([, h]) => h.event === 'afterRead' && h.kind === 'derive');
678
+ const redacts = entries.filter(([, h]) => h.event === 'afterRead' && h.kind === 'redact');
679
+ const budget: EvalBudget = { steps: 0 }; // ONE budget for the whole page
680
+ const out: Record<string, unknown>[] = [];
681
+ let dropped = 0;
682
+ const checkTarget = (id: string, kind: string, field: string | undefined): string => {
683
+ // belt-and-braces (mirrors runWriteHooks): validateHookDef rejects these at
684
+ // config time, but this is a public export a future caller could misfeed.
685
+ if (!field || FORBIDDEN_PROPS.has(field) || !PROP_RE.test(field)) {
686
+ throw new HookEvalError(`${kind} '${id}' targets a forbidden field`);
687
+ }
688
+ return field;
689
+ };
690
+ for (const row of rows) {
691
+ const raw = row.data;
692
+ const data: Record<string, unknown> =
693
+ raw && typeof raw === 'object' && !Array.isArray(raw)
694
+ ? { ...(raw as Record<string, unknown>) }
695
+ : {};
696
+ const ctx: HookContext = { item: data, before: null, now, caller: caller ?? null };
697
+ let drop = false;
698
+ for (const [id, h] of visibility) {
699
+ if (!truthy(evalExpr(parseCached(id, h.expr), ctx, budget))) { drop = true; break; }
700
+ }
701
+ if (drop) { dropped += 1; continue; }
702
+ // derives BEFORE redacts, so a derive can read a field a redact then hides.
703
+ for (const [id, h] of derives) {
704
+ const field = checkTarget(id, 'derive', h.field);
705
+ const v = evalExpr(parseCached(id, h.expr), ctx, budget);
706
+ // Same scalar guard as the write path: an object/array result would alias
707
+ // the row by reference into the response (a leak + unserializable shape).
708
+ if (v !== null && typeof v === 'object') {
709
+ throw new HookEvalError(`derive '${id}' must produce a scalar (got ${Array.isArray(v) ? 'array' : 'object'})`);
710
+ }
711
+ data[field] = v;
712
+ }
713
+ for (const [id, h] of redacts) {
714
+ const field = checkTarget(id, 'redact', h.field);
715
+ if (truthy(evalExpr(parseCached(id, h.expr), ctx, budget))) delete data[field];
716
+ }
717
+ out.push({ ...row, data });
718
+ }
719
+ return { rows: out, dropped };
720
+ }
721
+
722
+ // ── editor support: inputs (refs), output (inferred type), highlighting ─────
723
+ // These power a FIELD-AWARE dashboard editor: which fields the expression reads
724
+ // (its INPUTS), the type it produces (its OUTPUT — checked against the derive
725
+ // target field), and token-level syntax highlighting. All pure + dependency-free.
726
+
727
+ export interface ExprRef {
728
+ root: 'item' | 'before' | 'now' | 'caller';
729
+ /** dotted path under the root, e.g. item.profile.age → ['profile','age']. */
730
+ path: string[];
731
+ }
732
+
733
+ /** Collect every variable reference an expression reads (its inputs). */
734
+ export function extractRefs(root: Node): ExprRef[] {
735
+ const out: ExprRef[] = [];
736
+ const seen = new Set<string>();
737
+ const add = (r: ExprRef): void => {
738
+ const k = `${r.root}.${r.path.join('.')}`;
739
+ if (!seen.has(k)) { seen.add(k); out.push(r); }
740
+ };
741
+ const walk = (nd: Node): void => {
742
+ switch (nd.t) {
743
+ case 'member': {
744
+ const path: string[] = [];
745
+ let cur: Node = nd;
746
+ while (cur.t === 'member') { path.unshift(cur.prop); cur = cur.obj; }
747
+ if (cur.t === 'var') add({ root: cur.name as ExprRef['root'], path });
748
+ else walk(cur);
749
+ return;
750
+ }
751
+ case 'var': add({ root: nd.name as ExprRef['root'], path: [] }); return;
752
+ case 'unary': walk(nd.arg); return;
753
+ case 'bin': walk(nd.l); walk(nd.r); return;
754
+ case 'tern': walk(nd.c); walk(nd.a); walk(nd.b); return;
755
+ case 'call': nd.args.forEach(walk); return;
756
+ default: return;
757
+ }
758
+ };
759
+ walk(root);
760
+ return out;
761
+ }
762
+
763
+ export type InferredType = 'number' | 'string' | 'boolean' | 'null' | 'unknown';
764
+ const NUM_FNS = new Set(['min', 'max', 'abs', 'round', 'floor', 'ceil', 'sqrt', 'pow', 'sign', 'len', 'number', 'daysBetween', 'yearsBetween']);
765
+ const STR_FNS = new Set(['lower', 'upper', 'trim', 'substr', 'concat', 'string']);
766
+ const BOOL_FNS = new Set(['contains', 'startsWith', 'endsWith', 'not', 'isNull', 'bool']);
767
+
768
+ /** Best-effort static type of an expression's result (its output). 'unknown' for
769
+ * member access / coalesce / mixed ternaries — never a false-positive mismatch. */
770
+ export function inferType(nd: Node): InferredType {
771
+ switch (nd.t) {
772
+ case 'num': return 'number';
773
+ case 'str': return 'string';
774
+ case 'bool': return 'boolean';
775
+ case 'null': return 'null';
776
+ case 'var': return nd.name === 'now' ? 'string' : 'unknown';
777
+ case 'member': return 'unknown';
778
+ case 'unary': return nd.op === '!' ? 'boolean' : 'number';
779
+ case 'bin':
780
+ return ['==', '!=', '<', '<=', '>', '>=', '&&', '||'].includes(nd.op) ? 'boolean' : 'number';
781
+ case 'tern': {
782
+ const a = inferType(nd.a); const b = inferType(nd.b);
783
+ return a === b ? a : 'unknown';
784
+ }
785
+ case 'call':
786
+ if (NUM_FNS.has(nd.fn)) return 'number';
787
+ if (STR_FNS.has(nd.fn)) return 'string';
788
+ if (BOOL_FNS.has(nd.fn)) return 'boolean';
789
+ return 'unknown'; // coalesce / ifNull
790
+ default: return 'unknown';
791
+ }
792
+ }
793
+
794
+ export type HlKind = 'num' | 'str' | 'fn' | 'var' | 'kw' | 'op' | 'ident' | 'ws' | 'err';
795
+
796
+ /** Token-level highlighter over the FULL source (whitespace + bad chars kept),
797
+ * so a dashboard editor can render a colored overlay behind a textarea. */
798
+ export function highlightTokens(src: string): Array<{ value: string; kind: HlKind }> {
799
+ const out: Array<{ value: string; kind: HlKind }> = [];
800
+ let i = 0; const n = src.length;
801
+ const push = (value: string, kind: HlKind): void => { if (value) out.push({ value, kind }); };
802
+ while (i < n) {
803
+ const c = src[i]!;
804
+ if (/\s/.test(c)) { let j = i; while (j < n && /\s/.test(src[j]!)) j++; push(src.slice(i, j), 'ws'); i = j; continue; }
805
+ if (c === '"' || c === "'") { let j = i + 1; while (j < n && src[j] !== c) { if (src[j] === '\\') j++; j++; } j = Math.min(j + 1, n); push(src.slice(i, j), 'str'); i = j; continue; }
806
+ if (/[0-9]/.test(c)) { let j = i; while (j < n && /[0-9.]/.test(src[j]!)) j++; push(src.slice(i, j), 'num'); i = j; continue; }
807
+ if (/[A-Za-z_]/.test(c)) {
808
+ let j = i; while (j < n && /[A-Za-z0-9_]/.test(src[j]!)) j++;
809
+ const w = src.slice(i, j);
810
+ const kind: HlKind = (w === 'true' || w === 'false' || w === 'null') ? 'kw' : FN_SET.has(w) ? 'fn' : ROOT_SET.has(w) ? 'var' : 'ident';
811
+ push(w, kind); i = j; continue;
812
+ }
813
+ const two = src.slice(i, i + 2);
814
+ if (['||', '&&', '==', '!=', '<=', '>='].includes(two)) { push(two, 'op'); i += 2; continue; }
815
+ push(c, /[+\-*/%<>!?:().,]/.test(c) ? 'op' : 'err'); i += 1;
816
+ }
817
+ return out;
818
+ }
819
+
820
+ // small bounded parse cache (exprs are tiny + capped); keyed by source string.
821
+ const PARSE_CACHE = new Map<string, Node>();
822
+ function parseCached(_id: string, expr: string): Node {
823
+ let ast = PARSE_CACHE.get(expr);
824
+ if (!ast) {
825
+ ast = parseExpr(expr);
826
+ if (PARSE_CACHE.size > 500) PARSE_CACHE.clear();
827
+ PARSE_CACHE.set(expr, ast);
828
+ }
829
+ return ast;
830
+ }