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