@vxil/feature-configs 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,12 +7,20 @@ import { FormatRegistry, OptionalKind, Type } from '@sinclair/typebox';
7
7
  import { Value } from '@sinclair/typebox/value';
8
8
  import { validateHooksConfig } from './hooks.js';
9
9
  import { validateCdcConfig, validateReadModelsConfig } from './readmodels.js';
10
+ import { isFnTriggerSubscriptionUrl } from './apiState.js';
10
11
  // Re-export the CMS lifecycle-hook engine so feature workers (cms-v1, runtime
11
12
  // eval) and the control plane (config-time validation) share one definition.
12
13
  export * from './hooks.js';
13
14
  // Re-export the cms-rel read-model/cdc config gates (the pure-mirror split:
14
15
  // grammar validated here at config-write; field existence at runtime).
15
16
  export * from './readmodels.js';
17
+ // Re-export the DECLARED API STATE planner (roadmap §4.11 P0-3): pure
18
+ // declared-vs-live reconciliation, shared by the control-plane apply path and
19
+ // the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
20
+ export * from './apiState.js';
21
+ // Key-order-insensitive JSON: the one declared-vs-stored manifest compare
22
+ // (control-plane shallowDiff, CLI diffManifest).
23
+ export * from './canonicalJson.js';
16
24
  // TypeBox validates `format:` only for registered formats — register the ones
17
25
  // our schemas use (pragmatic RFC-lite email check; providers do the real one).
18
26
  if (!FormatRegistry.Has('email')) {
@@ -140,6 +148,12 @@ export function validateNotificationOverrides(templates) {
140
148
  }
141
149
  return errs;
142
150
  }
