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