@vxil/feature-configs 0.9.1 → 0.11.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/dist/hooks.d.ts CHANGED
@@ -10,12 +10,16 @@ export declare const HOOK_LIMITS: {
10
10
  readonly maxReadHooksPerCollection: 10;
11
11
  };
12
12
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
13
- * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
14
- * populated ONLY on the READ path (runReadHooks); on the write path and in
15
- * server-caller mode it is null, exactly like `before` on a create. It carries
16
- * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
17
- * ('end_user' | 'tenant'). It is the missing "caller/session context" a read
18
- * hook keys on to redact/derive per-viewer. */
13
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only.
14
+ * It carries `caller.endUserId` (the verified session sub, or null),
15
+ * `caller.principal` ('end_user' | 'tenant') and `caller.roles` (the VERIFIED
16
+ * session role claims of the signed-in session — never a body or header
17
+ * value; null in server-caller mode). READ hooks always get it
18
+ * (server mode: `{ endUserId: null, principal: 'tenant', roles: null }`).
19
+ * WRITE hooks get it in verified end-user mode only; on a
20
+ * server write it is null, exactly like `before` on a create — so a role rule
21
+ * reads `isNull(caller) || contains(caller.roles, 'manager')`. `contains` on
22
+ * an array is exact element membership. */
19
23
  export declare const HOOK_ROOT_VARS: readonly ["item", "before", "now", "caller"];
20
24
  /** The ONLY callable functions. Each is pure + deterministic + bounded. */
21
25
  export declare const HOOK_FUNCTIONS: readonly ["min", "max", "abs", "round", "floor", "ceil", "sqrt", "pow", "sign", "len", "lower", "upper", "trim", "substr", "contains", "startsWith", "endsWith", "concat", "coalesce", "ifNull", "not", "isNull", "number", "string", "bool", "daysBetween", "yearsBetween"];