151
+ /** AWS region grammar for `notifications.ses.region` (`us-east-1`,
152
+ * `eu-central-1`, `ap-southeast-2`, `us-gov-west-1`, …): two-letter partition,
153
+ * one or more lowercase words, a single digit. Pinned as a pattern rather than
154
+ * a list so a newly launched SES region needs no vxil release; a typo still
155
+ * fails at `vxil push` (TypeBox), never as a DNS error on the first send. */
156
+ export const SES_REGION_PATTERN = '^[a-z]{2}(-[a-z]+)+-\\d$';
143
157
  export const NotificationsConfigSchema = Type.Object({
144
158
  enabled: Type.Boolean({ default: true }),
145
159
  fromEmail: Type.String({ format: 'email' }),
@@ -156,10 +170,27 @@ export const NotificationsConfigSchema = Type.Object({
156
170
  // to the tenant so a signed event for tenant A can never validate at tenant
157
171
  // B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
158
172
  webhookSecretRef: Type.Optional(Type.String()),
159
- // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP.
160
- provider: Type.Union([Type.Literal('resend'), Type.Literal('mock')], {
173
+ // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP;
174
+ // 'ses' (2026-09-19) is the second real provider — Amazon SES v2, BYO IAM
175
+ // keys, same delivery/suppression/preference pipeline as resend.
176
+ provider: Type.Union([Type.Literal('resend'), Type.Literal('ses'), Type.Literal('mock')], {
161
177
  default: 'resend',
162
178
  }),
179
+ // Amazon SES credentials (2026-09-19). ONE optional bag (= ONE leaf per the
180
+ // cap rule — funded by folding `rateLimit` into an Optional bag below);
181
+ // NO `default: {}` so an absent bag stays absent (the auth `security`
182
+ // precedent). Both refs are `secret:<name>` POINTERS into
183
+ // public.tenant_secrets (feature='notifications', KEK_NOTIFICATIONS) —
184
+ // exactly how resendApiKeyRef resolves; the region is plain config (not a
185
+ // secret) and is pattern-pinned to the AWS region grammar so a typo fails
186
+ // at `vxil push` instead of as a DNS error on the first send. Required
187
+ // (all three) when provider === 'ses' — the cross-field check in
188
+ // validateFeatureConfig, mirroring the resend posture.
189
+ ses: Type.Optional(Type.Object({
190
+ region: Type.String({ pattern: SES_REGION_PATTERN, maxLength: 32 }),
191
+ accessKeyIdRef: Type.String({ minLength: 1 }),
192
+ secretAccessKeyRef: Type.String({ minLength: 1 }),
193
+ })),
163
194
  defaultLocale: Type.String({ default: 'en-US' }),
164
195
  // nested objects carry `default: {}` so Value.Default can materialize them
165
196
  // and then recurse into the leaf defaults.
@@ -176,10 +207,16 @@ export const NotificationsConfigSchema = Type.Object({
176
207
  }),
177
208
  }, { default: {} })),
178
209
  suppression: Type.Object({ softBounceThreshold: Type.Integer({ default: 3 }) }, { default: {} }),
179
- rateLimit: Type.Object({
210
+ // `rateLimit` became an OPTIONAL bag (2 leaves → 1, the M21 `retry` trick)
211
+ // on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
212
+ // `default: {}`, so Value.Default still materializes
213
+ // rateLimit.{perDay,perTenantSec} into every persisted manifest exactly as
214
+ // before (zero behavioral delta); only the TS type is now optional (the
215
+ // worker reads via core.ts `rateLimitOf()`'s fallback).
216
+ rateLimit: Type.Optional(Type.Object({
180
217
  perDay: Type.Integer({ default: 100000 }),
181
218
  perTenantSec: Type.Integer({ default: 50 }),
182
- }, { default: {} }),
219
+ }, { default: {} })),
183
220
  // `templates` became an OPTIONAL bag (1 leaf, the same M21 trick `retry`
184
221
  // uses) to fund the D4 per-locale `overrides` map WITHOUT moving the count:
185
222
  // countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
@@ -342,6 +379,45 @@ const OidcProviderSchema = Type.Object({
342
379
  // (issuer, sub) anchor; a matching email that is not yet linked → 409.
343
380
  autoLink: Type.Optional(Type.Boolean()),
344
381
  });
382
+ // ── redirect / return-URL scheme rule — ONE predicate, two consumers ─────────
383
+ // `security.allowedRedirectOrigins` (below) and auth-v1's runtime shape guard
384
+ // on every caller-supplied `redirect_url` / `redirect_uri` (core.ts
385
+ // `isAllowedRedirectUrl`) must agree on WHICH schemes a sign-in link may be
386
+ // delivered to, or a tenant could allow-list an origin the worker then refuses
387
+ // (or the reverse). The rule (cvskit gap 7, 2026-09-19): `https://` on any
388
+ // host, plus PLAIN `http://` ONLY on the two loopback names — `localhost` and
389
+ // `127.0.0.1`, any port — so a local dev server can complete a magic link
390
+ // without a staging origin. Any other `http://` (a LAN host, `*.local`, a
391
+ // `localhost` sub-label like `app.localhost`, `[::1]`) stays refused: the
392
+ // token rides the link in the clear, and only a loopback name is guaranteed
393
+ // never to leave the developer's machine. The TypeBox `pattern` is the coarse
394
+ // shape check the schema needs; `validateFeatureConfig` re-checks every entry
395
+ // with THIS predicate, and auth-v1 calls it on the parsed URL — the parity test
396
+ // in index.test.ts / redirectRule.test.ts holds the two together. The CLI's
397
+ // production promotion gate still refuses any `http://` origin on a
398
+ // production push (cli-sdk.md), so a loopback entry is a DEV-tenant affair.
399
+ export const REDIRECT_ORIGIN_PATTERN = '^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$';
400
+ /** THE scheme rule for a redirect/return URL: https anywhere, http on loopback
401
+ * only. Takes the parsed URL (or any {protocol, hostname} pair) so the config
402
+ * validator and the worker's runtime guard call the identical predicate. */
403
+ export function redirectSchemeAllowed(u) {
404
+ if (u.protocol === 'https:')
405
+ return true;
406
+ if (u.protocol !== 'http:')
407
+ return false;
408
+ const host = u.hostname.toLowerCase();
409
+ return host === 'localhost' || host === '127.0.0.1';
410
+ }
411
+ /** An allow-list ENTRY is a bare origin that parses and passes the scheme
412
+ * rule. Used by validateFeatureConfig (auth) on top of the coarse pattern. */
413
+ export function isAllowedRedirectOriginEntry(entry) {
414
+ try {
415
+ return redirectSchemeAllowed(new URL(entry));
416
+ }
417
+ catch {
418
+ return false;
419
+ }
420
+ }
345
421
  export const AuthConfigSchema = Type.Object({
346
422
  enabled: Type.Boolean({ default: true }),
347
423
  // D3 (auth wave 2026-09-10): the six method toggles collapsed into ONE
@@ -486,6 +562,9 @@ export const AuthConfigSchema = Type.Object({
486
562
  // • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
487
563
  // redirect/return URL (magic link, password reset, email verification,
488
564
  // OAuth start) must match one origin exactly → 422 redirect_not_allowed.
565
+ // Entries are `https://host[:port]`, or `http://localhost[:port]` /
566
+ // `http://127.0.0.1[:port]` for local development (REDIRECT_ORIGIN_PATTERN
567
+ // + redirectSchemeAllowed above — the one rule auth-v1 enforces too).
489
568
  security: Type.Optional(Type.Object({
490
569
  lockout: Type.Optional(Type.Object({
491
570
  maxFailures: Type.Integer({ default: 10, minimum: 3, maximum: 100 }),
@@ -494,15 +573,47 @@ export const AuthConfigSchema = Type.Object({
494
573
  })),
495
574
  breachedPasswords: Type.Boolean({ default: false }),
496
575
  captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
497
- allowedRedirectOrigins: Type.Optional(Type.Array(Type.String({ minLength: 8, maxLength: 253, pattern: '^https://[^/?#\\s]+$' }), { maxItems: 32 })),
576
+ allowedRedirectOrigins: Type.Optional(Type.Array(Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }), { maxItems: 32 })),
498
577
  })),
499
578
  });
579
+ // ── DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23) ──────────────────────
580
+ // A rate-limit policy is a ROW in the feature's own KV store, written by
581
+ // POST/PUT/DELETE /v1/rate-limits/policies. It used to be API state OUTSIDE
582
+ // vxil.config (a journaled deviation: "one writer per datum, and the writer is
583
+ // the route"), which made a second environment non-reproducible — every tenant
584
+ // ended up with an `ensure-rate-limit-policies.ts` + a nightly assert. The
585
+ // rule is unchanged — one writer per datum — but the writer is now THE
586
+ // REPOSITORY: `policies[]` declares the set, and `vxil push` / `POST /v1/apply`
587
+ // CONVERGE the live rows onto it (upsert by `name`, via the feature's own
588
+ // routes; workers/control-plane/src/handlers/apiState.ts). Live rows the
589
+ // config does NOT declare are LEFT in place and reported as drift (deleted
590
+ // only under --allow-destructive), so adopting this block on an existing
591
+ // tenant is never a silent wipe. The item shape MIRRORS the route body
592
+ // (rate-limits-v1 core.ts PolicyBody) exactly; `limit` / `window_seconds`
593
+ // omitted = derived from `defaults` on create and NOT asserted afterwards;
594
+ // `behavior` / `algorithm` omitted = the route defaults ('block' /
595
+ // 'sliding_window') and compared as such. An ARRAY is ONE leaf (countLeaves).
596
+ export const RL_POLICY_NAME_PATTERN = '^[a-zA-Z0-9_.:-]+$';
597
+ /** GET /v1/rate-limits/policies lists at most 100 with no cursor (core.ts
598
+ * listPolicies) — a declared set past that could never be reconciled. */
599
+ export const RL_MAX_DECLARED_POLICIES = 100;
600
+ /** rate-limits-v1 core.ts createPolicy: at most 8 distinct `{var}`s. */
601
+ export const RL_KEY_TEMPLATE_MAX_VARS = 8;
602
+ export const DeclaredRlPolicySchema = Type.Object({
603
+ name: Type.String({ minLength: 1, maxLength: 100, pattern: RL_POLICY_NAME_PATTERN }),
604
+ key_template: Type.String({ minLength: 1, maxLength: 300 }),
605
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000_000 })),
606
+ window_seconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 86_400 })),
607
+ behavior: Type.Optional(Type.Union([Type.Literal('block'), Type.Literal('shape')])),
608
+ algorithm: Type.Optional(Type.Union([Type.Literal('sliding_window'), Type.Literal('token_bucket')])),
609
+ });
500
610
  export const RateLimitsConfigSchema = Type.Object({
501
611
  enabled: Type.Boolean({ default: true }),
502
612
  defaults: Type.Object({
503
613
  perTenantSec: Type.Integer({ default: 100, minimum: 1, maximum: 10000 }),
504
614
  burstSize: Type.Integer({ default: 200, minimum: 1, maximum: 20000 }),
505
615
  }, { default: {} }),
616
+ policies: Type.Optional(Type.Array(DeclaredRlPolicySchema, { maxItems: RL_MAX_DECLARED_POLICIES })),
506
617
  });
