@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/README.md +58 -0
- package/dist/hooks.d.ts +1 -1
- package/dist/hooks.js +4 -4
- package/dist/index.d.ts +99 -33
- package/dist/index.js +343 -44
- package/dist/readmodels.js +1 -1
- package/package.json +2 -2
- package/src/hooks.ts +4 -4
- package/src/index.test.ts +411 -26
- package/src/index.ts +418 -99
- package/src/readmodels.ts +1 -1
package/src/hooks.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// CMS lifecycle hooks — the SAFE expression engine (docs/
|
|
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
|
|
21
|
-
//
|
|
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/
|
|
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
|
|
44
|
-
// enabled(1) + methods(6) + providers(1) + session(1)
|
|
45
|
-
// + emailVerification(1) + magicLink(1) + otp(1) + anonymous(1)
|
|
46
|
-
// + orgClaims(1) =
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(
|
|
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('
|
|
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);
|
|
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(
|
|
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
|
|
453
|
-
expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBe(
|
|
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
|
|
567
|
-
expect(countLeaves(CopilotConfigSchema)).toBe(
|
|
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(
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
expect(
|
|
658
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
695
|
-
expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(
|
|
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
|
+
});
|