@vxil/feature-configs 0.4.0 → 0.5.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
@@ -7,6 +7,7 @@ import { FormatRegistry, OptionalKind, Type, type Static, type TSchema } from '@
7
7
  import { Value } from '@sinclair/typebox/value';
8
8
  import { validateHooksConfig, type HookDef } from './hooks.js';
9
9
  import { validateCdcConfig, validateReadModelsConfig } from './readmodels.js';
10
+ import { isFnTriggerSubscriptionUrl } from './apiState.js';
10
11
 
11
12
  // Re-export the CMS lifecycle-hook engine so feature workers (cms-v1, runtime
12
13
  // eval) and the control plane (config-time validation) share one definition.
@@ -14,6 +15,10 @@ export * from './hooks.js';
14
15
  // Re-export the cms-rel read-model/cdc config gates (the pure-mirror split:
15
16
  // grammar validated here at config-write; field existence at runtime).
16
17
  export * from './readmodels.js';
18
+ // Re-export the DECLARED API STATE planner (roadmap §4.11 P0-3): pure
19
+ // declared-vs-live reconciliation, shared by the control-plane apply path and
20
+ // the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
21
+ export * from './apiState.js';
17
22
 
18
23
  // TypeBox validates `format:` only for registered formats — register the ones
19
24
  // our schemas use (pragmatic RFC-lite email check; providers do the real one).
@@ -153,6 +158,13 @@ export function validateNotificationOverrides(
153
158
  return errs;
154
159
  }
155
160
 
