@vxil/feature-configs 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +19 -6
- package/dist/index.js +166 -16
- package/package.json +3 -2
- package/src/index.ts +173 -22
- package/src/hooks.test.ts +0 -451
- package/src/index.test.ts +0 -1255
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
|
|
@@ -1276,7 +1369,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1276
1369
|
// Opt-in period-end enforcement (money-path operations wave, decision D2
|
|
1277
1370
|
// option a). ABSENT (the default) = today's behaviour: a subscription whose
|
|
1278
1371
|
// current_period_end passed with no provider event stays entitled forever
|
|
1279
|
-
// (the provider is the only clock). PRESENT = the
|
|
1372
|
+
// (the provider is the only clock). PRESENT = the nightly reconcile sweep
|
|
1280
1373
|
// flips an `active`/`trialing` row whose current_period_end + slackHours
|
|
1281
1374
|
// has passed with no renewal to status 'lapsed' (+ refold, +
|
|
1282
1375
|
// payments.subscription.lapsed); a later provider renewal event revives it
|
|
@@ -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()),
|