@vxil/feature-configs 0.1.1 → 0.3.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/src/index.ts CHANGED
@@ -21,6 +21,108 @@ if (!FormatRegistry.Has('email')) {
21
21
  FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
22
22
  }
23
23
 
24
+ // ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth (audit F2) ─────
25
+ // `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
26
+ // functions "recover-by-price" meter). It must NEVER be grantable or consumable
27
+ // by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
28
+ // tenant that self-grants `fn_cpu_ms` (directly, or by routing a product/tier
29
+ // through the webhook/subscription grant reducers) runs vxil-billed function CPU
30
+ // for free. Defined HERE (the typebox-only shared package both the control-plane
31
+ // config-write gate AND payments-v1 import) so the runtime choke point and the
32
+ // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
33
+ export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
34
+
35
+ /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
36
+ * of which is restricted to internal platform machinery). */
37
+ export function isReservedCreditType(creditType: string): boolean {
38
+ return RESERVED_CREDIT_TYPES.has(creditType);
39
+ }
40
+
41
+ // ── notifications template catalog (D4 per-locale overrides) ─────────────────
42
+ // The SHIPPED template ids and the placeholders each body may interpolate. The
43
+ // renderer itself lives in workers/notifications-v1/src/templates.ts (templates
44
+ // are code, not rows); this table is the CONFIG-TIME half so a `vxil push` that
45
+ // overrides a template with a typo'd `{{placholder}}` is rejected at write time
46
+ // instead of silently rendering an empty string into a customer's email.
47
+ // Parity with the real renderer is asserted by the worker's templates.test.ts.
48
+ export const NOTIFICATION_TEMPLATE_PLACEHOLDERS: Readonly<Record<string, readonly string[]>> = {
49
+ 'magic-link': ['url', 'expires_minutes'],
50
+ 'otp-code': ['code', 'expires_minutes'],
51
+ welcome: ['app_name', 'first_name', 'first_name_greeting'],
52
+ transactional: ['subject', 'paragraph', 'cta_label', 'cta_url'],
53
+ };
54
+
55
+ /** Per-override caps (D4). Bodies are emails, not documents. */
56
+ export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
57
+ export const NOTIF_OVERRIDE_BODY_MAX = 20_000;
58
+ /** Whole-map caps: keeps one manifest (and the KV row every send reads) small. */
59
+ export const NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE = 20;
60
+ export const NOTIF_OVERRIDE_MAX_BYTES = 64 * 1024;
61
+
62
+ /** `{{ placeholder }}` occurrences in one override body (the worker renderer's
63
+ * grammar verbatim — templates.ts `interpolate`). */
64
+ const NOTIF_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
65
+ /** Same permissive BCP-47-ish shape the worker's auto-locale resolver accepts. */
66
+ const NOTIF_LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}$/;
67
+
68
+ /**
69
+ * D4 — validate `templates.overrides` at CONFIG-WRITE time. Returns [] when the
70
+ * bag is absent or clean. Every rule fails LOUD rather than shipping something
71
+ * that silently renders wrong in a customer's inbox:
72
+ * • overrides present with `allowOverride:false` → rejected (never inert);
73
+ * • an unknown template id / locale tag → rejected;
74
+ * • an unknown `{{placeholder}}` in subject/html/text → rejected (the class of
75
+ * typo that renders as an empty string in a live email);
76
+ * • size caps (per-locale count, whole-map bytes) → rejected.
77
+ * Pure, so it is unit-tested without a control plane.
78
+ */
79
+ export function validateNotificationOverrides(
80
+ templates: {
81
+ allowOverride?: boolean;
82
+ overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
83
+ } | undefined,
84
+ ): string[] {
85
+ const overrides = templates?.overrides;
86
+ if (!overrides || Object.keys(overrides).length === 0) return [];
87
+ const errs: string[] = [];
88
+ if (templates?.allowOverride !== true) {
89
+ errs.push('/templates/overrides: set templates.allowOverride: true to use per-locale template overrides (an override map with allowOverride:false would never be rendered)');
90
+ }
91
+ const bytes = new TextEncoder().encode(JSON.stringify(overrides)).byteLength;
92
+ if (bytes > NOTIF_OVERRIDE_MAX_BYTES) {
93
+ errs.push(`/templates/overrides: ${bytes} bytes exceeds the ${NOTIF_OVERRIDE_MAX_BYTES}-byte cap`);
94
+ }
95
+ for (const [templateId, byLocale] of Object.entries(overrides)) {
96
+ const allowed = NOTIFICATION_TEMPLATE_PLACEHOLDERS[templateId];
97
+ if (!allowed) {
98
+ errs.push(`/templates/overrides/${templateId}: unknown template id (one of: ${Object.keys(NOTIFICATION_TEMPLATE_PLACEHOLDERS).join(', ')})`);
99
+ continue;
100
+ }
101
+ const locales = Object.keys(byLocale ?? {});
102
+ if (locales.length > NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE) {
103
+ errs.push(`/templates/overrides/${templateId}: ${locales.length} locales exceeds the ${NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE}-per-template cap`);
104
+ }
105
+ for (const locale of locales) {
106
+ if (!NOTIF_LOCALE_RE.test(locale)) {
107
+ errs.push(`/templates/overrides/${templateId}/${locale}: not a BCP-47 locale tag (e.g. 'en-GB')`);
108
+ continue;
109
+ }
110
+ const variant = byLocale![locale]!;
111
+ for (const part of ['subject', 'html', 'text'] as const) {
112
+ const body = variant[part];
113
+ if (typeof body !== 'string') continue;
114
+ for (const m of body.matchAll(NOTIF_PLACEHOLDER_RE)) {
115
+ const key = m[1]!;
116
+ if (!allowed.includes(key)) {
117
+ errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${allowed.join(', ')})`);
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+ return errs;
124
+ }
125
+
24
126
  export const NotificationsConfigSchema = Type.Object({
25
127
  enabled: Type.Boolean({ default: true }),
26
128
  fromEmail: Type.String({ format: 'email' }),
@@ -30,14 +132,27 @@ export const NotificationsConfigSchema = Type.Object({
30
132
  // need no email account, so the mock path is zero-config. A cross-field
31
133
  // check in validateFeatureConfig requires it only when provider === 'resend'.
32
134
  resendApiKeyRef: Type.Optional(Type.String()),
135
+ // Optional per-tenant Resend/Svix ENDPOINT secret ref (public.tenant_secrets,
136
+ // envelope-encrypted under KEK_NOTIFICATIONS — same store as resendApiKeyRef).
137
+ // When set, inbound Resend webhooks are verified with THIS tenant's secret
138
+ // instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
139
+ // to the tenant so a signed event for tenant A can never validate at tenant
140
+ // B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
141
+ webhookSecretRef: Type.Optional(Type.String()),
33
142
  // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP.
34
143
  provider: Type.Union([Type.Literal('resend'), Type.Literal('mock')], {
35
144
  default: 'resend',
36
145
  }),
37
146
  defaultLocale: Type.String({ default: 'en-US' }),
38
147
  // nested objects carry `default: {}` so Value.Default can materialize them
39
- // and then recurse into the leaf defaults
40
- retry: Type.Object(
148
+ // and then recurse into the leaf defaults.
149
+ // `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
150
+ // Optional object as ONE) to fund `broadcast` below (M21/#4, 2026-07-18).
151
+ // It KEEPS `default: {}`, which Value.Default still materializes — so every
152
+ // persisted manifest carries retry.{maxAttempts,backoff} exactly as before
153
+ // (zero behavioral delta); only the TS type is now optional (workers read
154
+ // via retryOf()'s fallback).
155
+ retry: Type.Optional(Type.Object(
41
156
  {
42
157
  maxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }),
43
158
  backoff: Type.Union([Type.Literal('exponential'), Type.Literal('linear')], {
@@ -45,7 +160,7 @@ export const NotificationsConfigSchema = Type.Object({
45
160
  }),
46
161
  },
47
162
  { default: {} },
48
- ),
163
+ )),
49
164
  suppression: Type.Object(
50
165
  { softBounceThreshold: Type.Integer({ default: 3 }) },
51
166
  { default: {} },
@@ -57,19 +172,71 @@ export const NotificationsConfigSchema = Type.Object({
57
172
  },
58
173
  { default: {} },
59
174
  ),
60
- templates: Type.Object(
61
- { allowOverride: Type.Boolean({ default: false }) },
175
+ // `templates` became an OPTIONAL bag (1 leaf, the same M21 trick `retry`
176
+ // uses) to fund the D4 per-locale `overrides` map WITHOUT moving the count:
177
+ // countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
178
+ // Value.Default still materializes `templates.allowOverride` into every
179
+ // persisted manifest exactly as before — zero behavioral delta; only the TS
180
+ // type is optional (workers read via core.ts `templatesOf()`).
181
+ templates: Type.Optional(Type.Object(
182
+ {
183
+ allowOverride: Type.Boolean({ default: false }),
184
+ // D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
185
+ // template ids, as config DATA (a Record = 1 leaf, catalog size never
186
+ // moves the count). Shape: { [templateId]: { [locale]: { subject,
187
+ // html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
188
+ // built-ins — the worker reuses ONE renderer, so escaping/URL-scheme
189
+ // sanitisation are identical and there is no raw-output syntax.
190
+ // validateFeatureConfig rejects: an unknown template id, an unknown
191
+ // placeholder, a malformed locale tag, an over-size body, and (fail
192
+ // LOUD, never silently inert) overrides present with allowOverride:false.
193
+ overrides: Type.Optional(Type.Record(
194
+ Type.String(),
195
+ Type.Record(Type.String(), Type.Object({
196
+ subject: Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX }),
197
+ html: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
198
+ text: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
199
+ })),
200
+ )),
201
+ },
62
202
  { default: {} },
63
- ),
203
+ )),
64
204
  /** in-app inbox channel (send with channel: 'inbox' | 'both') */
65
205
  inboxEnabled: Type.Boolean({ default: false }),
206
+ /** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
207
+ * disabled; per-campaign quiet_hours / freq_cap overrides live on the
208
+ * notifications.campaigns ROW (tenant data), not here. Folded into the
209
+ * canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
210
+ * worker-local extension, so campaigns 403'd via the real config path). */
211
+ broadcast: Type.Optional(Type.Object({
212
+ enabled: Type.Boolean({ default: false }),
213
+ /** tenant-default per-recipient quiet window (defer-not-drop); a
214
+ * per-campaign quiet_hours wins over it. Mirrors the campaigns-row shape
215
+ * (workers/notifications-v1 CampaignBody.quiet_hours). start/end are
216
+ * pattern-pinned to what the worker's parseHhMm actually parses — a
217
+ * looser string ('10pm') would validate but FAIL OPEN at runtime
218
+ * (quiet window silently ignored). */
219
+ defaultQuietHours: Type.Optional(Type.Object({
220
+ tz: Type.String({ minLength: 1, maxLength: 64 }),
221
+ start: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
222
+ end: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
223
+ })),
224
+ /** default rolling per-user-per-day campaign-send cap across campaigns */
225
+ freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
226
+ })),
66
227
  });
67
- // Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef, provider,
68
- // defaultLocale, retry.maxAttempts, retry.backoff,
228
+ // Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef,
229
+ // webhookSecretRef, provider, defaultLocale, retry (Optional bag = 1),
69
230
  // suppression.softBounceThreshold, rateLimit.perDay, rateLimit.perTenantSec,
70
- // templates.allowOverride, inboxEnabled → 14. Cap = 15.
231
+ // templates (Optional bag = 1 — was templates.allowOverride, collapsed 2026-09-10
232
+ // to fund the D4 `overrides` map at zero net cost), inboxEnabled,
233
+ // broadcast (Optional bag = 1) → 15. Cap = 15 — AT the cap; the next flag must
234
+ // collapse something. (`templates.overrides` is a Record MAP inside the ONE
235
+ // optional templates leaf — DATA, not a flag — so catalog size never moves it.)
71
236
 
72
237
  export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
238
+ /** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
239
+ export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
73
240
 
74
241
  export const JobsConfigSchema = Type.Object({
75
242
  enabled: Type.Boolean({ default: true }),
@@ -160,10 +327,61 @@ const AppleRefsSchema = Type.Object({
160
327
  // Value.Clean stripping the field out of PUT /v1/config/auth.
161
328
  bundleIds: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
162
329
  });
330
+ // RB-2 — the generic OIDC / SSO bridge (roadmap §4.6.B, §1B.5; features/auth.md
331
+ // §6.7). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
332
+ // bag so it spends NO leaf (an Optional object bag = ONE leaf; auth stays 11).
333
+ // Declarative + opt-in: the block's PRESENCE enables the `oidc` provider (no
334
+ // methods.* toggle — a `default:false` flag would re-materialize every published
335
+ // manifest, breaking the D3 byte-identical fold). The worker discovers the
336
+ // endpoints + JWKS from `{issuer}/.well-known/openid-configuration` and verifies
337
+ // iss (byte-equal) / aud (= clientId) / nonce / exp / iat strictly, fail-closed.
338
+ // `clientId` is NOT a secret (it rides every authorize URL); `clientSecretRef`
339
+ // is a tenant_secrets POINTER (feature 'auth'), never the value. A SAML IdP
340
+ // plugs in through a broker (Okta / Entra / Auth0 / WorkOS) that speaks OIDC —
341
+ // there is deliberately NO native SAML.
342
+ const OidcProviderSchema = Type.Object({
343
+ // https URL, no query/fragment (the OIDC issuer identifier). Compared
344
+ // BYTE-EQUAL to the discovery document's `issuer` and the id_token `iss`.
345
+ issuer: Type.String({ minLength: 12, maxLength: 512, pattern: '^https://[^\\s?#]+$' }),
346
+ clientId: Type.String({ minLength: 1, maxLength: 512 }),
347
+ clientSecretRef: Type.String({ minLength: 1, maxLength: 200 }),
348
+ // authorize-request scopes; `openid` is always added by the worker. Default
349
+ // (absent) = openid email profile.
350
+ scopes: Type.Optional(Type.Array(
351
+ Type.String({ minLength: 1, maxLength: 64, pattern: '^[\\x21-\\x7e]+$' }), { maxItems: 16 },
352
+ )),
353
+ // claim NAMES to read (absent = the standard `email` / `name`; `roles` has
354
+ // no default — no claim named ⇒ no role claims are minted).
355
+ claims: Type.Optional(Type.Object({
356
+ email: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
357
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
358
+ roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
359
+ })),
360
+ // when non-empty, the asserted email's domain MUST be listed (fail-closed:
361
+ // no email ⇒ refused) → 403 oidc_domain_not_allowed; a listed domain also
362
+ // marks the email verified (the tenant declared the issuer authoritative).
363
+ allowedDomains: Type.Optional(Type.Array(
364
+ Type.String({ minLength: 3, maxLength: 253, pattern: '^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$' }),
365
+ { maxItems: 32 },
366
+ )),
367
+ // default true: an identity whose VERIFIED email matches an existing user is
368
+ // linked to it (the shipped §6.4 rule). false: link only by the stable
369
+ // (issuer, sub) anchor; a matching email that is not yet linked → 409.
370
+ autoLink: Type.Optional(Type.Boolean()),
371
+ });
163
372
 
164
373
  export const AuthConfigSchema = Type.Object({
165
374
  enabled: Type.Boolean({ default: true }),
166
- methods: Type.Object(
375
+ // D3 (auth wave 2026-09-10): the six method toggles collapsed into ONE
376
+ // Optional bag (6 leaves → 1, countLeaves counts an Optional object as ONE) —
377
+ // the notifications `retry`/`broadcast` precedent. It KEEPS `default: {}`,
378
+ // which Value.Default still materializes, so every persisted manifest carries
379
+ // methods.{emailPassword,magicLink,google,github,apple,facebook} with the
380
+ // SAME keys and defaults as before — byte-identical folds for existing
381
+ // tenants (index.test.ts "D3 fold bytes"). Only the TS type is now optional;
382
+ // the worker's gate() normalizes an absent bag to the defaults so every
383
+ // reader (config.methods.<flag>) is unchanged.
384
+ methods: Type.Optional(Type.Object(
167
385
  {
168
386
  emailPassword: Type.Boolean({ default: true }),
169
387
  magicLink: Type.Boolean({ default: true }),
@@ -173,7 +391,7 @@ export const AuthConfigSchema = Type.Object({
173
391
  facebook: Type.Boolean({ default: false }),
174
392
  },
175
393
  { default: {} },
176
- ),
394
+ )),
177
395
  // Per-provider BYO credential refs. ONE optional bag (= ONE leaf per the cap
178
396
  // rule) keyed by provider, so the four provider blocks (and any future one)
179
397
  // never inflate the flag count — the prior shape spent a leaf per top-level
@@ -191,6 +409,8 @@ export const AuthConfigSchema = Type.Object({
191
409
  github: Type.Optional(OAuthRefsSchema),
192
410
  apple: Type.Optional(AppleRefsSchema),
193
411
  facebook: Type.Optional(OAuthRefsSchema),
412
+ // RB-2: the generic OIDC / SSO issuer (presence = enabled; see above)
413
+ oidc: Type.Optional(OidcProviderSchema),
194
414
  })),
195
415
  // session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
196
416
  // the OTP/anonymous/orgClaims wave — the `{ default: {} }` keeps the inner
@@ -201,6 +421,12 @@ export const AuthConfigSchema = Type.Object({
201
421
  {
202
422
  ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
203
423
  refreshTtlDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
424
+ // A1 (auth wave 2026-09-10): concurrent-session cap per user with
425
+ // TAKE-OVER — a new sign-in revokes the OLDEST sessions past the cap
426
+ // (revoked_reason 'device_cap', edge cache written) and reports them as
427
+ // `took_over: [session_id…]`. Optional WITHOUT a default so existing
428
+ // manifests fold byte-identically; absent/0 = unlimited (today).
429
+ maxConcurrent: Type.Optional(Type.Integer({ minimum: 0, maximum: 100 })),
204
430
  },
205
431
  { default: {} },
206
432
  )),
@@ -231,6 +457,16 @@ export const AuthConfigSchema = Type.Object({
231
457
  codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
232
458
  maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
233
459
  resendCooldownSec: Type.Integer({ default: 60, minimum: 0, maximum: 600 }),
460
+ // F4-29 (auth wave 2026-09-10): test recipients — an OTP / step-up / email-
461
+ // claim request whose address matches an entry sends NO mail and returns
462
+ // the code as `test_code` (audit auth.otp.test_issued). Entries: an exact
463
+ // email, a `*@domain` glob, or a +E.164 number (accepted for the SMS
464
+ // channel; inert for email identifiers). ≤20; Optional (no default) so
465
+ // existing manifests fold byte-identically. Store-review / CI accounts
466
+ // only — never a real user's address.
467
+ testRecipients: Type.Optional(Type.Array(
468
+ Type.String({ minLength: 3, maxLength: 320 }), { maxItems: 20 },
469
+ )),
234
470
  })),
235
471
  // Anonymous (guest) sign-in (roadmap Tier-0). OPTIONAL bag = 1 leaf.
236
472
  anonymous: Type.Optional(Type.Object({
@@ -250,15 +486,49 @@ export const AuthConfigSchema = Type.Object({
250
486
  // its leaf was spent on the methods.{apple,facebook} toggles the shipped
251
487
  // §6.4 sign-in flow actually gates on. Re-adding it requires headroom or a
252
488
  // collapse elsewhere.
489
+ //
490
+ // Account-security controls (auth wave 2026-09-10, P0-3; features/auth.md
491
+ // §4b). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
492
+ // absent (existing manifests fold byte-identically) and every control is
493
+ // opt-in:
494
+ // • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
495
+ // sign-in + OTP verify (429 account_locked + Retry-After); a bounded
496
+ // counter row in auth.lockouts (migration 0088) survives restarts.
497
+ // • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
498
+ // chars leave the worker, never the password) at sign-up / reset-confirm;
499
+ // fail-OPEN on network error → 422 password_breached.
500
+ // • captchaSecretRef — a tenant_secrets ref (feature 'auth') holding the
501
+ // Turnstile secret; when set, sign-up / OTP request / magic-link request
502
+ // require `captcha_token` (403 captcha_failed otherwise).
503
+ // • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
504
+ // redirect/return URL (magic link, password reset, email verification,
505
+ // OAuth start) must match one origin exactly → 422 redirect_not_allowed.
506
+ security: Type.Optional(Type.Object({
507
+ lockout: Type.Optional(Type.Object({
508
+ maxFailures: Type.Integer({ default: 10, minimum: 3, maximum: 100 }),
509
+ windowMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
510
+ lockMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
511
+ })),
512
+ breachedPasswords: Type.Boolean({ default: false }),
513
+ captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
514
+ allowedRedirectOrigins: Type.Optional(Type.Array(
515
+ Type.String({ minLength: 8, maxLength: 253, pattern: '^https://[^/?#\\s]+$' }),
516
+ { maxItems: 32 },
517
+ )),
518
+ })),
253
519
  });
254
- // Leaves: enabled(1) + methods(6) + providers(1, optional bag holding the four
255
- // google/github/apple/facebook credential blocks) + session(1, optional bag)
256
- // + password(1, optional bag) + emailVerification(1) + magicLink(1) + otp(1,
257
- // optional bag) + anonymous(1, optional bag) + orgClaims(1, optional bag) = 15.
258
- // Cap = 15 — at the cap; the next flag must collapse something.
259
- // providers is an optional bag → adding social providers (apple/facebook, §6) never
260
- // grows the flag count; the prior 15-flag version spent a leaf per top-level
261
- // google/github block.
520
+ /** The security bag as persisted (present ⇒ leaf defaults applied). */
521
+ export type AuthSecurityConfig = NonNullable<Static<typeof AuthConfigSchema>['security']>;
522
+ /** The RB-2 generic OIDC / SSO issuer block (`providers.oidc`). */
523
+ export type AuthOidcConfig = Static<typeof OidcProviderSchema>;
524
+ // Leaves (auth wave 2026-09-10, D3): enabled(1) + methods(1, optional bag —
525
+ // was 6) + providers(1, optional bag holding the four google/github/apple/
526
+ // 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)
528
+ // + 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.
262
532
 
263
533
  export type AuthConfig = Static<typeof AuthConfigSchema>;
264
534
 
@@ -282,13 +552,16 @@ export type RateLimitsConfig = Static<typeof RateLimitsConfigSchema>;
282
552
 
283
553
  export const FilesConfigSchema = Type.Object({
284
554
  enabled: Type.Boolean({ default: true }),
285
- bucketRef: Type.String({ default: 'vxil-files' }),
555
+ // (F8-54) `bucketRef` was DELETED: the object-storage bucket is a platform
556
+ // binding on files-v1, never tenant-selectable, and no code ever read the
557
+ // leaf — declaring it invited "point files at my own bucket", which vxil does
558
+ // not offer. A persisted manifest that still carries it folds (Value.Clean).
286
559
  uploadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
287
560
  downloadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
288
561
  quotas: Type.Object(
289
562
  {
290
563
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
291
- // R2 is cheap but Neon-resident metadata + abuse aren't (pricing re-audit
564
+ // object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
292
565
  // 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
293
566
  // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
294
567
  // tenant tier threaded to files-v1 (docs/pricing-model-analysis.md §6).
@@ -299,13 +572,10 @@ export const FilesConfigSchema = Type.Object({
299
572
  { default: {} },
300
573
  ),
301
574
  allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
302
- contentScan: Type.Object(
303
- {
304
- enabled: Type.Boolean({ default: false }), // V1.5
305
- quarantineOnFail: Type.Boolean({ default: true }),
306
- },
307
- { default: {} },
308
- ),
575
+ // (F8-54) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
576
+ // there is no malware/content scanner in files-v1 (it was a "V1.5" placeholder
577
+ // neither leaf was ever read), so the knob promised quarantine that never
578
+ // happened. Re-declare it in the same change as a real scanner, not before.
309
579
  sharedLinks: Type.Object(
310
580
  {
311
581
  enabled: Type.Boolean({ default: true }),
@@ -338,7 +608,8 @@ export const FilesConfigSchema = Type.Object({
338
608
  boundingBoxes: Type.Boolean({ default: false }),
339
609
  })),
340
610
  });
341
- // Leaves: 12 + ttl(1, optional bag) + extractText(1, optional bag) = 14. Cap = 15.
611
+ // Leaves: 9 + ttl(1, optional bag) + extractText(1, optional bag) = 11. Cap = 15.
612
+ // (was 14 — F8-54 deleted the inert bucketRef leaf and the inert contentScan bag.)
342
613
 
343
614
  export type FilesConfig = Static<typeof FilesConfigSchema>;
344
615
 
@@ -458,7 +729,7 @@ export const CmsConfigSchema = Type.Object({
458
729
  }),
459
730
  )),
460
731
  // Realtime CDC bridge (cms-rel E): cms writes auto-publish a change event to
461
- // a realtime channel — config-only rewiring of postgres_changes-style subs.
732
+ // a realtime channel — config-only rewiring of database-change-event-style subs.
462
733
  // Fire-and-forget via waitUntil; at-most-once (guaranteed delivery stays
463
734
  // webhooks-out / functions cms-hook). ONE Type.Record leaf.
464
735
  cdc: Type.Optional(Type.Record(
@@ -491,10 +762,10 @@ export const McpConfigSchema = Type.Object({
491
762
  { default: 'all' },
492
763
  ),
493
764
  allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
494
- scopedKey: Type.Object(
495
- { perAgentKeys: Type.Boolean({ default: false }) }, // V1.5
496
- { default: {} },
497
- ),
765
+ // (F8-54) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
766
+ // a "dashboard-UI hint only" that no dashboard ever read, and key minting is a
767
+ // control-plane concern independent of MCP exposure — per-agent keys already
768
+ // work for every tenant, gated by nothing here.
498
769
  rateLimits: Type.Object(
499
770
  { toolCallsPerMin: Type.Integer({ default: 60, minimum: 1, maximum: 6000 }) },
500
771
  { default: {} },
@@ -544,10 +815,10 @@ export const McpConfigSchema = Type.Object({
544
815
  }),
545
816
  )),
546
817
  });
547
- // Leaves: 9 by countLeaves (enabled, exposureLevel, allowToolList,
548
- // scopedKey.perAgentKeys, rateLimits.toolCallsPerMin, branding.serverName,
549
- // branding.serverInstructions = 7, + customTools + prompts as ONE Type.Record
550
- // leaf each). Cap = 15.
818
+ // Leaves: 8 by countLeaves (enabled, exposureLevel, allowToolList,
819
+ // rateLimits.toolCallsPerMin, branding.serverName, branding.serverInstructions
820
+ // = 6, + customTools + prompts as ONE Type.Record leaf each). Cap = 15.
821
+ // (was 9 — F8-54 deleted the inert scopedKey.perAgentKeys leaf.)
551
822
 
552
823
  export type McpConfig = Static<typeof McpConfigSchema>;
553
824
 
@@ -632,12 +903,10 @@ export const ActivityFeedConfigSchema = Type.Object({
632
903
  { default: {} },
633
904
  ),
634
905
 
635
- aggregation: Type.Object(
636
- {
637
- maxGroupActivities: Type.Integer({ default: 15, minimum: 1, maximum: 100 }), // newest-N kept per group
638
- },
639
- { default: {} },
640
- ),
906
+ // (F8-54) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
907
+ // or returned a per-group activity LIST — an aggregated read returns the group
908
+ // rollup (activity_count/actor_count/last_actor), so there was never an N to
909
+ // bound and no code read the leaf. Re-declare it with a group-detail route.
641
910
 
642
911
  realtime: Type.Object(
643
912
  {
@@ -657,18 +926,17 @@ export const ActivityFeedConfigSchema = Type.Object({
657
926
  { default: {} },
658
927
  ),
659
928
 
660
- rateLimit: Type.Object(
661
- {
662
- addPerSec: Type.Integer({ default: 100, minimum: 1, maximum: 100_000 }), // per-tenant activity-write burst
663
- },
664
- { default: {} },
665
- ),
929
+ // (F8-54) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
930
+ // rate-limiter binding and never read it, so the declared per-tenant write
931
+ // burst was enforced by nothing (the edge front-door limiter and the
932
+ // per-tenant request meter are the real bounds). Re-declare it together with
933
+ // the binding that enforces it.
666
934
  });
667
935
  // Leaves: enabled(1), feedGroups(1, the Record bag → ONE leaf), fanout.{celebrityThreshold,
668
936
  // maxFanoutPerJob, maxConcurrentTasks, pendingCeiling}(+4=6), follow.{copyLimit,
669
- // maxFollowing}(+2=8), aggregation.maxGroupActivities(9), realtime.enabled(10),
670
- // crossChannel.{enabled, digestCadence}(+2=12), rateLimit.addPerSec(13).
671
- // countLeaves → 13. Cap = 15. (feedGroups is Type.Record → patternProperties, ONE leaf.)
937
+ // maxFollowing}(+2=8), realtime.enabled(9), crossChannel.{enabled, digestCadence}(+2=11).
938
+ // countLeaves → 11. Cap = 15. (feedGroups is Type.Record → patternProperties, ONE leaf.)
939
+ // (was 13 — F8-54 deleted the inert aggregation and rateLimit bags.)
672
940
 
673
941
  export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
674
942
 
@@ -681,13 +949,13 @@ export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
681
949
  // synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
682
950
  export const VectorSearchConfigSchema = Type.Object({
683
951
  enabled: Type.Boolean({ default: true }),
684
- // 'auto' resolves to pgvector until Lakebase is GA'd for the tier (#147).
952
+ // 'auto' resolves to the default managed vector backend for the tier (#147).
685
953
  backend: Type.Union(
686
954
  [Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')],
687
955
  { default: 'auto' },
688
956
  ),
689
957
  // 'byov'/'mock' need NO provider key (zero-config default); openai/cohere read a
690
- // BYO key from public.tenant_secrets via apiKeyRef (envelope-encrypted).
958
+ // BYO key from tenant secrets via apiKeyRef (encrypted at rest).
691
959
  embed: Type.Object(
692
960
  {
693
961
  provider: Type.Union(
@@ -941,6 +1209,13 @@ export const PaymentsConfigSchema = Type.Object({
941
1209
  // of the platform PROVIDER_WEBHOOK_SECRET — closes the multi-tenant RC
942
1210
  // webhook-forgery vector (mirrors stripe/paddle webhookSecretRef).
943
1211
  webhookSecretRef: Type.Optional(Type.String()),
1212
+ // Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
1213
+ // and PRODUCTION events to the SAME webhook with the same auth header, so a
1214
+ // sandbox purchase would otherwise fold into production entitlements. A
1215
+ // sandbox event is persisted as outcome 'rejected_environment' (200, never
1216
+ // folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
1217
+ // environments by signing secret / API base, so only RC carries this knob.
1218
+ acceptSandbox: Type.Boolean({ default: false }),
944
1219
  })),
945
1220
  paypal: Type.Optional(Type.Object({
946
1221
  clientIdRef: Type.String(),
@@ -953,10 +1228,10 @@ export const PaymentsConfigSchema = Type.Object({
953
1228
  // https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
954
1229
  // hardcoded host (audit 2026-07-10: the old fallback pointed at a dead apex).
955
1230
  returnUrl: Type.Optional(Type.String({ pattern: '^https://', maxLength: 512 })),
956
- prices: Type.Object(
957
- { catalogRef: Type.String({ default: 'default' }) }, // tenant-supplied price catalog ref
958
- { default: {} },
959
- ),
1231
+ // NB (F8-54, 2026-09-11): the former `prices.catalogRef` leaf was DELETED —
1232
+ // it named nothing (prices resolve from ledger.priceMap; no code path ever
1233
+ // read it). A persisted manifest that still carries `prices` folds:
1234
+ // validateFeatureConfig's Value.Clean strips the stray key.
960
1235
  defaults: Type.Object(
961
1236
  {
962
1237
  currency: Type.String({ default: 'usd' }),
@@ -990,22 +1265,44 @@ export const PaymentsConfigSchema = Type.Object({
990
1265
  // fold (refoldEntitlements WHERE tier IS NOT NULL) reflects the subscription.
991
1266
  priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
992
1267
  autoRefundOnJobFailure: Type.Boolean({ default: true }), // consume(jobId) reverses on DLQ/timeout
1268
+ // Grace window (money-path wave F1-7): a `past_due` subscription stays
1269
+ // entitled for this many days AFTER its current_period_end (the dunning
1270
+ // window the provider is retrying inside). 0 = today's behaviour (a past_due
1271
+ // row is never entitled). ONE predicate in the fold — NOT a dunning ladder:
1272
+ // no retries, no emails. Lives inside the ledger bag (no new top-level leaf).
1273
+ grace: Type.Optional(Type.Object({
1274
+ pastDueDays: Type.Integer({ default: 0, minimum: 0, maximum: 90 }),
1275
+ })),
1276
+ // Opt-in period-end enforcement (money-path operations wave, decision D2
1277
+ // option a). ABSENT (the default) = today's behaviour: a subscription whose
1278
+ // current_period_end passed with no provider event stays entitled forever
1279
+ // (the provider is the only clock). PRESENT = the hourly reconcile sweep
1280
+ // flips an `active`/`trialing` row whose current_period_end + slackHours
1281
+ // has passed with no renewal to status 'lapsed' (+ refold, +
1282
+ // payments.subscription.lapsed); a later provider renewal event revives it
1283
+ // (the two-clocks revive rule). Never a manual row without `until`. Lives
1284
+ // inside the ledger bag (no new top-level leaf).
1285
+ enforcePeriodEnd: Type.Optional(Type.Object({
1286
+ slackHours: Type.Integer({ default: 72, minimum: 0, maximum: 720 }),
1287
+ })),
993
1288
  })),
994
- webhooks: Type.Object(
995
- { forwardToTenantUrl: Type.Optional(Type.String({ format: 'uri' })) },
996
- { default: {} },
997
- ),
1289
+ // NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
1290
+ // was DELETED — it had zero readers (never forwarded anything). Outbound
1291
+ // delivery of payments state changes rides the audit_event → webhooks-out
1292
+ // spine: subscribe to the `payments.` event prefix (payments.md §7b).
998
1293
  });
999
1294
  // Leaves: enabled(1), provider(2), stripe?(3), paddle?(4), revenuecat?(5),
1000
- // paypal?(6), prices.catalogRef(7), defaults.{currency,trialDays}(+2=9),
1001
- // ledger?(10), webhooks.forwardToTenantUrl(11). countLeaves → 11. Cap = 15.
1002
- // (productMap / tierMap / priceMap are Type.Record MAPS inside the ONE optional
1003
- // ledger leaf — DATA, not flags — so catalog size never moves the count.)
1295
+ // paypal?(6), returnUrl(7), defaults.{currency,trialDays}(+2=9), ledger?(10).
1296
+ // countLeaves → 10. Cap = 15. (prices.catalogRef was deleted 2026-09-11.)
1297
+ // (productMap / tierMap / priceMap / grace / enforcePeriodEnd are inside the
1298
+ // ONE optional ledger leaf — DATA + two nested optional bags — so catalog size
1299
+ // never moves the count; revenuecat.acceptSandbox is inside the ONE optional
1300
+ // revenuecat leaf.)
1004
1301
 
1005
1302
  export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
1006
1303
 
1007
1304
  // functions feature (vxil-functions-design §4.c). Tenant-deployed backend edge
1008
- // functions on Workers-for-Platforms. The FUNCTION owns its identity (bundle via
1305
+ // functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
1009
1306
  // scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
1010
1307
  // bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
1011
1308
  // never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
@@ -1014,13 +1311,17 @@ export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
1014
1311
  // opt-in (enabled defaults to false), egress-guarded.
1015
1312
  export const FunctionsConfigSchema = Type.Object({
1016
1313
  enabled: Type.Boolean({ default: false }),
1017
- // isolate = WfP Worker (Lane 2, default); container = CF Containers (Lane 3, design-only).
1018
- runtime: Type.Union([Type.Literal('isolate'), Type.Literal('container')], { default: 'isolate' }),
1314
+ // (F8-54) `runtime` ('isolate' | 'container') was DELETED: the container lane
1315
+ // is design-only, nothing read the leaf, and accepting 'container' silently
1316
+ // ran the isolate anyway. It comes back with the lane, not before.
1019
1317
  defaultLimits: Type.Object(
1020
1318
  {
1319
+ // cpuMs is the ONLY per-dispatch limit the managed runtime accepts and the
1320
+ // only one anything reads (functions-v1 meter.ts + the dispatch cap).
1321
+ // (F8-54) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
1322
+ // runtime and not tenant-selectable, and no wall-clock abort was ever
1323
+ // applied — a declared 10s default that nothing enforced.
1021
1324
  cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 300_000 }),
1022
- timeoutMs: Type.Integer({ default: 10_000, minimum: 50, maximum: 300_000 }),
1023
- memoryMb: Type.Integer({ default: 128, minimum: 64, maximum: 1024 }),
1024
1325
  },
1025
1326
  { default: {} },
1026
1327
  ),
@@ -1096,11 +1397,12 @@ export const FunctionsConfigSchema = Type.Object({
1096
1397
  ),
1097
1398
  ),
1098
1399
  });
1099
- // Leaves: enabled(1), runtime(2), defaultLimits.{cpuMs,timeoutMs,memoryMb}(+3=5),
1100
- // metering.enabled(6), functions(7, the Type.Record bag → ONE leaf;
1101
- // bindings[]/scriptRef/scopes/secrets/egressAllow/limits/enabled/signature
1102
- // are DATA inside the MAP value and never move the count, like cms.hooks).
1103
- // countLeaves → 7. Cap = 15.
1400
+ // Leaves: enabled(1), defaultLimits.cpuMs(2), metering.enabled(3),
1401
+ // functions(4, the Type.Record bag → ONE leaf; bindings[]/scriptRef/scopes/
1402
+ // secrets/egressAllow/limits/enabled/signature are DATA inside the MAP value
1403
+ // and never move the count, like cms.hooks). countLeaves → 4. Cap = 15.
1404
+ // (was 7 — F8-54 deleted the inert runtime, defaultLimits.timeoutMs and
1405
+ // defaultLimits.memoryMb leaves.)
1104
1406
  export type FunctionsConfig = Static<typeof FunctionsConfigSchema>;
1105
1407
 
1106
1408
  // copilot feature (docs/copilot-agents-sku-design.md §4). Re-declared to match
@@ -1185,20 +1487,17 @@ export const CopilotConfigSchema = Type.Object({
1185
1487
  { default: {} },
1186
1488
  ),
1187
1489
 
1188
- // ── escalation: human hand-off (composes notifications/comments) ──────────
1189
- escalation: Type.Optional(Type.Object({
1190
- enabled: Type.Boolean({ default: false }),
1191
- handler: Type.Union(
1192
- [Type.Literal('comments'), Type.Literal('notifications')],
1193
- { default: 'comments' },
1194
- ),
1195
- notifyTemplate: Type.Optional(Type.String({ maxLength: 128 })),
1196
- })),
1490
+ // (F8-54) the `escalation` bag ({enabled, handler, notifyTemplate}) was
1491
+ // DELETED: the human hand-off it declared was never built — copilot-v1 read
1492
+ // none of the three leaves, so a tenant who turned it on got silence. The
1493
+ // shipped hand-off path is a tenant function on the conversation events.
1197
1494
 
1198
1495
  // ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
1199
1496
  limits: Type.Object({
1200
1497
  consumeCredits: Type.Boolean({ default: false }),
1201
- tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = inherit ai
1498
+ // (F8-54) `tokensPerUserPerDay` was DELETED here: token accounting is
1499
+ // delegated to ai-v1 (this bag's own doctrine) and only `ai`'s
1500
+ // limits.tokensPerUserPerDay is enforced — the copilot twin read nothing.
1202
1501
  }, { default: {} }),
1203
1502
 
1204
1503
  // ── widget / embed (the Stage-3 public chat surface) ──────────────────────
@@ -1209,10 +1508,10 @@ export const CopilotConfigSchema = Type.Object({
1209
1508
  theme: Type.Optional(Type.String({ maxLength: 32 })),
1210
1509
  }, { default: {} }),
1211
1510
  });
1212
- // Leaves: enabled(1), agents(2 — Type.Record MAP, ONE leaf), escalation?(3 —
1213
- // optional object, ONE leaf), limits.{consumeCredits,tokensPerUserPerDay}
1214
- // (+2=5), widget.{enabled,requireAuth,allowedOrigins,theme}(+4=9).
1215
- // countLeaves → 9. Cap = 15.
1511
+ // Leaves: enabled(1), agents(2 — Type.Record MAP, ONE leaf),
1512
+ // limits.consumeCredits(3), widget.{enabled,requireAuth,allowedOrigins,theme}
1513
+ // (+4=7). countLeaves → 7. Cap = 15.
1514
+ // (was 9 — F8-54 deleted the inert escalation bag and limits.tokensPerUserPerDay.)
1216
1515
 
1217
1516
  export type CopilotConfig = Static<typeof CopilotConfigSchema>;
1218
1517
 
@@ -1244,6 +1543,36 @@ export const CONFIG_FLAG_CAP = 15;
1244
1543
  * Per features/auth.md §4: an OPTIONAL object (e.g. a provider credential
1245
1544
  * block) counts as ONE flag — the tenant's decision is "configure it or
1246
1545
  * not", not each inner ref. */
1546
+ /** The auth lifecycle events an `authHook` binding may name (F4-30) — the
1547
+ * closed union `packages/config` types as AuthHookEvent; the control-plane
1548
+ * reconciler maps each to its `auth.<event>` audit-event prefix. */
1549
+ export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'] as const;
1550
+ export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
1551
+
1552
+ /** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
1553
+ * matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
1554
+ export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
1555
+ export const TEST_RECIPIENT_GLOB_RE = /^\*@[^\s@*]+\.[^\s@*]+$/;
1556
+ export const TEST_RECIPIENT_E164_RE = /^\+[1-9]\d{1,14}$/;
1557
+ export function isTestRecipientPattern(entry: string): boolean {
1558
+ return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry) || TEST_RECIPIENT_E164_RE.test(entry);
1559
+ }
1560
+ /** True iff `identifier` (an email today) matches one configured entry:
1561
+ * exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
1562
+ * an email. */
1563
+ export function matchesTestRecipient(identifier: string, entries: readonly string[] | undefined): boolean {
1564
+ if (!entries || entries.length === 0) return false;
1565
+ const id = identifier.trim().toLowerCase();
1566
+ const at = id.lastIndexOf('@');
1567
+ const domain = at >= 0 ? id.slice(at + 1) : null;
1568
+ return entries.some((raw) => {
1569
+ const e = raw.trim().toLowerCase();
1570
+ if (TEST_RECIPIENT_GLOB_RE.test(e)) return domain !== null && domain === e.slice(2);
1571
+ if (TEST_RECIPIENT_E164_RE.test(e)) return id === e;
1572
+ return id === e;
1573
+ });
1574
+ }
1575
+
1247
1576
  export function countLeaves(schema: TSchema): number {
1248
1577
  const t = schema as unknown as {
1249
1578
  type?: string;
@@ -1369,20 +1698,36 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1369
1698
  // Cross-field rule: notifications only needs a Resend key for the real
1370
1699
  // provider. The `mock` provider (and inbox-only setups) stay zero-config.
1371
1700
  if (feature === 'notifications') {
1372
- const v = withDefaults as { provider?: string; resendApiKeyRef?: string };
1701
+ const v = withDefaults as {
1702
+ provider?: string;
1703
+ resendApiKeyRef?: string;
1704
+ templates?: {
1705
+ allowOverride?: boolean;
1706
+ overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
1707
+ };
1708
+ };
1373
1709
  if (v.provider === 'resend' && !v.resendApiKeyRef) {
1374
1710
  return {
1375
1711
  ok: false,
1376
1712
  errors: ["/resendApiKeyRef: required when provider is 'resend' (omit it for provider 'mock')"],
1377
1713
  };
1378
1714
  }
1715
+ const overrideErrs = validateNotificationOverrides(v.templates);
1716
+ if (overrideErrs.length) return { ok: false, errors: overrideErrs.slice(0, 10) };
1379
1717
  }
1380
1718
  // Cross-field rule: a REAL payments provider needs its credential block; the
1381
1719
  // 'mock' provider (the deterministic default) stays zero-config so the whole
1382
1720
  // ledger path is testable without real keys (features/payments.md §0/§6).
1383
1721
  if (feature === 'payments') {
1384
- const v = withDefaults as { provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown };
1385
- const needsBlock: Record<string, keyof typeof v> = {
1722
+ const v = withDefaults as {
1723
+ provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
1724
+ ledger?: {
1725
+ productMap?: Record<string, { creditType?: string }>;
1726
+ tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
1727
+ priceMap?: Record<string, string>;
1728
+ };
1729
+ };
1730
+ const needsBlock: Record<string, 'stripe' | 'paddle' | 'revenuecat'> = {
1386
1731
  stripe: 'stripe', paddle: 'paddle', revenuecat: 'revenuecat',
1387
1732
  };
1388
1733
  const key = v.provider ? needsBlock[v.provider] : undefined;
@@ -1392,6 +1737,56 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1392
1737
  errors: [`/${String(key)}: required when provider is '${v.provider}' (omit it for provider 'mock')`],
1393
1738
  };
1394
1739
  }
1740
+ // A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
1741
+ // the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
1742
+ // user, running vxil-billed functions for free (audit F2). Rejected at write
1743
+ // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
1744
+ const ledgerErrs: string[] = [];
1745
+ for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
1746
+ if (rule.creditType && isReservedCreditType(rule.creditType)) {
1747
+ ledgerErrs.push(
1748
+ `/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
1749
+ );
1750
+ }
1751
+ }
1752
+ for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1753
+ (rule.grants ?? []).forEach((g, i) => {
1754
+ if (g.creditType && isReservedCreditType(g.creditType)) {
1755
+ ledgerErrs.push(
1756
+ `/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
1757
+ );
1758
+ }
1759
+ });
1760
+ }
1761
+ // Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
1762
+ // customer (priceMap miss → tier NULL → refold excluded the row). The
1763
+ // reducer now stamps such an event outcome 'error' (reprocessable), and
1764
+ // this lint catches the config half at `vxil push` time: every priceMap
1765
+ // value must be a tierMap key, and explicitly-declared tier ranks must be
1766
+ // unique (two tiers at the same rank make the multi-sub fold's winner
1767
+ // order-dependent). Omitted ranks (default 0) are NOT checked — rejecting
1768
+ // rank-less legacy configs on push would break existing tenants.
1769
+ const tierKeys = new Set(Object.keys(v.ledger?.tierMap ?? {}));
1770
+ for (const [priceRef, tier] of Object.entries(v.ledger?.priceMap ?? {})) {
1771
+ if (!tierKeys.has(tier)) {
1772
+ ledgerErrs.push(
1773
+ `/ledger/priceMap/${priceRef}: '${tier}' is not a tierMap key (a webhook for this price would fold to an unknown tier)`,
1774
+ );
1775
+ }
1776
+ }
1777
+ const rankOwner = new Map<number, string>();
1778
+ for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1779
+ if (typeof rule.rank !== 'number') continue;
1780
+ const prior = rankOwner.get(rule.rank);
1781
+ if (prior !== undefined) {
1782
+ ledgerErrs.push(
1783
+ `/ledger/tierMap/${tier}/rank: rank ${rule.rank} is already used by tier '${prior}' (tier ranks must be unique)`,
1784
+ );
1785
+ } else {
1786
+ rankOwner.set(rule.rank, tier);
1787
+ }
1788
+ }
1789
+ if (ledgerErrs.length) return { ok: false, errors: ledgerErrs.slice(0, 10) };
1395
1790
  }
1396
1791
  // Cross-field rule: CMS lifecycle hooks. Each hook's expression is parsed and
1397
1792
  // AST-validated against the closed sandbox allow-list HERE, at config-write
@@ -1475,6 +1870,16 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1475
1870
  if (urlErr) return { ok: false, errors: [`/providers/compat/openaiBaseUrl: ${urlErr}`] };
1476
1871
  }
1477
1872
  }
1873
+ // Cross-field rule: auth otp.testRecipients (F4-29) — every entry must be an
1874
+ // email, an `*@domain` glob, or a +E.164 number; anything else would never
1875
+ // match and silently do nothing.
1876
+ if (feature === 'auth') {
1877
+ const v = withDefaults as { otp?: { testRecipients?: string[] } };
1878
+ const bad = (v.otp?.testRecipients ?? []).filter((e) => !isTestRecipientPattern(e));
1879
+ if (bad.length) {
1880
+ return { ok: false, errors: bad.slice(0, 10).map((e) => `/otp/testRecipients: '${e}' is not an email, an *@domain glob, or a +E.164 number`) };
1881
+ }
1882
+ }
1478
1883
  // Cross-field rule: functions. Reject privilege-escalating scopes and malformed
1479
1884
  // bindings at config-write time (the deploy pipeline additionally asserts the
1480
1885
  // scriptRef sha is uploaded + clamps scopes via DENY_FUNCTION_SCOPES). Bindings
@@ -1503,10 +1908,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1503
1908
  if (b.kind === 'cmsHook' && !b.collection) {
1504
1909
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
1505
1910
  }
1506
- // authHook: 'user.created' is the only event today — reject typos at
1507
- // write time so a binding never silently subscribes to nothing.
1508
- if (b.kind === 'authHook' && b.event !== undefined && b.event !== 'user.created') {
1509
- errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be 'user.created' (or omitted)`);
1911
+ // authHook: a CLOSED event union (F4-30) — reject typos at write time
1912
+ // so a binding never silently subscribes to nothing.
1913
+ if (b.kind === 'authHook' && b.event !== undefined && !(AUTH_HOOK_EVENTS as readonly string[]).includes(b.event)) {
1914
+ errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be one of ${AUTH_HOOK_EVENTS.join(' | ')} (or omitted = user.created)`);
1510
1915
  }
1511
1916
  });
1512
1917
  // Manifest-bloat guard: the signature is opaque DATA carried inside the
@@ -1528,7 +1933,6 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1528
1933
  actions?: { mode?: string; allow?: Record<string, unknown> };
1529
1934
  guardrails?: { allowGuest?: boolean; guestToolAllow?: string[] };
1530
1935
  }>;
1531
- escalation?: { handler?: string; notifyTemplate?: string };
1532
1936
  widget?: { requireAuth?: boolean };
1533
1937
  };
1534
1938
  const errs: string[] = [];
@@ -1563,10 +1967,154 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1563
1967
  if (v.widget?.requireAuth === false && !anyGuestAgent) {
1564
1968
  errs.push('/widget/requireAuth: false requires at least one agent with guardrails.allowGuest: true');
1565
1969
  }
1566
- if (v.escalation?.handler === 'notifications' && !v.escalation.notifyTemplate) {
1567
- errs.push("/escalation/notifyTemplate: required when handler is 'notifications'");
1568
- }
1569
1970
  if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
1570
1971
  }