161
+ /** AWS region grammar for `notifications.ses.region` (`us-east-1`,
162
+ * `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
163
+ * one or more lowercase words, a single digit. Pinned as a pattern rather than
164
+ * a list so a newly launched SES region needs no vxil release; a typo still
165
+ * fails at `vxil push` (TypeBox), never as a DNS error on the first send. */
166
+ export const SES_REGION_PATTERN = '^[a-z]{2}(-[a-z]+)+-\\d$';
167
+
156
168
  export const NotificationsConfigSchema = Type.Object({
157
169
  enabled: Type.Boolean({ default: true }),
158
170
  fromEmail: Type.String({ format: 'email' }),
@@ -169,10 +181,27 @@ export const NotificationsConfigSchema = Type.Object({
169
181
  // to the tenant so a signed event for tenant A can never validate at tenant
170
182
  // B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
171
183
  webhookSecretRef: Type.Optional(Type.String()),
172
- // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP.
173
- provider: Type.Union([Type.Literal('resend'), Type.Literal('mock')], {
184
+ // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP;
185
+ // 'ses' (2026-09-19) is the second real provider — Amazon SES v2, BYO IAM
186
+ // keys, same delivery/suppression/preference pipeline as resend.
187
+ provider: Type.Union([Type.Literal('resend'), Type.Literal('ses'), Type.Literal('mock')], {
174
188
  default: 'resend',
175
189
  }),
190
+ // Amazon SES credentials (2026-09-19). ONE optional bag (= ONE leaf per the
191
+ // cap rule — funded by folding `rateLimit` into an Optional bag below);
192
+ // NO `default: {}` so an absent bag stays absent (the auth `security`
193
+ // precedent). Both refs are `secret:<name>` POINTERS into
194
+ // public.tenant_secrets (feature='notifications', KEK_NOTIFICATIONS) —
195
+ // exactly how resendApiKeyRef resolves; the region is plain config (not a
196
+ // secret) and is pattern-pinned to the AWS region grammar so a typo fails
197
+ // at `vxil push` instead of as a DNS error on the first send. Required
198
+ // (all three) when provider === 'ses' — the cross-field check in
199
+ // validateFeatureConfig, mirroring the resend posture.
200
+ ses: Type.Optional(Type.Object({
201
+ region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
202
+ accessKeyIdRef: Type.String({ minLength: 1 }),
203
+ secretAccessKeyRef: Type.String({ minLength: 1 }),
204
+ })),
176
205
  defaultLocale: Type.String({ default: 'en-US' }),
177
206
  // nested objects carry `default: {}` so Value.Default can materialize them
178
207
  // and then recurse into the leaf defaults.
@@ -195,13 +224,19 @@ export const NotificationsConfigSchema = Type.Object({
195
224
  { softBounceThreshold: Type.Integer({ default: 3 }) },
196
225
  { default: {} },
197
226
  ),
198
- rateLimit: Type.Object(
227
+ // `rateLimit` became an OPTIONAL bag (2 leaves → 1, the M21 `retry` trick)
228
+ // on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
229
+ // `default: {}`, so Value.Default still materializes
230
+ // rateLimit.{perDay,perTenantSec} into every persisted manifest exactly as
231
+ // before (zero behavioral delta); only the TS type is now optional (the
232
+ // worker reads via core.ts `rateLimitOf()`'s fallback).
233
+ rateLimit: Type.Optional(Type.Object(
199
234
  {
200
235
  perDay: Type.Integer({ default: 100000 }),
201
236
  perTenantSec: Type.Integer({ default: 50 }),
202
237
  },
203
238
  { default: {} },
204
- ),
239
+ )),
205
240
  // `templates` became an OPTIONAL bag (1 leaf, the same M21 trick `retry`
206
241
  // uses) to fund the D4 per-locale `overrides` map WITHOUT moving the count:
207
242
  // countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
@@ -263,13 +298,15 @@ export const NotificationsConfigSchema = Type.Object({
263
298
  })),
264
299
  });
265
300
  // Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef,
266
- // webhookSecretRef, provider, defaultLocale, retry (Optional bag = 1),
267
- // suppression.softBounceThreshold, rateLimit.perDay, rateLimit.perTenantSec,
268
- // templates (Optional bag = 1 — was templates.allowOverride, collapsed 2026-09-10
269
- // to fund the D4 `overrides` map at zero net cost), inboxEnabled,
270
- // broadcast (Optional bag = 1) → 15. Cap = 15 — AT the cap; the next flag must
271
- // collapse something. (`templates.overrides` is a Record MAP inside the ONE
272
- // optional templates leaf — DATA, not a flag — so catalog size never moves it.)
301
+ // webhookSecretRef, provider, ses (Optional bag = 1, 2026-09-19), defaultLocale,
302
+ // retry (Optional bag = 1), suppression.softBounceThreshold,
303
+ // rateLimit (Optional bag = 1 — was rateLimit.{perDay,perTenantSec}, collapsed
304
+ // 2026-09-19 to fund `ses` at zero net cost), templates (Optional bag = 1 — was
305
+ // templates.allowOverride, collapsed 2026-09-10 to fund the D4 `overrides` map
306
+ // at zero net cost), inboxEnabled, broadcast (Optional bag = 1) → 15. Cap = 15 —
307
+ // AT the cap; the next flag must collapse something. (`templates.overrides` is
308
+ // a Record MAP inside the ONE optional templates leaf — DATA, not a flag — so
309
+ // catalog size never moves it.)
273
310
 
274
311
  export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
275
312
  /** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
@@ -407,6 +444,46 @@ const OidcProviderSchema = Type.Object({
407
444
  autoLink: Type.Optional(Type.Boolean()),
408
445
  });
409
446
 
447
+ // ── redirect / return-URL scheme rule — ONE predicate, two consumers ─────────
448
+ // `security.allowedRedirectOrigins` (below) and auth-v1's runtime shape guard
449
+ // on every caller-supplied `redirect_url` / `redirect_uri` (core.ts
450
+ // `isAllowedRedirectUrl`) must agree on WHICH schemes a sign-in link may be
451
+ // delivered to, or a tenant could allow-list an origin the worker then refuses
452
+ // (or the reverse). The rule (cvskit gap 7, 2026-09-19): `https://` on any
453
+ // host, plus PLAIN `http://` ONLY on the two loopback names — `localhost` and
454
+ // `127.0.0.1`, any port — so a local dev server can complete a magic link
455
+ // without a staging origin. Any other `http://` (a LAN host, `*.local`, a
456
+ // `localhost` sub-label like `app.localhost`, `[::1]`) stays refused: the
457
+ // token rides the link in the clear, and only a loopback name is guaranteed
458
+ // never to leave the developer's machine. The TypeBox `pattern` is the coarse
459
+ // shape check the schema needs; `validateFeatureConfig` re-checks every entry
460
+ // with THIS predicate, and auth-v1 calls it on the parsed URL — the parity test
461
+ // in index.test.ts / redirectRule.test.ts holds the two together. The CLI's
462
+ // production promotion gate still refuses any `http://` origin on a
463
+ // production push (cli-sdk.md), so a loopback entry is a DEV-tenant affair.
464
+ export const REDIRECT_ORIGIN_PATTERN =
465
+ '^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$';
466
+
467
+ /** THE scheme rule for a redirect/return URL: https anywhere, http on loopback
468
+ * only. Takes the parsed URL (or any {protocol, hostname} pair) so the config
469
+ * validator and the worker's runtime guard call the identical predicate. */
470
+ export function redirectSchemeAllowed(u: { protocol: string; hostname: string }): boolean {
471
+ if (u.protocol === 'https:') return true;
472
+ if (u.protocol !== 'http:') return false;
473
+ const host = u.hostname.toLowerCase();
474
+ return host === 'localhost' || host === '127.0.0.1';
475
+ }
476
+
477
+ /** An allow-list ENTRY is a bare origin that parses and passes the scheme
478
+ * rule. Used by validateFeatureConfig (auth) on top of the coarse pattern. */
479
+ export function isAllowedRedirectOriginEntry(entry: string): boolean {
480
+ try {
481
+ return redirectSchemeAllowed(new URL(entry));
482
+ } catch {
483
+ return false;
484
+ }
485
+ }
486
+
410
487
  export const AuthConfigSchema = Type.Object({
411
488
  enabled: Type.Boolean({ default: true }),
412
489
  // D3 (auth wave 2026-09-10): the six method toggles collapsed into ONE
@@ -568,6 +645,9 @@ export const AuthConfigSchema = Type.Object({
568
645
  // • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
569
646
  // redirect/return URL (magic link, password reset, email verification,
570
647
  // OAuth start) must match one origin exactly → 422 redirect_not_allowed.
648
+ // Entries are `https://host[:port]`, or `http://localhost[:port]` /
649
+ // `http://127.0.0.1[:port]` for local development (REDIRECT_ORIGIN_PATTERN
650
+ // + redirectSchemeAllowed above — the one rule auth-v1 enforces too).
571
651
  security: Type.Optional(Type.Object({
572
652
  lockout: Type.Optional(Type.Object({
573
653
  maxFailures: Type.Integer({ default: 10, minimum: 3, maximum: 100 }),
@@ -577,7 +657,7 @@ export const AuthConfigSchema = Type.Object({
577
657
  breachedPasswords: Type.Boolean({ default: false }),
578
658
  captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
579
659
  allowedRedirectOrigins: Type.Optional(Type.Array(
580
- Type.String({ minLength: 8, maxLength: 253, pattern: '^https://[^/?#\\s]+$' }),
660
+ Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }),
581
661
  { maxItems: 32 },
582
662
  )),
583
663
  })),
@@ -598,6 +678,40 @@ export type AuthOidcConfig = Static<typeof OidcProviderSchema>;
598
678
 
599
679
  export type AuthConfig = Static<typeof AuthConfigSchema>;
600
680
 
681
+ // ── DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23) ──────────────────────
682
+ // A rate-limit policy is a ROW in the feature's own KV store, written by
683
+ // POST/PUT/DELETE /v1/rate-limits/policies. It used to be API state OUTSIDE
684
+ // vxil.config (a journaled deviation: "one writer per datum, and the writer is
685
+ // the route"), which made a second environment non-reproducible — every tenant
686
+ // ended up with an `ensure-rate-limit-policies.ts` + a nightly assert. The
687
+ // rule is unchanged — one writer per datum — but the writer is now THE
688
+ // REPOSITORY: `policies[]` declares the set, and `vxil push` / `POST /v1/apply`
689
+ // CONVERGE the live rows onto it (upsert by `name`, via the feature's own
690
+ // routes; workers/control-plane/src/handlers/apiState.ts). Live rows the
691
+ // config does NOT declare are LEFT in place and reported as drift (deleted
692
+ // only under --allow-destructive), so adopting this block on an existing
693
+ // tenant is never a silent wipe. The item shape MIRRORS the route body
694
+ // (rate-limits-v1 core.ts PolicyBody) exactly; `limit` / `window_seconds`
695
+ // omitted = derived from `defaults` on create and NOT asserted afterwards;
696
+ // `behavior` / `algorithm` omitted = the route defaults ('block' /
697
+ // 'sliding_window') and compared as such. An ARRAY is ONE leaf (countLeaves).
698
+ export const RL_POLICY_NAME_PATTERN = '^[a-zA-Z0-9_.:-]+$';
699
+ /** GET /v1/rate-limits/policies lists at most 100 with no cursor (core.ts
700
+ * listPolicies) — a declared set past that could never be reconciled. */
701
+ export const RL_MAX_DECLARED_POLICIES = 100;
702
+ /** rate-limits-v1 core.ts createPolicy: at most 8 distinct `{var}`s. */
703
+ export const RL_KEY_TEMPLATE_MAX_VARS = 8;
704
+
705
+ export const DeclaredRlPolicySchema = Type.Object({
706
+ name: Type.String({ minLength: 1, maxLength: 100, pattern: RL_POLICY_NAME_PATTERN }),
707
+ key_template: Type.String({ minLength: 1, maxLength: 300 }),
708
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000_000 })),
709
+ window_seconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86_400 })),
710
+ behavior: Type.Optional(Type.Union([Type.Literal('block'), Type.Literal('shape')])),
711
+ algorithm: Type.Optional(Type.Union([Type.Literal('sliding_window'), Type.Literal('token_bucket')])),
712
+ });
713
+ export type DeclaredRlPolicy = Static<typeof DeclaredRlPolicySchema>;
714
+
601
715
  export const RateLimitsConfigSchema = Type.Object({
602
716
  enabled: Type.Boolean({ default: true }),
603
717
  defaults: Type.Object(
@@ -607,12 +721,12 @@ export const RateLimitsConfigSchema = Type.Object({
607
721
  },
608
722
  { default: {} },
609
723
  ),
724
+ policies: Type.Optional(Type.Array(DeclaredRlPolicySchema, { maxItems: RL_MAX_DECLARED_POLICIES })),
610
725
  });
611
- // Leaves: 3. Cap = 15. Deviation (journaled): the doc's `policies` array is
612
- // managed via the dedicated REST surface (§1 contract) in the feature's own
613
- // KV, not duplicated into the config artifact — one writer per datum. The
614
- // per-identifier overrides live the same way (an `ovr:` KV sibling per policy,
615
- // cap 50 = a code constant, not a config leaf).
726
+ // Leaves: 3 + policies (1 — an array is ONE leaf) = 4. Cap = 15.
727
+ // The per-identifier OVERRIDES stay API state (an `ovr:` KV sibling per
728
+ // policy, cap 50 = a code constant, not a config leaf): they are per-customer
729
+ // operational exceptions, not a declaration of the backend's shape.
616
730
 
617
731
  export type RateLimitsConfig = Static<typeof RateLimitsConfigSchema>;
618
732
 
@@ -682,6 +796,12 @@ export type FilesConfig = Static<typeof FilesConfigSchema>;
682
796
  // webhooks-out (wishlist feature): outbound event fan-out over the jobs delivery
683
797
  // engine. Subscriptions live in their own table (§1 contract — one writer
684
798
  // per datum); config is just the capability gate + a cap.
799
+ export const DeclaredWebhookSubscriptionSchema = Type.Object({
800
+ target_url: Type.String({ minLength: 9, maxLength: 2000 }),
801
+ event_prefixes: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 100 }), { maxItems: 20 })),
802
+ });
803
+ export type DeclaredWebhookSubscription = Static<typeof DeclaredWebhookSubscriptionSchema>;
804
+
685
805
  export const WebhooksConfigSchema = Type.Object({
686
806
  enabled: Type.Boolean({ default: true }),
687
807
  maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
@@ -702,13 +822,30 @@ export const WebhooksConfigSchema = Type.Object({
702
822
  // decision with its own abuse review.
703
823
  //
704
824
  // An Optional object bag counts as ONE leaf (the countLeaves rule).
825
+ //
826
+ // `digestMinutes: 0` is IMMEDIATE (roadmap §4.11 P0-4c, 2026-09-23): the
827
+ // pass runs every minute for the tenant and mails the `error`-level failure
828
+ // rows that landed since its last mail — at most one mail per minute, still
829
+ // to the owner accounts. 1–4 are clamped up to 5 by the reader.
705
830
  alerts: Type.Optional(Type.Object({
706
831
  enabled: Type.Boolean({ default: false }),
707
832
  minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
708
- digestMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 1440 }),
833
+ digestMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 1440 }),
709
834
  })),
835
+ // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
836
+ // subscriptions as config. Keyed by `target_url` — the only stable identity a
837
+ // subscription has (there is no name column and no update route, so a changed
838
+ // prefix set is delete+recreate, exactly what the function-trigger reconciler
839
+ // does). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
840
+ // onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
841
+ // reported (deleted only under --allow-destructive). Rows on the platform's
842
+ // signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
843
+ // — they derive from the functions manifest — and never count as undeclared.
844
+ // Item shape mirrors handlers/webhooks.ts createSubscription's body. An
845
+ // Optional ARRAY is ONE leaf.
846
+ subscriptions: Type.Optional(Type.Array(DeclaredWebhookSubscriptionSchema, { maxItems: 200 })),
710
847
  });