@@ -64,25 +68,41 @@ export declare class HookEvalError extends Error {
64
68
  /** Thrown by a `validate` hook whose expression is falsy — maps to a clean 422. */
65
69
  export declare class HookRejection extends Error {
66
70
  }
71
+ /** A WRITE `validate` hook whose expression could not be evaluated (null
72
+ * arithmetic, a missing operand, a bad date …). Still a HookEvalError — the
73
+ * write is refused, never let through — but it carries the hook's id and its
74
+ * declared `message`, so the API can show the end user the tenant's own copy
75
+ * while keeping the engine detail for the developer. `message` stays the
76
+ * engine detail. */
77
+ export declare class HookValidateFault extends HookEvalError {
78
+ readonly hookId: string;
79
+ readonly tenantMessage: string | undefined;
80
+ constructor(hookId: string, tenantMessage: string | undefined, detail: string);
81
+ }
67
82
  /** Parse a hook expression into an AST. Throws HookParseError on any malformed input. */
68
83
  export declare function parseExpr(src: string): Node;
69
84
  /** Walk the AST and reject anything outside the allow-list, plus depth bounds.
70
85
  * Returns an array of human-readable errors (empty = safe). Pure, no eval. */
71
86
  export declare function validateAst(root: Node): string[];
72
- /** The VERIFIED caller/session context for read hooks (guide ch. 7). Read-only;
73
- * present only on the read path in end-user mode. In server mode / write path
74
- * the whole object is null. */
87
+ /** The VERIFIED caller/session context for Lane-A hooks (guide ch. 7).
88
+ * Read-only. Read hooks always get it; write hooks get it in verified end-user
89
+ * mode only (a server write passes null — see HOOK_ROOT_VARS). */
75
90
  export interface HookCaller {
76
91
  /** the verified end-user session sub, or null in server-caller mode. */
77
92
  endUserId: string | null;
78
93
  /** 'end_user' when a session was verified at the edge, else 'tenant'. */
79
94
  principal: 'end_user' | 'tenant';
95
+ /** the VERIFIED session role claims of the signed-in session; `[]` for a
96
+ * session without roles, null in server-caller mode. Optional so an older
97
+ * caller object still reads
98
+ * as "no roles". */
99
+ roles?: readonly string[] | null;
80
100
  }
81
101
  export interface HookContext {
82
102
  item: Record<string, unknown>;
83
103
  before: Record<string, unknown> | null;
84
104
  now: string;
85
- /** verified caller/session context (read path only); null otherwise. */
105
+ /** verified caller/session context; null on a server write. */
86
106
  caller?: HookCaller | null;
87
107
  }
88
108
  /** A mutable step counter SHARED across many evalExpr calls (the read path's
@@ -116,7 +136,10 @@ export interface HookRunResult {
116
136
  * Mutates a CLONE: derive hooks set fields; a validate hook that returns falsy
117
137
  * throws HookRejection (→ the caller maps to a 422 and the tx rolls back).
118
138
  * Throws HookEvalError on a runtime fault (also a clean 422, never a 500). */
119
- export declare function runWriteHooks(hooks: Record<string, HookDef> | undefined, collection: string, phase: 'create' | 'update', data: Record<string, unknown>, before: Record<string, unknown> | null, now: string): HookRunResult;
139
+ export declare function runWriteHooks(hooks: Record<string, HookDef> | undefined, collection: string, phase: 'create' | 'update', data: Record<string, unknown>, before: Record<string, unknown> | null, now: string,
140
+ /** the VERIFIED end-user caller (endUserId + principal + roles) in
141
+ * end-user mode; omitted / null on a server write (expressions see null). */
142
+ caller?: HookCaller | null): HookRunResult;
120
143
  export interface ReadHookResult {
121
144
  /** Surviving rows in input order. Each is a SHALLOW COPY with a copied `data`
122
145
  * bag — the caller's rows are never mutated and nothing here is persisted. */
@@ -143,8 +166,8 @@ export interface ReadHookResult {
143
166
  * fire here, and read events never fire in runWriteHooks. */
144
167
  export declare function runReadHooks(hooks: Record<string, HookDef> | undefined, collection: string, rows: ReadonlyArray<Record<string, unknown>>, now: string,
145
168
  /** verified caller/session context (guide ch. 7) — read-only, exposed to
146
- * expressions as `caller.endUserId` / `caller.principal`. Omitted ⇒ null
147
- * (server-caller mode). */
169
+ * expressions as `caller.endUserId` / `caller.principal` / `caller.roles`.
170
+ * Omitted ⇒ null (server-caller mode). */
148
171
  caller?: HookCaller | null): ReadHookResult;
149
172
  export interface ExprRef {
150
173
  root: 'item' | 'before' | 'now' | 'caller';
@@ -157,6 +180,36 @@ export type InferredType = 'number' | 'string' | 'boolean' | 'null' | 'unknown';
157
180
  /** Best-effort static type of an expression's result (its output). 'unknown' for
158
181
  * member access / coalesce / mixed ternaries — never a false-positive mismatch. */
159
182
  export declare function inferType(nd: Node): InferredType;
183
+ /** One declared field, as the typing pass needs it (cms field type + required). */
184
+ export interface HookFieldInfo {
185
+ type: string;
186
+ required?: boolean;
187
+ }
188
+ /** collection → field → declared info. */
189
+ export type HookFieldTypes = Record<string, Record<string, HookFieldInfo>>;
190
+ /** Type one parsed expression against one collection's declared fields.
191
+ * `errors` are shapes that can never work (refuse); `warnings` are shapes
192
+ * that work only for some stored values (json vs a scalar, `+` on a text
193
+ * field) and arithmetic on an optional field with no coalesce() — it faults
194
+ * the hook whenever the field is missing. `present` lists fields a derive sets first. */
195
+ export declare function checkHookExprTypes(root: Node, fields: Record<string, HookFieldInfo> | undefined, opts: {
196
+ kind: HookDef['kind'];
197
+ event: HookDef['event'];
198
+ present?: ReadonlySet<string> | undefined;
199
+ }): {
200
+ errors: string[];
201
+ warnings: string[];
202
+ };
203
+ /** Project declared collections (`{ <name>: { fields: { <f>: { type, required? } } } }`
204
+ * — the vxil.config / apply-bundle shape) onto HookFieldTypes. Malformed
205
+ * entries are skipped: no declared field means no verdict. */
206
+ export declare function hookFieldTypesOf(collections: unknown): HookFieldTypes;
207
+ /** Type every hook whose collection the caller declares (see checkHookExprTypes).
208
+ * Unparseable expressions are skipped — validateHookDef already reports them. */
209
+ export declare function checkHookTypes(hooks: Record<string, HookDef> | undefined, fieldTypes: HookFieldTypes): {
210
+ errors: string[];
211
+ warnings: string[];
212
+ };
160
213
  export type HlKind = 'num' | 'str' | 'fn' | 'var' | 'kw' | 'op' | 'ident' | 'ws' | 'err';
161
214
  /** Token-level highlighter over the FULL source (whitespace + bad chars kept),
162
215
  * so a dashboard editor can render a colored overlay behind a textarea. */
package/dist/hooks.js CHANGED
@@ -61,12 +61,16 @@ export const HOOK_LIMITS = {
61
61
  };
62
62
  // ── allow-lists ─────────────────────────────────────────────────────────────
63
63
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
64
- * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
65
- * populated ONLY on the READ path (runReadHooks); on the write path and in
66
- * server-caller mode it is null, exactly like `before` on a create. It carries
67
- * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
68
- * ('end_user' | 'tenant'). It is the missing "caller/session context" a read
69
- * hook keys on to redact/derive per-viewer. */
64
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only.
65
+ * It carries `caller.endUserId` (the verified session sub, or null),
66
+ * `caller.principal` ('end_user' | 'tenant') and `caller.roles` (the VERIFIED
67
+ * session role claims of the signed-in session — never a body or header
68
+ * value; null in server-caller mode). READ hooks always get it
69
+ * (server mode: `{ endUserId: null, principal: 'tenant', roles: null }`).
70
+ * WRITE hooks get it in verified end-user mode only; on a
71
+ * server write it is null, exactly like `before` on a create — so a role rule
72
+ * reads `isNull(caller) || contains(caller.roles, 'manager')`. `contains` on
73
+ * an array is exact element membership. */
70
74
  export const HOOK_ROOT_VARS = ['item', 'before', 'now', 'caller'];
71
75
  /** The ONLY callable functions. Each is pure + deterministic + bounded. */
72
76
  export const HOOK_FUNCTIONS = [
@@ -92,6 +96,21 @@ export class HookEvalError extends Error {
92
96
  /** Thrown by a `validate` hook whose expression is falsy — maps to a clean 422. */
93
97
  export class HookRejection extends Error {
94
98
  }
99
+ /** A WRITE `validate` hook whose expression could not be evaluated (null
100
+ * arithmetic, a missing operand, a bad date …). Still a HookEvalError — the
101
+ * write is refused, never let through — but it carries the hook's id and its
102
+ * declared `message`, so the API can show the end user the tenant's own copy
103
+ * while keeping the engine detail for the developer. `message` stays the
104
+ * engine detail. */
105
+ export class HookValidateFault extends HookEvalError {
106
+ hookId;
107
+ tenantMessage;
108
+ constructor(hookId, tenantMessage, detail) {
109
+ super(detail);
110
+ this.hookId = hookId;
111
+ this.tenantMessage = tenantMessage;
112
+ }
113
+ }
95
114
  const PUNCT = ['||', '&&', '==', '!=', '<=', '>=', '<', '>', '+', '-', '*', '/', '%', '!', '(', ')', ',', '.', '?', ':'];
96
115
  function tokenize(src) {
97
116
  if (src.length > HOOK_LIMITS.maxSourceLen) {
@@ -433,7 +452,16 @@ const FUNCS = {
433
452
  upper: (a) => str(a[0]).toUpperCase(),
434
453
  trim: (a) => str(a[0]).trim(),
435
454
  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); },
436
- contains: (a) => str(a[0]).includes(str(a[1])),
455
+ // An ARRAY haystack is exact element membership (`contains(caller.roles,
456
+ // 'manager')` must never match 'man' or 'managers'); a scalar haystack keeps
457
+ // the substring test. Elements compare as strings; objects never match.
458
+ contains: (a) => {
459
+ const needle = str(a[1]);
460
+ if (Array.isArray(a[0])) {
461
+ return a[0].some((el) => el !== null && typeof el !== 'object' && String(el) === needle);
462
+ }
463
+ return str(a[0]).includes(needle);
464
+ },
437
465
  startsWith: (a) => str(a[0]).startsWith(str(a[1])),
438
466
  endsWith: (a) => str(a[0]).endsWith(str(a[1])),
439
467
  concat: (a) => { const out = a.map(str).join(''); if (out.length > HOOK_LIMITS.maxStringLen)
@@ -475,7 +503,7 @@ export function evalExpr(root, ctx, shared) {
475
503
  return ctx.before;
476
504
  if (nd.name === 'now')
477
505
  return ctx.now;
478
- // caller (read path only) — null on the write path / server mode.
506
+ // caller — null on a server write (and on any path that passes none).
479
507
  if (nd.name === 'caller')
480
508
  return ctx.caller ?? null;
481
509
  throw new HookEvalError(`unknown variable '${nd.name}'`);
@@ -643,12 +671,15 @@ function eventMatches(event, phase) {
643
671
  * Mutates a CLONE: derive hooks set fields; a validate hook that returns falsy
644
672
  * throws HookRejection (→ the caller maps to a 422 and the tx rolls back).
645
673
  * Throws HookEvalError on a runtime fault (also a clean 422, never a 500). */
646
- export function runWriteHooks(hooks, collection, phase, data, before, now) {
674
+ export function runWriteHooks(hooks, collection, phase, data, before, now,
675
+ /** the VERIFIED end-user caller (endUserId + principal + roles) in
676
+ * end-user mode; omitted / null on a server write (expressions see null). */
677
+ caller) {
647
678
  const out = { ...data };
648
679
  const derived = [];
649
680
  if (!hooks)
650
681
  return { data: out, derived };
651
- const ctx = { item: out, before, now };
682
+ const ctx = { item: out, before, now, caller: caller ?? null };
652
683
  const entries = Object.entries(hooks).filter(([, h]) => h.collection === collection && h.enabled !== false && eventMatches(h.event, phase));
653
684
  // pass 1: derives (so validates can read derived values); pass 2: validates.
654
685
  for (const [id, h] of entries) {
@@ -675,7 +706,17 @@ export function runWriteHooks(hooks, collection, phase, data, before, now) {
675
706
  if (h.kind !== 'validate')
676
707
  continue;
677
708
  const ast = parseCached(id, h.expr);
678
- if (!truthy(evalExpr(ast, ctx))) {
709
+ let verdict;
710
+ try {
711
+ verdict = evalExpr(ast, ctx);
712
+ }
713
+ catch (e) {
714
+ // fail closed with the hook's own message attached (see HookValidateFault)
715
+ if (e instanceof HookEvalError)
716
+ throw new HookValidateFault(id, h.message || undefined, e.message);
717
+ throw e;
718
+ }
719
+ if (!truthy(verdict)) {
679
720
  throw new HookRejection(h.message || `validation hook '${id}' rejected the write`);
680
721
  }
681
722
  }
@@ -700,8 +741,8 @@ export function runWriteHooks(hooks, collection, phase, data, before, now) {
700
741
  * fire here, and read events never fire in runWriteHooks. */
701
742
  export function runReadHooks(hooks, collection, rows, now,
702
743
  /** verified caller/session context (guide ch. 7) — read-only, exposed to
703
- * expressions as `caller.endUserId` / `caller.principal`. Omitted ⇒ null
704
- * (server-caller mode). */
744
+ * expressions as `caller.endUserId` / `caller.principal` / `caller.roles`.
745
+ * Omitted ⇒ null (server-caller mode). */
705
746
  caller) {
706
747
  if (!hooks)
707
748
  return { rows: [...rows], dropped: 0 };
@@ -841,6 +882,207 @@ export function inferType(nd) {
841
882
  default: return 'unknown';
842
883
  }
843
884
  }
885
+ const FIELD_STATIC = {
886
+ string: 'string', text: 'string', datetime: 'string', relation: 'string', file: 'string',
887
+ int: 'number', float: 'number', bool: 'boolean', json: 'json',
888
+ };
889
+ const WRITE_EVENTS = new Set(['beforeCreate', 'beforeUpdate', 'beforeWrite']);
890
+ /** `item.<f>` / `before.<f>` (one level) → { root, field }; anything else → null. */
891
+ function fieldRef(nd) {
892
+ if (nd.t !== 'member' || nd.obj.t !== 'var')
893
+ return null;
894
+ if (nd.obj.name !== 'item' && nd.obj.name !== 'before')
895
+ return null;
896
+ return { root: nd.obj.name, field: nd.prop };
897
+ }
898
+ function staticTypeOf(nd, fields) {
899
+ if (nd.t === 'var' && (nd.name === 'item' || nd.name === 'before' || nd.name === 'caller'))
900
+ return 'object';
901
+ const ref = fieldRef(nd);
902
+ if (ref) {
903
+ const f = fields && Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : undefined;
904
+ return f ? FIELD_STATIC[f.type] ?? 'unknown' : 'unknown';
905
+ }
906
+ return inferType(nd);
907
+ }
908
+ function describeOperand(nd) {
909
+ const ref = fieldRef(nd);
910
+ if (ref)
911
+ return `${ref.root}.${ref.field}`;
912
+ if (nd.t === 'var')
913
+ return nd.name;
914
+ if (nd.t === 'str')
915
+ return JSON.stringify(nd.v);
916
+ return 'a text value';
917
+ }
918
+ /** An operand `+` can never turn into a number (num() → NaN → a fault on
919
+ * every evaluation): `now` (an ISO timestamp), a datetime field, or a text
920
+ * literal that is not numeric. Text fields are NOT here — "42" + 1 works. */
921
+ function nanOperand(nd, fields) {
922
+ if (nd.t === 'var' && nd.name === 'now')
923
+ return nd;
924
+ if (nd.t === 'str')
925
+ return Number.isFinite(Number(nd.v)) ? null : nd;
926
+ const ref = fieldRef(nd);
927
+ if (ref && fields && Object.prototype.hasOwnProperty.call(fields, ref.field) && fields[ref.field].type === 'datetime')
928
+ return nd;
929
+ return null;
930
+ }
931
+ /** Type one parsed expression against one collection's declared fields.
932
+ * `errors` are shapes that can never work (refuse); `warnings` are shapes
933
+ * that work only for some stored values (json vs a scalar, `+` on a text
934
+ * field) and arithmetic on an optional field with no coalesce() — it faults
935
+ * the hook whenever the field is missing. `present` lists fields a derive sets first. */
936
+ export function checkHookExprTypes(root, fields, opts) {
937
+ const errors = [];
938
+ const warnings = [];
939
+ const warned = new Set();
940
+ const writePath = WRITE_EVENTS.has(opts.event);
941
+ const optionalOperand = (nd) => {
942
+ if (!writePath || !fields)
943
+ return;
944
+ const ref = fieldRef(nd);
945
+ if (!ref)
946
+ return;
947
+ const f = Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : undefined;
948
+ if (!f || f.required === true || (ref.root === 'item' && opts.present?.has(ref.field)))
949
+ return;
950
+ const name = `${ref.root}.${ref.field}`;
951
+ if (warned.has(name))
952
+ return;
953
+ warned.add(name);
954
+ warnings.push(`arithmetic on '${name}', which is optional, faults the hook whenever it is missing — write coalesce(${name}, 0)`);
955
+ };
956
+ const walk = (nd) => {
957
+ switch (nd.t) {
958
+ case 'bin': {
959
+ let refused = false;
960
+ if (nd.op === '==' || nd.op === '!=') {
961
+ const lt = staticTypeOf(nd.l, fields);
962
+ const rt = staticTypeOf(nd.r, fields);
963
+ if (nd.l.t !== 'null' && nd.r.t !== 'null') {
964
+ const always = nd.op === '==' ? 'always false' : 'always true';
965
+ // provably an object on one side: a whole row, or json vs json/row
966
+ const objSide = lt === 'object' ? nd.l : rt === 'object' ? nd.r
967
+ : (lt === 'json' && rt === 'json') ? nd.l : null;
968
+ const jsonSide = lt === 'json' ? nd.l : rt === 'json' ? nd.r : null;
969
+ if (objSide) {
970
+ errors.push(`'${nd.op}' cannot compare '${describeOperand(objSide)}' (an object or json value: the comparison is ${always}) — compare a scalar inside it (e.g. ${describeOperand(objSide)}.status) or test isNull()`);
971
+ }
972
+ else if (jsonSide) {
973
+ warnings.push(`'${nd.op}' on json field '${describeOperand(jsonSide)}' compares only when it holds a scalar — when it holds an object or array the comparison is ${always}; compare a scalar inside it (e.g. ${describeOperand(jsonSide)}.status)`);
974
+ }
975
+ }
976
+ }
977
+ else if (nd.op === '+') {
978
+ const nan = nanOperand(nd.l, fields) ?? nanOperand(nd.r, fields);
979
+ if (nan) {
980
+ refused = true;
981
+ errors.push(`'+' adds numbers only, and '${describeOperand(nan)}' is never a number — use concat() to join text`);
982
+ }
983
+ else {
984
+ const strSide = staticTypeOf(nd.l, fields) === 'string' ? nd.l : staticTypeOf(nd.r, fields) === 'string' ? nd.r : null;
985
+ if (strSide) {
986
+ warnings.push(`'+' adds numbers only: '${describeOperand(strSide)}' is text, so the hook faults unless it holds a numeric string — use concat() to join text, or number() to add`);
987
+ }
988
+ }
989
+ }
990
+ if (!refused && (nd.op === '+' || nd.op === '-' || nd.op === '*' || nd.op === '/' || nd.op === '%')) {
991
+ optionalOperand(nd.l);
992
+ optionalOperand(nd.r);
993
+ }
994
+ walk(nd.l);
995
+ walk(nd.r);
996
+ return;
997
+ }
998
+ case 'unary':
999
+ if (nd.op === '-')
1000
+ optionalOperand(nd.arg);
1001
+ walk(nd.arg);
1002
+ return;
1003
+ case 'tern':
1004
+ walk(nd.c);
1005
+ walk(nd.a);
1006
+ walk(nd.b);
1007
+ return;
1008
+ case 'call':
1009
+ for (const a of nd.args)
1010
+ walk(a);
1011
+ return;
1012
+ case 'member':
1013
+ walk(nd.obj);
1014
+ return;
1015
+ default: return;
1016
+ }
1017
+ };
1018
+ walk(root);
1019
+ return { errors, warnings };
1020
+ }
1021
+ /** Project declared collections (`{ <name>: { fields: { <f>: { type, required? } } } }`
1022
+ * — the vxil.config / apply-bundle shape) onto HookFieldTypes. Malformed
1023
+ * entries are skipped: no declared field means no verdict. */
1024
+ export function hookFieldTypesOf(collections) {
1025
+ const out = {};
1026
+ if (!collections || typeof collections !== 'object')
1027
+ return out;
1028
+ for (const [name, def] of Object.entries(collections)) {
1029
+ const fields = def?.fields;
1030
+ if (!fields || typeof fields !== 'object')
1031
+ continue;
1032
+ const m = {};
1033
+ for (const [f, fd] of Object.entries(fields)) {
1034
+ const t = fd?.type;
1035
+ if (typeof t !== 'string')
1036
+ continue;
1037
+ m[f] = { type: t, ...(fd.required === true ? { required: true } : {}) };
1038
+ }
1039
+ out[name] = m;
1040
+ }
1041
+ return out;
1042
+ }
1043
+ /** Type every hook whose collection the caller declares (see checkHookExprTypes).
1044
+ * Unparseable expressions are skipped — validateHookDef already reports them. */
1045
+ export function checkHookTypes(hooks, fieldTypes) {
1046
+ const errors = [];
1047
+ const warnings = [];
1048
+ if (!hooks)
1049
+ return { errors, warnings };
1050
+ // fields a write-path derive sets before the validates run (runWriteHooks pass 1)
1051
+ const derived = new Map();
1052
+ for (const h of Object.values(hooks)) {
1053
+ if (h.kind === 'derive' && h.field && WRITE_EVENTS.has(h.event) && h.enabled !== false) {
1054
+ if (!derived.has(h.collection))
1055
+ derived.set(h.collection, new Set());
1056
+ derived.get(h.collection).add(h.field);
1057
+ }
1058
+ }
1059
+ for (const [id, h] of Object.entries(hooks)) {
1060
+ if (!Object.prototype.hasOwnProperty.call(fieldTypes, h.collection))
1061
+ continue;
1062
+ let ast;
1063
+ try {
1064
+ ast = parseExpr(h.expr ?? '');
1065
+ }
1066
+ catch {
1067
+ continue;
1068
+ }
1069
+ if (validateAst(ast).length)
1070
+ continue;
1071
+ const r = checkHookExprTypes(ast, fieldTypes[h.collection], {
1072
+ kind: h.kind, event: h.event, present: h.kind === 'validate' ? derived.get(h.collection) : undefined,
1073
+ });
1074
+ // a disabled hook never runs: its findings cannot break a write, so they warn
1075
+ if (h.enabled === false)
1076
+ for (const e of r.errors)
1077
+ warnings.push(`hooks.${id}.expr (disabled): ${e}`);
1078
+ else
1079
+ for (const e of r.errors)
1080
+ errors.push(`hooks.${id}.expr: ${e}`);
1081
+ for (const w of r.warnings)
1082
+ warnings.push(`hooks.${id}.expr: ${w}`);
1083
+ }
1084
+ return { errors, warnings };
1085
+ }
844
1086
  /** Token-level highlighter over the FULL source (whitespace + bad chars kept),
845
1087
  * so a dashboard editor can render a colored overlay behind a textarea. */
846
1088
  export function highlightTokens(src) {
package/dist/index.d.ts CHANGED
@@ -78,9 +78,10 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
78
78
  maxAttempts: import("@sinclair/typebox").TInteger;
79
79
  backoff: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"exponential">, import("@sinclair/typebox").TLiteral<"linear">]>;
80
80
  }>>;
81
- suppression: import("@sinclair/typebox").TObject<{
81
+ suppression: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
82
82
  softBounceThreshold: import("@sinclair/typebox").TInteger;
83
- }>;
83
+ testRecipients: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
84
+ }>>;
84
85
  rateLimit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
85
86
  perDay: import("@sinclair/typebox").TInteger;
86
87
  perTenantSec: import("@sinclair/typebox").TInteger;
@@ -217,6 +218,7 @@ declare const OidcProviderSchema: import("@sinclair/typebox").TObject<{
217
218
  email: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
218
219
  name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
219
220
  roles: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
221
+ rolePrefix: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
220
222
  }>>;
221
223
  allowedDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
222
224
  autoLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
@@ -274,6 +276,7 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
274
276
  email: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
275
277
  name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
276
278
  roles: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
279
+ rolePrefix: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
277
280
  }>>;
278
281
  allowedDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
279
282
  autoLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
@@ -318,6 +321,8 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
318
321
  breachedPasswords: import("@sinclair/typebox").TBoolean;
319
322
  captchaSecretRef: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
320
323
  allowedRedirectOrigins: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
324
+ inviteOnly: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
325
+ allowedEmailDomains: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
321
326
  }>>;
322
327
  }>;
323
328
  /** The security bag as persisted (present ⇒ leaf defaults applied). */
@@ -632,6 +637,7 @@ export declare const RagConfigSchema: import("@sinclair/typebox").TObject<{
632
637
  topK: import("@sinclair/typebox").TInteger;
633
638
  mode: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"hybrid">, import("@sinclair/typebox").TLiteral<"vector">, import("@sinclair/typebox").TLiteral<"keyword">]>;
634
639
  minScore: import("@sinclair/typebox").TNumber;
640
+ minSimilarity: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
635
641
  rerank: import("@sinclair/typebox").TBoolean;
636
642
  }>;
637
643
  boosts: import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TObject<{
@@ -832,6 +838,9 @@ export declare const TEST_RECIPIENT_EMAIL_RE: RegExp;
832
838
  export declare const TEST_RECIPIENT_GLOB_RE: RegExp;
833
839
  export declare const TEST_RECIPIENT_E164_RE: RegExp;
834
840
  export declare function isTestRecipientPattern(entry: string): boolean;
841
+ /** The MAIL subset of the grammar (notifications suppression.testRecipients):
842
+ * an exact email or an `*@domain` glob. */
843
+ export declare function isMailTestRecipientPattern(entry: string): boolean;
835
844
  /** True iff `identifier` (an email today) matches one configured entry:
836
845
  * exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
837
846
  * an email. */
package/dist/index.js CHANGED
@@ -231,7 +231,26 @@ export const NotificationsConfigSchema = Type.Object({
231
231
  default: 'exponential',
232
232
  }),
233
233
  }, { default: {} })),
234
- suppression: Type.Object({ softBounceThreshold: Type.Integer({ default: 3 }) }, { default: {} }),
234
+ // `suppression` became an OPTIONAL bag (1 leaf either way — it held the one
235
+ // leaf softBounceThreshold) on 2026-10-04 so the
236
+ // `testRecipients` list rides inside it at zero leaf cost (the schema is AT
237
+ // the cap). It KEEPS `default: {}`, so Value.Default still materializes
238
+ // suppression.softBounceThreshold into every persisted manifest exactly as
239
+ // before (byte-identical); only the TS type is optional (workers read via
240
+ // `config.suppression?.…` with the shipped fallback).
241
+ suppression: Type.Optional(Type.Object({
242
+ softBounceThreshold: Type.Integer({ default: 3 }),
243
+ // TEST-RECIPIENT SINK (2026-10-04) — the auth `otp.testRecipients` grammar and
244
+ // matcher (matchesTestRecipient): an exact email or a `*@domain` glob,
245
+ // ≤20 (validateFeatureConfig refuses anything else — a +E.164 entry
246
+ // could never match a mail recipient). A send whose recipient matches is
247
+ // rendered in full and recorded as a delivery with status `test_sink`
248
+ // (the render readable at GET /v1/notifications/deliveries/{id}); NO
249
+ // provider is called and the send is not billed. Optional, no default:
250
+ // absent = no sink. CI / store-review / QA addresses only — never a real
251
+ // user's address.
252
+ testRecipients: Type.Optional(Type.Array(Type.String({ minLength: 3, maxLength: 320 }), { maxItems: 20 })),
253
+ }, { default: {} })),
235
254
  // `rateLimit` became an OPTIONAL bag (2 leaves → 1, the `retry` trick)
236
255
  // on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
237
256
  // `default: {}`, so Value.Default still materializes
@@ -444,6 +463,15 @@ const OidcProviderSchema = Type.Object({
444
463
  email: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
445
464
  name: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
446
465
  roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
466
+ // G-38b: an optional namespace prepended to every mapped IdP role (e.g.
467
+ // `idp-`), so an IdP group named like an app role (`finance`, `owner`)
468
+ // arrives as `idp-finance` and cannot pass a `readRoles: ['finance']`
469
+ // gate. Absent = the raw group names (the shipped behaviour). The prefixed
470
+ // role still obeys the 32-char role bound (longer ones are dropped).
471
+ // The alphabet is the cms readRoles / orgs role slug alphabet
472
+ // (`^[a-z0-9][a-z0-9_-]*$`, cms-v1 READ_ROLE_RE), so a prefixed role can
473
+ // itself be named in a gate — a `:`/`.`/uppercase prefix never could.
474
+ rolePrefix: Type.Optional(Type.String({ minLength: 1, maxLength: 16, pattern: '^[a-z0-9][a-z0-9_-]{0,15}$' })),
447
475
  })),
448
476
  // when non-empty, the asserted email's domain MUST be listed (fail-closed:
449
477
  // no email ⇒ refused) → 403 oidc_domain_not_allowed; a listed domain also
@@ -648,6 +676,23 @@ export const AuthConfigSchema = Type.Object({
648
676
  breachedPasswords: Type.Boolean({ default: false }),
649
677
  captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
650
678
  allowedRedirectOrigins: Type.Optional(Type.Array(Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }), { maxItems: 32 })),
679
+ // G-3 sign-up gating (2026-10-05). Both Optional with NO default, so an
680
+ // existing manifest folds byte-identically and the gate is opt-in. When
681
+ // EITHER is set, every self-service account-create path (password sign-up,
682
+ // magic-link / OTP pre-register, the guest → email claim, social sign-in
683
+ // with google/github/apple/facebook) is checked, fail-closed:
684
+ // • inviteOnly — a new account only for an address the tenant already
685
+ // put in its user registry server-side (POST /v1/users, an import);
686
+ // • allowedEmailDomains — otherwise, only for an address at one of these
687
+ // domains (exact, lowercase; a provider must assert the address
688
+ // VERIFIED).
689
+ // With either set, password sign-up is refused (it proves nothing about
690
+ // the address — a stranger could squat an invitee's address), guest
691
+ // sign-in is refused (a guest is nobody's invitee), and an existing
692
+ // account is never affected. The generic OIDC issuer keeps its own gate
693
+ // (providers.oidc.allowedDomains + IdP assignment). auth-v1 signupGate.ts.
694
+ inviteOnly: Type.Optional(Type.Boolean()),
695
+ allowedEmailDomains: Type.Optional(Type.Array(Type.String({ minLength: 3, maxLength: 253, pattern: '^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$' }), { maxItems: 32 })),
651
696
  })),
652
697
  });
653
698
  // ── DECLARED API STATE (2026-09-23) ──────────────────────────────────────────
@@ -824,7 +869,9 @@ export const CmsConfigSchema = Type.Object({
824
869
  maxCollections: Type.Integer({ default: 25, minimum: 1, maximum: 200 }),
825
870
  maxFieldsPerCollection: Type.Integer({ default: 50, minimum: 1, maximum: 200 }),
826
871
  maxItemBytes: Type.Integer({ default: 256 * 1024, minimum: 1024, maximum: 1024 * 1024 }),
827
- maxItemsPerCollection: Type.Integer({ default: 100_000, minimum: 1 }),
872
+ // Capped at 10 M: every create counts the collection's live rows, so
873
+ // the cap also bounds what each create pays.
874
+ maxItemsPerCollection: Type.Integer({ default: 100_000, minimum: 1, maximum: 10_000_000 }),
828
875
  }, { default: {} }),
829
876
  query: Type.Object({
830
877
  maxPageSize: Type.Integer({ default: 100, minimum: 1, maximum: 500 }),
@@ -1164,6 +1211,12 @@ export const RagConfigSchema = Type.Object({
1164
1211
  mode: Type.Union([Type.Literal('hybrid'), Type.Literal('vector'), Type.Literal('keyword')], { default: 'hybrid' }),
1165
1212
  // drop retrieved chunks below this score BEFORE budgeting (0 = keep all).
1166
1213
  minScore: Type.Number({ default: 0 }),
1214
+ // drop retrieved chunks whose cosine `similarity` to the query is
1215
+ // below this floor (-1..1). Unlike minScore (a floor on the RRF rank
1216
+ // value, ~0.016-0.033 at the top) this is the relevance threshold a RAG
1217
+ // app means. Absent = no floor; a hit with no measured similarity (no
1218
+ // query vector: keyword mode) is never dropped by it.
1219
+ minSimilarity: Type.Optional(Type.Number({ minimum: -1, maximum: 1 })),
1167
1220
  // forward rerank:true to vector-search's post-RRF BYO rerank pass (it owns
1168
1221
  // the provider + key); inert until that pass exists/is configured there.
1169
1222
  rerank: Type.Boolean({ default: false }),
@@ -1659,6 +1712,11 @@ export const TEST_RECIPIENT_E164_RE = /^\+[1-9]\d{1,14}$/;
1659
1712
  export function isTestRecipientPattern(entry) {
1660
1713
  return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry) || TEST_RECIPIENT_E164_RE.test(entry);
1661
1714
  }
1715
+ /** The MAIL subset of the grammar (notifications suppression.testRecipients):
1716
+ * an exact email or an `*@domain` glob. */
1717
+ export function isMailTestRecipientPattern(entry) {
1718
+ return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry);
1719
+ }
1662
1720
  /** True iff `identifier` (an email today) matches one configured entry:
1663
1721
  * exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
1664
1722
  * an email. */
@@ -2107,6 +2165,17 @@ export function validateFeatureConfig(feature, raw) {
2107
2165
  if (errs.length)
2108
2166
  return { ok: false, errors: errs.slice(0, 10) };
2109
2167
  }
2168
+ // Cross-field rule (2026-10-04): notifications suppression.testRecipients — every
2169
+ // entry must be an email or an `*@domain` glob (the auth grammar minus
2170
+ // +E.164: a phone number can never match a mail recipient, so it would sit
2171
+ // in the list doing nothing).
2172
+ if (feature === 'notifications') {
2173
+ const v = withDefaults;
2174
+ const bad = (v.suppression?.testRecipients ?? []).filter((e) => !isMailTestRecipientPattern(e));
2175
+ if (bad.length) {
2176
+ return { ok: false, errors: bad.slice(0, 10).map((e) => `/suppression/testRecipients: '${e}' is not an email or an *@domain glob`) };
2177
+ }
2178
+ }
2110
2179
  // Cross-field rule: auth otp.testRecipients — every entry must be an
2111
2180
  // email, an `*@domain` glob, or a +E.164 number; anything else would never
2112
2181
  // match and silently do nothing.
@@ -35,4 +35,22 @@ interface CdcRuleLike {
35
35
  }
36
36
  /** Cross-field rule for CmsConfig.cdc (called from validateFeatureConfig). */
37
37
  export declare function validateCdcConfig(cdc: Record<string, CdcRuleLike> | undefined): string[];
38
+ /** What a payload:'full' change-data rule must know about its target
39
+ * collection's read gates. `readRoleFields`: fields with a non-empty
40
+ * `readRoles`; `endUserAccess`: the collection's end-user access mode. */
41
+ export interface CdcCollectionGates {
42
+ readRoleFields?: readonly string[];
43
+ endUserAccess?: 'readwrite' | 'read' | 'none';
44
+ }
45
+ /**
46
+ * A `payload: 'full'` change-data rule inlines the stored row into a realtime
47
+ * frame that every subscriber of the channel receives — it does not go through
48
+ * the read projection. So it must never target a collection whose reads are
49
+ * gated: a field with `readRoles` (the frame would carry the gated value), or
50
+ * an `endUserAccess` of 'read' / 'none' (the frame would bypass the
51
+ * collection's end-user gate). Pure; one problem line per offending ENABLED
52
+ * rule (an unknown collection is skipped). The safe shape is `payload: 'ids'`
53
+ * and a re-read through the cms, where every gate applies.
54
+ */
55
+ export declare function cdcFullFramesOverGatedCollections(cdc: unknown, collections: Record<string, CdcCollectionGates>): string[];
38
56
  export {};
@@ -227,3 +227,35 @@ export function validateCdcConfig(cdc) {
227
227
  }
228
228
  return errs;
229
229
  }
230
+ /**
231
+ * A `payload: 'full'` change-data rule inlines the stored row into a realtime
232
+ * frame that every subscriber of the channel receives — it does not go through
233
+ * the read projection. So it must never target a collection whose reads are
234
+ * gated: a field with `readRoles` (the frame would carry the gated value), or
235
+ * an `endUserAccess` of 'read' / 'none' (the frame would bypass the
236
+ * collection's end-user gate). Pure; one problem line per offending ENABLED
237
+ * rule (an unknown collection is skipped). The safe shape is `payload: 'ids'`
238
+ * and a re-read through the cms, where every gate applies.
239
+ */
240
+ export function cdcFullFramesOverGatedCollections(cdc, collections) {
241
+ if (!cdc || typeof cdc !== 'object' || Array.isArray(cdc))
242
+ return [];
243
+ const problems = [];
244
+ for (const [name, raw] of Object.entries(cdc)) {
245
+ const r = raw;
246
+ if (!r || r.payload !== 'full' || r.enabled === false || typeof r.collection !== 'string')
247
+ continue;
248
+ const g = Object.prototype.hasOwnProperty.call(collections, r.collection) ? collections[r.collection] : undefined;
249
+ if (!g)
250
+ continue;
251
+ const gated = (g.readRoleFields ?? []).filter((f) => typeof f === 'string');
252
+ const access = g.endUserAccess ?? 'readwrite';
253
+ if (gated.length > 0) {
254
+ problems.push(`cdc '${name}' publishes full '${r.collection}' rows, but field(s) ${gated.join(', ')} declare readRoles — every subscriber would receive them; use payload: 'ids' and re-read`);
255
+ }
256
+ else if (access !== 'readwrite') {
257
+ problems.push(`cdc '${name}' publishes full '${r.collection}' rows, but the collection's endUserAccess is '${access}' — a full frame would bypass that gate for every subscriber; use payload: 'ids' and re-read`);
258
+ }
259
+ }
260
+ return problems;
261
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",
package/src/hooks.ts CHANGED
@@ -63,12 +63,16 @@ export const HOOK_LIMITS = {
63
63
 
64
64
  // ── allow-lists ─────────────────────────────────────────────────────────────
65
65
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
66
- * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — 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. */
66
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only.
67
+ * It carries `caller.endUserId` (the verified session sub, or null),
68
+ * `caller.principal` ('end_user' | 'tenant') and `caller.roles` (the VERIFIED
69
+ * session role claims of the signed-in session — never a body or header
70
+ * value; null in server-caller mode). READ hooks always get it
71
+ * (server mode: `{ endUserId: null, principal: 'tenant', roles: null }`).
72
+ * WRITE hooks get it in verified end-user mode only; on a
73
+ * server write it is null, exactly like `before` on a create — so a role rule
74
+ * reads `isNull(caller) || contains(caller.roles, 'manager')`. `contains` on
75
+ * an array is exact element membership. */
72
76
  export const HOOK_ROOT_VARS = ['item', 'before', 'now', 'caller'] as const;
73
77
  /** The ONLY callable functions. Each is pure + deterministic + bounded. */
74
78
  export const HOOK_FUNCTIONS = [
@@ -107,6 +111,21 @@ export class HookParseError extends Error {}
107
111
  export class HookEvalError extends Error {}
108
112
  /** Thrown by a `validate` hook whose expression is falsy — maps to a clean 422. */
109
113
  export class HookRejection extends Error {}
114
+ /** A WRITE `validate` hook whose expression could not be evaluated (null
115
+ * arithmetic, a missing operand, a bad date …). Still a HookEvalError — the
116
+ * write is refused, never let through — but it carries the hook's id and its
117
+ * declared `message`, so the API can show the end user the tenant's own copy
118
+ * while keeping the engine detail for the developer. `message` stays the
119
+ * engine detail. */
120
+ export class HookValidateFault extends HookEvalError {
121
+ constructor(
122
+ readonly hookId: string,
123
+ readonly tenantMessage: string | undefined,
124
+ detail: string,
125
+ ) {
126
+ super(detail);
127
+ }
128
+ }
110
129
 
111
130
  // ── tokenizer ───────────────────────────────────────────────────────────────
112
131
  type Tok =
@@ -330,21 +349,26 @@ export function validateAst(root: Node): string[] {
330
349
  }
331
350
 
332
351
  // ── evaluator (bounded, deterministic interpreter) ──────────────────────────
333
- /** The VERIFIED caller/session context for read hooks (guide ch. 7). Read-only;
334
- * present only on the read path in end-user mode. In server mode / write path
335
- * the whole object is null. */
352
+ /** The VERIFIED caller/session context for Lane-A hooks (guide ch. 7).
353
+ * Read-only. Read hooks always get it; write hooks get it in verified end-user
354
+ * mode only (a server write passes null — see HOOK_ROOT_VARS). */
336
355
  export interface HookCaller {
337
356
  /** the verified end-user session sub, or null in server-caller mode. */
338
357
  endUserId: string | null;
339
358
  /** 'end_user' when a session was verified at the edge, else 'tenant'. */
340
359
  principal: 'end_user' | 'tenant';
360
+ /** the VERIFIED session role claims of the signed-in session; `[]` for a
361
+ * session without roles, null in server-caller mode. Optional so an older
362
+ * caller object still reads
363
+ * as "no roles". */
364
+ roles?: readonly string[] | null;
341
365
  }
342
366
 
343
367
  export interface HookContext {
344
368
  item: Record<string, unknown>;
345
369
  before: Record<string, unknown> | null;
346
370
  now: string; // injected ISO timestamp — the only ambient input
347
- /** verified caller/session context (read path only); null otherwise. */
371
+ /** verified caller/session context; null on a server write. */
348
372
  caller?: HookCaller | null;
349
373
  }
350
374
 
@@ -399,7 +423,16 @@ const FUNCS: Record<string, (a: unknown[]) => unknown> = {
399
423
  upper: (a) => str(a[0]).toUpperCase(),
400
424
  trim: (a) => str(a[0]).trim(),
401
425
  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])),
426
+ // An ARRAY haystack is exact element membership (`contains(caller.roles,
427
+ // 'manager')` must never match 'man' or 'managers'); a scalar haystack keeps
428
+ // the substring test. Elements compare as strings; objects never match.
429
+ contains: (a) => {
430
+ const needle = str(a[1]);
431
+ if (Array.isArray(a[0])) {
432
+ return a[0].some((el) => el !== null && typeof el !== 'object' && String(el) === needle);
433
+ }
434
+ return str(a[0]).includes(needle);
435
+ },
403
436
  startsWith: (a) => str(a[0]).startsWith(str(a[1])),
404
437
  endsWith: (a) => str(a[0]).endsWith(str(a[1])),
405
438
  concat: (a) => { const out = a.map(str).join(''); if (out.length > HOOK_LIMITS.maxStringLen) throw new HookEvalError('concat result too long'); return out; },
@@ -439,7 +472,7 @@ export function evalExpr(root: Node, ctx: HookContext, shared?: EvalBudget): unk
439
472
  if (nd.name === 'item') return ctx.item;
440
473
  if (nd.name === 'before') return ctx.before;
441
474
  if (nd.name === 'now') return ctx.now;
442
- // caller (read path only) — null on the write path / server mode.
475
+ // caller — null on a server write (and on any path that passes none).
443
476
  if (nd.name === 'caller') return ctx.caller ?? null;
444
477
  throw new HookEvalError(`unknown variable '${nd.name}'`);
445
478
  case 'member': return ownGet(ev(nd.obj), nd.prop);
@@ -593,11 +626,14 @@ export function runWriteHooks(
593
626
  data: Record<string, unknown>,
594
627
  before: Record<string, unknown> | null,
595
628
  now: string,
629
+ /** the VERIFIED end-user caller (endUserId + principal + roles) in
630
+ * end-user mode; omitted / null on a server write (expressions see null). */
631
+ caller?: HookCaller | null,
596
632
  ): HookRunResult {
597
633
  const out: Record<string, unknown> = { ...data };
598
634
  const derived: string[] = [];
599
635
  if (!hooks) return { data: out, derived };
600
- const ctx: HookContext = { item: out, before, now };
636
+ const ctx: HookContext = { item: out, before, now, caller: caller ?? null };
601
637
  const entries = Object.entries(hooks).filter(
602
638
  ([, h]) => h.collection === collection && h.enabled !== false && eventMatches(h.event, phase),
603
639
  );
@@ -624,7 +660,15 @@ export function runWriteHooks(
624
660
  for (const [id, h] of entries) {
625
661
  if (h.kind !== 'validate') continue;
626
662
  const ast = parseCached(id, h.expr);
627
- if (!truthy(evalExpr(ast, ctx))) {
663
+ let verdict: unknown;
664
+ try {
665
+ verdict = evalExpr(ast, ctx);
666
+ } catch (e) {
667
+ // fail closed with the hook's own message attached (see HookValidateFault)
668
+ if (e instanceof HookEvalError) throw new HookValidateFault(id, h.message || undefined, e.message);
669
+ throw e;
670
+ }
671
+ if (!truthy(verdict)) {
628
672
  throw new HookRejection(h.message || `validation hook '${id}' rejected the write`);
629
673
  }
630
674
  }
@@ -664,8 +708,8 @@ export function runReadHooks(
664
708
  rows: ReadonlyArray<Record<string, unknown>>,
665
709
  now: string,
666
710
  /** verified caller/session context (guide ch. 7) — read-only, exposed to
667
- * expressions as `caller.endUserId` / `caller.principal`. Omitted ⇒ null
668
- * (server-caller mode). */
711
+ * expressions as `caller.endUserId` / `caller.principal` / `caller.roles`.
712
+ * Omitted ⇒ null (server-caller mode). */
669
713
  caller?: HookCaller | null,
670
714
  ): ReadHookResult {
671
715
  if (!hooks) return { rows: [...rows], dropped: 0 };
@@ -791,6 +835,203 @@ export function inferType(nd: Node): InferredType {
791
835
  }
792
836
  }
793
837
 
838
+ // ── push-time typing against the declared fields ────────────────────────────
839
+ // validateHookDef is purely syntactic: it cannot know that `item.meta` is a
840
+ // json field, so `item.meta != before.meta` (ALWAYS true — eq() never compares
841
+ // objects) or `item.title + '!'` (ALWAYS a fault — `+` is numeric) pass it and
842
+ // fail on every write. Where the caller knows the collection's fields (a push
843
+ // or apply that declares them, the dashboard editor) this resolves `item.<f>` /
844
+ // `before.<f>` to the declared type. It REFUSES only shapes that fault or
845
+ // mis-evaluate for every value the field can hold:
846
+ // - `==` / `!=` on a whole row (`item`, `before`, `caller`), or a json field
847
+ // against another json field or a whole row (eq() never compares objects);
848
+ // - `+` with `now`, a datetime field, or a non-numeric text literal (`+` is
849
+ // numeric and num() turns those into NaN → a fault on every write).
850
+ // Shapes that work for SOME stored values are WARNINGS, never refusals: a json
851
+ // field against a scalar (`item.meta == 'active'` is right when meta holds a
852
+ // string — json accepts any value), and `+` on a text-typed field (num()
853
+ // coerces a numeric string such as "42"). Anything it cannot type (an
854
+ // undeclared collection or field, a nested path, coalesce, a mixed ternary)
855
+ // gets no verdict. Disabled hooks (enabled: false) only ever warn.
856
+
857
+ /** One declared field, as the typing pass needs it (cms field type + required). */
858
+ export interface HookFieldInfo { type: string; required?: boolean }
859
+ /** collection → field → declared info. */
860
+ export type HookFieldTypes = Record<string, Record<string, HookFieldInfo>>;
861
+
862
+ type StaticType = 'number' | 'string' | 'boolean' | 'null' | 'json' | 'object' | 'unknown';
863
+ const FIELD_STATIC: Record<string, StaticType> = {
864
+ string: 'string', text: 'string', datetime: 'string', relation: 'string', file: 'string',
865
+ int: 'number', float: 'number', bool: 'boolean', json: 'json',
866
+ };
867
+ const WRITE_EVENTS = new Set<HookDef['event']>(['beforeCreate', 'beforeUpdate', 'beforeWrite']);
868
+
869
+ /** `item.<f>` / `before.<f>` (one level) → { root, field }; anything else → null. */
870
+ function fieldRef(nd: Node): { root: 'item' | 'before'; field: string } | null {
871
+ if (nd.t !== 'member' || nd.obj.t !== 'var') return null;
872
+ if (nd.obj.name !== 'item' && nd.obj.name !== 'before') return null;
873
+ return { root: nd.obj.name, field: nd.prop };
874
+ }
875
+
876
+ function staticTypeOf(nd: Node, fields: Record<string, HookFieldInfo> | undefined): StaticType {
877
+ if (nd.t === 'var' && (nd.name === 'item' || nd.name === 'before' || nd.name === 'caller')) return 'object';
878
+ const ref = fieldRef(nd);
879
+ if (ref) {
880
+ const f = fields && Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : undefined;
881
+ return f ? FIELD_STATIC[f.type] ?? 'unknown' : 'unknown';
882
+ }
883
+ return inferType(nd);
884
+ }
885
+
886
+ function describeOperand(nd: Node): string {
887
+ const ref = fieldRef(nd);
888
+ if (ref) return `${ref.root}.${ref.field}`;
889
+ if (nd.t === 'var') return nd.name;
890
+ if (nd.t === 'str') return JSON.stringify(nd.v);
891
+ return 'a text value';
892
+ }
893
+
894
+ /** An operand `+` can never turn into a number (num() → NaN → a fault on
895
+ * every evaluation): `now` (an ISO timestamp), a datetime field, or a text
896
+ * literal that is not numeric. Text fields are NOT here — "42" + 1 works. */
897
+ function nanOperand(nd: Node, fields: Record<string, HookFieldInfo> | undefined): Node | null {
898
+ if (nd.t === 'var' && nd.name === 'now') return nd;
899
+ if (nd.t === 'str') return Number.isFinite(Number(nd.v)) ? null : nd;
900
+ const ref = fieldRef(nd);
901
+ if (ref && fields && Object.prototype.hasOwnProperty.call(fields, ref.field) && fields[ref.field]!.type === 'datetime') return nd;
902
+ return null;
903
+ }
904
+
905
+ /** Type one parsed expression against one collection's declared fields.
906
+ * `errors` are shapes that can never work (refuse); `warnings` are shapes
907
+ * that work only for some stored values (json vs a scalar, `+` on a text
908
+ * field) and arithmetic on an optional field with no coalesce() — it faults
909
+ * the hook whenever the field is missing. `present` lists fields a derive sets first. */
910
+ export function checkHookExprTypes(
911
+ root: Node,
912
+ fields: Record<string, HookFieldInfo> | undefined,
913
+ opts: { kind: HookDef['kind']; event: HookDef['event']; present?: ReadonlySet<string> | undefined },
914
+ ): { errors: string[]; warnings: string[] } {
915
+ const errors: string[] = [];
916
+ const warnings: string[] = [];
917
+ const warned = new Set<string>();
918
+ const writePath = WRITE_EVENTS.has(opts.event);
919
+ const optionalOperand = (nd: Node): void => {
920
+ if (!writePath || !fields) return;
921
+ const ref = fieldRef(nd);
922
+ if (!ref) return;
923
+ const f = Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : undefined;
924
+ if (!f || f.required === true || (ref.root === 'item' && opts.present?.has(ref.field))) return;
925
+ const name = `${ref.root}.${ref.field}`;
926
+ if (warned.has(name)) return;
927
+ warned.add(name);
928
+ warnings.push(`arithmetic on '${name}', which is optional, faults the hook whenever it is missing — write coalesce(${name}, 0)`);
929
+ };
930
+ const walk = (nd: Node): void => {
931
+ switch (nd.t) {
932
+ case 'bin': {
933
+ let refused = false;
934
+ if (nd.op === '==' || nd.op === '!=') {
935
+ const lt = staticTypeOf(nd.l, fields);
936
+ const rt = staticTypeOf(nd.r, fields);
937
+ if (nd.l.t !== 'null' && nd.r.t !== 'null') {
938
+ const always = nd.op === '==' ? 'always false' : 'always true';
939
+ // provably an object on one side: a whole row, or json vs json/row
940
+ const objSide = lt === 'object' ? nd.l : rt === 'object' ? nd.r
941
+ : (lt === 'json' && rt === 'json') ? nd.l : null;
942
+ const jsonSide = lt === 'json' ? nd.l : rt === 'json' ? nd.r : null;
943
+ if (objSide) {
944
+ errors.push(`'${nd.op}' cannot compare '${describeOperand(objSide)}' (an object or json value: the comparison is ${always}) — compare a scalar inside it (e.g. ${describeOperand(objSide)}.status) or test isNull()`);
945
+ } else if (jsonSide) {
946
+ warnings.push(`'${nd.op}' on json field '${describeOperand(jsonSide)}' compares only when it holds a scalar — when it holds an object or array the comparison is ${always}; compare a scalar inside it (e.g. ${describeOperand(jsonSide)}.status)`);
947
+ }
948
+ }
949
+ } else if (nd.op === '+') {
950
+ const nan = nanOperand(nd.l, fields) ?? nanOperand(nd.r, fields);
951
+ if (nan) {
952
+ refused = true;
953
+ errors.push(`'+' adds numbers only, and '${describeOperand(nan)}' is never a number — use concat() to join text`);
954
+ } else {
955
+ const strSide = staticTypeOf(nd.l, fields) === 'string' ? nd.l : staticTypeOf(nd.r, fields) === 'string' ? nd.r : null;
956
+ if (strSide) {
957
+ warnings.push(`'+' adds numbers only: '${describeOperand(strSide)}' is text, so the hook faults unless it holds a numeric string — use concat() to join text, or number() to add`);
958
+ }
959
+ }
960
+ }
961
+ if (!refused && (nd.op === '+' || nd.op === '-' || nd.op === '*' || nd.op === '/' || nd.op === '%')) {
962
+ optionalOperand(nd.l);
963
+ optionalOperand(nd.r);
964
+ }
965
+ walk(nd.l); walk(nd.r);
966
+ return;
967
+ }
968
+ case 'unary':
969
+ if (nd.op === '-') optionalOperand(nd.arg);
970
+ walk(nd.arg);
971
+ return;
972
+ case 'tern': walk(nd.c); walk(nd.a); walk(nd.b); return;
973
+ case 'call': for (const a of nd.args) walk(a); return;
974
+ case 'member': walk(nd.obj); return;
975
+ default: return;
976
+ }
977
+ };
978
+ walk(root);
979
+ return { errors, warnings };
980
+ }
981
+
982
+ /** Project declared collections (`{ <name>: { fields: { <f>: { type, required? } } } }`
983
+ * — the vxil.config / apply-bundle shape) onto HookFieldTypes. Malformed
984
+ * entries are skipped: no declared field means no verdict. */
985
+ export function hookFieldTypesOf(collections: unknown): HookFieldTypes {
986
+ const out: HookFieldTypes = {};
987
+ if (!collections || typeof collections !== 'object') return out;
988
+ for (const [name, def] of Object.entries(collections as Record<string, unknown>)) {
989
+ const fields = (def as { fields?: unknown } | null)?.fields;
990
+ if (!fields || typeof fields !== 'object') continue;
991
+ const m: Record<string, HookFieldInfo> = {};
992
+ for (const [f, fd] of Object.entries(fields as Record<string, unknown>)) {
993
+ const t = (fd as { type?: unknown } | null)?.type;
994
+ if (typeof t !== 'string') continue;
995
+ m[f] = { type: t, ...((fd as { required?: unknown }).required === true ? { required: true } : {}) };
996
+ }
997
+ out[name] = m;
998
+ }
999
+ return out;
1000
+ }
1001
+
1002
+ /** Type every hook whose collection the caller declares (see checkHookExprTypes).
1003
+ * Unparseable expressions are skipped — validateHookDef already reports them. */
1004
+ export function checkHookTypes(
1005
+ hooks: Record<string, HookDef> | undefined,
1006
+ fieldTypes: HookFieldTypes,
1007
+ ): { errors: string[]; warnings: string[] } {
1008
+ const errors: string[] = [];
1009
+ const warnings: string[] = [];
1010
+ if (!hooks) return { errors, warnings };
1011
+ // fields a write-path derive sets before the validates run (runWriteHooks pass 1)
1012
+ const derived = new Map<string, Set<string>>();
1013
+ for (const h of Object.values(hooks)) {
1014
+ if (h.kind === 'derive' && h.field && WRITE_EVENTS.has(h.event) && h.enabled !== false) {
1015
+ if (!derived.has(h.collection)) derived.set(h.collection, new Set());
1016
+ derived.get(h.collection)!.add(h.field);
1017
+ }
1018
+ }
1019
+ for (const [id, h] of Object.entries(hooks)) {
1020
+ if (!Object.prototype.hasOwnProperty.call(fieldTypes, h.collection)) continue;
1021
+ let ast: Node;
1022
+ try { ast = parseExpr(h.expr ?? ''); } catch { continue; }
1023
+ if (validateAst(ast).length) continue;
1024
+ const r = checkHookExprTypes(ast, fieldTypes[h.collection], {
1025
+ kind: h.kind, event: h.event, present: h.kind === 'validate' ? derived.get(h.collection) : undefined,
1026
+ });
1027
+ // a disabled hook never runs: its findings cannot break a write, so they warn
1028
+ if (h.enabled === false) for (const e of r.errors) warnings.push(`hooks.${id}.expr (disabled): ${e}`);
1029
+ else for (const e of r.errors) errors.push(`hooks.${id}.expr: ${e}`);
1030
+ for (const w of r.warnings) warnings.push(`hooks.${id}.expr: ${w}`);
1031
+ }
1032
+ return { errors, warnings };
1033
+ }
1034
+
794
1035
  export type HlKind = 'num' | 'str' | 'fn' | 'var' | 'kw' | 'op' | 'ident' | 'ws' | 'err';
795
1036
 
796
1037
  /** Token-level highlighter over the FULL source (whitespace + bad chars kept),
package/src/index.ts CHANGED
@@ -250,10 +250,31 @@ export const NotificationsConfigSchema = Type.Object({
250
250
  },
251
251
  { default: {} },
252
252
  )),
253
- suppression: Type.Object(
254
- { softBounceThreshold: Type.Integer({ default: 3 }) },
253
+ // `suppression` became an OPTIONAL bag (1 leaf either way — it held the one
254
+ // leaf softBounceThreshold) on 2026-10-04 so the
255
+ // `testRecipients` list rides inside it at zero leaf cost (the schema is AT
256
+ // the cap). It KEEPS `default: {}`, so Value.Default still materializes
257
+ // suppression.softBounceThreshold into every persisted manifest exactly as
258
+ // before (byte-identical); only the TS type is optional (workers read via
259
+ // `config.suppression?.…` with the shipped fallback).
260
+ suppression: Type.Optional(Type.Object(
261
+ {
262
+ softBounceThreshold: Type.Integer({ default: 3 }),
263
+ // TEST-RECIPIENT SINK (2026-10-04) — the auth `otp.testRecipients` grammar and
264
+ // matcher (matchesTestRecipient): an exact email or a `*@domain` glob,
265
+ // ≤20 (validateFeatureConfig refuses anything else — a +E.164 entry
266
+ // could never match a mail recipient). A send whose recipient matches is
267
+ // rendered in full and recorded as a delivery with status `test_sink`
268
+ // (the render readable at GET /v1/notifications/deliveries/{id}); NO
269
+ // provider is called and the send is not billed. Optional, no default:
270
+ // absent = no sink. CI / store-review / QA addresses only — never a real
271
+ // user's address.
272
+ testRecipients: Type.Optional(Type.Array(
273
+ Type.String({ minLength: 3, maxLength: 320 }), { maxItems: 20 },
274
+ )),
275
+ },
255
276
  { default: {} },
256
- ),
277
+ )),
257
278
  // `rateLimit` became an OPTIONAL bag (2 leaves → 1, the `retry` trick)
258
279
  // on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
259
280
  // `default: {}`, so Value.Default still materializes
@@ -329,7 +350,8 @@ export const NotificationsConfigSchema = Type.Object({
329
350
  });
330
351
  // Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef,
331
352
  // webhookSecretRef, provider, ses (Optional bag = 1, 2026-09-19), defaultLocale,
332
- // retry (Optional bag = 1), suppression.softBounceThreshold,
353
+ // retry (Optional bag = 1), suppression (Optional bag = 1 since 2026-10-04 —
354
+ // was suppression.softBounceThreshold; now also carries testRecipients),
333
355
  // rateLimit (Optional bag = 1 — was rateLimit.{perDay,perTenantSec}, collapsed
334
356
  // 2026-09-19 to fund `ses` at zero net cost), templates (Optional bag = 1 — was
335
357
  // templates.allowOverride, collapsed 2026-09-10 to fund the `overrides` map
@@ -512,6 +534,15 @@ const OidcProviderSchema = Type.Object({
512
534
  email: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
513
535
  name: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
514
536
  roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
537
+ // G-38b: an optional namespace prepended to every mapped IdP role (e.g.
538
+ // `idp-`), so an IdP group named like an app role (`finance`, `owner`)
539
+ // arrives as `idp-finance` and cannot pass a `readRoles: ['finance']`
540
+ // gate. Absent = the raw group names (the shipped behaviour). The prefixed
541
+ // role still obeys the 32-char role bound (longer ones are dropped).
542
+ // The alphabet is the cms readRoles / orgs role slug alphabet
543
+ // (`^[a-z0-9][a-z0-9_-]*$`, cms-v1 READ_ROLE_RE), so a prefixed role can
544
+ // itself be named in a gate — a `:`/`.`/uppercase prefix never could.
545
+ rolePrefix: Type.Optional(Type.String({ minLength: 1, maxLength: 16, pattern: '^[a-z0-9][a-z0-9_-]{0,15}$' })),
515
546
  })),
516
547
  // when non-empty, the asserted email's domain MUST be listed (fail-closed:
517
548
  // no email ⇒ refused) → 403 oidc_domain_not_allowed; a listed domain also
@@ -741,6 +772,26 @@ export const AuthConfigSchema = Type.Object({
741
772
  Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }),
742
773
  { maxItems: 32 },
743
774
  )),
775
+ // G-3 sign-up gating (2026-10-05). Both Optional with NO default, so an
776
+ // existing manifest folds byte-identically and the gate is opt-in. When
777
+ // EITHER is set, every self-service account-create path (password sign-up,
778
+ // magic-link / OTP pre-register, the guest → email claim, social sign-in
779
+ // with google/github/apple/facebook) is checked, fail-closed:
780
+ // • inviteOnly — a new account only for an address the tenant already
781
+ // put in its user registry server-side (POST /v1/users, an import);
782
+ // • allowedEmailDomains — otherwise, only for an address at one of these
783
+ // domains (exact, lowercase; a provider must assert the address
784
+ // VERIFIED).
785
+ // With either set, password sign-up is refused (it proves nothing about
786
+ // the address — a stranger could squat an invitee's address), guest
787
+ // sign-in is refused (a guest is nobody's invitee), and an existing
788
+ // account is never affected. The generic OIDC issuer keeps its own gate
789
+ // (providers.oidc.allowedDomains + IdP assignment). auth-v1 signupGate.ts.
790
+ inviteOnly: Type.Optional(Type.Boolean()),
791
+ allowedEmailDomains: Type.Optional(Type.Array(
792
+ Type.String({ minLength: 3, maxLength: 253, pattern: '^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$' }),
793
+ { maxItems: 32 },
794
+ )),
744
795
  })),
745
796
  });
746
797
  /** The security bag as persisted (present ⇒ leaf defaults applied). */
@@ -971,7 +1022,9 @@ export const CmsConfigSchema = Type.Object({
971
1022
  maxCollections: Type.Integer({ default: 25, minimum: 1, maximum: 200 }),
972
1023
  maxFieldsPerCollection: Type.Integer({ default: 50, minimum: 1, maximum: 200 }),
973
1024
  maxItemBytes: Type.Integer({ default: 256 * 1024, minimum: 1024, maximum: 1024 * 1024 }),
974
- maxItemsPerCollection: Type.Integer({ default: 100_000, minimum: 1 }),
1025
+ // Capped at 10 M: every create counts the collection's live rows, so
1026
+ // the cap also bounds what each create pays.
1027
+ maxItemsPerCollection: Type.Integer({ default: 100_000, minimum: 1, maximum: 10_000_000 }),
975
1028
  },
976
1029
  { default: {} },
977
1030
  ),
@@ -1465,6 +1518,12 @@ export const RagConfigSchema = Type.Object({
1465
1518
  ),
1466
1519
  // drop retrieved chunks below this score BEFORE budgeting (0 = keep all).
1467
1520
  minScore: Type.Number({ default: 0 }),
1521
+ // drop retrieved chunks whose cosine `similarity` to the query is
1522
+ // below this floor (-1..1). Unlike minScore (a floor on the RRF rank
1523
+ // value, ~0.016-0.033 at the top) this is the relevance threshold a RAG
1524
+ // app means. Absent = no floor; a hit with no measured similarity (no
1525
+ // query vector: keyword mode) is never dropped by it.
1526
+ minSimilarity: Type.Optional(Type.Number({ minimum: -1, maximum: 1 })),
1468
1527
  // forward rerank:true to vector-search's post-RRF BYO rerank pass (it owns
1469
1528
  // the provider + key); inert until that pass exists/is configured there.
1470
1529
  rerank: Type.Boolean({ default: false }),
@@ -1506,9 +1565,9 @@ export const RagConfigSchema = Type.Object({
1506
1565
  streaming: Type.Boolean({ default: true }),
1507
1566
  });
1508
1567
  // Leaves: enabled(1), defaultCollection(2),
1509
- // retrieval.{topK,mode,minScore,rerank}(+4=6), boosts(7 — Type.Record = ONE leaf),
1510
- // context.{maxTokens,strategy,tokenizer}(+3=10), defaultTemplate(11),
1511
- // citations(12), streaming(13). countLeaves → 13. Cap = 15.
1568
+ // retrieval.{topK,mode,minScore,minSimilarity,rerank}(+5=7), boosts(8 — Type.Record = ONE
1569
+ // leaf), context.{maxTokens,strategy,tokenizer}(+3=11), defaultTemplate(12),
1570
+ // citations(13), streaming(14). countLeaves → 14. Cap = 15.
1512
1571
 
1513
1572
  export type RagConfig = Static<typeof RagConfigSchema>;
1514
1573
 
@@ -2048,6 +2107,11 @@ export const TEST_RECIPIENT_E164_RE = /^\+[1-9]\d{1,14}$/;
2048
2107
  export function isTestRecipientPattern(entry: string): boolean {
2049
2108
  return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry) || TEST_RECIPIENT_E164_RE.test(entry);
2050
2109
  }
2110
+ /** The MAIL subset of the grammar (notifications suppression.testRecipients):
2111
+ * an exact email or an `*@domain` glob. */
2112
+ export function isMailTestRecipientPattern(entry: string): boolean {
2113
+ return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry);
2114
+ }
2051
2115
  /** True iff `identifier` (an email today) matches one configured entry:
2052
2116
  * exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
2053
2117
  * an email. */
@@ -2535,6 +2599,17 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2535
2599
  const errs = validateDeclaredWebhookSubscriptions(v.subscriptions, v.maxSubscriptions);
2536
2600
  if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
2537
2601
  }
2602
+ // Cross-field rule (2026-10-04): notifications suppression.testRecipients — every
2603
+ // entry must be an email or an `*@domain` glob (the auth grammar minus
2604
+ // +E.164: a phone number can never match a mail recipient, so it would sit
2605
+ // in the list doing nothing).
2606
+ if (feature === 'notifications') {
2607
+ const v = withDefaults as { suppression?: { testRecipients?: string[] } };
2608
+ const bad = (v.suppression?.testRecipients ?? []).filter((e) => !isMailTestRecipientPattern(e));
2609
+ if (bad.length) {
2610
+ return { ok: false, errors: bad.slice(0, 10).map((e) => `/suppression/testRecipients: '${e}' is not an email or an *@domain glob`) };
2611
+ }
2612
+ }
2538
2613
  // Cross-field rule: auth otp.testRecipients — every entry must be an
2539
2614
  // email, an `*@domain` glob, or a +E.164 number; anything else would never
2540
2615
  // match and silently do nothing.
package/src/readmodels.ts CHANGED
@@ -246,3 +246,42 @@ export function validateCdcConfig(
246
246
  }
247
247
  return errs;
248
248
  }
249
+
250
+ /** What a payload:'full' change-data rule must know about its target
251
+ * collection's read gates. `readRoleFields`: fields with a non-empty
252
+ * `readRoles`; `endUserAccess`: the collection's end-user access mode. */
253
+ export interface CdcCollectionGates {
254
+ readRoleFields?: readonly string[];
255
+ endUserAccess?: 'readwrite' | 'read' | 'none';
256
+ }
257
+
258
+ /**
259
+ * A `payload: 'full'` change-data rule inlines the stored row into a realtime
260
+ * frame that every subscriber of the channel receives — it does not go through
261
+ * the read projection. So it must never target a collection whose reads are
262
+ * gated: a field with `readRoles` (the frame would carry the gated value), or
263
+ * an `endUserAccess` of 'read' / 'none' (the frame would bypass the
264
+ * collection's end-user gate). Pure; one problem line per offending ENABLED
265
+ * rule (an unknown collection is skipped). The safe shape is `payload: 'ids'`
266
+ * and a re-read through the cms, where every gate applies.
267
+ */
268
+ export function cdcFullFramesOverGatedCollections(
269
+ cdc: unknown, collections: Record<string, CdcCollectionGates>,
270
+ ): string[] {
271
+ if (!cdc || typeof cdc !== 'object' || Array.isArray(cdc)) return [];
272
+ const problems: string[] = [];
273
+ for (const [name, raw] of Object.entries(cdc as Record<string, unknown>)) {
274
+ const r = raw as { collection?: unknown; payload?: unknown; enabled?: unknown } | null;
275
+ if (!r || r.payload !== 'full' || r.enabled === false || typeof r.collection !== 'string') continue;
276
+ const g = Object.prototype.hasOwnProperty.call(collections, r.collection) ? collections[r.collection] : undefined;
277
+ if (!g) continue;
278
+ const gated = (g.readRoleFields ?? []).filter((f) => typeof f === 'string');
279
+ const access = g.endUserAccess ?? 'readwrite';
280
+ if (gated.length > 0) {
281
+ problems.push(`cdc '${name}' publishes full '${r.collection}' rows, but field(s) ${gated.join(', ')} declare readRoles — every subscriber would receive them; use payload: 'ids' and re-read`);
282
+ } else if (access !== 'readwrite') {
283
+ problems.push(`cdc '${name}' publishes full '${r.collection}' rows, but the collection's endUserAccess is '${access}' — a full frame would bypass that gate for every subscriber; use payload: 'ids' and re-read`);
284
+ }
285
+ }
286
+ return problems;
287
+ }