@vxil/feature-configs 0.9.0 → 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;
@@ -362,7 +363,7 @@ export declare const FilesConfigSchema: import("@sinclair/typebox").TObject<{
362
363
  downloadUrlTtl: import("@sinclair/typebox").TInteger;
363
364
  quotas: import("@sinclair/typebox").TObject<{
364
365
  maxObjectBytes: import("@sinclair/typebox").TInteger;
365
- maxTotalBytes: import("@sinclair/typebox").TInteger;
366
+ maxTotalBytes: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
366
367
  maxObjectCount: import("@sinclair/typebox").TInteger;
367
368
  }>;
368
369
  allowedContentTypes: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
@@ -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
@@ -700,11 +719,16 @@ export const FilesConfigSchema = Type.Object({
700
719
  quotas: Type.Object({
701
720
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
702
721
  // object storage is cheap but the database-resident metadata + abuse aren't
703
- // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
704
- // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
705
- // tenant tier threaded to files-v1 (plan tiers).
722
+ // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute.
706
723
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
707
- maxTotalBytes: Type.Integer({ default: 10 * 1024 * 1024 * 1024, minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 }),
724
+ // NO default (2026-10-04): UNSET means "the plan's storage ceiling"
725
+ // (control-plane plans.ts FILES_MAX_TOTAL_BYTES_BY_TIER — Free/Developer
726
+ // 10 GiB, Team 50 GiB, Business 256 GiB, Enterprise 1 TiB), resolved by
727
+ // files-v1 where the quota is enforced, so a plan change applies at once.
728
+ // A value is the project's own LOWER cap (above the plan → 422 at config
729
+ // write; a downgrade clamps it). It used to default to 10 GiB on every
730
+ // plan, so a Team project that never set it stayed at 10 GiB.
731
+ maxTotalBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 })),
708
732
  maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
709
733
  }, { default: {} }),
710
734
  allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
@@ -726,7 +750,7 @@ export const FilesConfigSchema = Type.Object({
726
750
  enabled: Type.Boolean({ default: false }),
727
751
  // per-bucket default; omitted = never expire by default
728
752
  defaultExpiresInSeconds: Type.Optional(Type.Integer({ minimum: 60 })),
729
- sweepCron: Type.String({ default: '0 * * * *' }), // jobs-v1 TTL sweep schedule
753
+ sweepCron: Type.String({ default: '*/15 * * * *' }), // jobs-v1 TTL sweep schedule (every 15 min since 2026-10-04; was hourly)
730
754
  })),
731
755
  extractText: Type.Optional(Type.Object({
732
756
  enabled: Type.Boolean({ default: false }),
@@ -819,7 +843,9 @@ export const CmsConfigSchema = Type.Object({
819
843
  maxCollections: Type.Integer({ default: 25, minimum: 1, maximum: 200 }),
820
844
  maxFieldsPerCollection: Type.Integer({ default: 50, minimum: 1, maximum: 200 }),
821
845
  maxItemBytes: Type.Integer({ default: 256 * 1024, minimum: 1024, maximum: 1024 * 1024 }),
822
- 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 }),
823
849
  }, { default: {} }),
824
850
  query: Type.Object({
825
851
  maxPageSize: Type.Integer({ default: 100, minimum: 1, maximum: 500 }),
@@ -1654,6 +1680,11 @@ export const TEST_RECIPIENT_E164_RE = /^\+[1-9]\d{1,14}$/;
1654
1680
  export function isTestRecipientPattern(entry) {
1655
1681
  return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry) || TEST_RECIPIENT_E164_RE.test(entry);
1656
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
+ }
1657
1688
  /** True iff `identifier` (an email today) matches one configured entry:
1658
1689
  * exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
1659
1690
  * an email. */
@@ -2102,6 +2133,17 @@ export function validateFeatureConfig(feature, raw) {
2102
2133
  if (errs.length)
2103
2134
  return { ok: false, errors: errs.slice(0, 10) };
2104
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
+ }
2105
2147
  // Cross-field rule: auth otp.testRecipients — every entry must be an
2106
2148
  // email, an `*@domain` glob, or a +E.164 number; anything else would never
2107
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.0",
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
@@ -823,11 +845,16 @@ export const FilesConfigSchema = Type.Object({
823
845
  {
824
846
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
825
847
  // object storage is cheap but the database-resident metadata + abuse aren't
826
- // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
827
- // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
828
- // tenant tier threaded to files-v1 (plan tiers).
848
+ // (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute.
829
849
  maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
830
- maxTotalBytes: Type.Integer({ default: 10 * 1024 * 1024 * 1024, minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 }),
850
+ // NO default (2026-10-04): UNSET means "the plan's storage ceiling"
851
+ // (control-plane plans.ts FILES_MAX_TOTAL_BYTES_BY_TIER — Free/Developer
852
+ // 10 GiB, Team 50 GiB, Business 256 GiB, Enterprise 1 TiB), resolved by
853
+ // files-v1 where the quota is enforced, so a plan change applies at once.
854
+ // A value is the project's own LOWER cap (above the plan → 422 at config
855
+ // write; a downgrade clamps it). It used to default to 10 GiB on every
856
+ // plan, so a Team project that never set it stayed at 10 GiB.
857
+ maxTotalBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 })),
831
858
  maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
832
859
  },
833
860
  { default: {} },
@@ -854,7 +881,7 @@ export const FilesConfigSchema = Type.Object({
854
881
  enabled: Type.Boolean({ default: false }),
855
882
  // per-bucket default; omitted = never expire by default
856
883
  defaultExpiresInSeconds: Type.Optional(Type.Integer({ minimum: 60 })),
857
- sweepCron: Type.String({ default: '0 * * * *' }), // jobs-v1 TTL sweep schedule
884
+ sweepCron: Type.String({ default: '*/15 * * * *' }), // jobs-v1 TTL sweep schedule (every 15 min since 2026-10-04; was hourly)
858
885
  })),
859
886
  extractText: Type.Optional(Type.Object({
860
887
  enabled: Type.Boolean({ default: false }),
@@ -966,7 +993,9 @@ export const CmsConfigSchema = Type.Object({
966
993
  maxCollections: Type.Integer({ default: 25, minimum: 1, maximum: 200 }),
967
994
  maxFieldsPerCollection: Type.Integer({ default: 50, minimum: 1, maximum: 200 }),
968
995
  maxItemBytes: Type.Integer({ default: 256 * 1024, minimum: 1024, maximum: 1024 * 1024 }),
969
- 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 }),
970
999
  },
971
1000
  { default: {} },
972
1001
  ),
@@ -2043,6 +2072,11 @@ export const TEST_RECIPIENT_E164_RE = /^\+[1-9]\d{1,14}$/;
2043
2072
  export function isTestRecipientPattern(entry: string): boolean {
2044
2073
  return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry) || TEST_RECIPIENT_E164_RE.test(entry);
2045
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
+ }
2046
2080
  /** True iff `identifier` (an email today) matches one configured entry:
2047
2081
  * exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
2048
2082
  * an email. */
@@ -2530,6 +2564,17 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2530
2564
  const errs = validateDeclaredWebhookSubscriptions(v.subscriptions, v.maxSubscriptions);
2531
2565
  if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
2532
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
+ }
2533
2578
  // Cross-field rule: auth otp.testRecipients — every entry must be an
2534
2579
  // email, an `*@domain` glob, or a +E.164 number; anything else would never
2535
2580
  // match and silently do nothing.