711
- // Leaves: 3 + alerts(1, optional bag) = 4. Cap = 15.
848
+ // Leaves: 3 + alerts(1, optional bag) + subscriptions(1, array) = 5. Cap = 15.
712
849
 
713
850
  export type WebhooksConfig = Static<typeof WebhooksConfigSchema>;
714
851
 
@@ -732,8 +869,9 @@ export const CmsConfigSchema = Type.Object({
732
869
  draftPublish: Type.Boolean({ default: true }),
733
870
  // cms end-user default-deny fail-safe (path-to-100 §3.2, Feature B). When ON,
734
871
  // a VERIFIED end-user key (owner-scope mode) is DENIED access to any
735
- // collection that declares no owner_field — 404 on read, 403 on write —
736
- // instead of the default tenant-wide-shared behavior. Server-caller mode is a
872
+ // collection that declares no owner_field — `403 server_only` on read AND
873
+ // write (finding 29, 2026-09-20; reads used to be a 404) — instead of the
874
+ // default tenant-wide-shared behavior. Server-caller mode is a
737
875
  // byte-for-byte no-op. Default OFF preserves today's shared semantics
738
876
  // (owner.int.test.ts's shared-collection invariant). A collection that DOES
739
877
  // declare an ownerField is unaffected. ONE boolean leaf.
@@ -1133,6 +1271,16 @@ export type VectorSearchConfig = Static<typeof VectorSearchConfigSchema>;
1133
1271
  // intelligence (prompt TEMPLATES are config-as-code, stored/rendered but never
1134
1272
  // authored). 'mock' is the deterministic default until a BYO key is provisioned;
1135
1273
  // the real providers route via tenant_secrets keyRefs.
1274
+ export const AI_TEMPLATE_NAME_PATTERN = '^[a-zA-Z0-9_.\\-]+$';
1275
+ export const AI_MAX_DECLARED_TEMPLATES = 50;
1276
+ export const DeclaredAiTemplateSchema = Type.Object({
1277
+ template: Type.String({ minLength: 1, maxLength: 128, pattern: AI_TEMPLATE_NAME_PATTERN }),
1278
+ user: Type.String({ minLength: 1, maxLength: 64_000 }),
1279
+ system: Type.Optional(Type.String({ maxLength: 32_000 })),
1280
+ schema: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
1281
+ });
1282
+ export type DeclaredAiTemplate = Static<typeof DeclaredAiTemplateSchema>;
1283
+
1136
1284
  export const AiConfigSchema = Type.Object({
1137
1285
  enabled: Type.Boolean({ default: true }),
1138
1286
  // 'azure' uses the OpenAI adapter shape (azure-openai compatible; pair it with
@@ -1175,19 +1323,38 @@ export const AiConfigSchema = Type.Object({
1175
1323
  tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
1176
1324
  consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
1177
1325
  }, { default: {} }),
1178
- streaming: Type.Object({
1326
+ // `streaming` became an OPTIONAL bag (3 leaves → 1, the M21 `retry` trick)
1327
+ // on 2026-09-23 to fund the declared `templates[]` below. It KEEPS
1328
+ // `default: {}`, so Value.Default still materializes
1329
+ // streaming.{enabled,replayBufferFrames,flushMs} into every persisted
1330
+ // manifest exactly as before (zero behavioral delta — ai-v1 declares its own
1331
+ // schema mirror plain and reads the materialized leaves); only the TS type
1332
+ // here is optional.
1333
+ streaming: Type.Optional(Type.Object({
1179
1334
  enabled: Type.Boolean({ default: true }),
1180
1335
  replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), // §2a ring-buffer depth
1181
1336
  flushMs: Type.Integer({ default: 50, minimum: 0 }), // §2a/#148 token→frame coalesce window
1182
- }, { default: {} }),
1337
+ }, { default: {} })),
1338
+ // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's stored
1339
+ // prompt templates as config. vxil STORES + versions, never authors (ai.md
1340
+ // §0) — declaring them here changes WHO writes the row (the repository, via
1341
+ // `vxil push`), not what vxil does with it. Converged by CONTENT: the
1342
+ // control-plane hashes each declared entry (@vxil/runtime
1343
+ // aiTemplateContentSha256) against the `content_sha256` GET /v1/ai/templates
1344
+ // stamps on the latest version, and POSTs a new version ONLY when the content
1345
+ // differs — so a push is idempotent and versions stay monotonic per name.
1346
+ // Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
1347
+ // name). Stored templates the config does not declare are reported (there is
1348
+ // no delete route — they are never removed). Bounded to 50 entries: the
1349
+ // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
1350
+ templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
1183
1351
  });
1184
1352
  // Leaves: enabled(1), defaultProvider(2), providers.{openai,anthropic,gemini}KeyRef(+3=5),
1185
1353
  // providers.compat(+1=6 — Optional object → ONE leaf),
1186
1354
  // defaults.{model,maxTokens,temperature}(+3=9), cache.ttlSeconds(10),
1187
1355
  // limits.{tokensPerUserPerDay,consumeCredits}(+2=12),
1188
- // streaming.{enabled,replayBufferFrames,flushMs}(+3=15). countLeaves → 15. Cap = 15.
1189
- // ZERO headroom: any future ai knob must ride inside compat (free) or another
1190
- // optional object.
1356
+ // streaming(+1=13 — Optional bag since 2026-09-23, was 3 flat leaves),
1357
+ // templates(+1=14 — array). countLeaves → 14. Cap = 15. ONE leaf of headroom.
1191
1358
 
1192
1359
  export type AiConfig = Static<typeof AiConfigSchema>;
1193
1360
 
@@ -1369,7 +1536,7 @@ export const PaymentsConfigSchema = Type.Object({
1369
1536
  // Opt-in period-end enforcement (money-path operations wave, decision D2
1370
1537
  // option a). ABSENT (the default) = today's behaviour: a subscription whose
1371
1538
  // current_period_end passed with no provider event stays entitled forever
1372
- // (the provider is the only clock). PRESENT = the hourly reconcile sweep
1539
+ // (the provider is the only clock). PRESENT = the nightly reconcile sweep
1373
1540
  // flips an `active`/`trialing` row whose current_period_end + slackHours
1374
1541
  // has passed with no renewal to status 'lapsed' (+ refold, +
1375
1542
  // payments.subscription.lapsed); a later provider renewal event revives it
@@ -1821,6 +1988,89 @@ function publicHttpsUrlError(url: string): string | null {
1821
1988
  * validation below and the control-plane deploy clamp. */
1822
1989
  export const DENY_FUNCTION_SCOPES = new Set(['admin', '*', 'features:write', 'functions:write', 'secrets:write']);
1823
1990
 
1991
+ // ── declared API state — config-write validators (roadmap §4.11 P0-3) ────────
1992
+ /** `{var}` names of a rate-limit key template (rate-limits-v1 core.ts
1993
+ * templateVars parity). */
1994
+ export function rlTemplateVars(template: string): string[] {
1995
+ return [...template.matchAll(/\{([a-zA-Z0-9_]+)\}/g)].map((m) => m[1]!);
1996
+ }
1997
+
1998
+ export function validateDeclaredRlPolicies(policies: readonly DeclaredRlPolicy[] | undefined): string[] {
1999
+ const errs: string[] = [];
2000
+ const seen = new Set<string>();
2001
+ (policies ?? []).forEach((p, i) => {
2002
+ if (seen.has(p.name)) errs.push(`/policies/${i}/name: duplicate policy name '${p.name}' (policies converge by name, so it must be unique)`);
2003
+ seen.add(p.name);
2004
+ if (new Set(rlTemplateVars(p.key_template)).size > RL_KEY_TEMPLATE_MAX_VARS) {
2005
+ errs.push(`/policies/${i}/key_template: too many variables (max ${RL_KEY_TEMPLATE_MAX_VARS})`);
2006
+ }
2007
+ });
2008
+ return errs;
2009
+ }
2010
+
2011
+ export function validateDeclaredWebhookSubscriptions(
2012
+ subscriptions: readonly DeclaredWebhookSubscription[] | undefined, maxSubscriptions: number | undefined,
2013
+ ): string[] {
2014
+ const errs: string[] = [];
2015
+ const seen = new Set<string>();
2016
+ const subs = subscriptions ?? [];
2017
+ if (maxSubscriptions !== undefined && subs.length > maxSubscriptions) {
2018
+ errs.push(`/subscriptions: ${subs.length} declared but maxSubscriptions is ${maxSubscriptions} (raise maxSubscriptions or declare fewer)`);
2019
+ }
2020
+ subs.forEach((sub, i) => {
2021
+ if (seen.has(sub.target_url)) errs.push(`/subscriptions/${i}/target_url: duplicate target_url (subscriptions converge by target_url, so it must be unique)`);
2022
+ seen.add(sub.target_url);
2023
+ const urlErr = publicHttpsUrlError(sub.target_url);
2024
+ if (urlErr) errs.push(`/subscriptions/${i}/target_url: ${urlErr}`);
2025
+ else if (isFnTriggerSubscriptionUrl(sub.target_url)) {
2026
+ errs.push(`/subscriptions/${i}/target_url: a function-delivery lane is never declared here — declare the trigger on the function (functions.<name>.trigger) and the platform reconciles its subscription`);
2027
+ }
2028
+ });
2029
+ return errs;
2030
+ }
2031
+
2032
+ /** The stored-template `schema` bounds — MIRRORED from @vxil/runtime
2033
+ * (AI_TEMPLATE_SCHEMA_MAX_BYTES / AI_TEMPLATE_SCHEMA_MAX_DEPTH; this package is
2034
+ * typebox-only). ai-v1 putTemplate refuses the same numbers at the route, so a
2035
+ * declared entry that passes here is never a 422 at converge. */
2036
+ export const AI_TEMPLATE_SCHEMA_MAX_BYTES = 32_000;
2037
+ export const AI_TEMPLATE_SCHEMA_MAX_DEPTH = 64;
2038
+
2039
+ /** Nesting depth of a JSON value, iteratively (`{}` = 1, scalar = 0). */
2040
+ function jsonDepthOf(v: unknown): number {
2041
+ let max = 0;
2042
+ const stack: Array<{ v: unknown; d: number }> = [{ v, d: 0 }];
2043
+ while (stack.length) {
2044
+ const { v: cur, d } = stack.pop()!;
2045
+ if (cur === null || typeof cur !== 'object') continue;
2046
+ const depth = d + 1;
2047
+ if (depth > max) max = depth;
2048
+ for (const c of Array.isArray(cur) ? cur : Object.values(cur as Record<string, unknown>)) {
2049
+ if (c !== null && typeof c === 'object') stack.push({ v: c, d: depth });
2050
+ }
2051
+ }
2052
+ return max;
2053
+ }
2054
+
2055
+ export function validateDeclaredAiTemplates(templates: readonly DeclaredAiTemplate[] | undefined): string[] {
2056
+ const errs: string[] = [];
2057
+ const seen = new Set<string>();
2058
+ (templates ?? []).forEach((t, i) => {
2059
+ if (seen.has(t.template)) errs.push(`/templates/${i}/template: duplicate template name '${t.template}' (templates converge by name, so it must be unique)`);
2060
+ seen.add(t.template);
2061
+ if (t.schema === undefined) return;
2062
+ if (typeof t.schema !== 'object' || t.schema === null || Array.isArray(t.schema)) {
2063
+ errs.push(`/templates/${i}/schema: must be a JSON-Schema object`);
2064
+ return;
2065
+ }
2066
+ const bytes = JSON.stringify(t.schema).length;
2067
+ if (bytes > AI_TEMPLATE_SCHEMA_MAX_BYTES) errs.push(`/templates/${i}/schema: too large (${bytes} > ${AI_TEMPLATE_SCHEMA_MAX_BYTES} bytes serialized)`);
2068
+ const depth = jsonDepthOf(t.schema);
2069
+ if (depth > AI_TEMPLATE_SCHEMA_MAX_DEPTH) errs.push(`/templates/${i}/schema: nests too deep (${depth} > ${AI_TEMPLATE_SCHEMA_MAX_DEPTH} levels)`);
2070
+ });
2071
+ return errs;
2072
+ }
2073
+
1824
2074
  export function validateFeatureConfig(feature: string, raw: unknown): ConfigValidation {
1825
2075
  const schema = FEATURE_SCHEMAS[feature];
1826
2076
  if (!schema) {
@@ -1852,6 +2102,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1852
2102
  const v = withDefaults as {
1853
2103
  provider?: string;
1854
2104
  resendApiKeyRef?: string;
2105
+ ses?: { region?: string; accessKeyIdRef?: string; secretAccessKeyRef?: string };
1855
2106
  templates?: {
1856
2107
  allowOverride?: boolean;
1857
2108
  overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
@@ -1863,6 +2114,16 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
1863
2114
  errors: ["/resendApiKeyRef: required when provider is 'resend' (omit it for provider 'mock')"],
1864
2115
  };
1865
2116
  }
2117
+ // Same posture for the second real provider: `ses` without its bag is a
2118
+ // config the worker could only fail at send time, so refuse it at push.
2119
+ // (The bag's three members are each schema-required once the bag is
2120
+ // present, so a partial bag already failed TypeBox above.)
2121
+ if (v.provider === 'ses' && !v.ses) {
2122
+ return {
2123
+ ok: false,
2124
+ errors: ["/ses: required when provider is 'ses' — { region, accessKeyIdRef, secretAccessKeyRef } (omit it for provider 'mock')"],
2125
+ };
2126
+ }
1866
2127
  const overrideErrs = validateNotificationOverrides(v.templates);
1867
2128
  if (overrideErrs.length) return { ok: false, errors: overrideErrs.slice(0, 10) };
1868
2129
  }
@@ -2014,22 +2275,56 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
2014
2275
  // write time (pure mirror of @vxil/runtime assertPublicHttpsUrl; ai-v1 re-runs
2015
2276
  // the authoritative runtime guard at use time — the mcp customTools idiom).
2016
2277
  if (feature === 'ai') {
2017
- const v = withDefaults as { providers?: { compat?: { openaiBaseUrl?: string } } };
2278
+ const v = withDefaults as {
2279
+ providers?: { compat?: { openaiBaseUrl?: string } };
2280
+ templates?: DeclaredAiTemplate[];
2281
+ };
2018
2282
  const baseUrl = v.providers?.compat?.openaiBaseUrl;
2019
2283
  if (baseUrl) {
2020
2284
  const urlErr = publicHttpsUrlError(baseUrl);
2021
2285
  if (urlErr) return { ok: false, errors: [`/providers/compat/openaiBaseUrl: ${urlErr}`] };
2022
2286
  }
2287
+ const tplErrs = validateDeclaredAiTemplates(v.templates);
2288
+ if (tplErrs.length) return { ok: false, errors: tplErrs.slice(0, 10) };
2289
+ }
2290
+ // Cross-field rules: declared API state (roadmap §4.11 P0-3). Each list is
2291
+ // converged by NAME/URL, so a duplicate key is ambiguous and refused at push;
2292
+ // the per-item rules mirror the feature route's own validation so a declared
2293
+ // entry can never be one the converge would 422 on.
2294
+ if (feature === 'rate-limits') {
2295
+ const v = withDefaults as { policies?: DeclaredRlPolicy[] };
2296
+ const errs = validateDeclaredRlPolicies(v.policies);
2297
+ if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
2298
+ }
2299
+ if (feature === 'webhooks') {
2300
+ const v = withDefaults as { maxSubscriptions?: number; subscriptions?: DeclaredWebhookSubscription[] };
2301
+ const errs = validateDeclaredWebhookSubscriptions(v.subscriptions, v.maxSubscriptions);
2302
+ if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
2023
2303
  }
2024
2304
  // Cross-field rule: auth otp.testRecipients (F4-29) — every entry must be an
2025
2305
  // email, an `*@domain` glob, or a +E.164 number; anything else would never
2026
2306
  // match and silently do nothing.
2027
2307
  if (feature === 'auth') {
2028
- const v = withDefaults as { otp?: { testRecipients?: string[] } };
2308
+ const v = withDefaults as {
2309
+ otp?: { testRecipients?: string[] };
2310
+ security?: { allowedRedirectOrigins?: string[] };
2311
+ };
2029
2312
  const bad = (v.otp?.testRecipients ?? []).filter((e) => !isTestRecipientPattern(e));
2030
2313
  if (bad.length) {
2031
2314
  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`) };
2032
2315
  }
2316
+ // security.allowedRedirectOrigins: the SAME scheme predicate auth-v1 applies
2317
+ // at runtime (redirectSchemeAllowed) — https anywhere, http on loopback only.
2318
+ // The TypeBox pattern above already refused the shape; this re-check is what
2319
+ // keeps the validator and the worker on one rule (parity-tested).
2320
+ const badOrigin = (v.security?.allowedRedirectOrigins ?? []).filter((e) => !isAllowedRedirectOriginEntry(e));
2321
+ if (badOrigin.length) {
2322
+ return {
2323
+ ok: false,
2324
+ errors: badOrigin.slice(0, 10).map((e) =>
2325
+ `/security/allowedRedirectOrigins: '${e}' must be an https:// origin, or http://localhost[:port] / http://127.0.0.1[:port] for local development (plain http on any other host is refused)`),
2326
+ };
2327
+ }
2033
2328
  }
2034
2329
  // Cross-field rule: functions. Reject privilege-escalating scopes and malformed
2035
2330
  // bindings at config-write time (the deploy pipeline additionally asserts the