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