@vxil/feature-configs 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "TypeBox config schemas for every vxil feature — the typed manifests vxil.config.ts is built on. INTERNAL workspace package: bundled into the published `vxil` package (via @vxil/config), not published separately.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",
package/src/index.test.ts CHANGED
@@ -4,9 +4,14 @@ import {
4
4
  CopilotConfigSchema,
5
5
  FEATURE_SCHEMAS,
6
6
  NotificationsConfigSchema,
7
+ PaymentsConfigSchema,
8
+ RESERVED_CREDIT_TYPES,
7
9
  countLeaves,
10
+ isReservedCreditType,
8
11
  setKnownMcpTools,
9
12
  validateFeatureConfig,
13
+ NOTIF_OVERRIDE_MAX_BYTES, NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE,
14
+ validateNotificationOverrides,
10
15
  } from './index.js';
11
16
  // The LIVE mcp catalog (zero-import pure data) — the same relative-import
12
17
  // convention as packages/cli/src/mcp.ts. Test-only: the publish build
@@ -14,8 +19,11 @@ import {
14
19
  import { TOOLS } from '../../../workers/mcp-v1/src/tools.js';
15
20
 
16
21
  describe('15-flag cap analyzer (architecture §6)', () => {
17
- it('notifications schema has 14 leaves — under the cap', () => {
18
- expect(countLeaves(NotificationsConfigSchema)).toBe(14);
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);
19
27
  expect(countLeaves(NotificationsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
20
28
  });
21
29
 
@@ -34,19 +42,45 @@ describe('15-flag cap analyzer (architecture §6)', () => {
34
42
  }
35
43
  });
36
44
 
37
- it('auth schema sits AT the cap (optional bags = 1 leaf each)', () => {
38
- // enabled(1) + methods(6) + providers(1) + session(1) + password(1)
39
- // + emailVerification(1) + magicLink(1) + otp(1) + anonymous(1)
40
- // + orgClaims(1) = 15. The OTP/anonymous/orgClaims wave funded itself by
41
- // collapsing session/password to optional bags and dropping the
42
- // consumer-less emailVerification.tokenTtlHours (same precedent as the
43
- // dropped `redirects` block). The cap check is `>`, so 15 passes.
44
- expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(15);
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);
45
53
  expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
46
54
  });
47
55
 
48
- it('files schema is 14 leaves after the ttl/extractText optional bags', () => {
49
- expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBe(14);
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);
50
84
  expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
51
85
  });
52
86
 
@@ -129,6 +163,74 @@ describe('validateFeatureConfig', () => {
129
163
  expect(r.ok).toBe(false);
130
164
  });
131
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
+
132
234
  it('rejects an unknown feature', () => {
133
235
  const r = validateFeatureConfig('telepathy', { enabled: true });
134
236
  expect(r.ok).toBe(false);
@@ -298,6 +400,29 @@ describe('validateFeatureConfig', () => {
298
400
  expect(bare.value).not.toHaveProperty('orgClaims');
299
401
  });
300
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
+
301
426
  it('auth: otp knob bounds are enforced (maxAttempts 3..10)', () => {
302
427
  expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 2 } }).ok).toBe(false);
303
428
  expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 11 } }).ok).toBe(false);
@@ -312,9 +437,13 @@ describe('validateFeatureConfig', () => {
312
437
  expect(okCfg.ok).toBe(true);
313
438
  const explicit = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.created' }]));
314
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
+ }
315
444
  const bad = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.deleted' }]));
316
445
  expect(bad.ok).toBe(false);
317
- expect(bad.errors.join(' ')).toContain("must be 'user.created'");
446
+ expect(bad.errors.join(' ')).toContain('user.created | session.created | session.revoked | signin.failure');
318
447
  });
319
448
 
320
449
  it('functions: DENY_FUNCTION_SCOPES rejects cross-tenant/self-integrity scopes but ALLOWS business scopes', () => {
@@ -375,8 +504,8 @@ describe('validateFeatureConfig', () => {
375
504
  });
376
505
 
377
506
  describe('mcp config: custom tools + prompts (mcp.md §6.5/§11)', () => {
378
- it('McpConfigSchema stays at 9 leaves (customTools/prompts are ONE Record leaf each)', () => {
379
- expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBe(9);
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);
380
509
  expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
381
510
  });
382
511
 
@@ -489,8 +618,8 @@ describe('mcp config: custom tools + prompts (mcp.md §6.5/§11)', () => {
489
618
  });
490
619
 
491
620
  describe('copilot config (docs/copilot-agents-sku-design.md §4)', () => {
492
- it('has exactly 9 leaves — under the cap (agents/allow/collections are DATA)', () => {
493
- expect(countLeaves(CopilotConfigSchema)).toBe(9);
621
+ it('has exactly 7 leaves — under the cap (agents/allow/collections are DATA)', () => {
622
+ expect(countLeaves(CopilotConfigSchema)).toBe(7);
494
623
  expect(countLeaves(CopilotConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
495
624
  });
496
625
 
@@ -575,19 +704,27 @@ describe('copilot config (docs/copilot-agents-sku-design.md §4)', () => {
575
704
  expect(good.ok).toBe(true);
576
705
  });
577
706
 
578
- it("escalation.handler 'notifications' requires notifyTemplate", () => {
579
- const bad = validateFeatureConfig('copilot', {
580
- agents: {},
581
- escalation: { enabled: true, handler: 'notifications' },
582
- });
583
- expect(bad.ok).toBe(false);
584
- expect(bad.errors.join(' ')).toContain('notifyTemplate');
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
+ });
585
714
 
586
- const good = validateFeatureConfig('copilot', {
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,
587
719
  agents: {},
588
720
  escalation: { enabled: true, handler: 'notifications', notifyTemplate: 'escalation' },
721
+ limits: { consumeCredits: true, tokensPerUserPerDay: 5000 },
589
722
  });
590
- expect(good.ok).toBe(true);
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
591
728
  });
592
729
  });
593
730
 
@@ -617,8 +754,8 @@ describe('functions config — signature (Level-1 typed I/O)', () => {
617
754
  expect(r.errors.join(' ')).toContain('/functions/hello/signature');
618
755
  });
619
756
 
620
- it('functions schema stays at 7 leaves (signature is DATA in the map)', () => {
621
- expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(7);
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);
622
759
  });
623
760
  });
624
761
 
@@ -737,3 +874,382 @@ describe('cms-rel config: readModels + cdc (cms-relational-depth §3/§4)', () =
737
874
  expect(validateFeatureConfig('cms', { cdc: bag }).ok).toBe(false);
738
875
  });
739
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
+ });