507
618
  export const FilesConfigSchema = Type.Object({
508
619
  enabled: Type.Boolean({ default: true }),
@@ -556,6 +667,10 @@ export const FilesConfigSchema = Type.Object({
556
667
  // webhooks-out (wishlist feature): outbound event fan-out over the jobs delivery
557
668
  // engine. Subscriptions live in their own table (§1 contract — one writer
558
669
  // per datum); config is just the capability gate + a cap.
670
+ export const DeclaredWebhookSubscriptionSchema = Type.Object({
671
+ target_url: Type.String({ minLength: 9, maxLength: 2000 }),
672
+ event_prefixes: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 100 }), { maxItems: 20 })),
673
+ });
559
674
  export const WebhooksConfigSchema = Type.Object({
560
675
  enabled: Type.Boolean({ default: true }),
561
676
  maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
@@ -576,11 +691,28 @@ export const WebhooksConfigSchema = Type.Object({
576
691
  // decision with its own abuse review.
577
692
  //
578
693
  // An Optional object bag counts as ONE leaf (the countLeaves rule).
694
+ //
695
+ // `digestMinutes: 0` is IMMEDIATE (roadmap §4.11 P0-4c, 2026-09-23): the
696
+ // pass runs every minute for the tenant and mails the `error`-level failure
697
+ // rows that landed since its last mail — at most one mail per minute, still
698
+ // to the owner accounts. 1–4 are clamped up to 5 by the reader.
579
699
  alerts: Type.Optional(Type.Object({
580
700
  enabled: Type.Boolean({ default: false }),
581
701
  minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
582
- digestMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 1440 }),
702
+ digestMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 1440 }),
583
703
  })),
