@vxil/feature-configs 0.9.1 → 0.10.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
@@ -64,6 +64,17 @@ export declare class HookEvalError extends Error {
64
64
  /** Thrown by a `validate` hook whose expression is falsy — maps to a clean 422. */
65
65
  export declare class HookRejection extends Error {
66
66
  }
67
+ /** A WRITE `validate` hook whose expression could not be evaluated (null
68
+ * arithmetic, a missing operand, a bad date …). Still a HookEvalError — the
69
+ * write is refused, never let through — but it carries the hook's id and its
70
+ * declared `message`, so the API can show the end user the tenant's own copy
71
+ * while keeping the engine detail for the developer. `message` stays the
72
+ * engine detail. */
73
+ export declare class HookValidateFault extends HookEvalError {
74
+ readonly hookId: string;
75
+ readonly tenantMessage: string | undefined;
76
+ constructor(hookId: string, tenantMessage: string | undefined, detail: string);
77
+ }
67
78
  /** Parse a hook expression into an AST. Throws HookParseError on any malformed input. */
68
79
  export declare function parseExpr(src: string): Node;
69
80
  /** Walk the AST and reject anything outside the allow-list, plus depth bounds.
@@ -157,6 +168,36 @@ export type InferredType = 'number' | 'string' | 'boolean' | 'null' | 'unknown';
157
168
  /** Best-effort static type of an expression's result (its output). 'unknown' for
158
169
  * member access / coalesce / mixed ternaries — never a false-positive mismatch. */
159
170
  export declare function inferType(nd: Node): InferredType;
171
+ /** One declared field, as the typing pass needs it (cms field type + required). */
172
+ export interface HookFieldInfo {
173
+ type: string;
174
+ required?: boolean;
175
+ }
176
+ /** collection → field → declared info. */
177
+ export type HookFieldTypes = Record<string, Record<string, HookFieldInfo>>;
178
+ /** Type one parsed expression against one collection's declared fields.
179
+ * `errors` are shapes that can never work (refuse); `warnings` are shapes
180
+ * that work only for some stored values (json vs a scalar, `+` on a text
181
+ * field) and arithmetic on an optional field with no coalesce() — it faults
182
+ * the hook whenever the field is missing. `present` lists fields a derive sets first. */
183
+ export declare function checkHookExprTypes(root: Node, fields: Record<string, HookFieldInfo> | undefined, opts: {
184
+ kind: HookDef['kind'];
185
+ event: HookDef['event'];
186
+ present?: ReadonlySet<string> | undefined;
187
+ }): {
188
+ errors: string[];
189
+ warnings: string[];
190
+ };
191
+ /** Project declared collections (`{ <name>: { fields: { <f>: { type, required? } } } }`
192
+ * — the vxil.config / apply-bundle shape) onto HookFieldTypes. Malformed
193
+ * entries are skipped: no declared field means no verdict. */
194
+ export declare function hookFieldTypesOf(collections: unknown): HookFieldTypes;
195
+ /** Type every hook whose collection the caller declares (see checkHookExprTypes).
196
+ * Unparseable expressions are skipped — validateHookDef already reports them. */
197
+ export declare function checkHookTypes(hooks: Record<string, HookDef> | undefined, fieldTypes: HookFieldTypes): {
198
+ errors: string[];
199
+ warnings: string[];
200
+ };
160
201
  export type HlKind = 'num' | 'str' | 'fn' | 'var' | 'kw' | 'op' | 'ident' | 'ws' | 'err';
161
202
  /** Token-level highlighter over the FULL source (whitespace + bad chars kept),
162
203
  * so a dashboard editor can render a colored overlay behind a textarea. */
package/dist/hooks.js CHANGED
@@ -92,6 +92,21 @@ export class HookEvalError extends Error {
92
92
  /** Thrown by a `validate` hook whose expression is falsy — maps to a clean 422. */
93
93
  export class HookRejection extends Error {
94
94
  }
95
+ /** A WRITE `validate` hook whose expression could not be evaluated (null
96
+ * arithmetic, a missing operand, a bad date …). Still a HookEvalError — the
97
+ * write is refused, never let through — but it carries the hook's id and its
98
+ * declared `message`, so the API can show the end user the tenant's own copy
99
+ * while keeping the engine detail for the developer. `message` stays the
100
+ * engine detail. */
101
+ export class HookValidateFault extends HookEvalError {
102
+ hookId;
103
+ tenantMessage;
104
+ constructor(hookId, tenantMessage, detail) {
105
+ super(detail);
106
+ this.hookId = hookId;
107
+ this.tenantMessage = tenantMessage;
108
+ }
109
+ }
95
110
  const PUNCT = ['||', '&&', '==', '!=', '<=', '>=', '<', '>', '+', '-', '*', '/', '%', '!', '(', ')', ',', '.', '?', ':'];
96
111
  function tokenize(src) {
97
112
  if (src.length > HOOK_LIMITS.maxSourceLen) {
@@ -675,7 +690,17 @@ export function runWriteHooks(hooks, collection, phase, data, before, now) {
675
690
  if (h.kind !== 'validate')
676
691
  continue;
677
692
  const ast = parseCached(id, h.expr);
678
- if (!truthy(evalExpr(ast, ctx))) {
693
+ let verdict;
694
+ try {
695
+ verdict = evalExpr(ast, ctx);
696
+ }
697
+ catch (e) {
698
+ // fail closed with the hook's own message attached (see HookValidateFault)
699
+ if (e instanceof HookEvalError)
700
+ throw new HookValidateFault(id, h.message || undefined, e.message);
701
+ throw e;
702
+ }
703
+ if (!truthy(verdict)) {
679
704
  throw new HookRejection(h.message || `validation hook '${id}' rejected the write`);
680
705
  }
681
706
  }
@@ -841,6 +866,207 @@ export function inferType(nd) {
841
866
  default: return 'unknown';
842
867
  }
843
868
  }
869
+ const FIELD_STATIC = {
870
+ string: 'string', text: 'string', datetime: 'string', relation: 'string', file: 'string',
871
+ int: 'number', float: 'number', bool: 'boolean', json: 'json',
872
+ };
873
+ const WRITE_EVENTS = new Set(['beforeCreate', 'beforeUpdate', 'beforeWrite']);
874
+ /** `item.<f>` / `before.<f>` (one level) → { root, field }; anything else → null. */
875
+ function fieldRef(nd) {
876
+ if (nd.t !== 'member' || nd.obj.t !== 'var')
877
+ return null;
878
+ if (nd.obj.name !== 'item' && nd.obj.name !== 'before')
879
+ return null;
880
+ return { root: nd.obj.name, field: nd.prop };
881
+ }
882
+ function staticTypeOf(nd, fields) {
883
+ if (nd.t === 'var' && (nd.name === 'item' || nd.name === 'before' || nd.name === 'caller'))
884
+ return 'object';
885
+ const ref = fieldRef(nd);
886
+ if (ref) {
887
+ const f = fields && Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : undefined;
888
+ return f ? FIELD_STATIC[f.type] ?? 'unknown' : 'unknown';
889
+ }
890
+ return inferType(nd);
891
+ }
892
+ function describeOperand(nd) {
893
+ const ref = fieldRef(nd);
894
+ if (ref)
895
+ return `${ref.root}.${ref.field}`;
896
+ if (nd.t === 'var')
897
+ return nd.name;
898
+ if (nd.t === 'str')
899
+ return JSON.stringify(nd.v);
900
+ return 'a text value';
901
+ }
902
+ /** An operand `+` can never turn into a number (num() → NaN → a fault on
903
+ * every evaluation): `now` (an ISO timestamp), a datetime field, or a text
904
+ * literal that is not numeric. Text fields are NOT here — "42" + 1 works. */
905
+ function nanOperand(nd, fields) {
906
+ if (nd.t === 'var' && nd.name === 'now')
907
+ return nd;
908
+ if (nd.t === 'str')
909
+ return Number.isFinite(Number(nd.v)) ? null : nd;
910
+ const ref = fieldRef(nd);
911
+ if (ref && fields && Object.prototype.hasOwnProperty.call(fields, ref.field) && fields[ref.field].type === 'datetime')
912
+ return nd;
913
+ return null;
914
+ }
915
+ /** Type one parsed expression against one collection's declared fields.
916
+ * `errors` are shapes that can never work (refuse); `warnings` are shapes
917
+ * that work only for some stored values (json vs a scalar, `+` on a text
918
+ * field) and arithmetic on an optional field with no coalesce() — it faults
919
+ * the hook whenever the field is missing. `present` lists fields a derive sets first. */
920
+ export function checkHookExprTypes(root, fields, opts) {
921
+ const errors = [];
922
+ const warnings = [];
923
+ const warned = new Set();
924
+ const writePath = WRITE_EVENTS.has(opts.event);
925
+ const optionalOperand = (nd) => {
926
+ if (!writePath || !fields)
927
+ return;
928
+ const ref = fieldRef(nd);
929
+ if (!ref)
930
+ return;
931
+ const f = Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : undefined;
932
+ if (!f || f.required === true || (ref.root === 'item' && opts.present?.has(ref.field)))
933
+ return;
934
+ const name = `${ref.root}.${ref.field}`;
935
+ if (warned.has(name))
936
+ return;
937
+ warned.add(name);
938
+ warnings.push(`arithmetic on '${name}', which is optional, faults the hook whenever it is missing — write coalesce(${name}, 0)`);
939
+ };
940
+ const walk = (nd) => {
941
+ switch (nd.t) {
942
+ case 'bin': {
943
+ let refused = false;
944
+ if (nd.op === '==' || nd.op === '!=') {
945
+ const lt = staticTypeOf(nd.l, fields);
946
+ const rt = staticTypeOf(nd.r, fields);
947
+ if (nd.l.t !== 'null' && nd.r.t !== 'null') {
948
+ const always = nd.op === '==' ? 'always false' : 'always true';
949
+ // provably an object on one side: a whole row, or json vs json/row
950
+ const objSide = lt === 'object' ? nd.l : rt === 'object' ? nd.r
951
+ : (lt === 'json' && rt === 'json') ? nd.l : null;
952
+ const jsonSide = lt === 'json' ? nd.l : rt === 'json' ? nd.r : null;
953
+ if (objSide) {
954
+ 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()`);
955
+ }
956
+ else if (jsonSide) {
957
+ 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)`);
958
+ }
959
+ }
960
+ }
961
+ else if (nd.op === '+') {
962
+ const nan = nanOperand(nd.l, fields) ?? nanOperand(nd.r, fields);
963
+ if (nan) {
964
+ refused = true;
965
+ errors.push(`'+' adds numbers only, and '${describeOperand(nan)}' is never a number — use concat() to join text`);
966
+ }
967
+ else {
968
+ const strSide = staticTypeOf(nd.l, fields) === 'string' ? nd.l : staticTypeOf(nd.r, fields) === 'string' ? nd.r : null;
969
+ if (strSide) {
970
+ 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`);
971
+ }
972
+ }
973
+ }
974
+ if (!refused && (nd.op === '+' || nd.op === '-' || nd.op === '*' || nd.op === '/' || nd.op === '%')) {
975
+ optionalOperand(nd.l);
976
+ optionalOperand(nd.r);
977
+ }
978
+ walk(nd.l);
979
+ walk(nd.r);
980
+ return;
981
+ }
982
+ case 'unary':
983
+ if (nd.op === '-')
984
+ optionalOperand(nd.arg);
985
+ walk(nd.arg);
986
+ return;
987
+ case 'tern':
988
+ walk(nd.c);
989
+ walk(nd.a);
990
+ walk(nd.b);
991
+ return;
992
+ case 'call':
993
+ for (const a of nd.args)
994
+ walk(a);
995
+ return;
996
+ case 'member':
997
+ walk(nd.obj);
998
+ return;
999
+ default: return;
1000
+ }
1001
+ };
1002
+ walk(root);
1003
+ return { errors, warnings };
1004
+ }
1005
+ /** Project declared collections (`{ <name>: { fields: { <f>: { type, required? } } } }`
1006
+ * — the vxil.config / apply-bundle shape) onto HookFieldTypes. Malformed
1007
+ * entries are skipped: no declared field means no verdict. */
1008
+ export function hookFieldTypesOf(collections) {
1009
+ const out = {};
1010
+ if (!collections || typeof collections !== 'object')
1011
+ return out;
1012
+ for (const [name, def] of Object.entries(collections)) {
1013
+ const fields = def?.fields;
1014
+ if (!fields || typeof fields !== 'object')
1015
+ continue;
1016
+ const m = {};
1017
+ for (const [f, fd] of Object.entries(fields)) {
1018
+ const t = fd?.type;
1019
+ if (typeof t !== 'string')
1020
+ continue;
1021
+ m[f] = { type: t, ...(fd.required === true ? { required: true } : {}) };
1022
+ }
1023
+ out[name] = m;
1024
+ }
1025
+ return out;
1026
+ }
1027
+ /** Type every hook whose collection the caller declares (see checkHookExprTypes).
1028
+ * Unparseable expressions are skipped — validateHookDef already reports them. */
1029
+ export function checkHookTypes(hooks, fieldTypes) {
1030
+ const errors = [];
1031
+ const warnings = [];
1032
+ if (!hooks)
1033
+ return { errors, warnings };
1034
+ // fields a write-path derive sets before the validates run (runWriteHooks pass 1)
1035
+ const derived = new Map();
1036
+ for (const h of Object.values(hooks)) {
1037
+ if (h.kind === 'derive' && h.field && WRITE_EVENTS.has(h.event) && h.enabled !== false) {
1038
+ if (!derived.has(h.collection))
1039
+ derived.set(h.collection, new Set());
1040
+ derived.get(h.collection).add(h.field);
1041
+ }
1042
+ }
1043
+ for (const [id, h] of Object.entries(hooks)) {
1044
+ if (!Object.prototype.hasOwnProperty.call(fieldTypes, h.collection))
1045
+ continue;
1046
+ let ast;
1047
+ try {
1048
+ ast = parseExpr(h.expr ?? '');
1049
+ }
1050
+ catch {
1051
+ continue;
1052
+ }
1053
+ if (validateAst(ast).length)
1054
+ continue;
1055
+ const r = checkHookExprTypes(ast, fieldTypes[h.collection], {
1056
+ kind: h.kind, event: h.event, present: h.kind === 'validate' ? derived.get(h.collection) : undefined,
1057
+ });
1058
+ // a disabled hook never runs: its findings cannot break a write, so they warn
1059
+ if (h.enabled === false)
1060
+ for (const e of r.errors)
1061
+ warnings.push(`hooks.${id}.expr (disabled): ${e}`);
1062
+ else
1063
+ for (const e of r.errors)
1064
+ errors.push(`hooks.${id}.expr: ${e}`);
1065
+ for (const w of r.warnings)
1066
+ warnings.push(`hooks.${id}.expr: ${w}`);
1067
+ }
1068
+ return { errors, warnings };
1069
+ }
844
1070
  /** Token-level highlighter over the FULL source (whitespace + bad chars kept),
845
1071
  * so a dashboard editor can render a colored overlay behind a textarea. */
846
1072
  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;
@@ -832,6 +833,9 @@ export declare const TEST_RECIPIENT_EMAIL_RE: RegExp;
832
833
  export declare const TEST_RECIPIENT_GLOB_RE: RegExp;
833
834
  export declare const TEST_RECIPIENT_E164_RE: RegExp;
834
835
  export declare function isTestRecipientPattern(entry: string): boolean;
836
+ /** The MAIL subset of the grammar (notifications suppression.testRecipients):
837
+ * an exact email or an `*@domain` glob. */
838
+ export declare function isMailTestRecipientPattern(entry: string): boolean;
835
839
  /** True iff `identifier` (an email today) matches one configured entry:
836
840
  * exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
837
841
  * 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
@@ -824,7 +843,9 @@ export const CmsConfigSchema = Type.Object({
824
843
  maxCollections: Type.Integer({ default: 25, minimum: 1, maximum: 200 }),
825
844
  maxFieldsPerCollection: Type.Integer({ default: 50, minimum: 1, maximum: 200 }),
826
845
  maxItemBytes: Type.Integer({ default: 256 * 1024, minimum: 1024, maximum: 1024 * 1024 }),
827
- maxItemsPerCollection: Type.Integer({ default: 100_000, minimum: 1 }),
846
+ // Capped at 10 M: every create counts the collection's live rows, so
847
+ // the cap also bounds what each create pays.
848
+ maxItemsPerCollection: Type.Integer({ default: 100_000, minimum: 1, maximum: 10_000_000 }),
828
849
  }, { default: {} }),
829
850
  query: Type.Object({
830
851
  maxPageSize: Type.Integer({ default: 100, minimum: 1, maximum: 500 }),
@@ -1659,6 +1680,11 @@ export const TEST_RECIPIENT_E164_RE = /^\+[1-9]\d{1,14}$/;
1659
1680
  export function isTestRecipientPattern(entry) {
1660
1681
  return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry) || TEST_RECIPIENT_E164_RE.test(entry);
1661
1682
  }
1683
+ /** The MAIL subset of the grammar (notifications suppression.testRecipients):
1684
+ * an exact email or an `*@domain` glob. */
1685
+ export function isMailTestRecipientPattern(entry) {
1686
+ return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry);
1687
+ }
1662
1688
  /** True iff `identifier` (an email today) matches one configured entry:
1663
1689
  * exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
1664
1690
  * an email. */
@@ -2107,6 +2133,17 @@ export function validateFeatureConfig(feature, raw) {
2107
2133
  if (errs.length)
2108
2134
  return { ok: false, errors: errs.slice(0, 10) };
2109
2135
  }
2136
+ // Cross-field rule (2026-10-04): notifications suppression.testRecipients — every
2137
+ // entry must be an email or an `*@domain` glob (the auth grammar minus
2138
+ // +E.164: a phone number can never match a mail recipient, so it would sit
2139
+ // in the list doing nothing).
2140
+ if (feature === 'notifications') {
2141
+ const v = withDefaults;
2142
+ const bad = (v.suppression?.testRecipients ?? []).filter((e) => !isMailTestRecipientPattern(e));
2143
+ if (bad.length) {
2144
+ return { ok: false, errors: bad.slice(0, 10).map((e) => `/suppression/testRecipients: '${e}' is not an email or an *@domain glob`) };
2145
+ }
2146
+ }
2110
2147
  // Cross-field rule: auth otp.testRecipients — every entry must be an
2111
2148
  // email, an `*@domain` glob, or a +E.164 number; anything else would never
2112
2149
  // match and silently do nothing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.9.1",
3
+ "version": "0.10.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
@@ -107,6 +107,21 @@ export class HookParseError extends Error {}
107
107
  export class HookEvalError extends Error {}
108
108
  /** Thrown by a `validate` hook whose expression is falsy — maps to a clean 422. */
109
109
  export class HookRejection extends Error {}
110
+ /** A WRITE `validate` hook whose expression could not be evaluated (null
111
+ * arithmetic, a missing operand, a bad date …). Still a HookEvalError — the
112
+ * write is refused, never let through — but it carries the hook's id and its
113
+ * declared `message`, so the API can show the end user the tenant's own copy
114
+ * while keeping the engine detail for the developer. `message` stays the
115
+ * engine detail. */
116
+ export class HookValidateFault extends HookEvalError {
117
+ constructor(
118
+ readonly hookId: string,
119
+ readonly tenantMessage: string | undefined,
120
+ detail: string,
121
+ ) {
122
+ super(detail);
123
+ }
124
+ }
110
125
 
111
126
  // ── tokenizer ───────────────────────────────────────────────────────────────
112
127
  type Tok =
@@ -624,7 +639,15 @@ export function runWriteHooks(
624
639
  for (const [id, h] of entries) {
625
640
  if (h.kind !== 'validate') continue;
626
641
  const ast = parseCached(id, h.expr);
627
- if (!truthy(evalExpr(ast, ctx))) {
642
+ let verdict: unknown;
643
+ try {
644
+ verdict = evalExpr(ast, ctx);
645
+ } catch (e) {
646
+ // fail closed with the hook's own message attached (see HookValidateFault)
647
+ if (e instanceof HookEvalError) throw new HookValidateFault(id, h.message || undefined, e.message);
648
+ throw e;
649
+ }
650
+ if (!truthy(verdict)) {
628
651
  throw new HookRejection(h.message || `validation hook '${id}' rejected the write`);
629
652
  }
630
653
  }
@@ -791,6 +814,203 @@ export function inferType(nd: Node): InferredType {
791
814
  }
792
815
  }
793
816
 
817
+ // ── push-time typing against the declared fields ────────────────────────────
818
+ // validateHookDef is purely syntactic: it cannot know that `item.meta` is a
819
+ // json field, so `item.meta != before.meta` (ALWAYS true — eq() never compares
820
+ // objects) or `item.title + '!'` (ALWAYS a fault — `+` is numeric) pass it and
821
+ // fail on every write. Where the caller knows the collection's fields (a push
822
+ // or apply that declares them, the dashboard editor) this resolves `item.<f>` /
823
+ // `before.<f>` to the declared type. It REFUSES only shapes that fault or
824
+ // mis-evaluate for every value the field can hold:
825
+ // - `==` / `!=` on a whole row (`item`, `before`, `caller`), or a json field
826
+ // against another json field or a whole row (eq() never compares objects);
827
+ // - `+` with `now`, a datetime field, or a non-numeric text literal (`+` is
828
+ // numeric and num() turns those into NaN → a fault on every write).
829
+ // Shapes that work for SOME stored values are WARNINGS, never refusals: a json
830
+ // field against a scalar (`item.meta == 'active'` is right when meta holds a
831
+ // string — json accepts any value), and `+` on a text-typed field (num()
832
+ // coerces a numeric string such as "42"). Anything it cannot type (an
833
+ // undeclared collection or field, a nested path, coalesce, a mixed ternary)
834
+ // gets no verdict. Disabled hooks (enabled: false) only ever warn.
835
+
836
+ /** One declared field, as the typing pass needs it (cms field type + required). */
837
+ export interface HookFieldInfo { type: string; required?: boolean }
838
+ /** collection → field → declared info. */
839
+ export type HookFieldTypes = Record<string, Record<string, HookFieldInfo>>;
840
+
841
+ type StaticType = 'number' | 'string' | 'boolean' | 'null' | 'json' | 'object' | 'unknown';
842
+ const FIELD_STATIC: Record<string, StaticType> = {
843
+ string: 'string', text: 'string', datetime: 'string', relation: 'string', file: 'string',
844
+ int: 'number', float: 'number', bool: 'boolean', json: 'json',
845
+ };
846
+ const WRITE_EVENTS = new Set<HookDef['event']>(['beforeCreate', 'beforeUpdate', 'beforeWrite']);
847
+
848
+ /** `item.<f>` / `before.<f>` (one level) → { root, field }; anything else → null. */
849
+ function fieldRef(nd: Node): { root: 'item' | 'before'; field: string } | null {
850
+ if (nd.t !== 'member' || nd.obj.t !== 'var') return null;
851
+ if (nd.obj.name !== 'item' && nd.obj.name !== 'before') return null;
852
+ return { root: nd.obj.name, field: nd.prop };
853
+ }
854
+
855
+ function staticTypeOf(nd: Node, fields: Record<string, HookFieldInfo> | undefined): StaticType {
856
+ if (nd.t === 'var' && (nd.name === 'item' || nd.name === 'before' || nd.name === 'caller')) return 'object';
857
+ const ref = fieldRef(nd);
858
+ if (ref) {
859
+ const f = fields && Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : undefined;
860
+ return f ? FIELD_STATIC[f.type] ?? 'unknown' : 'unknown';
861
+ }
862
+ return inferType(nd);
863
+ }
864
+
865
+ function describeOperand(nd: Node): string {
866
+ const ref = fieldRef(nd);
867
+ if (ref) return `${ref.root}.${ref.field}`;
868
+ if (nd.t === 'var') return nd.name;
869
+ if (nd.t === 'str') return JSON.stringify(nd.v);
870
+ return 'a text value';
871
+ }
872
+
873
+ /** An operand `+` can never turn into a number (num() → NaN → a fault on
874
+ * every evaluation): `now` (an ISO timestamp), a datetime field, or a text
875
+ * literal that is not numeric. Text fields are NOT here — "42" + 1 works. */
876
+ function nanOperand(nd: Node, fields: Record<string, HookFieldInfo> | undefined): Node | null {
877
+ if (nd.t === 'var' && nd.name === 'now') return nd;
878
+ if (nd.t === 'str') return Number.isFinite(Number(nd.v)) ? null : nd;
879
+ const ref = fieldRef(nd);
880
+ if (ref && fields && Object.prototype.hasOwnProperty.call(fields, ref.field) && fields[ref.field]!.type === 'datetime') return nd;
881
+ return null;
882
+ }
883
+
884
+ /** Type one parsed expression against one collection's declared fields.
885
+ * `errors` are shapes that can never work (refuse); `warnings` are shapes
886
+ * that work only for some stored values (json vs a scalar, `+` on a text
887
+ * field) and arithmetic on an optional field with no coalesce() — it faults
888
+ * the hook whenever the field is missing. `present` lists fields a derive sets first. */
889
+ export function checkHookExprTypes(
890
+ root: Node,
891
+ fields: Record<string, HookFieldInfo> | undefined,
892
+ opts: { kind: HookDef['kind']; event: HookDef['event']; present?: ReadonlySet<string> | undefined },
893
+ ): { errors: string[]; warnings: string[] } {
894
+ const errors: string[] = [];
895
+ const warnings: string[] = [];
896
+ const warned = new Set<string>();
897
+ const writePath = WRITE_EVENTS.has(opts.event);
898
+ const optionalOperand = (nd: Node): void => {
899
+ if (!writePath || !fields) return;
900
+ const ref = fieldRef(nd);
901
+ if (!ref) return;
902
+ const f = Object.prototype.hasOwnProperty.call(fields, ref.field) ? fields[ref.field] : undefined;
903
+ if (!f || f.required === true || (ref.root === 'item' && opts.present?.has(ref.field))) return;
904
+ const name = `${ref.root}.${ref.field}`;
905
+ if (warned.has(name)) return;
906
+ warned.add(name);
907
+ warnings.push(`arithmetic on '${name}', which is optional, faults the hook whenever it is missing — write coalesce(${name}, 0)`);
908
+ };
909
+ const walk = (nd: Node): void => {
910
+ switch (nd.t) {
911
+ case 'bin': {
912
+ let refused = false;
913
+ if (nd.op === '==' || nd.op === '!=') {
914
+ const lt = staticTypeOf(nd.l, fields);
915
+ const rt = staticTypeOf(nd.r, fields);
916
+ if (nd.l.t !== 'null' && nd.r.t !== 'null') {
917
+ const always = nd.op === '==' ? 'always false' : 'always true';
918
+ // provably an object on one side: a whole row, or json vs json/row
919
+ const objSide = lt === 'object' ? nd.l : rt === 'object' ? nd.r
920
+ : (lt === 'json' && rt === 'json') ? nd.l : null;
921
+ const jsonSide = lt === 'json' ? nd.l : rt === 'json' ? nd.r : null;
922
+ if (objSide) {
923
+ 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()`);
924
+ } else if (jsonSide) {
925
+ 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)`);
926
+ }
927
+ }
928
+ } else if (nd.op === '+') {
929
+ const nan = nanOperand(nd.l, fields) ?? nanOperand(nd.r, fields);
930
+ if (nan) {
931
+ refused = true;
932
+ errors.push(`'+' adds numbers only, and '${describeOperand(nan)}' is never a number — use concat() to join text`);
933
+ } else {
934
+ const strSide = staticTypeOf(nd.l, fields) === 'string' ? nd.l : staticTypeOf(nd.r, fields) === 'string' ? nd.r : null;
935
+ if (strSide) {
936
+ 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`);
937
+ }
938
+ }
939
+ }
940
+ if (!refused && (nd.op === '+' || nd.op === '-' || nd.op === '*' || nd.op === '/' || nd.op === '%')) {
941
+ optionalOperand(nd.l);
942
+ optionalOperand(nd.r);
943
+ }
944
+ walk(nd.l); walk(nd.r);
945
+ return;
946
+ }
947
+ case 'unary':
948
+ if (nd.op === '-') optionalOperand(nd.arg);
949
+ walk(nd.arg);
950
+ return;
951
+ case 'tern': walk(nd.c); walk(nd.a); walk(nd.b); return;
952
+ case 'call': for (const a of nd.args) walk(a); return;
953
+ case 'member': walk(nd.obj); return;
954
+ default: return;
955
+ }
956
+ };
957
+ walk(root);
958
+ return { errors, warnings };
959
+ }
960
+
961
+ /** Project declared collections (`{ <name>: { fields: { <f>: { type, required? } } } }`
962
+ * — the vxil.config / apply-bundle shape) onto HookFieldTypes. Malformed
963
+ * entries are skipped: no declared field means no verdict. */
964
+ export function hookFieldTypesOf(collections: unknown): HookFieldTypes {
965
+ const out: HookFieldTypes = {};
966
+ if (!collections || typeof collections !== 'object') return out;
967
+ for (const [name, def] of Object.entries(collections as Record<string, unknown>)) {
968
+ const fields = (def as { fields?: unknown } | null)?.fields;
969
+ if (!fields || typeof fields !== 'object') continue;
970
+ const m: Record<string, HookFieldInfo> = {};
971
+ for (const [f, fd] of Object.entries(fields as Record<string, unknown>)) {
972
+ const t = (fd as { type?: unknown } | null)?.type;
973
+ if (typeof t !== 'string') continue;
974
+ m[f] = { type: t, ...((fd as { required?: unknown }).required === true ? { required: true } : {}) };
975
+ }
976
+ out[name] = m;
977
+ }
978
+ return out;
979
+ }
980
+
981
+ /** Type every hook whose collection the caller declares (see checkHookExprTypes).
982
+ * Unparseable expressions are skipped — validateHookDef already reports them. */
983
+ export function checkHookTypes(
984
+ hooks: Record<string, HookDef> | undefined,
985
+ fieldTypes: HookFieldTypes,
986
+ ): { errors: string[]; warnings: string[] } {
987
+ const errors: string[] = [];
988
+ const warnings: string[] = [];
989
+ if (!hooks) return { errors, warnings };
990
+ // fields a write-path derive sets before the validates run (runWriteHooks pass 1)
991
+ const derived = new Map<string, Set<string>>();
992
+ for (const h of Object.values(hooks)) {
993
+ if (h.kind === 'derive' && h.field && WRITE_EVENTS.has(h.event) && h.enabled !== false) {
994
+ if (!derived.has(h.collection)) derived.set(h.collection, new Set());
995
+ derived.get(h.collection)!.add(h.field);
996
+ }
997
+ }
998
+ for (const [id, h] of Object.entries(hooks)) {
999
+ if (!Object.prototype.hasOwnProperty.call(fieldTypes, h.collection)) continue;
1000
+ let ast: Node;
1001
+ try { ast = parseExpr(h.expr ?? ''); } catch { continue; }
1002
+ if (validateAst(ast).length) continue;
1003
+ const r = checkHookExprTypes(ast, fieldTypes[h.collection], {
1004
+ kind: h.kind, event: h.event, present: h.kind === 'validate' ? derived.get(h.collection) : undefined,
1005
+ });
1006
+ // a disabled hook never runs: its findings cannot break a write, so they warn
1007
+ if (h.enabled === false) for (const e of r.errors) warnings.push(`hooks.${id}.expr (disabled): ${e}`);
1008
+ else for (const e of r.errors) errors.push(`hooks.${id}.expr: ${e}`);
1009
+ for (const w of r.warnings) warnings.push(`hooks.${id}.expr: ${w}`);
1010
+ }
1011
+ return { errors, warnings };
1012
+ }
1013
+
794
1014
  export type HlKind = 'num' | 'str' | 'fn' | 'var' | 'kw' | 'op' | 'ident' | 'ws' | 'err';
