@vxil/feature-configs 0.4.0 → 0.4.1
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/index.js +1 -1
- package/package.json +3 -2
- package/src/index.ts +1 -1
- package/src/hooks.test.ts +0 -451
- package/src/index.test.ts +0 -1423
package/dist/index.js
CHANGED
|
@@ -1063,7 +1063,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1063
1063
|
// Opt-in period-end enforcement (money-path operations wave, decision D2
|
|
1064
1064
|
// option a). ABSENT (the default) = today's behaviour: a subscription whose
|
|
1065
1065
|
// current_period_end passed with no provider event stays entitled forever
|
|
1066
|
-
// (the provider is the only clock). PRESENT = the
|
|
1066
|
+
// (the provider is the only clock). PRESENT = the nightly reconcile sweep
|
|
1067
1067
|
// flips an `active`/`trialing` row whose current_period_end + slackHours
|
|
1068
1068
|
// has passed with no renewal to status 'lapsed' (+ refold, +
|
|
1069
1069
|
// payments.subscription.lapsed); a later provider renewal event revives it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/feature-configs",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"dist",
|
|
27
|
-
"src"
|
|
27
|
+
"src",
|
|
28
|
+
"!src/**/*.test.ts"
|
|
28
29
|
],
|
|
29
30
|
"dependencies": {
|
|
30
31
|
"@sinclair/typebox": "^0.34.0"
|
package/src/index.ts
CHANGED
|
@@ -1369,7 +1369,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1369
1369
|
// Opt-in period-end enforcement (money-path operations wave, decision D2
|
|
1370
1370
|
// option a). ABSENT (the default) = today's behaviour: a subscription whose
|
|
1371
1371
|
// current_period_end passed with no provider event stays entitled forever
|
|
1372
|
-
// (the provider is the only clock). PRESENT = the
|
|
1372
|
+
// (the provider is the only clock). PRESENT = the nightly reconcile sweep
|
|
1373
1373
|
// flips an `active`/`trialing` row whose current_period_end + slackHours
|
|
1374
1374
|
// has passed with no renewal to status 'lapsed' (+ refold, +
|
|
1375
1375
|
// payments.subscription.lapsed); a later provider renewal event revives it
|
package/src/hooks.test.ts
DELETED
|
@@ -1,451 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
parseExpr, validateAst, evalExpr, runWriteHooks, runReadHooks, validateHookDef, validateHooksConfig,
|
|
4
|
-
extractRefs, inferType, highlightTokens,
|
|
5
|
-
HookParseError, HookEvalError, HookRejection, HOOK_LIMITS,
|
|
6
|
-
type HookContext, type HookDef,
|
|
7
|
-
} from './hooks.js';
|
|
8
|
-
import { validateFeatureConfig } from './index.js';
|
|
9
|
-
|
|
10
|
-
const ctx = (item: Record<string, unknown>, before: Record<string, unknown> | null = null): HookContext => ({
|
|
11
|
-
item, before, now: '2026-06-19T00:00:00.000Z',
|
|
12
|
-
});
|
|
13
|
-
const run = (src: string, c: HookContext): unknown => {
|
|
14
|
-
const ast = parseExpr(src);
|
|
15
|
-
expect(validateAst(ast)).toEqual([]);
|
|
16
|
-
return evalExpr(ast, c);
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
describe('expression evaluator — correctness', () => {
|
|
20
|
-
it('arithmetic + precedence', () => {
|
|
21
|
-
expect(run('1 + 2 * 3', ctx({}))).toBe(7);
|
|
22
|
-
expect(run('(1 + 2) * 3', ctx({}))).toBe(9);
|
|
23
|
-
expect(run('10 / 4', ctx({}))).toBe(2.5);
|
|
24
|
-
expect(run('10 % 3', ctx({}))).toBe(1);
|
|
25
|
-
expect(run('-5 + 2', ctx({}))).toBe(-3);
|
|
26
|
-
});
|
|
27
|
-
it('comparisons + booleans + ternary', () => {
|
|
28
|
-
expect(run('3 > 2 && 2 >= 2', ctx({}))).toBe(true);
|
|
29
|
-
expect(run('1 == 1 || 2 == 3', ctx({}))).toBe(true);
|
|
30
|
-
expect(run('!false', ctx({}))).toBe(true);
|
|
31
|
-
expect(run('5 > 3 ? "big" : "small"', ctx({}))).toBe('big');
|
|
32
|
-
expect(run('"a" < "b"', ctx({}))).toBe(true);
|
|
33
|
-
});
|
|
34
|
-
it('member access reads own item fields; missing → undefined-safe', () => {
|
|
35
|
-
expect(run('item.weight_kg', ctx({ weight_kg: 80 }))).toBe(80);
|
|
36
|
-
expect(run('item.profile.age', ctx({ profile: { age: 30 } }))).toBe(30);
|
|
37
|
-
expect(run('coalesce(item.missing, 5)', ctx({}))).toBe(5);
|
|
38
|
-
expect(run('isNull(item.missing)', ctx({}))).toBe(true);
|
|
39
|
-
});
|
|
40
|
-
it('built-in functions', () => {
|
|
41
|
-
expect(run('round(3.7)', ctx({}))).toBe(4);
|
|
42
|
-
expect(run('max(1, 9, 3)', ctx({}))).toBe(9);
|
|
43
|
-
expect(run('concat("a", "b", "c")', ctx({}))).toBe('abc');
|
|
44
|
-
expect(run('lower("HELLO")', ctx({}))).toBe('hello');
|
|
45
|
-
expect(run('len("abcd")', ctx({}))).toBe(4);
|
|
46
|
-
expect(run('contains("hello world", "world")', ctx({}))).toBe(true);
|
|
47
|
-
expect(run('yearsBetween(now, item.dob) > 18', ctx({ dob: '1990-01-01T00:00:00Z' }))).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
it('the real Mealix Mifflin–St Jeor BMR expression evaluates correctly', () => {
|
|
50
|
-
// male: (10*kg) + (6.25*cm) - (5*age) + 5
|
|
51
|
-
const expr = '(10 * item.weight_kg) + (6.25 * item.height_cm) - (5 * yearsBetween(now, item.dob)) + 5';
|
|
52
|
-
const v = run(expr, ctx({ weight_kg: 80, height_cm: 180, dob: '1994-06-19T00:00:00Z' })) as number;
|
|
53
|
-
// 800 + 1125 - 5*32 + 5 = 1770
|
|
54
|
-
expect(Math.round(v)).toBe(1770);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('runWriteHooks — derive + validate semantics', () => {
|
|
59
|
-
const hooks: Record<string, HookDef> = {
|
|
60
|
-
bmr: { collection: 'profiles', event: 'beforeWrite', kind: 'derive', field: 'bmr',
|
|
61
|
-
expr: 'round((10 * item.weight_kg) + (6.25 * item.height_cm) + 5)' },
|
|
62
|
-
posWeight: { collection: 'profiles', event: 'beforeWrite', kind: 'validate',
|
|
63
|
-
expr: 'item.weight_kg > 0', message: 'weight must be positive' },
|
|
64
|
-
};
|
|
65
|
-
it('derive sets the computed field; validate passes for valid input', () => {
|
|
66
|
-
const r = runWriteHooks(hooks, 'profiles', 'create', { weight_kg: 80, height_cm: 180 }, null, '2026-06-19T00:00:00Z');
|
|
67
|
-
expect(r.data.bmr).toBe(1930); // round(800 + 1125 + 5)
|
|
68
|
-
expect(r.derived).toContain('bmr');
|
|
69
|
-
});
|
|
70
|
-
it('validate hook rejects with its message', () => {
|
|
71
|
-
expect(() => runWriteHooks(hooks, 'profiles', 'create', { weight_kg: -1, height_cm: 180 }, null, 'x'))
|
|
72
|
-
.toThrow(HookRejection);
|
|
73
|
-
try { runWriteHooks(hooks, 'profiles', 'create', { weight_kg: 0, height_cm: 1 }, null, 'x'); }
|
|
74
|
-
catch (e) { expect((e as Error).message).toBe('weight must be positive'); }
|
|
75
|
-
});
|
|
76
|
-
it('does not run hooks for a different collection or disabled hooks', () => {
|
|
77
|
-
const r = runWriteHooks(hooks, 'other', 'create', { weight_kg: -5 }, null, 'x');
|
|
78
|
-
expect(r.derived).toEqual([]);
|
|
79
|
-
const disabled: Record<string, HookDef> = { h: { ...hooks.posWeight!, enabled: false } };
|
|
80
|
-
expect(() => runWriteHooks(disabled, 'profiles', 'create', { weight_kg: -1 }, null, 'x')).not.toThrow();
|
|
81
|
-
});
|
|
82
|
-
it('event matching: beforeCreate does not fire on update', () => {
|
|
83
|
-
const h = { c: { collection: 'p', event: 'beforeCreate' as const, kind: 'validate' as const, expr: 'false' } };
|
|
84
|
-
expect(() => runWriteHooks(h, 'p', 'update', {}, {}, 'x')).not.toThrow();
|
|
85
|
-
expect(() => runWriteHooks(h, 'p', 'create', {}, null, 'x')).toThrow(HookRejection);
|
|
86
|
-
});
|
|
87
|
-
it('a derived value is visible to a later validate hook', () => {
|
|
88
|
-
const h: Record<string, HookDef> = {
|
|
89
|
-
d: { collection: 'p', event: 'beforeWrite', kind: 'derive', field: 'total', expr: 'item.a + item.b' },
|
|
90
|
-
v: { collection: 'p', event: 'beforeWrite', kind: 'validate', expr: 'item.total <= 100', message: 'over 100' },
|
|
91
|
-
};
|
|
92
|
-
expect(() => runWriteHooks(h, 'p', 'create', { a: 60, b: 60 }, null, 'x')).toThrow(/over 100/);
|
|
93
|
-
expect(runWriteHooks(h, 'p', 'create', { a: 10, b: 20 }, null, 'x').data.total).toBe(30);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe('runReadHooks — beforeRead visibility + afterRead output shaping', () => {
|
|
98
|
-
const NOW = '2026-07-01T00:00:00.000Z';
|
|
99
|
-
const row = (id: string, data: Record<string, unknown>) =>
|
|
100
|
-
({ item_id: id, collection: 'articles', data });
|
|
101
|
-
const hooks: Record<string, HookDef> = {
|
|
102
|
-
visible: { collection: 'articles', event: 'beforeRead', kind: 'validate', expr: 'not(item.hidden)' },
|
|
103
|
-
teaser: { collection: 'articles', event: 'afterRead', kind: 'derive', field: 'teaser', expr: 'substr(item.body, 0, 4)' },
|
|
104
|
-
hideBody: { collection: 'articles', event: 'afterRead', kind: 'redact', field: 'body', expr: 'item.premium' },
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
it('afterRead derive sets the output field; the INPUT rows are never mutated', () => {
|
|
108
|
-
const input = [row('itm_1', { body: 'hello world' })];
|
|
109
|
-
const r = runReadHooks(hooks, 'articles', input, NOW);
|
|
110
|
-
expect(r.rows).toHaveLength(1);
|
|
111
|
-
expect((r.rows[0]!.data as Record<string, unknown>).teaser).toBe('hell');
|
|
112
|
-
expect((input[0]!.data as Record<string, unknown>).teaser).toBeUndefined(); // output copy only
|
|
113
|
-
expect(r.dropped).toBe(0);
|
|
114
|
-
});
|
|
115
|
-
it('redact removes the field when the expression is truthy, keeps it when falsy', () => {
|
|
116
|
-
const r = runReadHooks(hooks, 'articles', [
|
|
117
|
-
row('itm_1', { body: 'secret text', premium: true }),
|
|
118
|
-
row('itm_2', { body: 'public text', premium: false }),
|
|
119
|
-
], NOW);
|
|
120
|
-
expect((r.rows[0]!.data as Record<string, unknown>).body).toBeUndefined();
|
|
121
|
-
expect((r.rows[1]!.data as Record<string, unknown>).body).toBe('public text');
|
|
122
|
-
});
|
|
123
|
-
it('beforeRead validate falsy drops the row; survivors keep input order', () => {
|
|
124
|
-
const r = runReadHooks(hooks, 'articles', [
|
|
125
|
-
row('itm_1', { body: 'a', hidden: false }),
|
|
126
|
-
row('itm_2', { body: 'b', hidden: true }),
|
|
127
|
-
row('itm_3', { body: 'c', hidden: false }),
|
|
128
|
-
], NOW);
|
|
129
|
-
expect(r.rows.map((x) => x.item_id)).toEqual(['itm_1', 'itm_3']);
|
|
130
|
-
expect(r.dropped).toBe(1);
|
|
131
|
-
});
|
|
132
|
-
it('derives run BEFORE redacts (a derive can read a field a redact then hides)', () => {
|
|
133
|
-
const r = runReadHooks(hooks, 'articles', [row('itm_1', { body: 'premium body', premium: true })], NOW);
|
|
134
|
-
const data = r.rows[0]!.data as Record<string, unknown>;
|
|
135
|
-
expect(data.teaser).toBe('prem'); // computed from body…
|
|
136
|
-
expect(data.body).toBeUndefined(); // …which the redact then removed
|
|
137
|
-
});
|
|
138
|
-
it('a read derive that returns a non-scalar is rejected (no aliasing into the response)', () => {
|
|
139
|
-
const h: Record<string, HookDef> = {
|
|
140
|
-
bad: { collection: 'articles', event: 'afterRead', kind: 'derive', field: 'x', expr: 'item.sub' },
|
|
141
|
-
};
|
|
142
|
-
expect(() => runReadHooks(h, 'articles', [row('itm_1', { sub: { a: 1 } })], NOW)).toThrow(/scalar/);
|
|
143
|
-
});
|
|
144
|
-
it('read hooks NEVER fire in runWriteHooks; write hooks NEVER fire in runReadHooks', () => {
|
|
145
|
-
// a beforeRead validate that always fails must not reject a write…
|
|
146
|
-
const readOnly: Record<string, HookDef> = {
|
|
147
|
-
v: { collection: 'p', event: 'beforeRead', kind: 'validate', expr: 'false' },
|
|
148
|
-
d: { collection: 'p', event: 'afterRead', kind: 'derive', field: 'x', expr: '1' },
|
|
149
|
-
};
|
|
150
|
-
const w = runWriteHooks(readOnly, 'p', 'create', { a: 1 }, null, NOW);
|
|
151
|
-
expect(w.data.x).toBeUndefined();
|
|
152
|
-
expect(w.derived).toEqual([]);
|
|
153
|
-
// …and a beforeWrite validate/derive must not drop or shape a read.
|
|
154
|
-
const writeOnly: Record<string, HookDef> = {
|
|
155
|
-
v: { collection: 'p', event: 'beforeWrite', kind: 'validate', expr: 'false' },
|
|
156
|
-
d: { collection: 'p', event: 'beforeWrite', kind: 'derive', field: 'x', expr: '1' },
|
|
157
|
-
};
|
|
158
|
-
const r = runReadHooks(writeOnly, 'p', [{ item_id: 'itm_1', collection: 'p', data: { a: 1 } }], NOW);
|
|
159
|
-
expect(r.rows).toHaveLength(1);
|
|
160
|
-
expect(r.dropped).toBe(0);
|
|
161
|
-
expect((r.rows[0]!.data as Record<string, unknown>).x).toBeUndefined();
|
|
162
|
-
});
|
|
163
|
-
it('disabled/off-collection read hooks are skipped', () => {
|
|
164
|
-
const h: Record<string, HookDef> = {
|
|
165
|
-
off: { collection: 'articles', event: 'beforeRead', kind: 'validate', expr: 'false', enabled: false },
|
|
166
|
-
other: { collection: 'elsewhere', event: 'beforeRead', kind: 'validate', expr: 'false' },
|
|
167
|
-
};
|
|
168
|
-
const r = runReadHooks(h, 'articles', [row('itm_1', { a: 1 })], NOW);
|
|
169
|
-
expect(r.rows).toHaveLength(1);
|
|
170
|
-
});
|
|
171
|
-
it('the SHARED page budget bounds the whole request (exhaustion → HookEvalError)', () => {
|
|
172
|
-
// 10 read hooks (the per-collection cap) × a ~239-node expression ≈ 2.4k
|
|
173
|
-
// steps per row; ~120 rows ≈ 287k steps > maxReadEvalStepsPerRequest.
|
|
174
|
-
const wide = Array(120).fill('1').join('+'); // 239 AST nodes, < maxNodes
|
|
175
|
-
const h: Record<string, HookDef> = {};
|
|
176
|
-
for (let i = 0; i < HOOK_LIMITS.maxReadHooksPerCollection; i++) {
|
|
177
|
-
h[`d${i}`] = { collection: 'articles', event: 'afterRead', kind: 'derive', field: 'x', expr: wide };
|
|
178
|
-
}
|
|
179
|
-
const rows = Array.from({ length: 120 }, (_, i) => row(`itm_${i}`, {}));
|
|
180
|
-
expect(() => runReadHooks(h, 'articles', rows, NOW)).toThrow(/read evaluation budget/);
|
|
181
|
-
// a small page with the same hooks stays comfortably within budget
|
|
182
|
-
expect(() => runReadHooks(h, 'articles', rows.slice(0, 5), NOW)).not.toThrow();
|
|
183
|
-
});
|
|
184
|
-
it('a redact targeting a forbidden field is contained at runtime (belt-and-braces)', () => {
|
|
185
|
-
const h: Record<string, HookDef> = {
|
|
186
|
-
bad: { collection: 'articles', event: 'afterRead', kind: 'redact', field: '__proto__', expr: 'true' },
|
|
187
|
-
};
|
|
188
|
-
expect(() => runReadHooks(h, 'articles', [row('itm_1', {})], NOW)).toThrow(HookEvalError);
|
|
189
|
-
});
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
// ── read-hook caller/session context (design §5.6) ──────────────────────────
|
|
193
|
-
describe('runReadHooks — caller context (verified end-user principal)', () => {
|
|
194
|
-
const NOW = '2026-07-01T00:00:00.000Z';
|
|
195
|
-
const row = (id: string, data: Record<string, unknown>) =>
|
|
196
|
-
({ item_id: id, collection: 'notes', data });
|
|
197
|
-
// A per-viewer shaping bag: derive `is_mine`, redact `secret` when not the owner.
|
|
198
|
-
const hooks: Record<string, HookDef> = {
|
|
199
|
-
mine: { collection: 'notes', event: 'afterRead', kind: 'derive', field: 'is_mine', expr: 'caller.endUserId == item.owner' },
|
|
200
|
-
hideSecret: { collection: 'notes', event: 'afterRead', kind: 'redact', field: 'secret', expr: 'not(caller.endUserId == item.owner)' },
|
|
201
|
-
who: { collection: 'notes', event: 'afterRead', kind: 'derive', field: 'seen_by', expr: 'caller.principal' },
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
it('caller is passed to read hooks: derive/redact key on the verified end-user', () => {
|
|
205
|
-
const caller = { endUserId: 'usr_a', principal: 'end_user' as const };
|
|
206
|
-
const r = runReadHooks(hooks, 'notes', [
|
|
207
|
-
row('itm_1', { owner: 'usr_a', secret: 'mine' }),
|
|
208
|
-
row('itm_2', { owner: 'usr_b', secret: 'theirs' }),
|
|
209
|
-
], NOW, caller);
|
|
210
|
-
const own = r.rows[0]!.data as Record<string, unknown>;
|
|
211
|
-
const foreign = r.rows[1]!.data as Record<string, unknown>;
|
|
212
|
-
expect(own.is_mine).toBe(true);
|
|
213
|
-
expect(own.secret).toBe('mine'); // owner keeps the secret
|
|
214
|
-
expect(own.seen_by).toBe('end_user');
|
|
215
|
-
expect(foreign.is_mine).toBe(false);
|
|
216
|
-
expect(foreign.secret).toBeUndefined(); // non-owner: redacted
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('server-caller context {endUserId:null, principal:"tenant"} is honored', () => {
|
|
220
|
-
const caller = { endUserId: null, principal: 'tenant' as const };
|
|
221
|
-
const r = runReadHooks(hooks, 'notes', [row('itm_1', { owner: 'usr_a', secret: 's' })], NOW, caller);
|
|
222
|
-
const d = r.rows[0]!.data as Record<string, unknown>;
|
|
223
|
-
expect(d.is_mine).toBe(false); // null != 'usr_a'
|
|
224
|
-
expect(d.seen_by).toBe('tenant');
|
|
225
|
-
expect(d.secret).toBeUndefined(); // a server caller is not the owner → redacted by this hook
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it('caller omitted (other runReadHooks callers) → caller is null', () => {
|
|
229
|
-
const h: Record<string, HookDef> = {
|
|
230
|
-
p: { collection: 'notes', event: 'afterRead', kind: 'derive', field: 'p', expr: 'isNull(caller)' },
|
|
231
|
-
};
|
|
232
|
-
const r = runReadHooks(h, 'notes', [row('itm_1', {})], NOW);
|
|
233
|
-
expect((r.rows[0]!.data as Record<string, unknown>).p).toBe(true);
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it('validateAst allows `caller` as a root variable', () => {
|
|
237
|
-
expect(validateAst(parseExpr('caller.endUserId == item.owner'))).toEqual([]);
|
|
238
|
-
expect(validateAst(parseExpr('caller.principal == "end_user"'))).toEqual([]);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('caller is null on the WRITE path (a write derive referencing it does not throw)', () => {
|
|
242
|
-
const wh: Record<string, HookDef> = {
|
|
243
|
-
d: { collection: 'notes', event: 'beforeCreate', kind: 'derive', field: 'x', expr: 'isNull(caller)' },
|
|
244
|
-
};
|
|
245
|
-
const out = runWriteHooks(wh, 'notes', 'create', { title: 't' }, null, NOW);
|
|
246
|
-
expect(out.data.x).toBe(true);
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// ── the anti-malice red-team: every one of these MUST be rejected/contained ──
|
|
251
|
-
describe('SAFETY — malicious / abusive inputs are rejected or contained', () => {
|
|
252
|
-
const rejected = (src: string): string[] => {
|
|
253
|
-
try { return validateAst(parseExpr(src)); }
|
|
254
|
-
catch (e) { return [(e as Error).message]; } // parse error also counts as rejected
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
it('rejects unknown root variables (no globals/host reach)', () => {
|
|
258
|
-
for (const bad of ['globalThis', 'process', 'self', 'window', 'global', 'env', 'require']) {
|
|
259
|
-
expect(rejected(`${bad}.x`).length).toBeGreaterThan(0);
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
it('rejects unknown functions (no fetch/eval/require/Function)', () => {
|
|
263
|
-
for (const fn of ['fetch', 'eval', 'require', 'Function', 'setTimeout', 'import', 'constructor']) {
|
|
264
|
-
expect(rejected(`${fn}(1)`).length).toBeGreaterThan(0);
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
it('rejects prototype-pollution property names at PARSE time', () => {
|
|
268
|
-
for (const p of ['__proto__', 'constructor', 'prototype']) {
|
|
269
|
-
expect(() => parseExpr(`item.${p}`)).toThrow(HookParseError);
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
it('member access never traverses the prototype chain at eval time', () => {
|
|
273
|
-
// even if a forbidden prop slipped through, ownGet returns undefined.
|
|
274
|
-
const ast = parseExpr('item.toString'); // toString is a proto method, not own
|
|
275
|
-
expect(validateAst(ast)).toEqual([]);
|
|
276
|
-
expect(evalExpr(ast, ctx({}))).toBeUndefined(); // NOT the function
|
|
277
|
-
});
|
|
278
|
-
it('a derive hook cannot target a forbidden field name', () => {
|
|
279
|
-
expect(validateHookDef('x', { collection: 'p', event: 'beforeWrite', kind: 'derive', field: '__proto__', expr: '1' }).length)
|
|
280
|
-
.toBeGreaterThan(0);
|
|
281
|
-
expect(validateHookDef('x', { collection: 'p', event: 'beforeWrite', kind: 'derive', field: 'constructor', expr: '1' }).length)
|
|
282
|
-
.toBeGreaterThan(0);
|
|
283
|
-
});
|
|
284
|
-
it('there is no regex in the language → ReDoS is impossible', () => {
|
|
285
|
-
// a "regex-looking" string is just a string literal, never compiled.
|
|
286
|
-
expect(run('contains(item.s, "(a+)+$")', ctx({ s: 'plain' }))).toBe(false);
|
|
287
|
-
});
|
|
288
|
-
it('caps source length, node count, and depth', () => {
|
|
289
|
-
expect(() => parseExpr('x'.repeat(HOOK_LIMITS.maxSourceLen + 1))).toThrow(HookParseError);
|
|
290
|
-
// deeply NESTED member access grows AST depth (parens don't wrap a node).
|
|
291
|
-
const deep = 'item' + '.a'.repeat(HOOK_LIMITS.maxDepth + 5);
|
|
292
|
-
expect(validateAst(parseExpr(deep)).some((e) => /deep/.test(e))).toBe(true);
|
|
293
|
-
const wide = Array(HOOK_LIMITS.maxNodes + 50).fill('1').join('+');
|
|
294
|
-
expect(() => parseExpr(wide)).toThrow(/node cap|too long/);
|
|
295
|
-
});
|
|
296
|
-
it('division/modulo by zero is an error, not Infinity/NaN', () => {
|
|
297
|
-
expect(() => evalExpr(parseExpr('1 / 0'), ctx({}))).toThrow(HookEvalError);
|
|
298
|
-
expect(() => evalExpr(parseExpr('1 % 0'), ctx({}))).toThrow(HookEvalError);
|
|
299
|
-
});
|
|
300
|
-
it('numeric overflow and non-finite are contained', () => {
|
|
301
|
-
expect(() => evalExpr(parseExpr('pow(10, 100)'), ctx({}))).toThrow(HookEvalError);
|
|
302
|
-
expect(() => evalExpr(parseExpr('item.x * item.x'), ctx({ x: 1e9 }))).toThrow(HookEvalError); // 1e18 > 1e15 cap
|
|
303
|
-
});
|
|
304
|
-
it('caps result string length (no memory blowup via concat)', () => {
|
|
305
|
-
const big = 'x'.repeat(3000);
|
|
306
|
-
expect(() => evalExpr(parseExpr('concat(item.a, item.a)'), ctx({ a: big }))).toThrow(HookEvalError);
|
|
307
|
-
});
|
|
308
|
-
it('is deterministic — no Date.now()/Math.random() reachable', () => {
|
|
309
|
-
expect(rejected('random()').length).toBeGreaterThan(0);
|
|
310
|
-
expect(rejected('now()').length).toBeGreaterThan(0); // now is a VAR not a function
|
|
311
|
-
// `now` as a variable is deterministic (the injected value)
|
|
312
|
-
expect(run('now', ctx({}))).toBe('2026-06-19T00:00:00.000Z');
|
|
313
|
-
});
|
|
314
|
-
it('string with operators/quotes is data, never code', () => {
|
|
315
|
-
expect(run('item.note', ctx({ note: '1; DROP TABLE x; --' }))).toBe('1; DROP TABLE x; --');
|
|
316
|
-
expect(run('"a" == "a"', ctx({}))).toBe(true);
|
|
317
|
-
});
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
describe('editor support — extractRefs / inferType / highlightTokens', () => {
|
|
321
|
-
it('extractRefs collects the input fields an expression reads', () => {
|
|
322
|
-
const refs = extractRefs(parseExpr('item.weight_kg > 0 && before.height_cm < item.profile.h'));
|
|
323
|
-
const keys = refs.map((r) => `${r.root}.${r.path.join('.')}`).sort();
|
|
324
|
-
expect(keys).toEqual(['before.height_cm', 'item.profile.h', 'item.weight_kg']);
|
|
325
|
-
});
|
|
326
|
-
it('extractRefs handles a bare root + now', () => {
|
|
327
|
-
expect(extractRefs(parseExpr('now')).map((r) => r.root)).toEqual(['now']);
|
|
328
|
-
expect(extractRefs(parseExpr('coalesce(item, 1)')).map((r) => `${r.root}.${r.path.join('.')}`)).toEqual(['item.']);
|
|
329
|
-
});
|
|
330
|
-
it('inferType produces the output type for derive/validate checking', () => {
|
|
331
|
-
expect(inferType(parseExpr('round(item.x * 2)'))).toBe('number');
|
|
332
|
-
expect(inferType(parseExpr('concat(item.a, "x")'))).toBe('string');
|
|
333
|
-
expect(inferType(parseExpr('item.x > 3 && item.y < 5'))).toBe('boolean');
|
|
334
|
-
expect(inferType(parseExpr('lower(item.name)'))).toBe('string');
|
|
335
|
-
expect(inferType(parseExpr('item.maybe'))).toBe('unknown');
|
|
336
|
-
expect(inferType(parseExpr('coalesce(item.a, item.b)'))).toBe('unknown');
|
|
337
|
-
expect(inferType(parseExpr('now'))).toBe('string');
|
|
338
|
-
});
|
|
339
|
-
it('highlightTokens covers the full source and classifies tokens', () => {
|
|
340
|
-
const toks = highlightTokens('round(item.x) + 5');
|
|
341
|
-
expect(toks.map((t) => t.value).join('')).toBe('round(item.x) + 5'); // lossless
|
|
342
|
-
expect(toks.find((t) => t.value === 'round')?.kind).toBe('fn');
|
|
343
|
-
expect(toks.find((t) => t.value === 'item')?.kind).toBe('var');
|
|
344
|
-
expect(toks.find((t) => t.value === '5')?.kind).toBe('num');
|
|
345
|
-
});
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
describe('SAFETY — red-team regressions (confirmed breaches, now fixed)', () => {
|
|
349
|
-
it('zero-arity min()/max() do not leak ±Infinity (must error)', () => {
|
|
350
|
-
expect(() => evalExpr(parseExpr('min()'), ctx({}))).toThrow(HookEvalError);
|
|
351
|
-
expect(() => evalExpr(parseExpr('max()'), ctx({}))).toThrow(HookEvalError);
|
|
352
|
-
// and via a derive sink: a config-valid-looking min() derive must reject, not persist null
|
|
353
|
-
expect(() => runWriteHooks(
|
|
354
|
-
{ h: { collection: 'p', event: 'beforeWrite', kind: 'derive', field: 'score', expr: 'min()' } },
|
|
355
|
-
'p', 'create', { a: 1 }, null, 'x',
|
|
356
|
-
)).toThrow(HookEvalError);
|
|
357
|
-
});
|
|
358
|
-
it('a derive that returns a non-scalar is rejected (no aliasing/circular persist)', () => {
|
|
359
|
-
for (const expr of ['item', 'before', 'item.sub', 'coalesce(item, 1)', 'ifNull(item.sub, item)']) {
|
|
360
|
-
expect(() => runWriteHooks(
|
|
361
|
-
{ h: { collection: 'p', event: 'beforeWrite', kind: 'derive', field: 'self', expr } },
|
|
362
|
-
'p', 'update', { sub: { x: 1 } }, { y: 2 }, 'x',
|
|
363
|
-
)).toThrow(/scalar/);
|
|
364
|
-
}
|
|
365
|
-
});
|
|
366
|
-
it('str() on a circular object is a contained HookEvalError, never a raw TypeError', () => {
|
|
367
|
-
const circ: Record<string, unknown> = { a: 1 };
|
|
368
|
-
circ.self = circ;
|
|
369
|
-
expect(() => evalExpr(parseExpr('string(item)'), ctx(circ))).toThrow(HookEvalError);
|
|
370
|
-
expect(() => evalExpr(parseExpr('concat(item, "x")'), ctx(circ))).toThrow(HookEvalError);
|
|
371
|
-
});
|
|
372
|
-
it('runWriteHooks rejects a forbidden derive target at runtime (belt-and-braces)', () => {
|
|
373
|
-
expect(() => runWriteHooks(
|
|
374
|
-
{ h: { collection: 'p', event: 'beforeWrite', kind: 'derive', field: '__proto__', expr: '1' } },
|
|
375
|
-
'p', 'create', {}, null, 'x',
|
|
376
|
-
)).toThrow(HookEvalError);
|
|
377
|
-
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
|
378
|
-
});
|
|
379
|
-
it('date math obeys the numeric magnitude invariant', () => {
|
|
380
|
-
expect(run('daysBetween("2026-01-02T00:00:00Z", "2026-01-01T00:00:00Z")', ctx({}))).toBe(1);
|
|
381
|
-
expect(typeof run('yearsBetween(now, item.dob)', ctx({ dob: '1990-01-01T00:00:00Z' }))).toBe('number');
|
|
382
|
-
});
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
describe('validateHooksConfig + validateFeatureConfig(cms) — the config-time gate', () => {
|
|
386
|
-
it('rejects a derive hook with no target field', () => {
|
|
387
|
-
expect(validateHookDef('h', { collection: 'p', event: 'beforeWrite', kind: 'derive', expr: '1' }).length).toBeGreaterThan(0);
|
|
388
|
-
});
|
|
389
|
-
it('rejects a malicious expression at config time', () => {
|
|
390
|
-
const bad = validateHooksConfig({ h: { collection: 'p', event: 'beforeWrite', kind: 'validate', expr: 'fetch("http://evil")' } });
|
|
391
|
-
expect(bad.length).toBeGreaterThan(0);
|
|
392
|
-
});
|
|
393
|
-
it('caps the number of hooks', () => {
|
|
394
|
-
const many: Record<string, HookDef> = {};
|
|
395
|
-
for (let i = 0; i < 60; i++) many[`h${i}`] = { collection: 'p', event: 'beforeWrite', kind: 'validate', expr: 'true' };
|
|
396
|
-
expect(validateHooksConfig(many).some((e) => /too many hooks/.test(e))).toBe(true);
|
|
397
|
-
});
|
|
398
|
-
it('validateFeatureConfig rejects a cms config with a malicious hook', () => {
|
|
399
|
-
const r = validateFeatureConfig('cms', { hooks: { h: { collection: 'p', event: 'beforeWrite', kind: 'validate', expr: 'process.exit(1)' } } });
|
|
400
|
-
expect(r.ok).toBe(false);
|
|
401
|
-
});
|
|
402
|
-
it('validateFeatureConfig accepts a cms config with a valid hook', () => {
|
|
403
|
-
const r = validateFeatureConfig('cms', {
|
|
404
|
-
hooks: { bmr: { collection: 'profiles', event: 'beforeWrite', kind: 'derive', field: 'bmr',
|
|
405
|
-
expr: 'round((10 * item.weight_kg) + (6.25 * item.height_cm) + 5)' } },
|
|
406
|
-
});
|
|
407
|
-
expect(r.ok).toBe(true);
|
|
408
|
-
});
|
|
409
|
-
it('accepts the READ phases: beforeRead validate + afterRead derive/redact', () => {
|
|
410
|
-
const r = validateFeatureConfig('cms', {
|
|
411
|
-
hooks: {
|
|
412
|
-
visible: { collection: 'articles', event: 'beforeRead', kind: 'validate', expr: 'not(item.hidden)' },
|
|
413
|
-
teaser: { collection: 'articles', event: 'afterRead', kind: 'derive', field: 'teaser', expr: 'substr(item.body, 0, 80)' },
|
|
414
|
-
hide: { collection: 'articles', event: 'afterRead', kind: 'redact', field: 'body', expr: 'item.premium' },
|
|
415
|
-
},
|
|
416
|
-
});
|
|
417
|
-
expect(r.ok).toBe(true);
|
|
418
|
-
});
|
|
419
|
-
it('enforces the kind/event compatibility matrix', () => {
|
|
420
|
-
// redact without a field
|
|
421
|
-
expect(validateHookDef('h', { collection: 'p', event: 'afterRead', kind: 'redact', expr: 'true' })
|
|
422
|
-
.some((e) => /field is required/.test(e))).toBe(true);
|
|
423
|
-
// validate on afterRead
|
|
424
|
-
expect(validateHookDef('h', { collection: 'p', event: 'afterRead', kind: 'validate', expr: 'true' })
|
|
425
|
-
.some((e) => /cannot run on afterRead/.test(e))).toBe(true);
|
|
426
|
-
// derive on beforeRead
|
|
427
|
-
expect(validateHookDef('h', { collection: 'p', event: 'beforeRead', kind: 'derive', field: 'x', expr: '1' })
|
|
428
|
-
.some((e) => /cannot run on beforeRead/.test(e))).toBe(true);
|
|
429
|
-
// redact on a write event
|
|
430
|
-
expect(validateHookDef('h', { collection: 'p', event: 'beforeWrite', kind: 'redact', field: 'x', expr: 'true' })
|
|
431
|
-
.some((e) => /only valid on afterRead/.test(e))).toBe(true);
|
|
432
|
-
// the schema-level union also rejects the combos through validateFeatureConfig
|
|
433
|
-
const r = validateFeatureConfig('cms', {
|
|
434
|
-
hooks: { h: { collection: 'p', event: 'beforeCreate', kind: 'redact', field: 'x', expr: 'true' } },
|
|
435
|
-
});
|
|
436
|
-
expect(r.ok).toBe(false);
|
|
437
|
-
});
|
|
438
|
-
it('caps read hooks per collection at the limit', () => {
|
|
439
|
-
const many: Record<string, HookDef> = {};
|
|
440
|
-
for (let i = 0; i <= HOOK_LIMITS.maxReadHooksPerCollection; i++) {
|
|
441
|
-
many[`r${i}`] = { collection: 'articles', event: 'afterRead', kind: 'redact', field: 'x', expr: 'true' };
|
|
442
|
-
}
|
|
443
|
-
// one over the cap on ONE collection trips; the write-hook cap (50) does not
|
|
444
|
-
expect(validateHooksConfig(many).some((e) => /too many read hooks/.test(e))).toBe(true);
|
|
445
|
-
const spread: Record<string, HookDef> = {
|
|
446
|
-
a: { collection: 'a', event: 'beforeRead', kind: 'validate', expr: 'true' },
|
|
447
|
-
b: { collection: 'b', event: 'beforeRead', kind: 'validate', expr: 'true' },
|
|
448
|
-
};
|
|
449
|
-
expect(validateHooksConfig(spread)).toEqual([]);
|
|
450
|
-
});
|
|
451
|
-
});
|