@vxil/feature-configs 0.3.1 → 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/src/index.test.ts DELETED
@@ -1,1255 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- CONFIG_FLAG_CAP,
4
- CopilotConfigSchema,
5
- FEATURE_SCHEMAS,
6
- NotificationsConfigSchema,
7
- PaymentsConfigSchema,
8
- RESERVED_CREDIT_TYPES,
9
- countLeaves,
10
- isReservedCreditType,
11
- setKnownMcpTools,
12
- validateFeatureConfig,
13
- NOTIF_OVERRIDE_MAX_BYTES, NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE,
14
- validateNotificationOverrides,
15
- } from './index.js';
16
- // The LIVE mcp catalog (zero-import pure data) — the same relative-import
17
- // convention as packages/cli/src/mcp.ts. Test-only: the publish build
18
- // (tsconfig.build.json) excludes tests, so rootDir stays clean.
19
- import { TOOLS } from '../../../workers/mcp-v1/src/tools.js';
20
-
21
- describe('15-flag cap analyzer (architecture §6)', () => {
22
- it('notifications schema has 15 leaves — at the cap (retry + broadcast are Optional bags, M21)', () => {
23
- // enabled, fromEmail, fromName, replyTo, resendApiKeyRef, webhookSecretRef,
24
- // provider, defaultLocale, retry(bag=1), suppression.softBounceThreshold,
25
- // rateLimit(2), templates.allowOverride, inboxEnabled, broadcast(bag=1) = 15.
26
- expect(countLeaves(NotificationsConfigSchema)).toBe(15);
27
- expect(countLeaves(NotificationsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
28
- });
29
-
30
- it('EVERY registered feature schema is ≤ 15 leaves (the CI gate)', () => {
31
- for (const [feature, schema] of Object.entries(FEATURE_SCHEMAS)) {
32
- expect(countLeaves(schema), `${feature} exceeds the flag cap`).toBeLessThanOrEqual(
33
- CONFIG_FLAG_CAP,
34
- );
35
- }
36
- });
37
-
38
- it('every shipped feature has a registered config schema (typebox-config-required)', () => {
39
- const SHIPPED_FEATURES = ['notifications', 'jobs', 'auth'];
40
- for (const f of SHIPPED_FEATURES) {
41
- expect(FEATURE_SCHEMAS[f], `feature '${f}' has no TypeBox config schema`).toBeDefined();
42
- }
43
- });
44
-
45
- it('auth schema is 11 leaves after D3 (methods collapsed to ONE optional bag; was 15 at the cap)', () => {
46
- // enabled(1) + methods(1, optional bag — was 6) + providers(1) + session(1)
47
- // + password(1) + emailVerification(1) + magicLink(1) + otp(1) + anonymous(1)
48
- // + orgClaims(1) + security(1, optional bag — P0-3) = 11. The auth wave
49
- // (2026-09-10, D3) funded itself by the methods collapse — the notifications
50
- // retry/broadcast precedent; session.maxConcurrent / otp.testRecipients ride
51
- // inside existing bags and never move the count.
52
- expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(11);
53
- expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
54
- });
55
-
56
- it('D3 fold bytes: existing auth manifests fold BYTE-IDENTICALLY after the methods collapse', () => {
57
- // The three inputs below were folded through the PRE-collapse schema
58
- // (methods = a required Type.Object with { default: {} }) and the exact
59
- // JSON captured; the collapse keeps `default: {}` on the now-Optional bag,
60
- // so Value.Default still materializes every method flag in the same key
61
- // order with the same defaults. Any drift here = a behavioural delta for a
62
- // published tenant manifest (the worker reads config.methods.<flag>).
63
- const CASES: Array<[unknown, string]> = [
64
- [{},
65
- '{"enabled":true,"methods":{"emailPassword":true,"magicLink":true,"google":false,"github":false,"apple":false,"facebook":false},"session":{"ttlMinutes":60,"refreshTtlDays":30},"password":{"minLength":8,"requireMixed":false},"emailVerification":{"required":false},"magicLink":{"tokenTtlMinutes":15}}'],
66
- [{ methods: { google: true, github: true }, providers: { google: { clientIdRef: 'secret:g_id', clientSecretRef: 'secret:g_sec' } } },
67
- '{"methods":{"google":true,"github":true,"emailPassword":true,"magicLink":true,"apple":false,"facebook":false},"providers":{"google":{"clientIdRef":"secret:g_id","clientSecretRef":"secret:g_sec"}},"enabled":true,"session":{"ttlMinutes":60,"refreshTtlDays":30},"password":{"minLength":8,"requireMixed":false},"emailVerification":{"required":false},"magicLink":{"tokenTtlMinutes":15}}'],
68
- [{ enabled: true, methods: { emailPassword: false, magicLink: true, apple: true, facebook: false }, session: { ttlMinutes: 120 }, password: { minLength: 12, requireMixed: true }, emailVerification: { required: true }, magicLink: { tokenTtlMinutes: 30 }, otp: { enabled: true, maxAttempts: 7 }, anonymous: { enabled: true }, orgClaims: { enabled: true } },
69
- '{"enabled":true,"methods":{"emailPassword":false,"magicLink":true,"apple":true,"facebook":false,"google":false,"github":false},"session":{"ttlMinutes":120,"refreshTtlDays":30},"password":{"minLength":12,"requireMixed":true},"emailVerification":{"required":true},"magicLink":{"tokenTtlMinutes":30},"otp":{"enabled":true,"maxAttempts":7,"codeTtlMinutes":10,"resendCooldownSec":60},"anonymous":{"enabled":true},"orgClaims":{"enabled":true}}'],
70
- ];
71
- for (const [input, expected] of CASES) {
72
- const v = validateFeatureConfig('auth', input);
73
- expect(v.ok, v.errors.join()).toBe(true);
74
- expect(JSON.stringify(v.value)).toBe(expected);
75
- }
76
- // a manifest that omits methods entirely still folds to the full flag set
77
- // (the worker never sees an absent bag on a published manifest)
78
- const m = (validateFeatureConfig('auth', { enabled: true }).value as { methods: Record<string, boolean> }).methods;
79
- expect(m).toEqual({ emailPassword: true, magicLink: true, google: false, github: false, apple: false, facebook: false });
80
- });
81
-
82
- it('files schema is 11 leaves (F8-54 removed bucketRef + the contentScan bag)', () => {
83
- expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBe(11);
84
- expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
85
- });
86
-
87
- it('jobs schema is 13 leaves (generation gains reserve caps: +2 = 6 generation leaves)', () => {
88
- // enabled(1) + retry(1) + retention(2) + dlqDailyQuota(1) + schedules(1)
89
- // + concurrency(1) + generation(6: maxConcurrent, defaultTimeoutMs,
90
- // maxTimeoutMs, pollMaxAttempts, maxReserveCredits, maxOutstandingReserveCredits)
91
- // = 13, with 2 leaves of headroom under the cap.
92
- expect(countLeaves(FEATURE_SCHEMAS['jobs']!)).toBe(13);
93
- expect(countLeaves(FEATURE_SCHEMAS['jobs']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
94
- });
95
-
96
- it('vector-search schema is 14 leaves (rerank optional object +1, sync array +1)', () => {
97
- expect(countLeaves(FEATURE_SCHEMAS['vector-search']!)).toBe(14);
98
- expect(countLeaves(FEATURE_SCHEMAS['vector-search']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
99
- });
100
-
101
- it('ai schema is exactly 15 leaves — providers.compat (Optional object) counts as ONE; ZERO headroom', () => {
102
- // enabled + defaultProvider + 3 keyRefs + compat(1) + defaults(3) + cache(1)
103
- // + limits(2) + streaming(3) = 15. Any future ai knob must ride inside
104
- // compat (free) or another optional object.
105
- expect(countLeaves(FEATURE_SCHEMAS['ai']!)).toBe(15);
106
- expect(countLeaves(FEATURE_SCHEMAS['ai']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
107
- });
108
- });
109
-
110
- describe('ai config — compat.openaiBaseUrl write-time validation', () => {
111
- it('accepts a public https base + an openrouter default provider', () => {
112
- const v = validateFeatureConfig('ai', {
113
- defaultProvider: 'openrouter',
114
- providers: { compat: { openrouterKeyRef: 'secret:openrouter', openaiBaseUrl: 'https://api.deepseek.com/v1' } },
115
- });
116
- expect(v.ok).toBe(true);
117
- const manifest = v.value as { providers: { compat?: { openrouterKeyRef?: string } } };
118
- expect(manifest.providers.compat?.openrouterKeyRef).toBe('secret:openrouter');
119
- });
120
-
121
- it('rejects http://, private-IP and internal-host bases before they can publish', () => {
122
- for (const bad of ['http://api.example.com/v1', 'https://10.1.2.3/v1', 'https://metadata.google.internal/v1', 'https://localhost:8080/v1']) {
123
- const v = validateFeatureConfig('ai', { providers: { compat: { openaiBaseUrl: bad } } });
124
- expect(v.ok, bad).toBe(false);
125
- expect(v.errors?.[0]).toContain('/providers/compat/openaiBaseUrl');
126
- }
127
- });
128
-
129
- it('defaults leave compat absent (the optional-object leaf is free until used)', () => {
130
- const v = validateFeatureConfig('ai', {});
131
- expect(v.ok).toBe(true);
132
- expect((v.value as { providers: { compat?: unknown } }).providers.compat).toBeUndefined();
133
- });
134
- });
135
-
136
- describe('validateFeatureConfig', () => {
137
- const minimal = {
138
- fromEmail: 'noreply@acme.com',
139
- resendApiKeyRef: 'secret:resend_api_key',
140
- };
141
-
142
- it('accepts a minimal config and applies defaults', () => {
143
- const r = validateFeatureConfig('notifications', minimal);
144
- expect(r.ok).toBe(true);
145
- const v = r.value as Record<string, unknown>;
146
- expect(v.enabled).toBe(true);
147
- expect(v.provider).toBe('resend');
148
- expect((v.retry as Record<string, unknown>).maxAttempts).toBe(5);
149
- expect((v.rateLimit as Record<string, unknown>).perTenantSec).toBe(50);
150
- });
151
-
152
- it('rejects a malformed fromEmail', () => {
153
- const r = validateFeatureConfig('notifications', { ...minimal, fromEmail: 'not-an-email' });
154
- expect(r.ok).toBe(false);
155
- expect(r.errors.join(' ')).toContain('fromEmail');
156
- });
157
-
158
- it('rejects retry.maxAttempts out of range', () => {
159
- const r = validateFeatureConfig('notifications', {
160
- ...minimal,
161
- retry: { maxAttempts: 99, backoff: 'exponential' },
162
- });
163
- expect(r.ok).toBe(false);
164
- });
165
-
166
- it('broadcast SURVIVES Value.Clean and persists via the real config path (M21/#4 regression)', () => {
167
- // The HIGH this guards: `broadcast` used to live only in a worker-local
168
- // schema, so this exact pipeline (Default → Clean → Check) silently
169
- // DROPPED it and createCampaign 403'd on every real-config tenant.
170
- const r = validateFeatureConfig('notifications', {
171
- ...minimal,
172
- broadcast: { enabled: true },
173
- });
174
- expect(r.ok).toBe(true);
175
- const v = r.value as {
176
- broadcast?: { enabled: boolean; freqCapPerUserPerDay: number };
177
- };
178
- expect(v.broadcast).toBeDefined();
179
- expect(v.broadcast!.enabled).toBe(true);
180
- // leaf default materialized inside the present bag
181
- expect(v.broadcast!.freqCapPerUserPerDay).toBe(5);
182
- });
183
-
184
- it('broadcast.defaultQuietHours round-trips; absent broadcast stays absent (defaults-off deny)', () => {
185
- const withQh = validateFeatureConfig('notifications', {
186
- ...minimal,
187
- broadcast: {
188
- enabled: true,
189
- defaultQuietHours: { tz: 'UTC', start: '22:00', end: '08:00' },
190
- },
191
- });
192
- expect(withQh.ok).toBe(true);
193
- const v = withQh.value as {
194
- broadcast?: { defaultQuietHours?: { tz: string; start: string; end: string } };
195
- };
196
- expect(v.broadcast!.defaultQuietHours).toEqual({ tz: 'UTC', start: '22:00', end: '08:00' });
197
-
198
- const absent = validateFeatureConfig('notifications', minimal);
199
- expect(absent.ok).toBe(true);
200
- expect((absent.value as { broadcast?: unknown }).broadcast).toBeUndefined();
201
- // while retry (Optional bag WITH default:{}) still materializes — the
202
- // shape-preserving half of the M21 leaf-budget fund
203
- expect(((absent.value as { retry?: { maxAttempts: number } }).retry ?? {}).maxAttempts).toBe(5);
204
- });
205
-
206
- it('rejects a quiet-hours window parseHhMm cannot parse (would fail OPEN at run time)', () => {
207
- const bad = validateFeatureConfig('notifications', {
208
- ...minimal,
209
- broadcast: {
210
- enabled: true,
211
- defaultQuietHours: { tz: 'UTC', start: '10pm', end: '08:00' },
212
- },
213
- });
214
- expect(bad.ok).toBe(false);
215
- expect(bad.errors.join(' ')).toContain('start');
216
- });
217
-
218
- it('rejects a malformed broadcast bag (unknown sub-keys stripped; bad types fail)', () => {
219
- const badType = validateFeatureConfig('notifications', {
220
- ...minimal,
221
- broadcast: { enabled: 'yes' },
222
- });
223
- expect(badType.ok).toBe(false);
224
-
225
- const sneaky = validateFeatureConfig('notifications', {
226
- ...minimal,
227
- broadcast: { enabled: true, maxAudiencePerRun: 100000 },
228
- });
229
- expect(sneaky.ok).toBe(true); // unknown key CLEANED, not persisted
230
- expect(Object.keys((sneaky.value as { broadcast: object }).broadcast))
231
- .not.toContain('maxAudiencePerRun');
232
- });
233
-
234
- it('rejects an unknown feature', () => {
235
- const r = validateFeatureConfig('telepathy', { enabled: true });
236
- expect(r.ok).toBe(false);
237
- expect(r.errors[0]).toContain('unknown feature');
238
- });
239
-
240
- it('rejects an unknown provider', () => {
241
- const r = validateFeatureConfig('notifications', { ...minimal, provider: 'sendgrid' });
242
- expect(r.ok).toBe(false);
243
- });
244
-
245
- it("provider 'mock' needs no resendApiKeyRef (zero-config); provider 'resend' still requires it", () => {
246
- const mock = validateFeatureConfig('notifications', {
247
- fromEmail: 'noreply@acme.com', provider: 'mock', inboxEnabled: true,
248
- });
249
- expect(mock.ok).toBe(true);
250
-
251
- const resendNoRef = validateFeatureConfig('notifications', {
252
- fromEmail: 'noreply@acme.com', provider: 'resend',
253
- });
254
- expect(resendNoRef.ok).toBe(false);
255
- expect(resendNoRef.errors.join(' ')).toContain('resendApiKeyRef');
256
- });
257
-
258
- it('strips unknown top-level AND nested keys so they are never persisted (audit #112/#118)', () => {
259
- const r = validateFeatureConfig('notifications', {
260
- ...minimal,
261
- bogusTopLevel: 'evil',
262
- retry: { maxAttempts: 3, backoff: 'linear', sneaky: 'nope' },
263
- });
264
- expect(r.ok).toBe(true);
265
- const v = r.value as Record<string, unknown>;
266
- // declared keys survive (incl. materialized defaults)
267
- expect(v.fromEmail).toBe('noreply@acme.com');
268
- expect((v.retry as Record<string, unknown>).maxAttempts).toBe(3);
269
- // unknown keys at every depth are removed — the stored manifest is closed
270
- expect(v).not.toHaveProperty('bogusTopLevel');
271
- expect(v.retry).not.toHaveProperty('sneaky');
272
- });
273
-
274
- it('auth: accepts apple + facebook provider config blocks + method toggles (§6.4)', () => {
275
- const r = validateFeatureConfig('auth', {
276
- methods: { google: false, github: false, apple: true, facebook: true },
277
- providers: {
278
- apple: {
279
- servicesId: 'com.acme.web',
280
- teamId: 'TEAM123456',
281
- keyId: 'KEY7890AB',
282
- p8KeyRef: 'secret:apple_signin_p8',
283
- },
284
- facebook: {
285
- clientIdRef: 'secret:facebook_app_id',
286
- clientSecretRef: 'secret:facebook_app_secret',
287
- },
288
- },
289
- });
290
- expect(r.ok).toBe(true);
291
- const v = r.value as { providers?: Record<string, Record<string, unknown>> };
292
- // the apple/facebook BYO refs survive Value.Clean (declared → persisted)
293
- expect(v.providers?.apple).toEqual({
294
- servicesId: 'com.acme.web',
295
- teamId: 'TEAM123456',
296
- keyId: 'KEY7890AB',
297
- p8KeyRef: 'secret:apple_signin_p8',
298
- });
299
- expect(v.providers?.facebook).toEqual({
300
- clientIdRef: 'secret:facebook_app_id',
301
- clientSecretRef: 'secret:facebook_app_secret',
302
- });
303
- // The enable toggles survive Value.Clean — the regression that made the
304
- // shipped oauthCore apple/facebook flow un-enablable via the pipeline.
305
- const m = (r.value as { methods: Record<string, boolean> }).methods;
306
- expect(m.apple).toBe(true);
307
- expect(m.facebook).toBe(true);
308
- });
309
-
310
- it('files: extractText + ttl round-trip WITHOUT being stripped (the Value.Clean regression)', () => {
311
- const r = validateFeatureConfig('files', {
312
- extractText: { enabled: true, provider: 'gcv', keyRef: 'secret:ocr_api_key' },
313
- ttl: { enabled: true, defaultExpiresInSeconds: 3600 },
314
- });
315
- expect(r.ok).toBe(true);
316
- const v = r.value as {
317
- extractText?: Record<string, unknown>;
318
- ttl?: Record<string, unknown>;
319
- };
320
- expect(v.extractText).toMatchObject({
321
- enabled: true,
322
- provider: 'gcv',
323
- keyRef: 'secret:ocr_api_key',
324
- // inner defaults materialize on a provided bag
325
- asyncOverJobs: true,
326
- boundingBoxes: false,
327
- });
328
- expect(v.ttl).toMatchObject({
329
- enabled: true,
330
- defaultExpiresInSeconds: 3600,
331
- sweepCron: '0 * * * *',
332
- });
333
- // omitted bags stay omitted (optional — no default:{} materialization)
334
- const bare = validateFeatureConfig('files', { enabled: true });
335
- expect(bare.ok).toBe(true);
336
- expect(bare.value).not.toHaveProperty('extractText');
337
- expect(bare.value).not.toHaveProperty('ttl');
338
- // unknown providers are rejected, not silently coerced
339
- const bad = validateFeatureConfig('files', { extractText: { enabled: true, provider: 'tesseract' } });
340
- expect(bad.ok).toBe(false);
341
- });
342
-
343
- it('auth: facebook accepts optional audRefs (extra native-aud allow-list)', () => {
344
- const r = validateFeatureConfig('auth', {
345
- providers: {
346
- facebook: {
347
- clientIdRef: 'secret:fb_app_id',
348
- clientSecretRef: 'secret:fb_app_secret',
349
- audRefs: ['secret:fb_ios_client', 'secret:fb_android_client'],
350
- },
351
- },
352
- });
353
- expect(r.ok).toBe(true);
354
- const v = r.value as { providers?: { facebook?: { audRefs?: string[] } } };
355
- expect(v.providers?.facebook?.audRefs).toEqual(['secret:fb_ios_client', 'secret:fb_android_client']);
356
- });
357
-
358
- it('auth: apple bundleIds round-trip through validateFeatureConfig (native iOS aud)', () => {
359
- // Regression: before the schema carried bundleIds, Value.Clean STRIPPED the
360
- // field on publish, so genuine iOS ASAuthorization id_tokens (aud = bundle
361
- // id) could never be allow-listed via the pipeline (the worker already
362
- // reads providers.apple.bundleIds — oauthCore.ts).
363
- const r = validateFeatureConfig('auth', {
364
- providers: {
365
- apple: {
366
- servicesId: 'com.acme.web', teamId: 'TEAM123456', keyId: 'KEY7890AB',
367
- p8KeyRef: 'secret:apple_p8', bundleIds: ['com.acme.app', 'com.acme.app.dev'],
368
- },
369
- },
370
- });
371
- expect(r.ok).toBe(true);
372
- const v = r.value as { providers?: { apple?: { bundleIds?: string[] } } };
373
- expect(v.providers?.apple?.bundleIds).toEqual(['com.acme.app', 'com.acme.app.dev']);
374
- });
375
-
376
- it('auth: otp/anonymous/orgClaims optional bags round-trip; session/password stay materialized', () => {
377
- const r = validateFeatureConfig('auth', {
378
- otp: { enabled: true, codeTtlMinutes: 5 },
379
- anonymous: { enabled: true },
380
- orgClaims: { enabled: true },
381
- });
382
- expect(r.ok).toBe(true);
383
- const v = r.value as Record<string, unknown>;
384
- // provided bags survive Value.Clean with inner defaults materialized
385
- expect(v.otp).toEqual({ enabled: true, codeTtlMinutes: 5, maxAttempts: 5, resendCooldownSec: 60 });
386
- expect(v.anonymous).toEqual({ enabled: true });
387
- expect(v.orgClaims).toEqual({ enabled: true });
388
- // session/password are optional bags but keep `{ default: {} }`, so a bare
389
- // publish still materializes the knobs the worker reads (back-compat).
390
- expect(v.session).toEqual({ ttlMinutes: 60, refreshTtlDays: 30 });
391
- expect(v.password).toEqual({ minLength: 8, requireMixed: false });
392
- // tokenTtlHours is GONE (consumer-less — dropped for cap headroom)
393
- expect(v.emailVerification).toEqual({ required: false });
394
-
395
- // omitted new bags stay omitted (absent = disabled at the worker gate)
396
- const bare = validateFeatureConfig('auth', {});
397
- expect(bare.ok).toBe(true);
398
- expect(bare.value).not.toHaveProperty('otp');
399
- expect(bare.value).not.toHaveProperty('anonymous');
400
- expect(bare.value).not.toHaveProperty('orgClaims');
401
- });
402
-
403
- it('auth: RB-2 providers.oidc round-trips, spends NO leaf, and rejects a bad issuer', () => {
404
- const oidc = {
405
- issuer: 'https://login.acme.example', clientId: 'acme', clientSecretRef: 'secret:oidc_client_secret',
406
- scopes: ['openid', 'groups'], claims: { roles: 'groups' }, allowedDomains: ['acme.example'], autoLink: false,
407
- };
408
- const r = validateFeatureConfig('auth', { providers: { oidc } });
409
- expect(r.ok, r.errors.join()).toBe(true);
410
- expect((r.value as { providers: { oidc: unknown } }).providers.oidc).toEqual(oidc);
411
- // the minimal block (presence = enabled) also round-trips, with NO defaults materialized
412
- const min = validateFeatureConfig('auth', { providers: { oidc: { issuer: 'https://login.acme.example', clientId: 'a', clientSecretRef: 'secret:s' } } });
413
- expect(min.ok).toBe(true);
414
- expect((min.value as { providers: { oidc: Record<string, unknown> } }).providers.oidc).toEqual({ issuer: 'https://login.acme.example', clientId: 'a', clientSecretRef: 'secret:s' });
415
- // still 11 leaves — the block lives INSIDE the providers bag
416
- expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(11);
417
- // issuer must be https with no query/fragment; domains lowercase dotted
418
- for (const issuer of ['http://login.acme.example', 'https://login.acme.example/?x=1', 'https://a#b', 'login.acme.example']) {
419
- expect(validateFeatureConfig('auth', { providers: { oidc: { issuer, clientId: 'a', clientSecretRef: 'secret:s' } } }).ok, issuer).toBe(false);
420
- }
421
- expect(validateFeatureConfig('auth', { providers: { oidc: { ...oidc, allowedDomains: ['ACME.example'] } } }).ok).toBe(false);
422
- // a missing required field fails the pipeline (the worker would 501 anyway)
423
- expect(validateFeatureConfig('auth', { providers: { oidc: { issuer: 'https://login.acme.example', clientId: 'a' } } }).ok).toBe(false);
424
- });
425
-
426
- it('auth: otp knob bounds are enforced (maxAttempts 3..10)', () => {
427
- expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 2 } }).ok).toBe(false);
428
- expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 11 } }).ok).toBe(false);
429
- expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 10 } }).ok).toBe(true);
430
- });
431
-
432
- it('functions: an authHook binding is accepted; a bad event is rejected', () => {
433
- const fn = (bindings: unknown[]) => ({
434
- functions: { provision: { scriptRef: 'fn-t-provision-abc', bindings } },
435
- });
436
- const okCfg = validateFeatureConfig('functions', fn([{ kind: 'authHook' }]));
437
- expect(okCfg.ok).toBe(true);
438
- const explicit = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.created' }]));
439
- expect(explicit.ok).toBe(true);
440
- // F4-30: the CLOSED event union — every member accepted, anything else rejected
441
- for (const event of ['session.created', 'session.revoked', 'signin.failure']) {
442
- expect(validateFeatureConfig('functions', fn([{ kind: 'authHook', event }])).ok, event).toBe(true);
443
- }
444
- const bad = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.deleted' }]));
445
- expect(bad.ok).toBe(false);
446
- expect(bad.errors.join(' ')).toContain('user.created | session.created | session.revoked | signin.failure');
447
- });
448
-
449
- it('functions: DENY_FUNCTION_SCOPES rejects cross-tenant/self-integrity scopes but ALLOWS business scopes', () => {
450
- const fn = (scopes: string[]) => ({ functions: { f: { scriptRef: 'fn-t-f-abc', scopes } } });
451
- // ALLOWED — legitimate function business actions (checkout saga, email, user
452
- // mgmt, fn→fn composition).
453
- for (const s of ['payments:write', 'notifications:send', 'users:write', 'users:read', 'cms:write', 'functions:invoke']) {
454
- expect(validateFeatureConfig('functions', fn([s])).ok, `expected '${s}' allowed`).toBe(true);
455
- }
456
- // DENIED — cross-tenant (admin/*) + self-integrity (features:write/functions:write/secrets:write).
457
- for (const s of ['admin', '*', 'features:write', 'functions:write', 'secrets:write']) {
458
- const r = validateFeatureConfig('functions', fn([s]));
459
- expect(r.ok, `expected '${s}' denied`).toBe(false);
460
- expect(r.errors.join(' ')).toContain('never grantable');
461
- }
462
- });
463
-
464
- it('auth: rejects an apple block missing a required field (teamId)', () => {
465
- const r = validateFeatureConfig('auth', {
466
- providers: {
467
- apple: { servicesId: 'com.acme.web', keyId: 'KEY7890AB', p8KeyRef: 'secret:apple_p8' },
468
- },
469
- });
470
- expect(r.ok).toBe(false);
471
- expect(r.errors.join(' ')).toContain('teamId');
472
- });
473
-
474
- it('auth: strips an unknown provider key (config is closed — only the 4 known providers)', () => {
475
- const r = validateFeatureConfig('auth', {
476
- providers: {
477
- google: { clientIdRef: 'secret:g_id', clientSecretRef: 'secret:g_secret' },
478
- twitter: { clientIdRef: 'secret:x_id', clientSecretRef: 'secret:x_secret' },
479
- },
480
- });
481
- expect(r.ok).toBe(true);
482
- const v = r.value as { providers?: Record<string, unknown> };
483
- expect(v.providers).toHaveProperty('google');
484
- expect(v.providers).not.toHaveProperty('twitter');
485
- });
486
-
487
- it('a non-schema key named like a prototype pollutant is dropped, not persisted', () => {
488
- // Closing the schema (Value.Clean) means an attacker-supplied own key —
489
- // including an own enumerable "__proto__" string key smuggled via JSON —
490
- // never reaches the stored manifest. (Deep-merge proto-pollution itself is
491
- // guarded in the control-plane merge layer; this asserts the validator
492
- // doesn't widen the persisted object.)
493
- const raw = JSON.parse(`{"fromEmail":"a@b.c","resendApiKeyRef":"r","__proto__":{"polluted":true}}`) as Record<string, unknown>;
494
- const r = validateFeatureConfig('notifications', raw);
495
- expect(r.ok).toBe(true);
496
- // The persisted manifest is its OWN enumerable keys (what csql.json /
497
- // JSON.stringify serialize) — none of them is the smuggled pollutant.
498
- const ownKeys = Object.keys(r.value as object);
499
- expect(ownKeys).not.toContain('__proto__');
500
- expect(ownKeys).not.toContain('polluted');
501
- expect(Object.prototype.hasOwnProperty.call(r.value, 'polluted')).toBe(false);
502
- expect(JSON.parse(JSON.stringify(r.value)) as Record<string, unknown>).not.toHaveProperty('polluted');
503
- });
504
- });
505
-
506
- describe('mcp config: custom tools + prompts (mcp.md §6.5/§11)', () => {
507
- it('McpConfigSchema is 8 leaves (customTools/prompts are ONE Record leaf each; F8-54 removed scopedKey)', () => {
508
- expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBe(8);
509
- expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
510
- });
511
-
512
- const tool = (over: Record<string, unknown> = {}) => ({
513
- customTools: {
514
- crm_lookup: {
515
- description: 'Look up a CRM contact by email.',
516
- url: 'https://api.acme.example/mcp/crm-lookup',
517
- ...over,
518
- },
519
- },
520
- });
521
-
522
- it('accepts a well-formed custom tool (config round-trips)', () => {
523
- const r = validateFeatureConfig('mcp', tool({
524
- inputSchema: { type: 'object', properties: { email: { type: 'string' } } },
525
- timeoutMs: 5000,
526
- }));
527
- expect(r.ok).toBe(true);
528
- const v = r.value as { customTools: Record<string, { url: string; timeoutMs: number }> };
529
- expect(v.customTools.crm_lookup!.url).toBe('https://api.acme.example/mcp/crm-lookup');
530
- expect(v.customTools.crm_lookup!.timeoutMs).toBe(5000);
531
- });
532
-
533
- it('rejects a bad tool name, an http:// URL, and internal/private hosts', () => {
534
- const badName = validateFeatureConfig('mcp', {
535
- customTools: { 'Bad-Name': { description: 'x', url: 'https://ok.example/a' } },
536
- });
537
- expect(badName.ok).toBe(false);
538
- expect(badName.errors.join(' ')).toContain('name must match');
539
-
540
- for (const url of [
541
- 'http://api.acme.example/hook', // scheme
542
- 'https://user:pass@api.acme.example/hook', // embedded creds
543
- 'https://localhost/hook',
544
- 'https://foo.localhost/hook',
545
- 'https://db.internal/hook',
546
- 'https://printer.local/hook',
547
- 'https://10.1.2.3/hook',
548
- 'https://169.254.169.254/latest/meta-data',
549
- 'https://[::1]/hook',
550
- 'not a url',
551
- ]) {
552
- const r = validateFeatureConfig('mcp', tool({ url }));
553
- expect(r.ok, `should reject ${url}`).toBe(false);
554
- expect(r.errors.join(' ')).toContain('/customTools/crm_lookup/url');
555
- }
556
- });
557
-
558
- it('rejects >32 custom tools and an oversized inputSchema', () => {
559
- const many: Record<string, unknown> = {};
560
- for (let i = 0; i < 33; i++) {
561
- many[`tool_${i}`] = { description: 'x', url: 'https://ok.example/t' };
562
- }
563
- const over = validateFeatureConfig('mcp', { customTools: many });
564
- expect(over.ok).toBe(false);
565
- expect(over.errors.join(' ')).toContain('at most 32');
566
-
567
- const fat = validateFeatureConfig('mcp', tool({
568
- inputSchema: { type: 'object', description: 'x'.repeat(9000) },
569
- }));
570
- expect(fat.ok).toBe(false);
571
- expect(fat.errors.join(' ')).toContain('8KB');
572
- });
573
-
574
- it('prompts: placeholders must be declared arguments; declared-but-unused is fine', () => {
575
- const good = validateFeatureConfig('mcp', {
576
- prompts: {
577
- draft_reply: {
578
- description: 'Draft a support reply.',
579
- template: 'Reply to {{customer}} about {{topic}}. Be concise.',
580
- arguments: [
581
- { name: 'customer', required: true },
582
- { name: 'topic' },
583
- { name: 'unused_extra' }, // allowed
584
- ],
585
- },
586
- },
587
- });
588
- expect(good.ok).toBe(true);
589
-
590
- const bad = validateFeatureConfig('mcp', {
591
- prompts: {
592
- draft_reply: { template: 'Reply to {{customer}}.', arguments: [{ name: 'topic' }] },
593
- },
594
- });
595
- expect(bad.ok).toBe(false);
596
- expect(bad.errors.join(' ')).toContain('{{customer}}');
597
- });
598
-
599
- it('prompts: rejects a bad name and >32 prompts', () => {
600
- const badName = validateFeatureConfig('mcp', {
601
- prompts: { 'Bad Name': { template: 'x' } },
602
- });
603
- expect(badName.ok).toBe(false);
604
-
605
- const many: Record<string, unknown> = {};
606
- for (let i = 0; i < 33; i++) many[`p_${i}`] = { template: 'x' };
607
- const over = validateFeatureConfig('mcp', { prompts: many });
608
- expect(over.ok).toBe(false);
609
- expect(over.errors.join(' ')).toContain('at most 32');
610
- });
611
-
612
- it('a bare mcp config (no customTools/prompts) still validates with defaults', () => {
613
- const r = validateFeatureConfig('mcp', { enabled: true });
614
- expect(r.ok).toBe(true);
615
- expect(r.value).not.toHaveProperty('customTools');
616
- expect(r.value).not.toHaveProperty('prompts');
617
- });
618
- });
619
-
620
- describe('copilot config (docs/copilot-agents-sku-design.md §4)', () => {
621
- it('has exactly 7 leaves — under the cap (agents/allow/collections are DATA)', () => {
622
- expect(countLeaves(CopilotConfigSchema)).toBe(7);
623
- expect(countLeaves(CopilotConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
624
- });
625
-
626
- it('quickstart minimal body materializes a valid zero-agent manifest', () => {
627
- const r = validateFeatureConfig('copilot', { enabled: true });
628
- expect(r.ok).toBe(true);
629
- const v = r.value as Record<string, unknown>;
630
- expect(v.agents).toEqual({});
631
- expect((v.widget as Record<string, unknown>).enabled).toBe(false);
632
- expect((v.limits as Record<string, unknown>).consumeCredits).toBe(false);
633
- });
634
-
635
- // NOTE: runs BEFORE the setKnownMcpTools registration below (vitest executes
636
- // tests in file order) — asserts the published-package posture where no
637
- // catalog is available and the name-existence rule is skipped.
638
- it('without a registered catalog, unknown tool names pass (name rule skipped)', () => {
639
- const r = validateFeatureConfig('copilot', {
640
- agents: { a: { name: 'A', actions: { mode: 'actions', allow: { not_a_tool: {} } } } },
641
- });
642
- expect(r.ok).toBe(true);
643
- });
644
-
645
- it('with the live catalog registered, an unknown allow key is rejected; real names pass', () => {
646
- setKnownMcpTools(TOOLS.map((t) => t.name));
647
- const bad = validateFeatureConfig('copilot', {
648
- agents: { a: { name: 'A', actions: { mode: 'actions', allow: { not_a_tool: {} } } } },
649
- });
650
- expect(bad.ok).toBe(false);
651
- expect(bad.errors.join(' ')).toContain('not_a_tool');
652
-
653
- const good = validateFeatureConfig('copilot', {
654
- agents: {
655
- a: {
656
- name: 'A',
657
- actions: { mode: 'actions', allow: { cms_query_items: {}, cms_create_item: { requireConfirm: true } } },
658
- },
659
- },
660
- });
661
- expect(good.ok).toBe(true);
662
- });
663
-
664
- it('guestToolAllow must be a subset of actions.allow', () => {
665
- const r = validateFeatureConfig('copilot', {
666
- agents: {
667
- a: {
668
- name: 'A',
669
- actions: { mode: 'actions', allow: { cms_query_items: {} } },
670
- guardrails: { allowGuest: true, guestToolAllow: ['cms_create_item'] },
671
- },
672
- },
673
- });
674
- expect(r.ok).toBe(false);
675
- expect(r.errors.join(' ')).toContain('not in actions.allow');
676
- });
677
-
678
- it('a non-empty guestToolAllow requires allowGuest: true', () => {
679
- const r = validateFeatureConfig('copilot', {
680
- agents: {
681
- a: {
682
- name: 'A',
683
- actions: { mode: 'actions', allow: { cms_query_items: {} } },
684
- guardrails: { allowGuest: false, guestToolAllow: ['cms_query_items'] },
685
- },
686
- },
687
- });
688
- expect(r.ok).toBe(false);
689
- expect(r.errors.join(' ')).toContain('allowGuest');
690
- });
691
-
692
- it('widget.requireAuth=false requires ≥1 agent with allowGuest', () => {
693
- const bad = validateFeatureConfig('copilot', {
694
- agents: { a: { name: 'A' } },
695
- widget: { enabled: true, requireAuth: false, allowedOrigins: ['https://example.com'] },
696
- });
697
- expect(bad.ok).toBe(false);
698
- expect(bad.errors.join(' ')).toContain('allowGuest');
699
-
700
- const good = validateFeatureConfig('copilot', {
701
- agents: { a: { name: 'A', guardrails: { allowGuest: true } } },
702
- widget: { enabled: true, requireAuth: false, allowedOrigins: ['https://example.com'] },
703
- });
704
- expect(good.ok).toBe(true);
705
- });
706
-
707
- it('the inert escalation bag + limits.tokensPerUserPerDay are GONE (F8-54)', () => {
708
- const props = (CopilotConfigSchema as unknown as { properties: Record<string, unknown> }).properties;
709
- expect(props['escalation']).toBeUndefined();
710
- const limits = props['limits'] as { properties: Record<string, unknown> };
711
- expect(limits.properties['tokensPerUserPerDay']).toBeUndefined();
712
- expect(limits.properties['consumeCredits']).toBeDefined(); // the READ leaf stays
713
- });
714
-
715
- it('a PERSISTED manifest carrying the deleted copilot keys still folds (F8-54)', () => {
716
- // The pre-F8-54 shape a live tenant may still have in config_versions/KV.
717
- const r = validateFeatureConfig('copilot', {
718
- enabled: true,
719
- agents: {},
720
- escalation: { enabled: true, handler: 'notifications', notifyTemplate: 'escalation' },
721
- limits: { consumeCredits: true, tokensPerUserPerDay: 5000 },
722
- });
723
- expect(r.ok).toBe(true); // Value.Clean strips the unknown keys — never a 422
724
- const v = r.value as { escalation?: unknown; limits: Record<string, unknown> };
725
- expect(v.escalation).toBeUndefined();
726
- expect(v.limits['tokensPerUserPerDay']).toBeUndefined();
727
- expect(v.limits['consumeCredits']).toBe(true); // the surviving leaf is untouched
728
- });
729
- });
730
-
731
- // fn-online-typed-io: the per-function `signature` (Level-1 typed I/O) is
732
- // DECLARED schema data inside the functions Type.Record value — Value.Clean
733
- // must keep it, the size guard must bound it, and the leaf count must not move.
734
- describe('functions config — signature (Level-1 typed I/O)', () => {
735
- const entry = (extra: Record<string, unknown> = {}) => ({
736
- enabled: true,
737
- functions: {
738
- hello: { scriptRef: 'fn-t-hello-0123456789ab', ...extra },
739
- },
740
- });
741
-
742
- it('signature survives validateFeatureConfig (Value.Clean keeps declared keys)', () => {
743
- const sig = { input: { name: 'string' }, output: { ok: 'boolean' } };
744
- const r = validateFeatureConfig('functions', entry({ signature: sig }));
745
- expect(r.ok).toBe(true);
746
- const fns = (r.value as { functions: Record<string, { signature?: unknown }> }).functions;
747
- expect(fns.hello!.signature).toEqual(sig);
748
- });
749
-
750
- it('rejects an oversize signature (manifest bloat guard), naming the path', () => {
751
- const big = { input: { blob: 'x'.repeat(9_000) } };
752
- const r = validateFeatureConfig('functions', entry({ signature: big }));
753
- expect(r.ok).toBe(false);
754
- expect(r.errors.join(' ')).toContain('/functions/hello/signature');
755
- });
756
-
757
- it('functions schema is 4 leaves (signature is DATA in the map; F8-54 removed runtime + 2 defaultLimits)', () => {
758
- expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(4);
759
- });
760
- });
761
-
762
- describe('cms-rel config: readModels + cdc (cms-relational-depth §3/§4)', () => {
763
- it('cms schema lands at 15 leaves (relations +3, readModels +1, cdc +1, strictEndUserScope +1) — AT the cap', () => {
764
- expect(countLeaves(FEATURE_SCHEMAS['cms']!)).toBe(15);
765
- expect(countLeaves(FEATURE_SCHEMAS['cms']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
766
- });
767
-
768
- it('relations block defaults + range enforcement', () => {
769
- const r = validateFeatureConfig('cms', {});
770
- expect(r.ok).toBe(true);
771
- const rel = (r.value as { relations: Record<string, number> }).relations;
772
- expect(rel).toEqual({ maxExpandDepth: 1, maxExpandFields: 5, maxComputedFieldsPerCollection: 10 });
773
- expect(validateFeatureConfig('cms', { relations: { maxExpandDepth: 3 } }).ok).toBe(false);
774
- });
775
-
776
- const Z1 = {
777
- readModels: {
778
- discover_featured: {
779
- collection: 'posts', kind: 'rank',
780
- spec: {
781
- groupBy: 'channel', metric: { fn: 'sum', field: 'view_count' },
782
- rank: 'percent_rank', direction: 'desc',
783
- filter: { $status: 'published', 'channel.visibility': 'public' },
784
- window: { field: 'created_at', sinceDays: 30 },
785
- },
786
- materialize: { cron: '0 3 * * *', to: 'discover_rollup' },
787
- },
788
- },
789
- };
790
-
791
- it('accepts the Zaawi Z1 declared read-model (rank + join filter + cron)', () => {
792
- const r = validateFeatureConfig('cms', Z1);
793
- expect(r.errors ?? []).toEqual([]);
794
- expect(r.ok).toBe(true);
795
- // Value.Clean must NOT hollow out the opaque spec (the functions signature idiom)
796
- const kept = (r.value as { readModels: Record<string, { spec: Record<string, unknown> }> })
797
- .readModels.discover_featured!.spec;
798
- expect(kept.groupBy).toBe('channel');
799
- expect((kept.filter as Record<string, unknown>)['channel.visibility']).toBe('public');
800
- });
801
-
802
- it('accepts a Z3-style aggregate read-model', () => {
803
- const r = validateFeatureConfig('cms', {
804
- readModels: {
805
- daily_bookings: {
806
- collection: 'bookings', kind: 'aggregate',
807
- spec: {
808
- aggregates: [{ fn: 'count' }, { fn: 'sum', field: 'amount', as: 'revenue' }],
809
- groupBy: ['space'], window: { field: 'created_at', sinceDays: 1 },
810
- },
811
- materialize: { cron: '0 2 * * *', to: 'analytics_daily' },
812
- },
813
- },
814
- });
815
- expect(r.errors ?? []).toEqual([]);
816
- });
817
-
818
- it('rejects fn/rank outside the allow-lists at CONFIG-WRITE time', () => {
819
- const bad = (spec: object, kind = 'aggregate') => validateFeatureConfig('cms', {
820
- readModels: { m: { collection: 'c', kind, spec, materialize: { cron: '* * * * *', to: 't' } } },
821
- });
822
- expect(bad({ aggregates: [{ fn: 'array_agg', field: 'x' }] }).ok).toBe(false);
823
- expect(bad({ groupBy: 'x', rank: 'ntile' }, 'rank').ok).toBe(false);
824
- expect(bad({ groupBy: 'x', rank: 'rank() OVER (ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)' }, 'rank').ok).toBe(false);
825
- });
826
-
827
- it('rejects structural violations: arity, window, spec size, names, cron, limit smuggling', () => {
828
- const rm = (over: object) => validateFeatureConfig('cms', {
829
- readModels: {
830
- m: {
831
- collection: 'c', kind: 'aggregate', spec: { aggregates: [{ fn: 'count' }] },
832
- materialize: { cron: '0 2 * * *', to: 't' }, ...over,
833
- },
834
- },
835
- });
836
- expect(rm({ spec: { aggregates: [{ fn: 'count' }], groupBy: ['a', 'b', 'c'] } }).ok).toBe(false);
837
- expect(rm({ spec: { aggregates: [{ fn: 'count' }], window: { field: 'x', sinceDays: 999 } } }).ok).toBe(false);
838
- expect(rm({ spec: { aggregates: [{ fn: 'count' }], limit: 10 } }).ok).toBe(false);
839
- expect(rm({ spec: { aggregates: [{ fn: 'count' }], filter: { 'a.b.c': 1 } } }).ok).toBe(false);
840
- expect(rm({ spec: { aggregates: [{ fn: 'count' }], filter: { 'a.x': 1, 'b.y': 2 } } }).ok).toBe(false);
841
- expect(rm({ materialize: { cron: 'not-a-cron', to: 't' } }).ok).toBe(false);
842
- expect(rm({ materialize: { cron: '0 2 * * *', to: 'Bad Slug!' } }).ok).toBe(false);
843
- // an over-large serialized spec
844
- expect(rm({ spec: { aggregates: [{ fn: 'count' }], filter: { note: 'x'.repeat(5000) } } }).ok).toBe(false);
845
- // bad read-model name
846
- const r = validateFeatureConfig('cms', {
847
- readModels: { 'Bad Name': { collection: 'c', kind: 'aggregate', spec: { aggregates: [{ fn: 'count' }] } } },
848
- });
849
- expect(r.ok).toBe(false);
850
- });
851
-
852
- it('caps the bag at 20 read-models', () => {
853
- const bag: Record<string, unknown> = {};
854
- for (let i = 0; i < 21; i++) {
855
- bag[`m${i}`] = { collection: 'c', kind: 'aggregate', spec: { aggregates: [{ fn: 'count' }] } };
856
- }
857
- expect(validateFeatureConfig('cms', { readModels: bag }).ok).toBe(false);
858
- });
859
-
860
- it('cdc: accepts a rule bag; rejects bad channel/events/payload and >25 rules', () => {
861
- const ok = validateFeatureConfig('cms', {
862
- cdc: {
863
- posts_live: { collection: 'posts', channel: 'posts:live', events: ['created', 'updated'], payload: 'ids' },
864
- },
865
- });
866
- expect(ok.errors ?? []).toEqual([]);
867
- const bad = (rule: object) => validateFeatureConfig('cms', { cdc: { r: rule } });
868
- expect(bad({ collection: 'posts', channel: 'has spaces!' }).ok).toBe(false);
869
- expect(bad({ collection: 'posts', channel: 'c', events: ['archived'] }).ok).toBe(false);
870
- expect(bad({ collection: 'posts', channel: 'c', payload: 'everything' }).ok).toBe(false);
871
- expect(bad({ channel: 'c' }).ok).toBe(false);
872
- const bag: Record<string, unknown> = {};
873
- for (let i = 0; i < 26; i++) bag[`r${i}`] = { collection: 'c', channel: `ch${i}` };
874
- expect(validateFeatureConfig('cms', { cdc: bag }).ok).toBe(false);
875
- });
876
- });
877
-
878
- // ── payments: reserved (vxil-COGS) credit type cannot be mapped in ledger (F2) ─
879
- // A tenant that maps a product/tier grant to `fn_cpu_ms` and then triggers a
880
- // webhook/subscription would credit the vxil-COGS meter type through the grant
881
- // reducers → free vxil-billed functions. Rejected at config-write time so the
882
- // tenant gets a clear `vxil push` error instead of a silent runtime skip.
883
- describe('payments: reserved credit_type rejected in ledger grant maps (F2)', () => {
884
- it('RESERVED_CREDIT_TYPES / isReservedCreditType are the shared SSOT', () => {
885
- expect(RESERVED_CREDIT_TYPES.has('fn_cpu_ms')).toBe(true);
886
- expect(isReservedCreditType('fn_cpu_ms')).toBe(true);
887
- expect(isReservedCreditType('tokens')).toBe(false);
888
- });
889
-
890
- it('rejects fn_cpu_ms in productMap.creditType', () => {
891
- const v = validateFeatureConfig('payments', {
892
- ledger: {
893
- productMap: { prod_free_cpu: { creditType: 'fn_cpu_ms', amount: 1000, period: 'once' } },
894
- tierMap: {},
895
- },
896
- });
897
- expect(v.ok).toBe(false);
898
- expect(v.errors.some((e) => e.includes('/ledger/productMap/prod_free_cpu/creditType') && e.includes('reserved'))).toBe(true);
899
- });
900
-
901
- it('rejects fn_cpu_ms in a tierMap grant', () => {
902
- const v = validateFeatureConfig('payments', {
903
- ledger: {
904
- productMap: {},
905
- tierMap: {
906
- pro: {
907
- entitlements: [], quotas: {},
908
- grants: [{ creditType: 'tokens', amount: 5, period: 'monthly' },
909
- { creditType: 'fn_cpu_ms', amount: 999_999, period: 'monthly' }],
910
- },
911
- },
912
- },
913
- });
914
- expect(v.ok).toBe(false);
915
- expect(v.errors.some((e) => e.includes('/ledger/tierMap/pro/grants/1/creditType') && e.includes('reserved'))).toBe(true);
916
- });
917
-
918
- it('accepts an ORDINARY credit_type through the same maps (no false-positive)', () => {
919
- const v = validateFeatureConfig('payments', {
920
- ledger: {
921
- productMap: { pack_100: { creditType: 'tokens', amount: 100, period: 'once' } },
922
- tierMap: { pro: { entitlements: ['pro'], quotas: {}, grants: [{ creditType: 'tokens', amount: 5, period: 'monthly' }] } },
923
- },
924
- });
925
- expect(v.ok, v.errors.join()).toBe(true);
926
- });
927
-
928
- it('the reserved-type refinement is a VALIDATION rule, not a leaf — cap unaffected', () => {
929
- // The refinement adds cross-field checks, never a schema property, so the
930
- // payments leaf count is unchanged and comfortably under the cap.
931
- expect(countLeaves(PaymentsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
932
- });
933
- });
934
-
935
- // ── payments: money-path wave (F1-3 config lint, F1-4 acceptSandbox, F1-7 grace,
936
- // F3-19 forwardToTenantUrl deleted) ─────────────────────────────────────────
937
- describe('payments: priceMap ⊆ tierMap + unique ranks (F1-3 / D3 config lint)', () => {
938
- const base = {
939
- tierMap: {
940
- free: { entitlements: [], quotas: {}, rank: 0 },
941
- pro: { entitlements: ['pro'], quotas: {}, rank: 10 },
942
- },
943
- productMap: {},
944
- };
945
-
946
- it('rejects a priceMap value that is not a tierMap key', () => {
947
- const v = validateFeatureConfig('payments', {
948
- ledger: { ...base, priceMap: { price_pro_monthly: 'pro', price_team: 'team' } },
949
- });
950
- expect(v.ok).toBe(false);
951
- expect(v.errors.some((e) => e.includes('/ledger/priceMap/price_team') && e.includes("'team'"))).toBe(true);
952
- });
953
-
954
- it('accepts a priceMap whose every value is a tierMap key', () => {
955
- const v = validateFeatureConfig('payments', {
956
- ledger: { ...base, priceMap: { price_pro_monthly: 'pro', price_pro_yearly: 'pro' } },
957
- });
958
- expect(v.ok, v.errors.join()).toBe(true);
959
- });
960
-
961
- it('rejects two tiers declaring the SAME rank (fold winner would be order-dependent)', () => {
962
- const v = validateFeatureConfig('payments', {
963
- ledger: {
964
- productMap: {},
965
- tierMap: {
966
- pro: { entitlements: ['pro'], quotas: {}, rank: 10 },
967
- team: { entitlements: ['team'], quotas: {}, rank: 10 },
968
- },
969
- },
970
- });
971
- expect(v.ok).toBe(false);
972
- expect(v.errors.some((e) => e.includes('/ledger/tierMap/team/rank') && e.includes('already used'))).toBe(true);
973
- });
974
-
975
- it('does NOT reject rank-less tiers (legacy configs keep pushing)', () => {
976
- const v = validateFeatureConfig('payments', {
977
- ledger: {
978
- productMap: {},
979
- tierMap: {
980
- pro: { entitlements: ['pro'], quotas: {} },
981
- team: { entitlements: ['team'], quotas: {} },
982
- },
983
- },
984
- });
985
- expect(v.ok, v.errors.join()).toBe(true);
986
- });
987
- });
988
-
989
- describe('payments: environment + grace knobs live INSIDE existing bags (leaf budget)', () => {
990
- it('revenuecat.acceptSandbox defaults to false inside the revenuecat bag', () => {
991
- const v = validateFeatureConfig('payments', {
992
- provider: 'revenuecat',
993
- revenuecat: { projectId: 'p', publicSdkKey: 'k', secretApiKeyRef: 'secret:rc' },
994
- });
995
- expect(v.ok, v.errors.join()).toBe(true);
996
- const rc = (v.value as { revenuecat: { acceptSandbox: boolean } }).revenuecat;
997
- expect(rc.acceptSandbox).toBe(false);
998
- });
999
-
1000
- it('ledger.grace.pastDueDays defaults to 0 when the bag is present, is bounded 0..90', () => {
1001
- const ok = validateFeatureConfig('payments', {
1002
- ledger: { productMap: {}, tierMap: {}, grace: {} },
1003
- });
1004
- expect(ok.ok, ok.errors.join()).toBe(true);
1005
- expect((ok.value as { ledger: { grace: { pastDueDays: number } } }).ledger.grace.pastDueDays).toBe(0);
1006
- expect(validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, grace: { pastDueDays: 91 } } }).ok).toBe(false);
1007
- expect(validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, grace: { pastDueDays: -1 } } }).ok).toBe(false);
1008
- });
1009
-
1010
- it('the inert webhooks.forwardToTenantUrl leaf is GONE (F3-19)', () => {
1011
- const props = (PaymentsConfigSchema as unknown as { properties: Record<string, unknown> }).properties;
1012
- expect(props['webhooks']).toBeUndefined();
1013
- expect(JSON.stringify(PaymentsConfigSchema)).not.toContain('forwardToTenantUrl');
1014
- });
1015
-
1016
- it('PaymentsConfig sits at 10 leaves (was 11 — the deleted prices.catalogRef leaf is a net budget gain)', () => {
1017
- // enabled, provider, stripe?, paddle?, revenuecat?, paypal?, returnUrl,
1018
- // defaults.currency, defaults.trialDays, ledger? = 10. (grace and
1019
- // enforcePeriodEnd are bags INSIDE the one optional ledger leaf.)
1020
- expect(countLeaves(PaymentsConfigSchema)).toBe(10);
1021
- expect(countLeaves(PaymentsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
1022
- });
1023
-
1024
- it('the inert prices.catalogRef leaf is GONE (F8-54) and a persisted manifest carrying it still folds', () => {
1025
- const props = (PaymentsConfigSchema as unknown as { properties: Record<string, unknown> }).properties;
1026
- expect(props['prices']).toBeUndefined();
1027
- expect(JSON.stringify(PaymentsConfigSchema)).not.toContain('catalogRef');
1028
- // the shipped manifests all carry `prices: { catalogRef: 'default' }` — Value.Clean strips it
1029
- const v = validateFeatureConfig('payments', { provider: 'mock', prices: { catalogRef: 'default' }, ledger: { productMap: {}, tierMap: {} } });
1030
- expect(v.ok, v.errors.join()).toBe(true);
1031
- expect((v.value as Record<string, unknown>).prices).toBeUndefined();
1032
- });
1033
-
1034
- it('ledger.enforcePeriodEnd (D2) is an optional bag inside ledger with a 72h default slack, bounded 0..720', () => {
1035
- const on = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, enforcePeriodEnd: {} } });
1036
- expect(on.ok).toBe(true);
1037
- expect((on.value as { ledger: { enforcePeriodEnd: { slackHours: number } } }).ledger.enforcePeriodEnd.slackHours).toBe(72);
1038
- const off = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {} } });
1039
- expect((off.value as { ledger: { enforcePeriodEnd?: unknown } }).ledger.enforcePeriodEnd).toBeUndefined();
1040
- expect(validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, enforcePeriodEnd: { slackHours: 721 } } }).ok).toBe(false);
1041
- });
1042
- });
1043
-
1044
- // ── notifications D4: per-locale template overrides ─────────────────────────
1045
- describe('notifications: templates.overrides (D4 per-locale overrides)', () => {
1046
- const base = { fromEmail: 'a@b.test', provider: 'mock' as const };
1047
- const good = {
1048
- welcome: {
1049
- 'fr-FR': {
1050
- subject: 'Bienvenue sur {{app_name}} !',
1051
- html: '<p>Salut{{first_name_greeting}} — {{app_name}}</p>',
1052
- },
1053
- },
1054
- };
1055
-
1056
- it('accepts a well-formed override under allowOverride: true', () => {
1057
- const v = validateFeatureConfig('notifications', {
1058
- ...base, templates: { allowOverride: true, overrides: good },
1059
- });
1060
- expect(v.ok, v.errors.join()).toBe(true);
1061
- });
1062
-
1063
- it('REJECTS overrides when allowOverride is false — never silently inert', () => {
1064
- const v = validateFeatureConfig('notifications', {
1065
- ...base, templates: { allowOverride: false, overrides: good },
1066
- });
1067
- expect(v.ok).toBe(false);
1068
- expect(v.errors.join()).toContain('allowOverride');
1069
- });
1070
-
1071
- it('rejects an unknown template id', () => {
1072
- const v = validateFeatureConfig('notifications', {
1073
- ...base,
1074
- templates: { allowOverride: true, overrides: { invoice: { 'en-US': { subject: 'x' } } } },
1075
- });
1076
- expect(v.ok).toBe(false);
1077
- expect(v.errors.join()).toContain('unknown template id');
1078
- });
1079
-
1080
- it('rejects an unknown placeholder — the typo that would render as an empty string', () => {
1081
- const v = validateFeatureConfig('notifications', {
1082
- ...base,
1083
- templates: {
1084
- allowOverride: true,
1085
- overrides: { welcome: { 'fr-FR': { subject: 'Salut {{app_nmae}}' } } },
1086
- },
1087
- });
1088
- expect(v.ok).toBe(false);
1089
- expect(v.errors.join()).toContain("unknown placeholder '{{app_nmae}}'");
1090
- });
1091
-
1092
- it('checks placeholders in html and text too, not just the subject', () => {
1093
- for (const part of ['html', 'text'] as const) {
1094
- const v = validateFeatureConfig('notifications', {
1095
- ...base,
1096
- templates: {
1097
- allowOverride: true,
1098
- overrides: { welcome: { 'fr-FR': { subject: 'ok', [part]: '{{nope}}' } } },
1099
- },
1100
- });
1101
- expect(v.ok, part).toBe(false);
1102
- expect(v.errors.join()).toContain(`/${part}`);
1103
- }
1104
- });
1105
-
1106
- it('rejects a malformed locale tag', () => {
1107
- const v = validateFeatureConfig('notifications', {
1108
- ...base,
1109
- templates: { allowOverride: true, overrides: { welcome: { fr_FR: { subject: 'x' } } } },
1110
- });
1111
- expect(v.ok).toBe(false);
1112
- expect(v.errors.join()).toContain('BCP-47');
1113
- });
1114
-
1115
- it('enforces the per-template locale cap and the whole-map byte cap', () => {
1116
- const manyLocales: Record<string, { subject: string }> = {};
1117
- for (let i = 0; i < NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE + 1; i++) {
1118
- manyLocales[`en-x${i}`] = { subject: 'x' };
1119
- }
1120
- const capped = validateFeatureConfig('notifications', {
1121
- ...base, templates: { allowOverride: true, overrides: { welcome: manyLocales } },
1122
- });
1123
- expect(capped.ok).toBe(false);
1124
- expect(capped.errors.join()).toContain('per-template cap');
1125
-
1126
- const huge = validateFeatureConfig('notifications', {
1127
- ...base,
1128
- templates: {
1129
- allowOverride: true,
1130
- overrides: {
1131
- welcome: {
1132
- 'fr-FR': { subject: 'x', html: 'y'.repeat(19_000), text: 'z'.repeat(19_000) },
1133
- 'de-DE': { subject: 'x', html: 'y'.repeat(19_000), text: 'z'.repeat(19_000) },
1134
- },
1135
- },
1136
- },
1137
- });
1138
- expect(huge.ok).toBe(false);
1139
- expect(huge.errors.join()).toContain(`${NOTIF_OVERRIDE_MAX_BYTES}-byte cap`);
1140
- });
1141
-
1142
- it('the pure validator is [] for an absent/empty bag', () => {
1143
- expect(validateNotificationOverrides(undefined)).toEqual([]);
1144
- expect(validateNotificationOverrides({ allowOverride: true })).toEqual([]);
1145
- expect(validateNotificationOverrides({ allowOverride: false, overrides: {} })).toEqual([]);
1146
- });
1147
-
1148
- it('a legacy manifest with NO templates bag still validates and still materializes it', () => {
1149
- const v = validateFeatureConfig('notifications', base);
1150
- expect(v.ok, v.errors.join()).toBe(true);
1151
- // `default: {}` survives the Optional collapse — persisted manifests unchanged
1152
- expect((v.value as { templates: { allowOverride: boolean } }).templates)
1153
- .toEqual({ allowOverride: false });
1154
- });
1155
-
1156
- it('NotificationsConfig stays AT the 15-leaf cap (overrides is a Record inside the ONE templates bag)', () => {
1157
- expect(countLeaves(NotificationsConfigSchema)).toBe(15);
1158
- expect(countLeaves(NotificationsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
1159
- });
1160
- });
1161
-
1162
- // ── F8-54: inert declared knobs — wire or delete ─────────────────────────────
1163
- // Six knobs shipped DECLARED but with no enforcing reader (register §8 / §12.4
1164
- // B8); the programmatic sweep (scripts/config-leaf-readers.ts + the
1165
- // tests/ci/src/config-leaf-readers.test.ts gate) found more. These assert the
1166
- // DELETED half: the leaf is gone from the schema, the cap count moved, and a
1167
- // manifest a live tenant already persisted with the old key STILL FOLDS —
1168
- // Value.Clean strips unknown keys, so a deletion can never 422 an existing
1169
- // tenant's next publish.
1170
- describe('F8-54: the deleted inert leaves stay deleted, and old manifests still fold', () => {
1171
- const props = (schema: unknown): Record<string, unknown> =>
1172
- (schema as { properties: Record<string, unknown> }).properties;
1173
-
1174
- it('files: bucketRef + the contentScan bag are gone (11 leaves, was 14)', () => {
1175
- const p = props(FEATURE_SCHEMAS['files']!);
1176
- expect(p['bucketRef']).toBeUndefined();
1177
- expect(p['contentScan']).toBeUndefined();
1178
- expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBe(11);
1179
- });
1180
-
1181
- it('files: a persisted manifest with bucketRef + contentScan still folds', () => {
1182
- const r = validateFeatureConfig('files', {
1183
- enabled: true,
1184
- bucketRef: 'vxil-files',
1185
- contentScan: { enabled: true, quarantineOnFail: false },
1186
- uploadUrlTtl: 1800,
1187
- });
1188
- expect(r.ok).toBe(true);
1189
- const v = r.value as Record<string, unknown>;
1190
- expect(v['bucketRef']).toBeUndefined();
1191
- expect(v['contentScan']).toBeUndefined();
1192
- expect(v['uploadUrlTtl']).toBe(1800); // the live leaves ride through untouched
1193
- });
1194
-
1195
- it('mcp: scopedKey is gone (8 leaves, was 9) and an old manifest folds', () => {
1196
- expect(props(FEATURE_SCHEMAS['mcp']!)['scopedKey']).toBeUndefined();
1197
- expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBe(8);
1198
- const r = validateFeatureConfig('mcp', {
1199
- enabled: true, scopedKey: { perAgentKeys: true }, exposureLevel: 'read-only',
1200
- });
1201
- expect(r.ok).toBe(true);
1202
- expect((r.value as Record<string, unknown>)['scopedKey']).toBeUndefined();
1203
- expect((r.value as Record<string, unknown>)['exposureLevel']).toBe('read-only');
1204
- });
1205
-
1206
- it('activity-feed: the aggregation + rateLimit bags are gone (11 leaves, was 13)', () => {
1207
- const p = props(FEATURE_SCHEMAS['activity-feed']!);
1208
- expect(p['aggregation']).toBeUndefined();
1209
- expect(p['rateLimit']).toBeUndefined();
1210
- expect(countLeaves(FEATURE_SCHEMAS['activity-feed']!)).toBe(11);
1211
- const r = validateFeatureConfig('activity-feed', {
1212
- enabled: true,
1213
- aggregation: { maxGroupActivities: 30 },
1214
- rateLimit: { addPerSec: 500 },
1215
- follow: { copyLimit: 42 },
1216
- });
1217
- expect(r.ok).toBe(true);
1218
- const v = r.value as Record<string, unknown>;
1219
- expect(v['aggregation']).toBeUndefined();
1220
- expect(v['rateLimit']).toBeUndefined();
1221
- expect((v['follow'] as Record<string, unknown>)['copyLimit']).toBe(42);
1222
- });
1223
-
1224
- it('functions: runtime + defaultLimits.{timeoutMs,memoryMb} are gone (4 leaves, was 7)', () => {
1225
- const p = props(FEATURE_SCHEMAS['functions']!);
1226
- expect(p['runtime']).toBeUndefined();
1227
- const dl = props(p['defaultLimits']);
1228
- expect(dl['timeoutMs']).toBeUndefined();
1229
- expect(dl['memoryMb']).toBeUndefined();
1230
- expect(dl['cpuMs']).toBeDefined(); // the ONE enforced limit stays
1231
- expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(4);
1232
- });
1233
-
1234
- it('functions: a persisted manifest with runtime + the dead limits still folds', () => {
1235
- const r = validateFeatureConfig('functions', {
1236
- enabled: true,
1237
- runtime: 'container',
1238
- defaultLimits: { cpuMs: 120, timeoutMs: 30_000, memoryMb: 512 },
1239
- metering: { enabled: true },
1240
- });
1241
- expect(r.ok).toBe(true);
1242
- const v = r.value as Record<string, unknown>;
1243
- expect(v['runtime']).toBeUndefined();
1244
- const dl = v['defaultLimits'] as Record<string, unknown>;
1245
- expect(dl['cpuMs']).toBe(120); // the enforced value survives
1246
- expect(dl['timeoutMs']).toBeUndefined();
1247
- expect(dl['memoryMb']).toBeUndefined();
1248
- });
1249
-
1250
- it('every surviving schema still fits the cap (the deletions only freed budget)', () => {
1251
- for (const [feature, schema] of Object.entries(FEATURE_SCHEMAS)) {
1252
- expect(countLeaves(schema), `${feature} exceeds the flag cap`).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
1253
- }
1254
- });
1255
- });