@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 +41 -0
- package/dist/hooks.js +227 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +49 -7
- package/package.json +1 -1
- package/src/hooks.ts +221 -1
- package/src/index.ts +55 -10
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
254
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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.
|