1571
1972
  return { ok: true, errors: [], value: withDefaults };
1572
1973
  }
1974
+
1975
+ // ─────────────────────────────────────────────────────────────────────────────
1976
+ // PLANNER GROUNDING (roadmap §4.6.E; docs/ai-planner-design.md §2).
1977
+ // FEATURE_KEYS is the literal feature list the planner catalog is projected
1978
+ // from; the planner-catalog CI gate asserts set-equality with
1979
+ // Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
1980
+ // list (and FEATURE_HINTS below — a tsc error via the Record key-closure)
1981
+ // turns the gate red. Coverage is derived-and-gated; intent (whenToUse) is
1982
+ // human-authored but gated-for-presence.
1983
+ // ─────────────────────────────────────────────────────────────────────────────
1984
+ export const FEATURE_KEYS = [
1985
+ 'notifications', 'jobs', 'auth', 'rate-limits', 'files', 'webhooks',
1986
+ 'comments', 'cms', 'mcp', 'realtime', 'presence', 'orgs', 'activity-feed',
1987
+ 'vector-search', 'ai', 'rag', 'payments', 'functions', 'copilot',
1988
+ ] as const;
1989
+ export type FeatureKey = (typeof FEATURE_KEYS)[number];
1990
+
1991
+ /** The hand-authored "when to use" intent a machine schema cannot convey.
1992
+ * REQUIRED for every feature (Record key-closure): a feature added to
1993
+ * FEATURE_KEYS without a hint is a compile error naming the missing key. */
1994
+ export interface PlannerHint {
1995
+ /** One or two sentences: when an app needs this feature. */
1996
+ whenToUse: string;
1997
+ /** Soft priors — words/needs that suggest the feature (NOT a matcher). */
1998
+ signals: string[];
1999
+ /** When NOT to pick it (disambiguation vs neighbors). */
2000
+ notFor: string;
2001
+ /** Features that must be enabled alongside (runtime substrate deps). */
2002
+ dependsOn: FeatureKey[];
2003
+ }
2004
+
2005
+ export const FEATURE_HINTS: Record<FeatureKey, PlannerHint> = {
2006
+ notifications: {
2007
+ whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
2008
+ signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
2009
+ notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
2010
+ dependsOn: [],
2011
+ },
2012
+ jobs: {
2013
+ whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
2014
+ signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
2015
+ notFor: 'Simple request-response logic that completes inline.',
2016
+ dependsOn: [],
2017
+ },
2018
+ auth: {
2019
+ whenToUse: 'End-users sign in/up to the app: passwords, magic links, OTP, social login, sessions. Almost every app with per-user data wants it.',
2020
+ signals: ['sign in', 'login', 'account', 'register', 'user', 'password', 'profile', 'member', 'seller', 'buyer'],
2021
+ notFor: 'Pure-public read-only content with no user state.',
2022
+ dependsOn: [],
2023
+ },
2024
+ 'rate-limits': {
2025
+ whenToUse: 'Throttling abuse-prone or costly operations: public forms, expensive endpoints, per-user quotas.',
2026
+ signals: ['rate limit', 'throttle', 'abuse', 'quota', 'spam'],
2027
+ notFor: 'General correctness — the platform already meters requests globally.',
2028
+ dependsOn: [],
2029
+ },
2030
+ files: {
2031
+ whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
2032
+ signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
2033
+ notFor: 'Structured records (cms) or text content authored in-app.',
2034
+ dependsOn: [],
2035
+ },
2036
+ webhooks: {
2037
+ whenToUse: 'Receiving events FROM external services (Stripe/GitHub/etc. callbacks) or fanning tenant events out TO external URLs.',
2038
+ signals: ['webhook', 'callback', 'external event', 'integration', 'sync with'],
2039
+ notFor: 'In-app eventing between vxil features (the audit bus covers that).',
2040
+ dependsOn: ['jobs'],
2041
+ },
2042
+ comments: {
2043
+ whenToUse: 'Threaded discussion, reviews, replies, or reactions attached to any topic/record; also direct messages between users.',
2044
+ signals: ['comment', 'review', 'discussion', 'reply', 'thread', 'DM', 'message'],
2045
+ notFor: 'A social following timeline (activity-feed) or live chat transport (realtime).',
2046
+ dependsOn: [],
2047
+ },
2048
+ cms: {
2049
+ whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, and optional keyless public reads. Most apps are, underneath, cms collections.',
2050
+ signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
2051
+ notFor: 'End-user identity (auth) or file bytes (files).',
2052
+ dependsOn: [],
2053
+ },
2054
+ mcp: {
2055
+ whenToUse: 'Exposing the tenant backend as typed tools an AI agent drives (Claude/Cursor etc.).',
2056
+ signals: ['agent', 'MCP', 'AI tools', 'tool-calling'],
2057
+ notFor: 'In-app AI text generation (ai) or chat over documents (rag).',
2058
+ dependsOn: [],
2059
+ },
2060
+ realtime: {
2061
+ whenToUse: 'Live in-page updates over channels: chat rooms, live boards, collaborative views, instant refresh when data changes.',
2062
+ signals: ['live', 'realtime', 'chat', 'instantly', 'websocket', 'multiplayer'],
2063
+ notFor: 'Email/inbox notifications (notifications) or historical timelines (activity-feed).',
2064
+ dependsOn: [],
2065
+ },
2066
+ presence: {
2067
+ whenToUse: 'Showing who is online/typing/active on a channel right now.',
2068
+ signals: ['online', 'who is here', 'typing', 'active users', 'presence'],
2069
+ notFor: 'Message delivery itself (realtime carries the messages).',
2070
+ dependsOn: ['realtime'],
2071
+ },
2072
+ orgs: {
2073
+ whenToUse: 'End-users grouped into teams/workspaces with roles and per-resource permissions (multi-member accounts, RBAC).',
2074
+ signals: ['team', 'workspace', 'organization', 'role', 'invite member', 'permission'],
2075
+ notFor: 'Simple per-user ownership (cms ownerField covers that without orgs).',
2076
+ dependsOn: ['auth'],
2077
+ },
2078
+ 'activity-feed': {
2079
+ whenToUse: 'Social timelines: follow/unfollow, personal feeds, notification feeds of who-did-what.',
2080
+ signals: ['feed', 'timeline', 'follow', 'social', 'activity', 'news feed'],
2081
+ notFor: 'Live transport (realtime) or email (notifications).',
2082
+ dependsOn: [],
2083
+ },
2084
+ 'vector-search': {
2085
+ whenToUse: 'Semantic/similarity search over content: find-similar, meaning-based search boxes.',
2086
+ signals: ['search', 'semantic', 'similar', 'find by meaning'],
2087
+ notFor: 'Exact filters/sorts over records (the cms query DSL covers those).',
2088
+ dependsOn: [],
2089
+ },
2090
+ ai: {
2091
+ whenToUse: 'Calling LLMs from the app: generate/summarize/classify text, prompt templates, streaming completions (BYO provider key).',
2092
+ signals: ['generate', 'summarize', 'AI', 'GPT', 'classify', 'rewrite', 'draft'],
2093
+ notFor: 'Answers grounded in the tenant’s own documents (rag) or an embedded agent (copilot).',
2094
+ dependsOn: [],
2095
+ },
2096
+ rag: {
2097
+ whenToUse: 'Question-answering grounded in the tenant’s own content with citations: “ask your docs/notes/knowledge base”.',
2098
+ signals: ['ask questions', 'chatbot over', 'knowledge base', 'Q&A', 'answers from documents'],
2099
+ notFor: 'Free-form generation with no grounding (ai).',
2100
+ dependsOn: ['vector-search', 'ai', 'files'],
2101
+ },
2102
+ payments: {
2103
+ whenToUse: 'A payments integration: the tenant connects their OWN Stripe/Paddle/PayPal/RevenueCat account for checkout sessions, subscriptions, entitlements, and usage credits. Funds never touch vxil.',
2104
+ signals: ['pay', 'subscription', 'checkout', 'billing', 'sale', 'order', 'price', 'monetize', 'credits'],
2105
+ notFor: 'Anything implying vxil processes money — it is an integration with the tenant’s own provider.',
2106
+ dependsOn: [],
2107
+ },
2108
+ functions: {
2109
+ whenToUse: 'ONLY for truly-unique server logic no feature or cms rule can express: bespoke sagas, custom integrations over the egress guard, computed endpoints. Prefer features/cms first; functions are the escape hatch.',
2110
+ signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
2111
+ notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
2112
+ dependsOn: [],
2113
+ },
2114
+ copilot: {
2115
+ whenToUse: 'An embeddable in-app AI assistant that retrieves tenant content and proposes/confirms actions against the tenant’s own API.',
2116
+ signals: ['assistant', 'copilot', 'in-app AI helper', 'agent widget'],
2117
+ notFor: 'Plain text generation (ai) or doc Q&A without actions (rag).',
2118
+ dependsOn: ['ai', 'vector-search', 'rag', 'mcp'],
2119
+ },
2120
+ };