@vxil/feature-configs 0.2.0 → 0.3.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/hooks.ts CHANGED
@@ -1,4 +1,4 @@
1
- // CMS lifecycle hooks — the SAFE expression engine (docs/cms-lifecycle-hooks-rung2-design.md, Lane A).
1
+ // CMS lifecycle hooks — the SAFE expression engine (https://vxil.com/docs/guide/07-validation-and-hooks).
2
2
  //
3
3
  // A "code-based" lifecycle hook is a small EXPRESSION the tenant authors. Three kinds:
4
4
  // - validate: the expression must evaluate truthy or the write is REJECTED (422)
@@ -17,8 +17,8 @@
17
17
  // CROSS-ROW AGGREGATION STAYS OUT — BY DESIGN. Read hooks are a pure function
18
18
  // of ONE row (+ `now`): there are no new root variables, no array/aggregate
19
19
  // functions, no access to sibling rows or other collections. Counts/sums/
20
- // group-bys across rows are the §5 identity-fork anti-item (docs/features/
21
- // cms.md §6.7) and must never enter this engine.
20
+ // group-bys across rows are the identity-fork anti-item (function territory:
21
+ // vxil.com/docs/guide/07-validation-and-hooks) and must never enter this engine.
22
22
  //
23
23
  // SAFETY (the "not malicious" guarantee) is structural, not heuristic:
24
24
  // * No JS is executed. `eval`/`Function`/the host runtime are never touched. The
@@ -63,7 +63,7 @@ export const HOOK_LIMITS = {
63
63
 
64
64
  // ── allow-lists ─────────────────────────────────────────────────────────────
65
65
  /** The ONLY root variables an expression may reference. `caller` is the VERIFIED
66
- * end-user principal (docs/end-user-principals-design.md §5.6) — read-only and
66
+ * end-user principal (https://vxil.com/docs/guide/09-security-and-multitenancy) — read-only and
67
67
  * populated ONLY on the READ path (runReadHooks); on the write path and in
68
68
  * server-caller mode it is null, exactly like `before` on a create. It carries
69
69
  * `caller.endUserId` (the verified session sub, or null) and `caller.principal`
package/src/index.test.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  isReservedCreditType,
11
11
  setKnownMcpTools,
12
12
  validateFeatureConfig,
13
+ NOTIF_OVERRIDE_MAX_BYTES, NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE,
14
+ validateNotificationOverrides,
13
15
  } from './index.js';
14
16
  // The LIVE mcp catalog (zero-import pure data) — the same relative-import
15
17
  // convention as packages/cli/src/mcp.ts. Test-only: the publish build
@@ -40,19 +42,45 @@ describe('15-flag cap analyzer (architecture §6)', () => {
40
42
  }
41
43
  });
42
44
 
43
- it('auth schema sits AT the cap (optional bags = 1 leaf each)', () => {
44
- // enabled(1) + methods(6) + providers(1) + session(1) + password(1)
45
- // + emailVerification(1) + magicLink(1) + otp(1) + anonymous(1)
46
- // + orgClaims(1) = 15. The OTP/anonymous/orgClaims wave funded itself by
47
- // collapsing session/password to optional bags and dropping the
48
- // consumer-less emailVerification.tokenTtlHours (same precedent as the
49
- // dropped `redirects` block). The cap check is `>`, so 15 passes.
50
- 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);
51
53
  expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
52
54
  });
53
55
 
54
- it('files schema is 14 leaves after the ttl/extractText optional bags', () => {
55
- 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);
56
84
  expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
57
85
  });
58
86
 
@@ -372,6 +400,29 @@ describe('validateFeatureConfig', () => {
372
400
  expect(bare.value).not.toHaveProperty('orgClaims');
373
401
  });
374
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
+
375
426
  it('auth: otp knob bounds are enforced (maxAttempts 3..10)', () => {
376
427
  expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 2 } }).ok).toBe(false);
377
428
  expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 11 } }).ok).toBe(false);
@@ -386,9 +437,13 @@ describe('validateFeatureConfig', () => {
386
437
  expect(okCfg.ok).toBe(true);
387
438
  const explicit = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.created' }]));
388
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
+ }
389
444
  const bad = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.deleted' }]));
390
445
  expect(bad.ok).toBe(false);
391
- expect(bad.errors.join(' ')).toContain("must be 'user.created'");
446
+ expect(bad.errors.join(' ')).toContain('user.created | session.created | session.revoked | signin.failure');
392
447
  });
393
448
 
394
449
  it('functions: DENY_FUNCTION_SCOPES rejects cross-tenant/self-integrity scopes but ALLOWS business scopes', () => {
@@ -449,8 +504,8 @@ describe('validateFeatureConfig', () => {
449
504
  });
450
505
 
451
506
  describe('mcp config: custom tools + prompts (mcp.md §6.5/§11)', () => {
452
- it('McpConfigSchema stays at 9 leaves (customTools/prompts are ONE Record leaf each)', () => {
453
- 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);
454
509
  expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
455
510
  });
456
511
 
@@ -563,8 +618,8 @@ describe('mcp config: custom tools + prompts (mcp.md §6.5/§11)', () => {
563
618
  });
564
619
 
565
620
  describe('copilot config (docs/copilot-agents-sku-design.md §4)', () => {
566
- it('has exactly 9 leaves — under the cap (agents/allow/collections are DATA)', () => {
567
- 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);
568
623
  expect(countLeaves(CopilotConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
569
624
  });
570
625
 
@@ -649,19 +704,27 @@ describe('copilot config (docs/copilot-agents-sku-design.md §4)', () => {
649
704
  expect(good.ok).toBe(true);
650
705
  });
651
706
 
652
- it("escalation.handler 'notifications' requires notifyTemplate", () => {
653
- const bad = validateFeatureConfig('copilot', {
654
- agents: {},
655
- escalation: { enabled: true, handler: 'notifications' },
656
- });
657
- expect(bad.ok).toBe(false);
658
- 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
+ });
659
714
 
660
- 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,
661
719
  agents: {},
662
720
  escalation: { enabled: true, handler: 'notifications', notifyTemplate: 'escalation' },
721
+ limits: { consumeCredits: true, tokensPerUserPerDay: 5000 },
663
722
  });
664
- 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
665
728
  });
666
729
  });
667
730
 
@@ -691,8 +754,8 @@ describe('functions config — signature (Level-1 typed I/O)', () => {
691
754
  expect(r.errors.join(' ')).toContain('/functions/hello/signature');
692
755
  });
693
756
 
694
- it('functions schema stays at 7 leaves (signature is DATA in the map)', () => {
695
- 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);
696
759
  });
697
760
  });
698
761
 
@@ -868,3 +931,325 @@ describe('payments: reserved credit_type rejected in ledger grant maps (F2)', ()
868
931
  expect(countLeaves(PaymentsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
869
932
  });
870
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
+ });