795
1015
 
796
1016
  /** 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
@@ -971,7 +993,9 @@ export const CmsConfigSchema = Type.Object({
971
993
  maxCollections: Type.Integer({ default: 25, minimum: 1, maximum: 200 }),
972
994
  maxFieldsPerCollection: Type.Integer({ default: 50, minimum: 1, maximum: 200 }),
973
995
  maxItemBytes: Type.Integer({ default: 256 * 1024, minimum: 1024, maximum: 1024 * 1024 }),
974
- maxItemsPerCollection: Type.Integer({ default: 100_000, minimum: 1 }),
996
+ // Capped at 10 M: every create counts the collection's live rows, so
997
+ // the cap also bounds what each create pays.
998
+ maxItemsPerCollection: Type.Integer({ default: 100_000, minimum: 1, maximum: 10_000_000 }),
975
999
  },
976
1000
  { default: {} },
977
1001
  ),
@@ -2048,6 +2072,11 @@ export const TEST_RECIPIENT_E164_RE = /^\+[1-9]\d{1,14}$/;
2048
2072
  export function isTestRecipientPattern(entry: string): boolean {
2049
2073
  return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry) || TEST_RECIPIENT_E164_RE.test(entry);
2050
2074
  }
2075
+ /** The MAIL subset of the grammar (notifications suppression.testRecipients):
2076
+ * an exact email or an `*@domain` glob. */
2077
+ export function isMailTestRecipientPattern(entry: string): boolean {
2078
+ return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry);
2079
+ }
2051
2080
  /** True iff `identifier` (an email today) matches one configured entry:
2052
2081
  * exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
2053
2082
  * an email. */
@@ -2535,6 +2564,17 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2535
2564
  const errs = validateDeclaredWebhookSubscriptions(v.subscriptions, v.maxSubscriptions);
2536
2565
  if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
2537
2566
  }
2567
+ // Cross-field rule (2026-10-04): notifications suppression.testRecipients — every
2568
+ // entry must be an email or an `*@domain` glob (the auth grammar minus
2569
+ // +E.164: a phone number can never match a mail recipient, so it would sit
2570
+ // in the list doing nothing).
2571
+ if (feature === 'notifications') {
2572
+ const v = withDefaults as { suppression?: { testRecipients?: string[] } };
2573
+ const bad = (v.suppression?.testRecipients ?? []).filter((e) => !isMailTestRecipientPattern(e));
2574
+ if (bad.length) {
2575
+ return { ok: false, errors: bad.slice(0, 10).map((e) => `/suppression/testRecipients: '${e}' is not an email or an *@domain glob`) };
2576
+ }
2577
+ }
2538
2578
  // Cross-field rule: auth otp.testRecipients — every entry must be an
2539
2579
  // email, an `*@domain` glob, or a +E.164 number; anything else would never
2540
2580
  // match and silently do nothing.