@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/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
- transactional: ['subject', 'paragraph', 'cta_label', 'cta_url'],
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 (!allowed.includes(key)) {
117
- errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${allowed.join(', ')})`);
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
- magicLink: Type.Object(
449
- { tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }) },
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) = 11. Cap = 15 — 4 leaves of headroom. Knobs added inside an
530
- // existing bag (session.maxConcurrent, otp.testRecipients, the security
531
- // sub-bags) never move the count.
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 (STUB until payments)
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
- // Optional per-tenant webhook secret ref (public.tenant_secrets). When set,
1208
- // inbound RevenueCat webhooks are verified with THIS tenant's secret instead
1209
- // of the platform PROVIDER_WEBHOOK_SECRET — closes the multi-tenant RC
1210
- // webhook-forgery vector (mirrors stripe/paddle webhookSecretRef).
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 hourly reconcile sweep
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()),