704
+ // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's OUTBOUND
705
+ // subscriptions as config. Keyed by `target_url` — the only stable identity a
706
+ // subscription has (there is no name column and no update route, so a changed
707
+ // prefix set is delete+recreate, exactly what the function-trigger reconciler
708
+ // does). `vxil push` / `POST /v1/apply` converge public.webhook_subscriptions
709
+ // onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
710
+ // reported (deleted only under --allow-destructive). Rows on the platform's
711
+ // signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
712
+ // — they derive from the functions manifest — and never count as undeclared.
713
+ // Item shape mirrors handlers/webhooks.ts createSubscription's body. An
714
+ // Optional ARRAY is ONE leaf.
715
+ subscriptions: Type.Optional(Type.Array(DeclaredWebhookSubscriptionSchema, { maxItems: 200 })),
584
716
  });
585
717
  // comments feature (wishlist): threaded discussion on tenant-defined topics.
586
718
  export const CommentsConfigSchema = Type.Object({
@@ -598,8 +730,9 @@ export const CmsConfigSchema = Type.Object({
598
730
  draftPublish: Type.Boolean({ default: true }),
599
731
  // cms end-user default-deny fail-safe (path-to-100 §3.2, Feature B). When ON,
600
732
  // a VERIFIED end-user key (owner-scope mode) is DENIED access to any
601
- // collection that declares no owner_field — 404 on read, 403 on write —
602
- // instead of the default tenant-wide-shared behavior. Server-caller mode is a
733
+ // collection that declares no owner_field — `403 server_only` on read AND
734
+ // write (finding 29, 2026-09-20; reads used to be a 404) — instead of the
735
+ // default tenant-wide-shared behavior. Server-caller mode is a
603
736
  // byte-for-byte no-op. Default OFF preserves today's shared semantics
604
737
  // (owner.int.test.ts's shared-collection invariant). A collection that DOES
605
738
  // declare an ownerField is unaffected. ONE boolean leaf.
@@ -862,6 +995,14 @@ export const VectorSearchConfigSchema = Type.Object({
862
995
  // intelligence (prompt TEMPLATES are config-as-code, stored/rendered but never
863
996
  // authored). 'mock' is the deterministic default until a BYO key is provisioned;
864
997
  // the real providers route via tenant_secrets keyRefs.
998
+ export const AI_TEMPLATE_NAME_PATTERN = '^[a-zA-Z0-9_.\\-]+$';
999
+ export const AI_MAX_DECLARED_TEMPLATES = 50;
1000
+ export const DeclaredAiTemplateSchema = Type.Object({
1001
+ template: Type.String({ minLength: 1, maxLength: 128, pattern: AI_TEMPLATE_NAME_PATTERN }),
1002
+ user: Type.String({ minLength: 1, maxLength: 64_000 }),
1003
+ system: Type.Optional(Type.String({ maxLength: 32_000 })),
1004
+ schema: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
1005
+ });
865
1006
  export const AiConfigSchema = Type.Object({
866
1007
  enabled: Type.Boolean({ default: true }),
867
1008
  // 'azure' uses the OpenAI adapter shape (azure-openai compatible; pair it with
@@ -901,11 +1042,31 @@ export const AiConfigSchema = Type.Object({
901
1042
  tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
902
1043
  consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
903
1044
  }, { default: {} }),
904
- streaming: Type.Object({
1045
+ // `streaming` became an OPTIONAL bag (3 leaves → 1, the M21 `retry` trick)
1046
+ // on 2026-09-23 to fund the declared `templates[]` below. It KEEPS
1047
+ // `default: {}`, so Value.Default still materializes
1048
+ // streaming.{enabled,replayBufferFrames,flushMs} into every persisted
1049
+ // manifest exactly as before (zero behavioral delta — ai-v1 declares its own
1050
+ // schema mirror plain and reads the materialized leaves); only the TS type
1051
+ // here is optional.
1052
+ streaming: Type.Optional(Type.Object({
905
1053
  enabled: Type.Boolean({ default: true }),
906
1054
  replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), // §2a ring-buffer depth
907
1055
  flushMs: Type.Integer({ default: 50, minimum: 0 }), // §2a/#148 token→frame coalesce window
908
- }, { default: {} }),
1056
+ }, { default: {} })),
1057
+ // DECLARED API STATE (roadmap §4.11 P0-3, 2026-09-23): the tenant's stored
1058
+ // prompt templates as config. vxil STORES + versions, never authors (ai.md
1059
+ // §0) — declaring them here changes WHO writes the row (the repository, via
1060
+ // `vxil push`), not what vxil does with it. Converged by CONTENT: the
1061
+ // control-plane hashes each declared entry (@vxil/runtime
1062
+ // aiTemplateContentSha256) against the `content_sha256` GET /v1/ai/templates
1063
+ // stamps on the latest version, and POSTs a new version ONLY when the content
1064
+ // differs — so a push is idempotent and versions stay monotonic per name.
1065
+ // Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
1066
+ // name). Stored templates the config does not declare are reported (there is
1067
+ // no delete route — they are never removed). Bounded to 50 entries: the
1068
+ // manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
1069
+ templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
909
1070
  });
910
1071
  // rag feature (features/rag.md §2). Re-declared to match workers/rag-v1/src/config.ts's
911
1072
  // exported RagConfigSchema. rag owns the PIPELINE knobs only — retrieval budget,
@@ -1436,6 +1597,91 @@ function publicHttpsUrlError(url) {
1436
1597
  * stays ALLOWED (fn→fn composition). Source of truth for both the config-write
1437
1598
  * validation below and the control-plane deploy clamp. */
1438
1599
  export const DENY_FUNCTION_SCOPES = new Set(['admin', '*', 'features:write', 'functions:write', 'secrets:write']);
1600
+ // ── declared API state — config-write validators (roadmap §4.11 P0-3) ────────
1601
+ /** `{var}` names of a rate-limit key template (rate-limits-v1 core.ts
1602
+ * templateVars parity). */
1603
+ export function rlTemplateVars(template) {
1604
+ return [...template.matchAll(/\{([a-zA-Z0-9_]+)\}/g)].map((m) => m[1]);
1605
+ }
1606
+ export function validateDeclaredRlPolicies(policies) {
1607
+ const errs = [];
1608
+ const seen = new Set();
1609
+ (policies ?? []).forEach((p, i) => {
1610
+ if (seen.has(p.name))
1611
+ errs.push(`/policies/${i}/name: duplicate policy name '${p.name}' (policies converge by name, so it must be unique)`);
1612
+ seen.add(p.name);
1613
+ if (new Set(rlTemplateVars(p.key_template)).size > RL_KEY_TEMPLATE_MAX_VARS) {
1614
+ errs.push(`/policies/${i}/key_template: too many variables (max ${RL_KEY_TEMPLATE_MAX_VARS})`);
1615
+ }
1616
+ });
1617
+ return errs;
1618
+ }
1619
+ export function validateDeclaredWebhookSubscriptions(subscriptions, maxSubscriptions) {
1620
+ const errs = [];
1621
+ const seen = new Set();
1622
+ const subs = subscriptions ?? [];
1623
+ if (maxSubscriptions !== undefined && subs.length > maxSubscriptions) {
1624
+ errs.push(`/subscriptions: ${subs.length} declared but maxSubscriptions is ${maxSubscriptions} (raise maxSubscriptions or declare fewer)`);
1625
+ }
1626
+ subs.forEach((sub, i) => {
1627
+ if (seen.has(sub.target_url))
1628
+ errs.push(`/subscriptions/${i}/target_url: duplicate target_url (subscriptions converge by target_url, so it must be unique)`);
1629
+ seen.add(sub.target_url);
1630
+ const urlErr = publicHttpsUrlError(sub.target_url);
1631
+ if (urlErr)
1632
+ errs.push(`/subscriptions/${i}/target_url: ${urlErr}`);
1633
+ else if (isFnTriggerSubscriptionUrl(sub.target_url)) {
1634
+ 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`);
1635
+ }
1636
+ });
1637
+ return errs;
1638
+ }
1639
+ /** The stored-template `schema` bounds — MIRRORED from @vxil/runtime
1640
+ * (AI_TEMPLATE_SCHEMA_MAX_BYTES / AI_TEMPLATE_SCHEMA_MAX_DEPTH; this package is
1641
+ * typebox-only). ai-v1 putTemplate refuses the same numbers at the route, so a
1642
+ * declared entry that passes here is never a 422 at converge. */
1643
+ export const AI_TEMPLATE_SCHEMA_MAX_BYTES = 32_000;
1644
+ export const AI_TEMPLATE_SCHEMA_MAX_DEPTH = 64;
1645
+ /** Nesting depth of a JSON value, iteratively (`{}` = 1, scalar = 0). */
1646
+ function jsonDepthOf(v) {
1647
+ let max = 0;
1648
+ const stack = [{ v, d: 0 }];
1649
+ while (stack.length) {
1650
+ const { v: cur, d } = stack.pop();
1651
+ if (cur === null || typeof cur !== 'object')
1652
+ continue;
1653
+ const depth = d + 1;
1654
+ if (depth > max)
1655
+ max = depth;
1656
+ for (const c of Array.isArray(cur) ? cur : Object.values(cur)) {
1657
+ if (c !== null && typeof c === 'object')
1658
+ stack.push({ v: c, d: depth });
1659
+ }
1660
+ }
1661
+ return max;
1662
+ }
1663
+ export function validateDeclaredAiTemplates(templates) {
1664
+ const errs = [];
1665
+ const seen = new Set();
1666
+ (templates ?? []).forEach((t, i) => {
1667
+ if (seen.has(t.template))
1668
+ errs.push(`/templates/${i}/template: duplicate template name '${t.template}' (templates converge by name, so it must be unique)`);
1669
+ seen.add(t.template);
1670
+ if (t.schema === undefined)
1671
+ return;
1672
+ if (typeof t.schema !== 'object' || t.schema === null || Array.isArray(t.schema)) {
1673
+ errs.push(`/templates/${i}/schema: must be a JSON-Schema object`);
1674
+ return;
1675
+ }
1676
+ const bytes = JSON.stringify(t.schema).length;
1677
+ if (bytes > AI_TEMPLATE_SCHEMA_MAX_BYTES)
1678
+ errs.push(`/templates/${i}/schema: too large (${bytes} > ${AI_TEMPLATE_SCHEMA_MAX_BYTES} bytes serialized)`);
1679
+ const depth = jsonDepthOf(t.schema);
1680
+ if (depth > AI_TEMPLATE_SCHEMA_MAX_DEPTH)
1681
+ errs.push(`/templates/${i}/schema: nests too deep (${depth} > ${AI_TEMPLATE_SCHEMA_MAX_DEPTH} levels)`);
1682
+ });
1683
+ return errs;
1684
+ }
1439
1685
  export function validateFeatureConfig(feature, raw) {
1440
1686
  const schema = FEATURE_SCHEMAS[feature];
1441
1687
  if (!schema) {
@@ -1466,6 +1712,16 @@ export function validateFeatureConfig(feature, raw) {
1466
1712
  errors: ["/resendApiKeyRef: required when provider is 'resend' (omit it for provider 'mock')"],
1467
1713
  };
1468
1714
  }
1715
+ // Same posture for the second real provider: `ses` without its bag is a
1716
+ // config the worker could only fail at send time, so refuse it at push.
1717
+ // (The bag's three members are each schema-required once the bag is
1718
+ // present, so a partial bag already failed TypeBox above.)
1719
+ if (v.provider === 'ses' && !v.ses) {
1720
+ return {
1721
+ ok: false,
1722
+ errors: ["/ses: required when provider is 'ses' — { region, accessKeyIdRef, secretAccessKeyRef } (omit it for provider 'mock')"],
1723
+ };
1724
+ }
1469
1725
  const overrideErrs = validateNotificationOverrides(v.templates);
1470
1726
  if (overrideErrs.length)
1471
1727
  return { ok: false, errors: overrideErrs.slice(0, 10) };
@@ -1611,6 +1867,25 @@ export function validateFeatureConfig(feature, raw) {
1611
1867
  if (urlErr)
1612
1868
  return { ok: false, errors: [`/providers/compat/openaiBaseUrl: ${urlErr}`] };
1613
1869
  }
1870
+ const tplErrs = validateDeclaredAiTemplates(v.templates);
1871
+ if (tplErrs.length)
1872
+ return { ok: false, errors: tplErrs.slice(0, 10) };
1873
+ }
1874
+ // Cross-field rules: declared API state (roadmap §4.11 P0-3). Each list is
1875
+ // converged by NAME/URL, so a duplicate key is ambiguous and refused at push;
1876
+ // the per-item rules mirror the feature route's own validation so a declared
1877
+ // entry can never be one the converge would 422 on.
1878
+ if (feature === 'rate-limits') {
1879
+ const v = withDefaults;
1880
+ const errs = validateDeclaredRlPolicies(v.policies);
1881
+ if (errs.length)
1882
+ return { ok: false, errors: errs.slice(0, 10) };
1883
+ }
1884
+ if (feature === 'webhooks') {
1885
+ const v = withDefaults;
1886
+ const errs = validateDeclaredWebhookSubscriptions(v.subscriptions, v.maxSubscriptions);
1887
+ if (errs.length)
1888
+ return { ok: false, errors: errs.slice(0, 10) };
1614
1889
  }
1615
1890
  // Cross-field rule: auth otp.testRecipients (F4-29) — every entry must be an
1616
1891
  // email, an `*@domain` glob, or a +E.164 number; anything else would never
@@ -1621,6 +1896,17 @@ export function validateFeatureConfig(feature, raw) {
1621
1896
  if (bad.length) {
1622
1897
  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`) };
1623
1898
  }
1899
+ // security.allowedRedirectOrigins: the SAME scheme predicate auth-v1 applies
1900
+ // at runtime (redirectSchemeAllowed) — https anywhere, http on loopback only.
1901
+ // The TypeBox pattern above already refused the shape; this re-check is what
1902
+ // keeps the validator and the worker on one rule (parity-tested).
1903
+ const badOrigin = (v.security?.allowedRedirectOrigins ?? []).filter((e) => !isAllowedRedirectOriginEntry(e));
1904
+ if (badOrigin.length) {
1905
+ return {
1906
+ ok: false,
1907
+ errors: badOrigin.slice(0, 10).map((e) => `/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)`),
1908
+ };
1909
+ }
1624
1910
  }
1625
1911
  // Cross-field rule: functions. Reject privilege-escalating scopes and malformed
1626
1912
  // bindings at config-write time (the deploy pipeline additionally asserts the
@@ -1673,8 +1959,14 @@ export function validateFeatureConfig(feature, raw) {
1673
1959
  for (const [id, agent] of Object.entries(v.agents ?? {})) {
1674
1960
  const allow = agent.actions?.allow ?? {};
1675
1961
  const guestAllow = agent.guardrails?.guestToolAllow ?? [];
1676
- if (agent.guardrails?.allowGuest === true)
1962
+ if (agent.guardrails?.allowGuest === true) {
1677
1963
  anyGuestAgent = true;
1964
+ // Guests share ONE tenant-wide bucket that spends the tenant's own AI
1965
+ // key: an unlimited (0) daily cap is never allowed alongside them.
1966
+ if (agent.guardrails.rateLimitPerUserPerDay === 0) {
1967
+ errs.push(`/agents/${id}/guardrails/rateLimitPerUserPerDay: 0 (unlimited) is not allowed with allowGuest: true — set a finite daily cap (guests share one tenant-wide bucket)`);
1968
+ }
1969
+ }
1678
1970
  if (knownMcpTools) {
1679
1971
  for (const tool of Object.keys(allow)) {
1680
1972
  if (!knownMcpTools.has(tool)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vxil/feature-configs",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vxil.com",