@vxil/feature-configs 0.3.1 → 0.4.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/dist/index.d.ts +19 -6
- package/dist/index.js +165 -15
- package/package.json +1 -1
- package/src/index.test.ts +176 -8
- package/src/index.ts +172 -21
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export declare const RESERVED_CREDIT_TYPES: ReadonlySet<string>;
|
|
|
6
6
|
* of which is restricted to internal platform machinery). */
|
|
7
7
|
export declare function isReservedCreditType(creditType: string): boolean;
|
|
8
8
|
export declare const NOTIFICATION_TEMPLATE_PLACEHOLDERS: Readonly<Record<string, readonly string[]>>;
|
|
9
|
+
/** Placeholders that are SLOTS in the BODY parts only — the renderer splits
|
|
10
|
+
* `html`/`text` on them (templates.ts `CTA_SLOT`) but the subject goes through
|
|
11
|
+
* the plain interpolator, where the same token is just a data key that does not
|
|
12
|
+
* exist and renders EMPTY. Rejected in `subject` for exactly the reason the
|
|
13
|
+
* whole allow-list exists: a silent empty render in a live email. */
|
|
14
|
+
export declare const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS: Readonly<Record<string, readonly string[]>>;
|
|
9
15
|
/** Per-override caps (D4). Bodies are emails, not documents. */
|
|
10
16
|
export declare const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
|
|
11
17
|
export declare const NOTIF_OVERRIDE_BODY_MAX = 20000;
|
|
@@ -54,7 +60,7 @@ export declare const NotificationsConfigSchema: import("@sinclair/typebox").TObj
|
|
|
54
60
|
templates: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
55
61
|
allowOverride: import("@sinclair/typebox").TBoolean;
|
|
56
62
|
overrides: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, import("@sinclair/typebox").TObject<{
|
|
57
|
-
subject: import("@sinclair/typebox").TString
|
|
63
|
+
subject: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
58
64
|
html: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
59
65
|
text: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
|
|
60
66
|
}>>>>;
|
|
@@ -203,9 +209,11 @@ export declare const AuthConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
203
209
|
emailVerification: import("@sinclair/typebox").TObject<{
|
|
204
210
|
required: import("@sinclair/typebox").TBoolean;
|
|
205
211
|
}>;
|
|
206
|
-
magicLink: import("@sinclair/typebox").TObject<{
|
|
212
|
+
magicLink: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
207
213
|
tokenTtlMinutes: import("@sinclair/typebox").TInteger;
|
|
208
|
-
|
|
214
|
+
resendCooldownSec: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
215
|
+
}>>;
|
|
216
|
+
registryAdopt: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
209
217
|
otp: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
210
218
|
enabled: import("@sinclair/typebox").TBoolean;
|
|
211
219
|
codeTtlMinutes: import("@sinclair/typebox").TInteger;
|
|
@@ -275,6 +283,11 @@ export declare const WebhooksConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
275
283
|
enabled: import("@sinclair/typebox").TBoolean;
|
|
276
284
|
maxSubscriptions: import("@sinclair/typebox").TInteger;
|
|
277
285
|
maxSources: import("@sinclair/typebox").TInteger;
|
|
286
|
+
alerts: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
287
|
+
enabled: import("@sinclair/typebox").TBoolean;
|
|
288
|
+
minLevel: import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"warn">, import("@sinclair/typebox").TLiteral<"error">]>;
|
|
289
|
+
digestMinutes: import("@sinclair/typebox").TInteger;
|
|
290
|
+
}>>;
|
|
278
291
|
}>;
|
|
279
292
|
export type WebhooksConfig = Static<typeof WebhooksConfigSchema>;
|
|
280
293
|
export declare const CommentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
@@ -554,6 +567,7 @@ export declare const PaymentsConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
554
567
|
enforcePeriodEnd: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
555
568
|
slackHours: import("@sinclair/typebox").TInteger;
|
|
556
569
|
}>>;
|
|
570
|
+
unmappedProduct: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"error">, import("@sinclair/typebox").TLiteral<"ignore">]>>;
|
|
557
571
|
}>>;
|
|
558
572
|
}>;
|
|
559
573
|
export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
|
|
@@ -579,9 +593,8 @@ export declare const FunctionsConfigSchema: import("@sinclair/typebox").TObject<
|
|
|
579
593
|
secrets: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
580
594
|
egressAllow: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>>;
|
|
581
595
|
limits: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
|
582
|
-
cpuMs: import("@sinclair/typebox").TInteger
|
|
583
|
-
timeoutMs: import("@sinclair/typebox").TInteger
|
|
584
|
-
memoryMb: import("@sinclair/typebox").TInteger;
|
|
596
|
+
cpuMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
597
|
+
timeoutMs: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
|
|
585
598
|
}>>;
|
|
586
599
|
enabled: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TBoolean>;
|
|
587
600
|
signature: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
|
package/dist/index.js
CHANGED
|
@@ -44,7 +44,19 @@ export const NOTIFICATION_TEMPLATE_PLACEHOLDERS = {
|
|
|
44
44
|
'magic-link': ['url', 'expires_minutes'],
|
|
45
45
|
'otp-code': ['code', 'expires_minutes'],
|
|
46
46
|
welcome: ['app_name', 'first_name', 'first_name_greeting'],
|
|
47
|
-
|
|
47
|
+
// `cta` (G-78) is a SLOT, not a data key: an override that writes `{{cta}}`
|
|
48
|
+
// chooses where the escaped CTA anchor built from cta_label + cta_url lands.
|
|
49
|
+
// Absent from the built-in bodies (which append it), so the worker parity
|
|
50
|
+
// suite pins it explicitly — see templates.test.ts.
|
|
51
|
+
transactional: ['subject', 'paragraph', 'cta_label', 'cta_url', 'cta'],
|
|
52
|
+
};
|
|
53
|
+
/** Placeholders that are SLOTS in the BODY parts only — the renderer splits
|
|
54
|
+
* `html`/`text` on them (templates.ts `CTA_SLOT`) but the subject goes through
|
|
55
|
+
* the plain interpolator, where the same token is just a data key that does not
|
|
56
|
+
* exist and renders EMPTY. Rejected in `subject` for exactly the reason the
|
|
57
|
+
* whole allow-list exists: a silent empty render in a live email. */
|
|
58
|
+
export const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS = {
|
|
59
|
+
transactional: ['cta'],
|
|
48
60
|
};
|
|
49
61
|
/** Per-override caps (D4). Bodies are emails, not documents. */
|
|
50
62
|
export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
|
|
@@ -96,14 +108,31 @@ export function validateNotificationOverrides(templates) {
|
|
|
96
108
|
continue;
|
|
97
109
|
}
|
|
98
110
|
const variant = byLocale[locale];
|
|
111
|
+
if (typeof variant !== 'object' || variant === null) {
|
|
112
|
+
errs.push(`/templates/overrides/${templateId}/${locale}: must be an object with at least one of subject, html, text`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// G-78 — every part is OPTIONAL (a body-only translation inherits the
|
|
116
|
+
// built-in subject), but an ALL-EMPTY variant is the new "inert config"
|
|
117
|
+
// and fails LOUD rather than silently rendering the built-in.
|
|
118
|
+
const nonEmpty = ['subject', 'html', 'text']
|
|
119
|
+
.filter((p) => typeof variant[p] === 'string' && variant[p] !== '');
|
|
120
|
+
if (nonEmpty.length === 0) {
|
|
121
|
+
errs.push(`/templates/overrides/${templateId}/${locale}: set at least one of subject, html, text`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// body-only SLOTS are not subject placeholders (see the map's doc comment)
|
|
125
|
+
const slots = NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS[templateId] ?? [];
|
|
126
|
+
const subjectAllowed = allowed.filter((k) => !slots.includes(k));
|
|
99
127
|
for (const part of ['subject', 'html', 'text']) {
|
|
100
128
|
const body = variant[part];
|
|
101
129
|
if (typeof body !== 'string')
|
|
102
130
|
continue;
|
|
131
|
+
const ok = part === 'subject' ? subjectAllowed : allowed;
|
|
103
132
|
for (const m of body.matchAll(NOTIF_PLACEHOLDER_RE)) {
|
|
104
133
|
const key = m[1];
|
|
105
|
-
if (!
|
|
106
|
-
errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${
|
|
134
|
+
if (!ok.includes(key)) {
|
|
135
|
+
errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${ok.join(', ')})`);
|
|
107
136
|
}
|
|
108
137
|
}
|
|
109
138
|
}
|
|
@@ -161,15 +190,23 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
161
190
|
allowOverride: Type.Boolean({ default: false }),
|
|
162
191
|
// D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
|
|
163
192
|
// template ids, as config DATA (a Record = 1 leaf, catalog size never
|
|
164
|
-
// moves the count). Shape: { [templateId]: { [locale]: { subject
|
|
193
|
+
// moves the count). Shape: { [templateId]: { [locale]: { subject?,
|
|
165
194
|
// html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
|
|
166
195
|
// built-ins — the worker reuses ONE renderer, so escaping/URL-scheme
|
|
167
196
|
// sanitisation are identical and there is no raw-output syntax.
|
|
168
197
|
// validateFeatureConfig rejects: an unknown template id, an unknown
|
|
169
198
|
// placeholder, a malformed locale tag, an over-size body, and (fail
|
|
170
199
|
// LOUD, never silently inert) overrides present with allowOverride:false.
|
|
171
|
-
overrides: Type.Optional(Type.Record(Type.String(),
|
|
172
|
-
|
|
200
|
+
overrides: Type.Optional(Type.Record(Type.String(),
|
|
201
|
+
// G-78: `subject` is OPTIONAL like html/text — a PARTIAL override
|
|
202
|
+
// inherits each omitted part from the built-in, so a body-only
|
|
203
|
+
// translation no longer forces you to restate the subject (and a
|
|
204
|
+
// subject-only 'ar' override no longer pairs an Arabic subject with the
|
|
205
|
+
// English built-in body). A strict widening: every config valid before
|
|
206
|
+
// this change is still valid. validateNotificationOverrides rejects the
|
|
207
|
+
// one shape this opens up — a variant with NO non-empty part.
|
|
208
|
+
Type.Record(Type.String(), Type.Object({
|
|
209
|
+
subject: Type.Optional(Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX })),
|
|
173
210
|
html: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
174
211
|
text: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
175
212
|
})))),
|
|
@@ -368,7 +405,35 @@ export const AuthConfigSchema = Type.Object({
|
|
|
368
405
|
// verify-email token flow it would bound was never built). Same precedent as
|
|
369
406
|
// the removed `redirects` block below. Its leaf funds the OTP wave.
|
|
370
407
|
emailVerification: Type.Object({ required: Type.Boolean({ default: false }) }, { default: {} }),
|
|
371
|
-
|
|
408
|
+
// P1-16 (2026-09-18): magicLink is now an OPTIONAL bag (= ONE leaf however
|
|
409
|
+
// many knobs it holds — the methods/session/password precedent) so the
|
|
410
|
+
// request cooldown could land without spending a second leaf. It KEEPS
|
|
411
|
+
// `default: {}`, so Value.Default still materializes
|
|
412
|
+
// `magicLink.tokenTtlMinutes` exactly as before — every persisted manifest
|
|
413
|
+
// folds byte-identically.
|
|
414
|
+
magicLink: Type.Optional(Type.Object({
|
|
415
|
+
tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
|
|
416
|
+
// P1-16: the per-(tenant, identifier) magic-link REQUEST cooldown — the
|
|
417
|
+
// `otp.resendCooldownSec` twin, same bounds so the two knobs read the
|
|
418
|
+
// same. A second request for the same address inside the window is a 429
|
|
419
|
+
// `magic_link_rate_limited` with a Retry-After header. Type.Optional with
|
|
420
|
+
// NO default (the `otp.testRecipients` precedent below) so existing
|
|
421
|
+
// manifests fold byte-identically; the 60s fallback is applied by the
|
|
422
|
+
// auth feature at read time. 0 disables the cooldown.
|
|
423
|
+
resendCooldownSec: Type.Optional(Type.Integer({ minimum: 0, maximum: 600 })),
|
|
424
|
+
}, { default: {} })),
|
|
425
|
+
// P1-5 identity continuity (2026-09-18) — OPT-IN registry adoption. When
|
|
426
|
+
// true, a sign-in by a method that PROVES control of the address — magic
|
|
427
|
+
// link, email OTP, or OAuth with a provider-verified address — reuses the id
|
|
428
|
+
// of the one matching pre-registered end-user (POST /v1/users) that has no
|
|
429
|
+
// account yet, instead of minting a fresh `user_<ulid>` and leaving the
|
|
430
|
+
// tenant with two records for one person. Password SIGN-UP never adopts: it
|
|
431
|
+
// proves nothing about the address (auth.md §8.1 PROVEN_ADOPT_METHODS).
|
|
432
|
+
// OFF by default, and Type.Optional with NO default: adoption means whoever
|
|
433
|
+
// proves control of a pre-registered address becomes that record — a change
|
|
434
|
+
// of security semantics for a tenant that bulk-imports contacts, whose
|
|
435
|
+
// addresses vxil never verified.
|
|
436
|
+
registryAdopt: Type.Optional(Type.Boolean()),
|
|
372
437
|
// Email OTP sign-in (roadmap Tier-0) + the knobs step-up re-auth shares.
|
|
373
438
|
// OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
|
|
374
439
|
// otp?.enabled === true).
|
|
@@ -495,6 +560,27 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
495
560
|
enabled: Type.Boolean({ default: true }),
|
|
496
561
|
maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
497
562
|
maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
563
|
+
// FAILURE-ALERT DIGEST (P0-2). Outbound subscriptions are the real-time
|
|
564
|
+
// channel; this is the "nobody is consuming them yet" fallback — a periodic
|
|
565
|
+
// e-mail summary of the tenant's FAILURE-class audit events (the level:
|
|
566
|
+
// 'failure' rows of the generated event catalog). Read by
|
|
567
|
+
// workers/control-plane/src/alertDigest.ts on the minute cron.
|
|
568
|
+
//
|
|
569
|
+
// RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this deviation is
|
|
570
|
+
// journaled here the way RateLimitsConfigSchema journals its own: an
|
|
571
|
+
// arbitrary `to` would turn vxil's own sending identity into a relay for
|
|
572
|
+
// tenant-authored content and open a PII egress path out of the audit trail.
|
|
573
|
+
// The digest goes to the OWNER-role dashboard accounts of the project (cap
|
|
574
|
+
// 3), who already receive platform mail (invites, magic links, resets), so no
|
|
575
|
+
// new consent surface is created. An arbitrary recipient is a separate
|
|
576
|
+
// decision with its own abuse review.
|
|
577
|
+
//
|
|
578
|
+
// An Optional object bag counts as ONE leaf (the countLeaves rule).
|
|
579
|
+
alerts: Type.Optional(Type.Object({
|
|
580
|
+
enabled: Type.Boolean({ default: false }),
|
|
581
|
+
minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
|
|
582
|
+
digestMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 1440 }),
|
|
583
|
+
})),
|
|
498
584
|
});
|
|
499
585
|
// comments feature (wishlist): threaded discussion on tenant-defined topics.
|
|
500
586
|
export const CommentsConfigSchema = Type.Object({
|
|
@@ -813,7 +899,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
813
899
|
cache: Type.Object({ ttlSeconds: Type.Integer({ default: 0, minimum: 0 }) }, { default: {} }), // 0 = off
|
|
814
900
|
limits: Type.Object({
|
|
815
901
|
tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
|
|
816
|
-
consumeCredits: Type.Boolean({ default: false }), // reserve→settle (
|
|
902
|
+
consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
|
|
817
903
|
}, { default: {} }),
|
|
818
904
|
streaming: Type.Object({
|
|
819
905
|
enabled: Type.Boolean({ default: true }),
|
|
@@ -902,10 +988,16 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
902
988
|
projectId: Type.String(),
|
|
903
989
|
publicSdkKey: Type.String(),
|
|
904
990
|
secretApiKeyRef: Type.String(),
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
// webhook-forgery vector
|
|
991
|
+
// Per-tenant webhook secret ref (public.tenant_secrets). Inbound RevenueCat
|
|
992
|
+
// webhooks are verified against THIS ref and nothing else: there is no
|
|
993
|
+
// platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
|
|
994
|
+
// (that fallback WAS the multi-tenant RC webhook-forgery vector; it is now
|
|
995
|
+
// frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
|
|
996
|
+
// permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
|
|
997
|
+
// Optional at the SCHEMA level only — leaving it unset does not disable
|
|
998
|
+
// verification, it fails CLOSED: every delivery is 401 bad_signature with a
|
|
999
|
+
// `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
|
|
1000
|
+
// webhookSecretRef.
|
|
909
1001
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
910
1002
|
// Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
|
|
911
1003
|
// and PRODUCTION events to the SAME webhook with the same auth header, so a
|
|
@@ -980,6 +1072,29 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
980
1072
|
enforcePeriodEnd: Type.Optional(Type.Object({
|
|
981
1073
|
slackHours: Type.Integer({ default: 72, minimum: 0, maximum: 720 }),
|
|
982
1074
|
})),
|
|
1075
|
+
// (2026-09 P2) What to do with a one-off purchase whose `product_id` is NOT
|
|
1076
|
+
// in productMap. 'error' (the DEFAULT and today's behaviour) stamps the
|
|
1077
|
+
// delivery outcome `error`, emits payments.webhook_event.failed and lands a
|
|
1078
|
+
// `stranded_webhook` reconcile finding. 'ignore' stamps it `ignored`, with
|
|
1079
|
+
// no failure event and no stranded finding.
|
|
1080
|
+
//
|
|
1081
|
+
// WHY IT EXISTS: the trigger is `event.productId && !grantRule`, and Paddle
|
|
1082
|
+
// carries items[0].price.product_id on EVERY transaction.completed —
|
|
1083
|
+
// INCLUDING subscription-renewal transactions, whose money folds through the
|
|
1084
|
+
// subscription.* events instead. A Paddle tenant that drives everything
|
|
1085
|
+
// through tierMap/priceMap therefore got one `error` row + one failure event
|
|
1086
|
+
// + one stranded finding PER RENEWAL, forever. 'ignore' is the one-line
|
|
1087
|
+
// answer. The charge row is still recorded either way — only the ledger
|
|
1088
|
+
// grant is declined — and an `ignored` row stays REPROCESSABLE, so mapping
|
|
1089
|
+
// the product later and re-running the delivery folds it.
|
|
1090
|
+
//
|
|
1091
|
+
// NO SCHEMA DEFAULT ON PURPOSE: the config-write pipeline runs
|
|
1092
|
+
// Value.Default, which MATERIALIZES an Optional-with-default into every
|
|
1093
|
+
// persisted manifest — that would rewrite every payments manifest carrying a
|
|
1094
|
+
// `ledger` bag and make `vxil push --dry-run` / `vxil diff` report drift for
|
|
1095
|
+
// every payments tenant. The READER owns the default
|
|
1096
|
+
// (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
|
|
1097
|
+
unmappedProduct: Type.Optional(Type.Union([Type.Literal('error'), Type.Literal('ignore')])),
|
|
983
1098
|
})),
|
|
984
1099
|
// NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
|
|
985
1100
|
// was DELETED — it had zero readers (never forwarded anything). Outbound
|
|
@@ -1048,10 +1163,45 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1048
1163
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
1049
1164
|
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
1050
1165
|
egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
|
|
1166
|
+
// Per-function resource declarations. BOTH members are OPTIONAL (the bag
|
|
1167
|
+
// used to REQUIRE all three, a latent 422 the moment anything sent it —
|
|
1168
|
+
// nothing ever did, because the CLI never carried it) and BOTH are
|
|
1169
|
+
// UNBOUNDED here on purpose: `deployFunction` validates the WHOLE merged
|
|
1170
|
+
// manifest, `Value.Clean` strips unknown keys but never clamps
|
|
1171
|
+
// out-of-range ones, so a min/max here would let ONE legacy entry 422
|
|
1172
|
+
// every future deploy for that tenant. The bounds (cpuMs 5..300000,
|
|
1173
|
+
// timeoutMs 1000..120000) are enforced at the ONE write path,
|
|
1174
|
+
// `normalizeLimits` in the deploy handler, which also sanitizes pre-existing
|
|
1175
|
+
// entries on merge.
|
|
1176
|
+
// cpuMs — TIGHTENS the per-dispatch isolate CPU cap (never widens
|
|
1177
|
+
// FN_MAX_CPU_MS) and RAISES (never lowers) the metering
|
|
1178
|
+
// reserve, whose floor stays defaultLimits.cpuMs. THE
|
|
1179
|
+
// CONSEQUENCE, stated because the knob's name hides it:
|
|
1180
|
+
// the reserve is the payments HOLD, and the pro-rated
|
|
1181
|
+
// settle clamps the measured wall-ms into [floor, reserve]
|
|
1182
|
+
// — it can never commit more than it holds — so raising
|
|
1183
|
+
// the reserve raises the settle CEILING. An invoke whose
|
|
1184
|
+
// wall time exceeds defaultLimits.cpuMs (50) then settles
|
|
1185
|
+
// at its measured wall-ms instead of being capped at 50:
|
|
1186
|
+
// on a METERED tenant, declaring cpuMs raises the bill for
|
|
1187
|
+
// long-running functions (cpuMs: 1000 turns an 800 ms
|
|
1188
|
+
// invoke from 50 into 800). It does NOT move anyone's
|
|
1189
|
+
// availability — the ACCOUNT-global daily capacity budget
|
|
1190
|
+
// counts the platform's own estimate, never this value
|
|
1191
|
+
// (functions-v1 capacity.ts `capacityReserveCpuMs`).
|
|
1192
|
+
// timeoutMs — the wall budget for ONE outbound fetch, delivered to the
|
|
1193
|
+
// egress guard as an outbound parameter. NOT an invocation
|
|
1194
|
+
// budget: the INVOCATION is bounded by the platform's own
|
|
1195
|
+
// FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
|
|
1196
|
+
// bigger per-fetch budget widens with you (audit FN-3).
|
|
1197
|
+
// (F8-54's standing "strip the twins" note is DISCHARGED here: `memoryMb`
|
|
1198
|
+
// is deleted — memory is fixed by the managed runtime and is not a
|
|
1199
|
+
// per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
|
|
1200
|
+
// Value.Clean strips it from an old config, so such a config still loads
|
|
1201
|
+
// and `vxil plan --explain` marks the key DROPPED.)
|
|
1051
1202
|
limits: Type.Optional(Type.Object({
|
|
1052
|
-
cpuMs: Type.Integer(),
|
|
1053
|
-
timeoutMs: Type.Integer(),
|
|
1054
|
-
memoryMb: Type.Integer(),
|
|
1203
|
+
cpuMs: Type.Optional(Type.Integer()),
|
|
1204
|
+
timeoutMs: Type.Optional(Type.Integer()),
|
|
1055
1205
|
})),
|
|
1056
1206
|
enabled: Type.Optional(Type.Boolean()),
|
|
1057
1207
|
// Level-1 typed I/O (cli-sdk design §4.5): the declared input/output
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/feature-configs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://vxil.com",
|
package/src/index.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
setKnownMcpTools,
|
|
12
12
|
validateFeatureConfig,
|
|
13
13
|
NOTIF_OVERRIDE_MAX_BYTES, NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE,
|
|
14
|
+
NOTIFICATION_TEMPLATE_PLACEHOLDERS,
|
|
14
15
|
validateNotificationOverrides,
|
|
15
16
|
} from './index.js';
|
|
16
17
|
// The LIVE mcp catalog (zero-import pure data) — the same relative-import
|
|
@@ -42,14 +43,16 @@ describe('15-flag cap analyzer (architecture §6)', () => {
|
|
|
42
43
|
}
|
|
43
44
|
});
|
|
44
45
|
|
|
45
|
-
it('auth schema is
|
|
46
|
+
it('auth schema is 12 leaves (D3 collapse = 11, + the P1-5 registryAdopt opt-in)', () => {
|
|
46
47
|
// enabled(1) + methods(1, optional bag — was 6) + providers(1) + session(1)
|
|
47
|
-
// + password(1) + emailVerification(1) + magicLink(1
|
|
48
|
-
// + orgClaims(1) + security(1, optional bag —
|
|
48
|
+
// + password(1) + emailVerification(1) + magicLink(1, optional bag since
|
|
49
|
+
// P1-16) + otp(1) + anonymous(1) + orgClaims(1) + security(1, optional bag —
|
|
50
|
+
// P0-3) + registryAdopt(1, P1-5) = 12, 3 under the cap. The auth wave
|
|
49
51
|
// (2026-09-10, D3) funded itself by the methods collapse — the notifications
|
|
50
|
-
// retry/broadcast precedent; session.maxConcurrent / otp.testRecipients
|
|
51
|
-
// inside existing bags and never move the
|
|
52
|
-
|
|
52
|
+
// retry/broadcast precedent; session.maxConcurrent / otp.testRecipients /
|
|
53
|
+
// magicLink.resendCooldownSec ride inside existing bags and never move the
|
|
54
|
+
// count.
|
|
55
|
+
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(12);
|
|
53
56
|
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
54
57
|
});
|
|
55
58
|
|
|
@@ -412,8 +415,8 @@ describe('validateFeatureConfig', () => {
|
|
|
412
415
|
const min = validateFeatureConfig('auth', { providers: { oidc: { issuer: 'https://login.acme.example', clientId: 'a', clientSecretRef: 'secret:s' } } });
|
|
413
416
|
expect(min.ok).toBe(true);
|
|
414
417
|
expect((min.value as { providers: { oidc: Record<string, unknown> } }).providers.oidc).toEqual({ issuer: 'https://login.acme.example', clientId: 'a', clientSecretRef: 'secret:s' });
|
|
415
|
-
// still
|
|
416
|
-
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(
|
|
418
|
+
// still 12 leaves — the block lives INSIDE the providers bag
|
|
419
|
+
expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(12);
|
|
417
420
|
// issuer must be https with no query/fragment; domains lowercase dotted
|
|
418
421
|
for (const issuer of ['http://login.acme.example', 'https://login.acme.example/?x=1', 'https://a#b', 'login.acme.example']) {
|
|
419
422
|
expect(validateFeatureConfig('auth', { providers: { oidc: { issuer, clientId: 'a', clientSecretRef: 'secret:s' } } }).ok, issuer).toBe(false);
|
|
@@ -423,6 +426,40 @@ describe('validateFeatureConfig', () => {
|
|
|
423
426
|
expect(validateFeatureConfig('auth', { providers: { oidc: { issuer: 'https://login.acme.example', clientId: 'a' } } }).ok).toBe(false);
|
|
424
427
|
});
|
|
425
428
|
|
|
429
|
+
it('P1-16: the magicLink Optional bag folds byte-identically and bounds resendCooldownSec', () => {
|
|
430
|
+
// The bag became Type.Optional (so the new knob costs NO leaf) but KEPT
|
|
431
|
+
// `default: {}`, and resendCooldownSec is Optional with NO default — so a
|
|
432
|
+
// manifest that never mentions the cooldown folds to exactly the bytes it
|
|
433
|
+
// folded to before the change. This is the assertion that keeps every
|
|
434
|
+
// PUBLISHED manifest unchanged (and the reason the worker, not the schema,
|
|
435
|
+
// supplies the 60s fallback).
|
|
436
|
+
expect(JSON.stringify((validateFeatureConfig('auth', {}).value as { magicLink: unknown }).magicLink))
|
|
437
|
+
.toBe('{"tokenTtlMinutes":15}');
|
|
438
|
+
expect(JSON.stringify((validateFeatureConfig('auth', { magicLink: { tokenTtlMinutes: 30 } }).value as { magicLink: unknown }).magicLink))
|
|
439
|
+
.toBe('{"tokenTtlMinutes":30}');
|
|
440
|
+
// set explicitly ⇒ persisted verbatim
|
|
441
|
+
const set = validateFeatureConfig('auth', { magicLink: { tokenTtlMinutes: 15, resendCooldownSec: 0 } });
|
|
442
|
+
expect(set.ok, set.errors.join()).toBe(true);
|
|
443
|
+
expect((set.value as { magicLink: Record<string, number> }).magicLink)
|
|
444
|
+
.toEqual({ tokenTtlMinutes: 15, resendCooldownSec: 0 });
|
|
445
|
+
// bounds mirror otp.resendCooldownSec exactly (0..600)
|
|
446
|
+
expect(validateFeatureConfig('auth', { magicLink: { resendCooldownSec: 600 } }).ok).toBe(true);
|
|
447
|
+
expect(validateFeatureConfig('auth', { magicLink: { resendCooldownSec: 601 } }).ok).toBe(false);
|
|
448
|
+
expect(validateFeatureConfig('auth', { magicLink: { resendCooldownSec: -1 } }).ok).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('P1-5: registryAdopt is an OPT-IN boolean with no default (absent stays absent)', () => {
|
|
452
|
+
const bare = validateFeatureConfig('auth', {});
|
|
453
|
+
expect(bare.ok).toBe(true);
|
|
454
|
+
expect(bare.value).not.toHaveProperty('registryAdopt');
|
|
455
|
+
for (const v of [true, false]) {
|
|
456
|
+
const r = validateFeatureConfig('auth', { registryAdopt: v });
|
|
457
|
+
expect(r.ok, r.errors.join()).toBe(true);
|
|
458
|
+
expect((r.value as { registryAdopt: boolean }).registryAdopt).toBe(v);
|
|
459
|
+
}
|
|
460
|
+
expect(validateFeatureConfig('auth', { registryAdopt: 'yes' }).ok).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
|
|
426
463
|
it('auth: otp knob bounds are enforced (maxAttempts 3..10)', () => {
|
|
427
464
|
expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 2 } }).ok).toBe(false);
|
|
428
465
|
expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 11 } }).ok).toBe(false);
|
|
@@ -932,6 +969,42 @@ describe('payments: reserved credit_type rejected in ledger grant maps (F2)', ()
|
|
|
932
969
|
});
|
|
933
970
|
});
|
|
934
971
|
|
|
972
|
+
// ── payments: ledger.unmappedProduct (2026-09 P2) ────────────────────────────
|
|
973
|
+
// The policy for a one-off purchase whose product_id is not in productMap.
|
|
974
|
+
// It lives INSIDE the one optional `ledger` bag, so the leaf budget is unmoved,
|
|
975
|
+
// and it carries NO schema default so no persisted manifest is rewritten.
|
|
976
|
+
describe('payments: ledger.unmappedProduct policy', () => {
|
|
977
|
+
it("accepts 'error' and 'ignore', rejects anything else", () => {
|
|
978
|
+
for (const v of ['error', 'ignore']) {
|
|
979
|
+
const r = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, unmappedProduct: v } });
|
|
980
|
+
expect(r.ok, r.ok ? '' : r.errors.join()).toBe(true);
|
|
981
|
+
}
|
|
982
|
+
const bad = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, unmappedProduct: 'skip' } });
|
|
983
|
+
expect(bad.ok).toBe(false);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it('LOAD-BEARING: it has NO schema default, so Value.Default never materializes it into a manifest', () => {
|
|
987
|
+
// A default would be written into every payments manifest carrying a
|
|
988
|
+
// `ledger` bag on the next PUT, making `vxil push --dry-run` / `vxil diff`
|
|
989
|
+
// report drift for every payments tenant. The READER owns the default.
|
|
990
|
+
const before = { ledger: { productMap: {}, tierMap: {} } };
|
|
991
|
+
const r = validateFeatureConfig('payments', before);
|
|
992
|
+
expect(r.ok, r.ok ? '' : r.errors.join()).toBe(true);
|
|
993
|
+
expect((r.value as { ledger?: Record<string, unknown> }).ledger).not.toHaveProperty('unmappedProduct');
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it("an explicit 'ignore' round-trips through the write pipeline verbatim", () => {
|
|
997
|
+
const r = validateFeatureConfig('payments', { ledger: { productMap: {}, tierMap: {}, unmappedProduct: 'ignore' } });
|
|
998
|
+
expect(r.ok).toBe(true);
|
|
999
|
+
expect((r.value as { ledger?: { unmappedProduct?: string } }).ledger?.unmappedProduct).toBe('ignore');
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it('the policy sits inside the ONE optional ledger leaf — the leaf budget is unchanged', () => {
|
|
1003
|
+
expect(countLeaves(PaymentsConfigSchema)).toBe(10);
|
|
1004
|
+
expect(countLeaves(PaymentsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
935
1008
|
// ── payments: money-path wave (F1-3 config lint, F1-4 acceptSandbox, F1-7 grace,
|
|
936
1009
|
// F3-19 forwardToTenantUrl deleted) ─────────────────────────────────────────
|
|
937
1010
|
describe('payments: priceMap ⊆ tierMap + unique ranks (F1-3 / D3 config lint)', () => {
|
|
@@ -1139,6 +1212,70 @@ describe('notifications: templates.overrides (D4 per-locale overrides)', () => {
|
|
|
1139
1212
|
expect(huge.errors.join()).toContain(`${NOTIF_OVERRIDE_MAX_BYTES}-byte cap`);
|
|
1140
1213
|
});
|
|
1141
1214
|
|
|
1215
|
+
// ── G-78: every part is Optional; an ALL-EMPTY variant is the only new refusal
|
|
1216
|
+
it('accepts a BODY-ONLY override (subject inherited from the built-in)', () => {
|
|
1217
|
+
const v = validateFeatureConfig('notifications', {
|
|
1218
|
+
...base,
|
|
1219
|
+
templates: {
|
|
1220
|
+
allowOverride: true,
|
|
1221
|
+
overrides: { transactional: { ar: { html: '<div dir="rtl">{{paragraph}}</div>' } } },
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it('accepts a TEXT-ONLY override', () => {
|
|
1228
|
+
const v = validateFeatureConfig('notifications', {
|
|
1229
|
+
...base,
|
|
1230
|
+
templates: {
|
|
1231
|
+
allowOverride: true,
|
|
1232
|
+
overrides: { transactional: { fr: { text: 'FR {{paragraph}}' } } },
|
|
1233
|
+
},
|
|
1234
|
+
});
|
|
1235
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
it('REJECTS a variant with no non-empty part (the new inert shape)', () => {
|
|
1239
|
+
// `{}` and all-empty bodies reach the cross-field rule (html/text carry no
|
|
1240
|
+
// minLength); an empty `subject` is still refused earlier by the schema's own
|
|
1241
|
+
// minLength. Either way the write fails LOUD — never a silently inert override.
|
|
1242
|
+
for (const variant of [{}, { html: '' }, { html: '', text: '' }]) {
|
|
1243
|
+
const v = validateFeatureConfig('notifications', {
|
|
1244
|
+
...base,
|
|
1245
|
+
templates: { allowOverride: true, overrides: { transactional: { ar: variant } } },
|
|
1246
|
+
});
|
|
1247
|
+
expect(v.ok, JSON.stringify(variant)).toBe(false);
|
|
1248
|
+
if (!v.ok) {
|
|
1249
|
+
expect(v.errors.join('\n'), JSON.stringify(variant))
|
|
1250
|
+
.toContain('set at least one of subject, html, text');
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const emptySubject = validateFeatureConfig('notifications', {
|
|
1254
|
+
...base,
|
|
1255
|
+
templates: { allowOverride: true, overrides: { transactional: { ar: { subject: '' } } } },
|
|
1256
|
+
});
|
|
1257
|
+
expect(emptySubject.ok).toBe(false);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
it('the pure validator refuses a non-object variant without throwing (total)', () => {
|
|
1261
|
+
expect(validateNotificationOverrides({
|
|
1262
|
+
allowOverride: true,
|
|
1263
|
+
overrides: { welcome: { 'fr-FR': null } },
|
|
1264
|
+
} as never).join('\n')).toContain('at least one of subject, html, text');
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
it('`cta` is an allowed transactional placeholder (the {{cta}} slot)', () => {
|
|
1268
|
+
expect(NOTIFICATION_TEMPLATE_PLACEHOLDERS['transactional']).toContain('cta');
|
|
1269
|
+
const v = validateFeatureConfig('notifications', {
|
|
1270
|
+
...base,
|
|
1271
|
+
templates: {
|
|
1272
|
+
allowOverride: true,
|
|
1273
|
+
overrides: { transactional: { ar: { html: '<p>{{paragraph}}</p>{{cta}}' } } },
|
|
1274
|
+
},
|
|
1275
|
+
});
|
|
1276
|
+
expect(v.ok, v.errors.join()).toBe(true);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1142
1279
|
it('the pure validator is [] for an absent/empty bag', () => {
|
|
1143
1280
|
expect(validateNotificationOverrides(undefined)).toEqual([]);
|
|
1144
1281
|
expect(validateNotificationOverrides({ allowOverride: true })).toEqual([]);
|
|
@@ -1247,6 +1384,37 @@ describe('F8-54: the deleted inert leaves stay deleted, and old manifests still
|
|
|
1247
1384
|
expect(dl['memoryMb']).toBeUndefined();
|
|
1248
1385
|
});
|
|
1249
1386
|
|
|
1387
|
+
// D.18 — the PER-FUNCTION bag (inside the Type.Record value), not defaultLimits.
|
|
1388
|
+
// It used to REQUIRE all three members whenever present, a latent 422 the moment
|
|
1389
|
+
// anything sent one; nothing ever did, because the CLI never carried it.
|
|
1390
|
+
it('functions: the per-function limits bag accepts each member ALONE', () => {
|
|
1391
|
+
const base = { scriptRef: 'fn-t-hello-abc123def456' };
|
|
1392
|
+
for (const limits of [{}, { cpuMs: 200 }, { timeoutMs: 60_000 }, { cpuMs: 200, timeoutMs: 60_000 }]) {
|
|
1393
|
+
const r = validateFeatureConfig('functions', {
|
|
1394
|
+
enabled: true, functions: { hello: { ...base, limits } },
|
|
1395
|
+
});
|
|
1396
|
+
expect(r.ok, JSON.stringify(limits)).toBe(true);
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
it('functions: a per-function memoryMb is STRIPPED, not rejected (the published F8-54 contract)', () => {
|
|
1401
|
+
// docs/guide/changelog.md publishes strip-and-ignore for removed config keys:
|
|
1402
|
+
// an already-saved manifest still loads and `vxil plan --explain` marks the
|
|
1403
|
+
// key DROPPED. The DEPLOY BODY is the surface that 422s it (normalizeLimits).
|
|
1404
|
+
const r = validateFeatureConfig('functions', {
|
|
1405
|
+
enabled: true,
|
|
1406
|
+
functions: { hello: { scriptRef: 'fn-t-hello-abc123def456', limits: { cpuMs: 200, memoryMb: 512 } } },
|
|
1407
|
+
});
|
|
1408
|
+
expect(r.ok).toBe(true);
|
|
1409
|
+
const fns = (r.value as { functions: Record<string, { limits: Record<string, unknown> }> }).functions;
|
|
1410
|
+
expect(fns.hello!.limits).toEqual({ cpuMs: 200 });
|
|
1411
|
+
expect(fns.hello!.limits['memoryMb']).toBeUndefined();
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
it('functions: the per-function bag is DATA — re-typing it never moves the leaf count', () => {
|
|
1415
|
+
expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(4);
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1250
1418
|
it('every surviving schema still fits the cap (the deletions only freed budget)', () => {
|
|
1251
1419
|
for (const [feature, schema] of Object.entries(FEATURE_SCHEMAS)) {
|
|
1252
1420
|
expect(countLeaves(schema), `${feature} exceeds the flag cap`).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
|
package/src/index.ts
CHANGED
|
@@ -49,7 +49,20 @@ export const NOTIFICATION_TEMPLATE_PLACEHOLDERS: Readonly<Record<string, readonl
|
|
|
49
49
|
'magic-link': ['url', 'expires_minutes'],
|
|
50
50
|
'otp-code': ['code', 'expires_minutes'],
|
|
51
51
|
welcome: ['app_name', 'first_name', 'first_name_greeting'],
|
|
52
|
-
|
|
52
|
+
// `cta` (G-78) is a SLOT, not a data key: an override that writes `{{cta}}`
|
|
53
|
+
// chooses where the escaped CTA anchor built from cta_label + cta_url lands.
|
|
54
|
+
// Absent from the built-in bodies (which append it), so the worker parity
|
|
55
|
+
// suite pins it explicitly — see templates.test.ts.
|
|
56
|
+
transactional: ['subject', 'paragraph', 'cta_label', 'cta_url', 'cta'],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Placeholders that are SLOTS in the BODY parts only — the renderer splits
|
|
60
|
+
* `html`/`text` on them (templates.ts `CTA_SLOT`) but the subject goes through
|
|
61
|
+
* the plain interpolator, where the same token is just a data key that does not
|
|
62
|
+
* exist and renders EMPTY. Rejected in `subject` for exactly the reason the
|
|
63
|
+
* whole allow-list exists: a silent empty render in a live email. */
|
|
64
|
+
export const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS: Readonly<Record<string, readonly string[]>> = {
|
|
65
|
+
transactional: ['cta'],
|
|
53
66
|
};
|
|
54
67
|
|
|
55
68
|
/** Per-override caps (D4). Bodies are emails, not documents. */
|
|
@@ -108,13 +121,30 @@ export function validateNotificationOverrides(
|
|
|
108
121
|
continue;
|
|
109
122
|
}
|
|
110
123
|
const variant = byLocale![locale]!;
|
|
124
|
+
if (typeof variant !== 'object' || variant === null) {
|
|
125
|
+
errs.push(`/templates/overrides/${templateId}/${locale}: must be an object with at least one of subject, html, text`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// G-78 — every part is OPTIONAL (a body-only translation inherits the
|
|
129
|
+
// built-in subject), but an ALL-EMPTY variant is the new "inert config"
|
|
130
|
+
// and fails LOUD rather than silently rendering the built-in.
|
|
131
|
+
const nonEmpty = (['subject', 'html', 'text'] as const)
|
|
132
|
+
.filter((p) => typeof variant[p] === 'string' && variant[p] !== '');
|
|
133
|
+
if (nonEmpty.length === 0) {
|
|
134
|
+
errs.push(`/templates/overrides/${templateId}/${locale}: set at least one of subject, html, text`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// body-only SLOTS are not subject placeholders (see the map's doc comment)
|
|
138
|
+
const slots = NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS[templateId] ?? [];
|
|
139
|
+
const subjectAllowed = allowed.filter((k) => !slots.includes(k));
|
|
111
140
|
for (const part of ['subject', 'html', 'text'] as const) {
|
|
112
141
|
const body = variant[part];
|
|
113
142
|
if (typeof body !== 'string') continue;
|
|
143
|
+
const ok = part === 'subject' ? subjectAllowed : allowed;
|
|
114
144
|
for (const m of body.matchAll(NOTIF_PLACEHOLDER_RE)) {
|
|
115
145
|
const key = m[1]!;
|
|
116
|
-
if (!
|
|
117
|
-
errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${
|
|
146
|
+
if (!ok.includes(key)) {
|
|
147
|
+
errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${ok.join(', ')})`);
|
|
118
148
|
}
|
|
119
149
|
}
|
|
120
150
|
}
|
|
@@ -183,7 +213,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
183
213
|
allowOverride: Type.Boolean({ default: false }),
|
|
184
214
|
// D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
|
|
185
215
|
// template ids, as config DATA (a Record = 1 leaf, catalog size never
|
|
186
|
-
// moves the count). Shape: { [templateId]: { [locale]: { subject
|
|
216
|
+
// moves the count). Shape: { [templateId]: { [locale]: { subject?,
|
|
187
217
|
// html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
|
|
188
218
|
// built-ins — the worker reuses ONE renderer, so escaping/URL-scheme
|
|
189
219
|
// sanitisation are identical and there is no raw-output syntax.
|
|
@@ -192,8 +222,15 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
192
222
|
// LOUD, never silently inert) overrides present with allowOverride:false.
|
|
193
223
|
overrides: Type.Optional(Type.Record(
|
|
194
224
|
Type.String(),
|
|
225
|
+
// G-78: `subject` is OPTIONAL like html/text — a PARTIAL override
|
|
226
|
+
// inherits each omitted part from the built-in, so a body-only
|
|
227
|
+
// translation no longer forces you to restate the subject (and a
|
|
228
|
+
// subject-only 'ar' override no longer pairs an Arabic subject with the
|
|
229
|
+
// English built-in body). A strict widening: every config valid before
|
|
230
|
+
// this change is still valid. validateNotificationOverrides rejects the
|
|
231
|
+
// one shape this opens up — a variant with NO non-empty part.
|
|
195
232
|
Type.Record(Type.String(), Type.Object({
|
|
196
|
-
subject: Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX }),
|
|
233
|
+
subject: Type.Optional(Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX })),
|
|
197
234
|
html: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
198
235
|
text: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
199
236
|
})),
|
|
@@ -445,10 +482,38 @@ export const AuthConfigSchema = Type.Object({
|
|
|
445
482
|
{ required: Type.Boolean({ default: false }) },
|
|
446
483
|
{ default: {} },
|
|
447
484
|
),
|
|
448
|
-
|
|
449
|
-
|
|
485
|
+
// P1-16 (2026-09-18): magicLink is now an OPTIONAL bag (= ONE leaf however
|
|
486
|
+
// many knobs it holds — the methods/session/password precedent) so the
|
|
487
|
+
// request cooldown could land without spending a second leaf. It KEEPS
|
|
488
|
+
// `default: {}`, so Value.Default still materializes
|
|
489
|
+
// `magicLink.tokenTtlMinutes` exactly as before — every persisted manifest
|
|
490
|
+
// folds byte-identically.
|
|
491
|
+
magicLink: Type.Optional(Type.Object(
|
|
492
|
+
{
|
|
493
|
+
tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
|
|
494
|
+
// P1-16: the per-(tenant, identifier) magic-link REQUEST cooldown — the
|
|
495
|
+
// `otp.resendCooldownSec` twin, same bounds so the two knobs read the
|
|
496
|
+
// same. A second request for the same address inside the window is a 429
|
|
497
|
+
// `magic_link_rate_limited` with a Retry-After header. Type.Optional with
|
|
498
|
+
// NO default (the `otp.testRecipients` precedent below) so existing
|
|
499
|
+
// manifests fold byte-identically; the 60s fallback is applied by the
|
|
500
|
+
// auth feature at read time. 0 disables the cooldown.
|
|
501
|
+
resendCooldownSec: Type.Optional(Type.Integer({ minimum: 0, maximum: 600 })),
|
|
502
|
+
},
|
|
450
503
|
{ default: {} },
|
|
451
|
-
),
|
|
504
|
+
)),
|
|
505
|
+
// P1-5 identity continuity (2026-09-18) — OPT-IN registry adoption. When
|
|
506
|
+
// true, a sign-in by a method that PROVES control of the address — magic
|
|
507
|
+
// link, email OTP, or OAuth with a provider-verified address — reuses the id
|
|
508
|
+
// of the one matching pre-registered end-user (POST /v1/users) that has no
|
|
509
|
+
// account yet, instead of minting a fresh `user_<ulid>` and leaving the
|
|
510
|
+
// tenant with two records for one person. Password SIGN-UP never adopts: it
|
|
511
|
+
// proves nothing about the address (auth.md §8.1 PROVEN_ADOPT_METHODS).
|
|
512
|
+
// OFF by default, and Type.Optional with NO default: adoption means whoever
|
|
513
|
+
// proves control of a pre-registered address becomes that record — a change
|
|
514
|
+
// of security semantics for a tenant that bulk-imports contacts, whose
|
|
515
|
+
// addresses vxil never verified.
|
|
516
|
+
registryAdopt: Type.Optional(Type.Boolean()),
|
|
452
517
|
// Email OTP sign-in (roadmap Tier-0) + the knobs step-up re-auth shares.
|
|
453
518
|
// OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
|
|
454
519
|
// otp?.enabled === true).
|
|
@@ -524,11 +589,12 @@ export type AuthOidcConfig = Static<typeof OidcProviderSchema>;
|
|
|
524
589
|
// Leaves (auth wave 2026-09-10, D3): enabled(1) + methods(1, optional bag —
|
|
525
590
|
// was 6) + providers(1, optional bag holding the four google/github/apple/
|
|
526
591
|
// facebook credential blocks + the RB-2 `oidc` issuer block) + session(1, optional bag) + password(1,
|
|
527
|
-
// optional bag) + emailVerification(1) + magicLink(1) + otp(1, optional bag)
|
|
592
|
+
// optional bag) + emailVerification(1) + magicLink(1, optional bag) + otp(1, optional bag)
|
|
528
593
|
// + anonymous(1, optional bag) + orgClaims(1, optional bag) + security(1,
|
|
529
|
-
// optional bag) =
|
|
530
|
-
// existing bag (session.maxConcurrent,
|
|
531
|
-
// sub-bags)
|
|
594
|
+
// optional bag) + registryAdopt(1, P1-5) = 12. Cap = 15 — 3 leaves of
|
|
595
|
+
// headroom. Knobs added inside an existing bag (session.maxConcurrent,
|
|
596
|
+
// otp.testRecipients, magicLink.resendCooldownSec, the security sub-bags)
|
|
597
|
+
// never move the count.
|
|
532
598
|
|
|
533
599
|
export type AuthConfig = Static<typeof AuthConfigSchema>;
|
|
534
600
|
|
|
@@ -620,8 +686,29 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
620
686
|
enabled: Type.Boolean({ default: true }),
|
|
621
687
|
maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
622
688
|
maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
689
|
+
// FAILURE-ALERT DIGEST (P0-2). Outbound subscriptions are the real-time
|
|
690
|
+
// channel; this is the "nobody is consuming them yet" fallback — a periodic
|
|
691
|
+
// e-mail summary of the tenant's FAILURE-class audit events (the level:
|
|
692
|
+
// 'failure' rows of the generated event catalog). Read by
|
|
693
|
+
// workers/control-plane/src/alertDigest.ts on the minute cron.
|
|
694
|
+
//
|
|
695
|
+
// RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this deviation is
|
|
696
|
+
// journaled here the way RateLimitsConfigSchema journals its own: an
|
|
697
|
+
// arbitrary `to` would turn vxil's own sending identity into a relay for
|
|
698
|
+
// tenant-authored content and open a PII egress path out of the audit trail.
|
|
699
|
+
// The digest goes to the OWNER-role dashboard accounts of the project (cap
|
|
700
|
+
// 3), who already receive platform mail (invites, magic links, resets), so no
|
|
701
|
+
// new consent surface is created. An arbitrary recipient is a separate
|
|
702
|
+
// decision with its own abuse review.
|
|
703
|
+
//
|
|
704
|
+
// An Optional object bag counts as ONE leaf (the countLeaves rule).
|
|
705
|
+
alerts: Type.Optional(Type.Object({
|
|
706
|
+
enabled: Type.Boolean({ default: false }),
|
|
707
|
+
minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
|
|
708
|
+
digestMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 1440 }),
|
|
709
|
+
})),
|
|
623
710
|
});
|
|
624
|
-
// Leaves: 3. Cap = 15.
|
|
711
|
+
// Leaves: 3 + alerts(1, optional bag) = 4. Cap = 15.
|
|
625
712
|
|
|
626
713
|
export type WebhooksConfig = Static<typeof WebhooksConfigSchema>;
|
|
627
714
|
|
|
@@ -1086,7 +1173,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1086
1173
|
cache: Type.Object({ ttlSeconds: Type.Integer({ default: 0, minimum: 0 }) }, { default: {} }), // 0 = off
|
|
1087
1174
|
limits: Type.Object({
|
|
1088
1175
|
tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
|
|
1089
|
-
consumeCredits: Type.Boolean({ default: false }), // reserve→settle (
|
|
1176
|
+
consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
|
|
1090
1177
|
}, { default: {} }),
|
|
1091
1178
|
streaming: Type.Object({
|
|
1092
1179
|
enabled: Type.Boolean({ default: true }),
|
|
@@ -1204,10 +1291,16 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1204
1291
|
projectId: Type.String(),
|
|
1205
1292
|
publicSdkKey: Type.String(),
|
|
1206
1293
|
secretApiKeyRef: Type.String(),
|
|
1207
|
-
//
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
// webhook-forgery vector
|
|
1294
|
+
// Per-tenant webhook secret ref (public.tenant_secrets). Inbound RevenueCat
|
|
1295
|
+
// webhooks are verified against THIS ref and nothing else: there is no
|
|
1296
|
+
// platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
|
|
1297
|
+
// (that fallback WAS the multi-tenant RC webhook-forgery vector; it is now
|
|
1298
|
+
// frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
|
|
1299
|
+
// permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
|
|
1300
|
+
// Optional at the SCHEMA level only — leaving it unset does not disable
|
|
1301
|
+
// verification, it fails CLOSED: every delivery is 401 bad_signature with a
|
|
1302
|
+
// `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
|
|
1303
|
+
// webhookSecretRef.
|
|
1211
1304
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
1212
1305
|
// Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
|
|
1213
1306
|
// and PRODUCTION events to the SAME webhook with the same auth header, so a
|
|
@@ -1285,6 +1378,29 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1285
1378
|
enforcePeriodEnd: Type.Optional(Type.Object({
|
|
1286
1379
|
slackHours: Type.Integer({ default: 72, minimum: 0, maximum: 720 }),
|
|
1287
1380
|
})),
|
|
1381
|
+
// (2026-09 P2) What to do with a one-off purchase whose `product_id` is NOT
|
|
1382
|
+
// in productMap. 'error' (the DEFAULT and today's behaviour) stamps the
|
|
1383
|
+
// delivery outcome `error`, emits payments.webhook_event.failed and lands a
|
|
1384
|
+
// `stranded_webhook` reconcile finding. 'ignore' stamps it `ignored`, with
|
|
1385
|
+
// no failure event and no stranded finding.
|
|
1386
|
+
//
|
|
1387
|
+
// WHY IT EXISTS: the trigger is `event.productId && !grantRule`, and Paddle
|
|
1388
|
+
// carries items[0].price.product_id on EVERY transaction.completed —
|
|
1389
|
+
// INCLUDING subscription-renewal transactions, whose money folds through the
|
|
1390
|
+
// subscription.* events instead. A Paddle tenant that drives everything
|
|
1391
|
+
// through tierMap/priceMap therefore got one `error` row + one failure event
|
|
1392
|
+
// + one stranded finding PER RENEWAL, forever. 'ignore' is the one-line
|
|
1393
|
+
// answer. The charge row is still recorded either way — only the ledger
|
|
1394
|
+
// grant is declined — and an `ignored` row stays REPROCESSABLE, so mapping
|
|
1395
|
+
// the product later and re-running the delivery folds it.
|
|
1396
|
+
//
|
|
1397
|
+
// NO SCHEMA DEFAULT ON PURPOSE: the config-write pipeline runs
|
|
1398
|
+
// Value.Default, which MATERIALIZES an Optional-with-default into every
|
|
1399
|
+
// persisted manifest — that would rewrite every payments manifest carrying a
|
|
1400
|
+
// `ledger` bag and make `vxil push --dry-run` / `vxil diff` report drift for
|
|
1401
|
+
// every payments tenant. The READER owns the default
|
|
1402
|
+
// (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
|
|
1403
|
+
unmappedProduct: Type.Optional(Type.Union([Type.Literal('error'), Type.Literal('ignore')])),
|
|
1288
1404
|
})),
|
|
1289
1405
|
// NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
|
|
1290
1406
|
// was DELETED — it had zero readers (never forwarded anything). Outbound
|
|
@@ -1377,11 +1493,46 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1377
1493
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
1378
1494
|
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
1379
1495
|
egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
|
|
1496
|
+
// Per-function resource declarations. BOTH members are OPTIONAL (the bag
|
|
1497
|
+
// used to REQUIRE all three, a latent 422 the moment anything sent it —
|
|
1498
|
+
// nothing ever did, because the CLI never carried it) and BOTH are
|
|
1499
|
+
// UNBOUNDED here on purpose: `deployFunction` validates the WHOLE merged
|
|
1500
|
+
// manifest, `Value.Clean` strips unknown keys but never clamps
|
|
1501
|
+
// out-of-range ones, so a min/max here would let ONE legacy entry 422
|
|
1502
|
+
// every future deploy for that tenant. The bounds (cpuMs 5..300000,
|
|
1503
|
+
// timeoutMs 1000..120000) are enforced at the ONE write path,
|
|
1504
|
+
// `normalizeLimits` in the deploy handler, which also sanitizes pre-existing
|
|
1505
|
+
// entries on merge.
|
|
1506
|
+
// cpuMs — TIGHTENS the per-dispatch isolate CPU cap (never widens
|
|
1507
|
+
// FN_MAX_CPU_MS) and RAISES (never lowers) the metering
|
|
1508
|
+
// reserve, whose floor stays defaultLimits.cpuMs. THE
|
|
1509
|
+
// CONSEQUENCE, stated because the knob's name hides it:
|
|
1510
|
+
// the reserve is the payments HOLD, and the pro-rated
|
|
1511
|
+
// settle clamps the measured wall-ms into [floor, reserve]
|
|
1512
|
+
// — it can never commit more than it holds — so raising
|
|
1513
|
+
// the reserve raises the settle CEILING. An invoke whose
|
|
1514
|
+
// wall time exceeds defaultLimits.cpuMs (50) then settles
|
|
1515
|
+
// at its measured wall-ms instead of being capped at 50:
|
|
1516
|
+
// on a METERED tenant, declaring cpuMs raises the bill for
|
|
1517
|
+
// long-running functions (cpuMs: 1000 turns an 800 ms
|
|
1518
|
+
// invoke from 50 into 800). It does NOT move anyone's
|
|
1519
|
+
// availability — the ACCOUNT-global daily capacity budget
|
|
1520
|
+
// counts the platform's own estimate, never this value
|
|
1521
|
+
// (functions-v1 capacity.ts `capacityReserveCpuMs`).
|
|
1522
|
+
// timeoutMs — the wall budget for ONE outbound fetch, delivered to the
|
|
1523
|
+
// egress guard as an outbound parameter. NOT an invocation
|
|
1524
|
+
// budget: the INVOCATION is bounded by the platform's own
|
|
1525
|
+
// FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
|
|
1526
|
+
// bigger per-fetch budget widens with you (audit FN-3).
|
|
1527
|
+
// (F8-54's standing "strip the twins" note is DISCHARGED here: `memoryMb`
|
|
1528
|
+
// is deleted — memory is fixed by the managed runtime and is not a
|
|
1529
|
+
// per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
|
|
1530
|
+
// Value.Clean strips it from an old config, so such a config still loads
|
|
1531
|
+
// and `vxil plan --explain` marks the key DROPPED.)
|
|
1380
1532
|
limits: Type.Optional(
|
|
1381
1533
|
Type.Object({
|
|
1382
|
-
cpuMs: Type.Integer(),
|
|
1383
|
-
timeoutMs: Type.Integer(),
|
|
1384
|
-
memoryMb: Type.Integer(),
|
|
1534
|
+
cpuMs: Type.Optional(Type.Integer()),
|
|
1535
|
+
timeoutMs: Type.Optional(Type.Integer()),
|
|
1385
1536
|
}),
|
|
1386
1537
|
),
|
|
1387
1538
|
enabled: Type.Optional(Type.Boolean()),
|