@vxil/feature-configs 0.7.0 → 0.8.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 +2 -2
- package/dist/apiState.js +2 -2
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +1 -1
- package/dist/index.d.ts +28 -16
- package/dist/index.js +196 -176
- package/dist/publicAssets.d.ts +73 -0
- package/dist/publicAssets.js +130 -0
- package/dist/readmodels.d.ts +1 -1
- package/dist/readmodels.js +1 -1
- package/package.json +1 -1
- package/src/apiState.ts +5 -7
- package/src/hooks.ts +2 -2
- package/src/index.ts +208 -188
- package/src/publicAssets.ts +151 -0
- package/src/readmodels.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Per-feature TypeBox config schemas + the 15-leaf cap analyzer.
|
|
2
|
-
// Location note
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Location note: each schema belongs to its feature, but the config-write
|
|
3
|
+
// path must validate writes against the same schema, so schemas live in this
|
|
4
|
+
// shared package
|
|
5
5
|
// and feature Workers import from here — one definition, two consumers.
|
|
6
6
|
import { FormatRegistry, OptionalKind, Type, type Static, type TSchema } from '@sinclair/typebox';
|
|
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
10
|
import { isFnTriggerSubscriptionUrl } from './apiState.js';
|
|
11
|
+
import { PublicAssetsConfigSchema, VARIANT_PRESET_NAME_PATTERN } from './publicAssets.js';
|
|
11
12
|
|
|
12
13
|
// Re-export the CMS lifecycle-hook engine so feature workers (cms-v1, runtime
|
|
13
14
|
// eval) and the control plane (config-time validation) share one definition.
|
|
@@ -15,13 +16,17 @@ export * from './hooks.js';
|
|
|
15
16
|
// Re-export the cms-rel read-model/cdc config gates (the pure-mirror split:
|
|
16
17
|
// grammar validated here at config-write; field existence at runtime).
|
|
17
18
|
export * from './readmodels.js';
|
|
18
|
-
// Re-export the DECLARED API STATE planner
|
|
19
|
+
// Re-export the DECLARED API STATE planner: pure
|
|
19
20
|
// declared-vs-live reconciliation, shared by the control-plane apply path and
|
|
20
21
|
// the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
|
|
21
22
|
export * from './apiState.js';
|
|
22
23
|
// Key-order-insensitive JSON: the one declared-vs-stored manifest compare
|
|
23
24
|
// (control-plane shallowDiff, CLI diffManifest).
|
|
24
25
|
export * from './canonicalJson.js';
|
|
26
|
+
// Public asset delivery (files.publicAssets): the publishable content-type
|
|
27
|
+
// allowlist, the URL/key shape and the variant presets — shared by files-v1
|
|
28
|
+
// (publish) and files-cdn (serve), which never import each other.
|
|
29
|
+
export * from './publicAssets.js';
|
|
25
30
|
|
|
26
31
|
// TypeBox validates `format:` only for registered formats — register the ones
|
|
27
32
|
// our schemas use (pragmatic RFC-lite email check; providers do the real one).
|
|
@@ -29,7 +34,7 @@ if (!FormatRegistry.Has('email')) {
|
|
|
29
34
|
FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
// ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth
|
|
37
|
+
// ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth ─────────────────
|
|
33
38
|
// `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
|
|
34
39
|
// functions "recover-by-price" meter). It must NEVER be grantable or consumable
|
|
35
40
|
// by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
|
|
@@ -40,7 +45,7 @@ if (!FormatRegistry.Has('email')) {
|
|
|
40
45
|
// config-write refusal share ONE list. payments-v1/core.ts re-exports these.
|
|
41
46
|
export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
|
|
42
47
|
|
|
43
|
-
/**
|
|
48
|
+
/** A function binding's `retry.maxAttempts` ceiling, and the
|
|
44
49
|
* binding kinds that may carry `retry` — the platform-delivered event lanes
|
|
45
50
|
* (an http invoke returns its own status; a cron tick's retry would overlap
|
|
46
51
|
* the next tick). Read by the schema, the deploy clamp and the CLI. */
|
|
@@ -53,7 +58,7 @@ export function isReservedCreditType(creditType: string): boolean {
|
|
|
53
58
|
return RESERVED_CREDIT_TYPES.has(creditType);
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
// ── notifications template catalog (
|
|
61
|
+
// ── notifications template catalog (per-locale overrides) ────────────────────
|
|
57
62
|
// The SHIPPED template ids and the placeholders each body may interpolate. The
|
|
58
63
|
// renderer itself lives in workers/notifications-v1/src/templates.ts (templates
|
|
59
64
|
// are code, not rows); this table is the CONFIG-TIME half so a `vxil push` that
|
|
@@ -80,7 +85,7 @@ export const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS: Readonly<Record<string, re
|
|
|
80
85
|
transactional: ['cta'],
|
|
81
86
|
};
|
|
82
87
|
|
|
83
|
-
/** Per-override caps
|
|
88
|
+
/** Per-override caps. Bodies are emails, not documents. */
|
|
84
89
|
export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
|
|
85
90
|
export const NOTIF_OVERRIDE_BODY_MAX = 20_000;
|
|
86
91
|
/** Whole-map caps: keeps one manifest (and the KV row every send reads) small. */
|
|
@@ -94,7 +99,7 @@ const NOTIF_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
|
94
99
|
const NOTIF_LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}$/;
|
|
95
100
|
|
|
96
101
|
/**
|
|
97
|
-
*
|
|
102
|
+
* Validate `templates.overrides` at CONFIG-WRITE time. Returns [] when the
|
|
98
103
|
* bag is absent or clean. Every rule fails LOUD rather than shipping something
|
|
99
104
|
* that silently renders wrong in a customer's inbox:
|
|
100
105
|
* • overrides present with `allowOverride:false` → rejected (never inert);
|
|
@@ -189,12 +194,12 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
189
194
|
// need no email account, so the mock path is zero-config. A cross-field
|
|
190
195
|
// check in validateFeatureConfig requires it only when provider === 'resend'.
|
|
191
196
|
resendApiKeyRef: Type.Optional(Type.String()),
|
|
192
|
-
// Optional per-tenant Resend/Svix ENDPOINT secret ref (
|
|
193
|
-
//
|
|
197
|
+
// Optional per-tenant Resend/Svix ENDPOINT secret ref (a pointer into the
|
|
198
|
+
// tenant's encrypted secret store — same store as resendApiKeyRef).
|
|
194
199
|
// When set, inbound Resend webhooks are verified with THIS tenant's secret
|
|
195
200
|
// instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
|
|
196
201
|
// to the tenant so a signed event for tenant A can never validate at tenant
|
|
197
|
-
// B's webhook URL (
|
|
202
|
+
// B's webhook URL (mirrors payments revenuecat.webhookSecretRef).
|
|
198
203
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
199
204
|
// 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP;
|
|
200
205
|
// 'ses' (2026-09-19) is the second real provider — Amazon SES v2, BYO IAM
|
|
@@ -206,7 +211,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
206
211
|
// cap rule — funded by folding `rateLimit` into an Optional bag below);
|
|
207
212
|
// NO `default: {}` so an absent bag stays absent (the auth `security`
|
|
208
213
|
// precedent). Both refs are `secret:<name>` POINTERS into
|
|
209
|
-
//
|
|
214
|
+
// the tenant's encrypted secret store (feature='notifications') —
|
|
210
215
|
// exactly how resendApiKeyRef resolves; the region is plain config (not a
|
|
211
216
|
// secret) and is pattern-pinned to the AWS region grammar so a typo fails
|
|
212
217
|
// at `vxil push` instead of as a DNS error on the first send. Required
|
|
@@ -228,7 +233,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
228
233
|
// nested objects carry `default: {}` so Value.Default can materialize them
|
|
229
234
|
// and then recurse into the leaf defaults.
|
|
230
235
|
// `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
|
|
231
|
-
// Optional object as ONE) to fund `broadcast` below (
|
|
236
|
+
// Optional object as ONE) to fund `broadcast` below (2026-07-18).
|
|
232
237
|
// It KEEPS `default: {}`, which Value.Default still materializes — so every
|
|
233
238
|
// persisted manifest carries retry.{maxAttempts,backoff} exactly as before
|
|
234
239
|
// (zero behavioral delta); only the TS type is now optional (workers read
|
|
@@ -246,7 +251,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
246
251
|
{ softBounceThreshold: Type.Integer({ default: 3 }) },
|
|
247
252
|
{ default: {} },
|
|
248
253
|
),
|
|
249
|
-
// `rateLimit` became an OPTIONAL bag (2 leaves → 1, the
|
|
254
|
+
// `rateLimit` became an OPTIONAL bag (2 leaves → 1, the `retry` trick)
|
|
250
255
|
// on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
|
|
251
256
|
// `default: {}`, so Value.Default still materializes
|
|
252
257
|
// rateLimit.{perDay,perTenantSec} into every persisted manifest exactly as
|
|
@@ -259,8 +264,8 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
259
264
|
},
|
|
260
265
|
{ default: {} },
|
|
261
266
|
)),
|
|
262
|
-
// `templates` became an OPTIONAL bag (1 leaf, the same
|
|
263
|
-
// uses) to fund the
|
|
267
|
+
// `templates` became an OPTIONAL bag (1 leaf, the same trick `retry`
|
|
268
|
+
// uses) to fund the per-locale `overrides` map WITHOUT moving the count:
|
|
264
269
|
// countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
|
|
265
270
|
// Value.Default still materializes `templates.allowOverride` into every
|
|
266
271
|
// persisted manifest exactly as before — zero behavioral delta; only the TS
|
|
@@ -268,7 +273,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
268
273
|
templates: Type.Optional(Type.Object(
|
|
269
274
|
{
|
|
270
275
|
allowOverride: Type.Boolean({ default: false }),
|
|
271
|
-
//
|
|
276
|
+
// (2026-09-10) TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
|
|
272
277
|
// template ids, as config DATA (a Record = 1 leaf, catalog size never
|
|
273
278
|
// moves the count). Shape: { [templateId]: { [locale]: { subject?,
|
|
274
279
|
// html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
|
|
@@ -297,10 +302,10 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
297
302
|
)),
|
|
298
303
|
/** in-app inbox channel (send with channel: 'inbox' | 'both') */
|
|
299
304
|
inboxEnabled: Type.Boolean({ default: false }),
|
|
300
|
-
/**
|
|
305
|
+
/** Broadcast campaigns channel. Optional bag (= 1 leaf): absent means
|
|
301
306
|
* disabled; per-campaign quiet_hours / freq_cap overrides live on the
|
|
302
307
|
* notifications.campaigns ROW (tenant data), not here. Folded into the
|
|
303
|
-
* canonical schema 2026-07-18 (
|
|
308
|
+
* canonical schema 2026-07-18 (Value.Clean previously STRIPPED the
|
|
304
309
|
* worker-local extension, so campaigns 403'd via the real config path). */
|
|
305
310
|
broadcast: Type.Optional(Type.Object({
|
|
306
311
|
enabled: Type.Boolean({ default: false }),
|
|
@@ -324,28 +329,26 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
324
329
|
// retry (Optional bag = 1), suppression.softBounceThreshold,
|
|
325
330
|
// rateLimit (Optional bag = 1 — was rateLimit.{perDay,perTenantSec}, collapsed
|
|
326
331
|
// 2026-09-19 to fund `ses` at zero net cost), templates (Optional bag = 1 — was
|
|
327
|
-
// templates.allowOverride, collapsed 2026-09-10 to fund the
|
|
332
|
+
// templates.allowOverride, collapsed 2026-09-10 to fund the `overrides` map
|
|
328
333
|
// at zero net cost), inboxEnabled, broadcast (Optional bag = 1) → 15. Cap = 15 —
|
|
329
334
|
// AT the cap; the next flag must collapse something. (`templates.overrides` is
|
|
330
335
|
// a Record MAP inside the ONE optional templates leaf — DATA, not a flag — so
|
|
331
336
|
// catalog size never moves it.)
|
|
332
337
|
|
|
333
338
|
export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
|
|
334
|
-
/** The
|
|
339
|
+
/** The broadcast bag as persisted (present ⇒ leaf defaults applied). */
|
|
335
340
|
export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
|
|
336
341
|
|
|
337
342
|
// ── jobs `generation` block: the ONE declaration of its defaults and bounds ──
|
|
338
|
-
// (
|
|
339
|
-
// copy"). The JobsConfigSchema `generation` leaf reads these for its
|
|
343
|
+
// (2026-09-25 — one copy of the generation defaults, not two). The JobsConfigSchema `generation` leaf reads these for its
|
|
340
344
|
// `default` / `minimum` / `maximum`, and workers/jobs-v1/src/generation.ts
|
|
341
345
|
// imports them (jobs-v1 already depends on @vxil/feature-configs; this package
|
|
342
346
|
// has no @vxil/types dependency, so the shared value lives here — the
|
|
343
347
|
// RESERVED_CREDIT_TYPES precedent). A future edit changes one object; the
|
|
344
|
-
// feature-configs unit test pins schema ↔ constant, and
|
|
345
|
-
//
|
|
346
|
-
// object-literal declaration of the constant reappears anywhere.
|
|
348
|
+
// feature-configs unit test pins schema ↔ constant, and a CI gate pins that no
|
|
349
|
+
// second object-literal declaration of the constant reappears anywhere.
|
|
347
350
|
|
|
348
|
-
/** Generation-lifecycle config defaults (
|
|
351
|
+
/** Generation-lifecycle config defaults (guide ch. 6, jobs). */
|
|
349
352
|
export const GENERATION_DEFAULTS = {
|
|
350
353
|
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
351
354
|
maxConcurrent: 20,
|
|
@@ -399,7 +402,7 @@ export const JobsConfigSchema = Type.Object({
|
|
|
399
402
|
{ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
|
|
400
403
|
{ default: {} },
|
|
401
404
|
),
|
|
402
|
-
//
|
|
405
|
+
// Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
|
|
403
406
|
// declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
|
|
404
407
|
// (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
|
|
405
408
|
// pre-fold manifest to the same numbers — a hand-mirrored copy used to live
|
|
@@ -414,14 +417,14 @@ export const JobsConfigSchema = Type.Object({
|
|
|
414
417
|
maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
|
|
415
418
|
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
416
419
|
pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
|
|
417
|
-
/** MANDATORY per-hold cap on a generation `reserve_credits.amount`
|
|
418
|
-
*
|
|
420
|
+
/** MANDATORY per-hold cap on a generation `reserve_credits.amount`
|
|
421
|
+
* (guide ch. 6, jobs). Every requested amount is CLAMPED to this (never rejected) — a
|
|
419
422
|
* conservative default so an untrusted deployed function that carries a
|
|
420
423
|
* reserve block can never hold more than a bounded amount per run without
|
|
421
424
|
* any tenant action. */
|
|
422
425
|
maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
|
|
423
426
|
/** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
|
|
424
|
-
* reserve holds across all in-flight generation runs (
|
|
427
|
+
* reserve holds across all in-flight generation runs (guide ch. 6, jobs): a
|
|
425
428
|
* reserve whose amount would push the tenant's outstanding-holds total over
|
|
426
429
|
* this is rejected 429, so a runaway function cannot hold every user at
|
|
427
430
|
* once. Defaulted so no tenant action is required to be safe. */
|
|
@@ -435,43 +438,42 @@ export const JobsConfigSchema = Type.Object({
|
|
|
435
438
|
export type JobsConfig = Static<typeof JobsConfigSchema>;
|
|
436
439
|
|
|
437
440
|
// One social-provider's BYO credential block. The *Ref fields are POINTERS into
|
|
438
|
-
//
|
|
439
|
-
//
|
|
441
|
+
// the tenant's encrypted secret store — never raw secrets (guide ch. 6, auth).
|
|
442
|
+
// google/github/facebook share the clientId/secret shape; apple is distinct
|
|
440
443
|
// (Sign in with Apple has no static secret — it mints an ES256 client_secret from
|
|
441
|
-
// the .p8, so it carries servicesId/teamId/keyId + a p8 keyRef
|
|
444
|
+
// the .p8, so it carries servicesId/teamId/keyId + a p8 keyRef).
|
|
442
445
|
const OAuthRefsSchema = Type.Object({
|
|
443
446
|
// clientId/secret refs are OPTIONAL at the schema layer: a tenant may stage a
|
|
444
447
|
// partial block, and the worker enforces presence at use (→ 501 if missing),
|
|
445
448
|
// matching the worker's OAuthProviderRefs shape (core.ts). They stay POINTERS
|
|
446
|
-
// into
|
|
449
|
+
// into the tenant's secret store — never raw secrets (guide ch. 6, auth).
|
|
447
450
|
clientIdRef: Type.Optional(Type.String()),
|
|
448
451
|
clientSecretRef: Type.Optional(Type.String()),
|
|
449
452
|
// extra native-aud allow-list entries (iOS/web client ids that differ from the
|
|
450
|
-
// primary clientIdRef) — also
|
|
453
|
+
// primary clientIdRef) — also secret-store refs (guide ch. 6, auth).
|
|
451
454
|
audRefs: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
|
|
452
455
|
});
|
|
453
456
|
const AppleRefsSchema = Type.Object({
|
|
454
457
|
servicesId: Type.String(), // the OAuth client_id / native aud (NOT a secret)
|
|
455
458
|
teamId: Type.String(),
|
|
456
459
|
keyId: Type.String(),
|
|
457
|
-
p8KeyRef: Type.String(), //
|
|
460
|
+
p8KeyRef: Type.String(), // secret-store ref → the encrypted .p8 PEM
|
|
458
461
|
// extra native-aud allow-list entries: genuine iOS ASAuthorization id_tokens
|
|
459
|
-
// carry the app BUNDLE ID as aud, not the Services ID (
|
|
462
|
+
// carry the app BUNDLE ID as aud, not the Services ID (guide ch. 6, auth). Plain
|
|
460
463
|
// config values — bundle ids are not secrets. The worker already honors them
|
|
461
464
|
// (oauthCore.ts nativeAudAllowList); declaring them here is what stops
|
|
462
465
|
// Value.Clean stripping the field out of PUT /v1/config/auth.
|
|
463
466
|
bundleIds: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
|
|
464
467
|
});
|
|
465
|
-
//
|
|
466
|
-
// §6.7). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
|
|
468
|
+
// The generic OIDC / SSO bridge (guide ch. 6, auth). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
|
|
467
469
|
// bag so it spends NO leaf (an Optional object bag = ONE leaf; auth stays 11).
|
|
468
470
|
// Declarative + opt-in: the block's PRESENCE enables the `oidc` provider (no
|
|
469
471
|
// methods.* toggle — a `default:false` flag would re-materialize every published
|
|
470
|
-
// manifest, breaking the
|
|
472
|
+
// manifest, breaking the byte-identical fold). The worker discovers the
|
|
471
473
|
// endpoints + JWKS from `{issuer}/.well-known/openid-configuration` and verifies
|
|
472
474
|
// iss (byte-equal) / aud (= clientId) / nonce / exp / iat strictly, fail-closed.
|
|
473
475
|
// `clientId` is NOT a secret (it rides every authorize URL); `clientSecretRef`
|
|
474
|
-
// is a
|
|
476
|
+
// is a secret-store POINTER (feature 'auth'), never the value. A SAML IdP
|
|
475
477
|
// plugs in through a broker (Okta / Entra / Auth0 / WorkOS) that speaks OIDC —
|
|
476
478
|
// there is deliberately NO native SAML.
|
|
477
479
|
const OidcProviderSchema = Type.Object({
|
|
@@ -500,7 +502,7 @@ const OidcProviderSchema = Type.Object({
|
|
|
500
502
|
{ maxItems: 32 },
|
|
501
503
|
)),
|
|
502
504
|
// default true: an identity whose VERIFIED email matches an existing user is
|
|
503
|
-
// linked to it (the shipped
|
|
505
|
+
// linked to it (the shipped social sign-in rule). false: link only by the stable
|
|
504
506
|
// (issuer, sub) anchor; a matching email that is not yet linked → 409.
|
|
505
507
|
autoLink: Type.Optional(Type.Boolean()),
|
|
506
508
|
});
|
|
@@ -510,7 +512,7 @@ const OidcProviderSchema = Type.Object({
|
|
|
510
512
|
// on every caller-supplied `redirect_url` / `redirect_uri` (core.ts
|
|
511
513
|
// `isAllowedRedirectUrl`) must agree on WHICH schemes a sign-in link may be
|
|
512
514
|
// delivered to, or a tenant could allow-list an origin the worker then refuses
|
|
513
|
-
// (or the reverse). The rule (
|
|
515
|
+
// (or the reverse). The rule (2026-09-19): `https://` on any
|
|
514
516
|
// host, plus PLAIN `http://` ONLY on the two loopback names — `localhost` and
|
|
515
517
|
// `127.0.0.1`, any port — so a local dev server can complete a magic link
|
|
516
518
|
// without a staging origin. Any other `http://` (a LAN host, `*.local`, a
|
|
@@ -521,7 +523,7 @@ const OidcProviderSchema = Type.Object({
|
|
|
521
523
|
// with THIS predicate, and auth-v1 calls it on the parsed URL — the parity test
|
|
522
524
|
// in index.test.ts / redirectRule.test.ts holds the two together. The CLI's
|
|
523
525
|
// production promotion gate still refuses any `http://` origin on a
|
|
524
|
-
// production push
|
|
526
|
+
// production push, so a loopback entry is a DEV-tenant affair.
|
|
525
527
|
export const REDIRECT_ORIGIN_PATTERN =
|
|
526
528
|
'^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$';
|
|
527
529
|
|
|
@@ -547,13 +549,13 @@ export function isAllowedRedirectOriginEntry(entry: string): boolean {
|
|
|
547
549
|
|
|
548
550
|
export const AuthConfigSchema = Type.Object({
|
|
549
551
|
enabled: Type.Boolean({ default: true }),
|
|
550
|
-
//
|
|
552
|
+
// (2026-09-10) the six method toggles collapsed into ONE
|
|
551
553
|
// Optional bag (6 leaves → 1, countLeaves counts an Optional object as ONE) —
|
|
552
554
|
// the notifications `retry`/`broadcast` precedent. It KEEPS `default: {}`,
|
|
553
555
|
// which Value.Default still materializes, so every persisted manifest carries
|
|
554
556
|
// methods.{emailPassword,magicLink,google,github,apple,facebook} with the
|
|
555
557
|
// SAME keys and defaults as before — byte-identical folds for existing
|
|
556
|
-
// tenants (index.test.ts
|
|
558
|
+
// tenants (index.test.ts pins the fold bytes). Only the TS type is now optional;
|
|
557
559
|
// the worker's gate() normalizes an absent bag to the defaults so every
|
|
558
560
|
// reader (config.methods.<flag>) is unchanged.
|
|
559
561
|
methods: Type.Optional(Type.Object(
|
|
@@ -571,12 +573,12 @@ export const AuthConfigSchema = Type.Object({
|
|
|
571
573
|
// rule) keyed by provider, so the four provider blocks (and any future one)
|
|
572
574
|
// never inflate the flag count — the prior shape spent a leaf per top-level
|
|
573
575
|
// google/github block. This bag REPLACES those two top-level blocks (−2, +1
|
|
574
|
-
// for the bag) and EXTENDS the accepted providers to apple + facebook
|
|
576
|
+
// for the bag) and EXTENDS the accepted providers to apple + facebook:
|
|
575
577
|
// • google/github/facebook → { clientIdRef, clientSecretRef, audRefs? }
|
|
576
578
|
// • apple → { servicesId, teamId, keyId, p8KeyRef }
|
|
577
|
-
// All *Ref fields are
|
|
579
|
+
// All *Ref fields are secret-store POINTERS, never raw secrets. The
|
|
578
580
|
// worker reads config.providers?.{google,github,apple,facebook} (oauthCore.ts).
|
|
579
|
-
// The
|
|
581
|
+
// The runtime sign-in flow for apple/facebook is SHIPPED in oauthCore.ts
|
|
580
582
|
// (id_token/access_token verification + ES256 Apple client_secret minting);
|
|
581
583
|
// methods.{apple,facebook} above are the enable toggles it gates on.
|
|
582
584
|
providers: Type.Optional(Type.Object({
|
|
@@ -584,11 +586,11 @@ export const AuthConfigSchema = Type.Object({
|
|
|
584
586
|
github: Type.Optional(OAuthRefsSchema),
|
|
585
587
|
apple: Type.Optional(AppleRefsSchema),
|
|
586
588
|
facebook: Type.Optional(OAuthRefsSchema),
|
|
587
|
-
//
|
|
589
|
+
// the generic OIDC / SSO issuer (presence = enabled; see above)
|
|
588
590
|
oidc: Type.Optional(OidcProviderSchema),
|
|
589
591
|
})),
|
|
590
592
|
// session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
|
|
591
|
-
// the OTP/anonymous/orgClaims
|
|
593
|
+
// the OTP/anonymous/orgClaims release — the `{ default: {} }` keeps the inner
|
|
592
594
|
// defaults materializing on publish, so the worker still reads fully-populated
|
|
593
595
|
// manifests; its `config.session?.ttlMinutes ?? 60` fallbacks cover sparse
|
|
594
596
|
// hand-built manifests only.
|
|
@@ -596,7 +598,7 @@ export const AuthConfigSchema = Type.Object({
|
|
|
596
598
|
{
|
|
597
599
|
ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
|
|
598
600
|
refreshTtlDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
|
|
599
|
-
//
|
|
601
|
+
// (2026-09-10) concurrent-session cap per user with
|
|
600
602
|
// TAKE-OVER — a new sign-in revokes the OLDEST sessions past the cap
|
|
601
603
|
// (revoked_reason 'device_cap', edge cache written) and reports them as
|
|
602
604
|
// `took_over: [session_id…]`. Optional WITHOUT a default so existing
|
|
@@ -612,15 +614,15 @@ export const AuthConfigSchema = Type.Object({
|
|
|
612
614
|
},
|
|
613
615
|
{ default: {} },
|
|
614
616
|
)),
|
|
615
|
-
//
|
|
617
|
+
// Note: emailVerification.tokenTtlHours was DROPPED —
|
|
616
618
|
// consumer-less (grep-verified: only this schema + dist mentioned it; the
|
|
617
619
|
// verify-email token flow it would bound was never built). Same precedent as
|
|
618
|
-
// the removed `redirects` block below. Its leaf funds the OTP
|
|
620
|
+
// the removed `redirects` block below. Its leaf funds the OTP sign-in bag.
|
|
619
621
|
emailVerification: Type.Object(
|
|
620
622
|
{ required: Type.Boolean({ default: false }) },
|
|
621
623
|
{ default: {} },
|
|
622
624
|
),
|
|
623
|
-
//
|
|
625
|
+
// (2026-09-18) magicLink is now an OPTIONAL bag (= ONE leaf however
|
|
624
626
|
// many knobs it holds — the methods/session/password precedent) so the
|
|
625
627
|
// request cooldown could land without spending a second leaf. It KEEPS
|
|
626
628
|
// `default: {}`, so Value.Default still materializes
|
|
@@ -629,7 +631,7 @@ export const AuthConfigSchema = Type.Object({
|
|
|
629
631
|
magicLink: Type.Optional(Type.Object(
|
|
630
632
|
{
|
|
631
633
|
tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
|
|
632
|
-
//
|
|
634
|
+
// the per-(tenant, identifier) magic-link REQUEST cooldown — the
|
|
633
635
|
// `otp.resendCooldownSec` twin, same bounds so the two knobs read the
|
|
634
636
|
// same. A second request for the same address inside the window is a 429
|
|
635
637
|
// `magic_link_rate_limited` with a Retry-After header. Type.Optional with
|
|
@@ -640,19 +642,19 @@ export const AuthConfigSchema = Type.Object({
|
|
|
640
642
|
},
|
|
641
643
|
{ default: {} },
|
|
642
644
|
)),
|
|
643
|
-
//
|
|
645
|
+
// Identity continuity (2026-09-18) — OPT-IN registry adoption. When
|
|
644
646
|
// true, a sign-in by a method that PROVES control of the address — magic
|
|
645
647
|
// link, email OTP, or OAuth with a provider-verified address — reuses the id
|
|
646
648
|
// of the one matching pre-registered end-user (POST /v1/users) that has no
|
|
647
649
|
// account yet, instead of minting a fresh `user_<ulid>` and leaving the
|
|
648
650
|
// tenant with two records for one person. Password SIGN-UP never adopts: it
|
|
649
|
-
// proves nothing about the address (
|
|
651
|
+
// proves nothing about the address (guide ch. 6, auth).
|
|
650
652
|
// OFF by default, and Type.Optional with NO default: adoption means whoever
|
|
651
653
|
// proves control of a pre-registered address becomes that record — a change
|
|
652
654
|
// of security semantics for a tenant that bulk-imports contacts, whose
|
|
653
655
|
// addresses vxil never verified.
|
|
654
656
|
registryAdopt: Type.Optional(Type.Boolean()),
|
|
655
|
-
// Email OTP sign-in
|
|
657
|
+
// Email OTP sign-in + the knobs step-up re-auth shares.
|
|
656
658
|
// OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
|
|
657
659
|
// otp?.enabled === true).
|
|
658
660
|
otp: Type.Optional(Type.Object({
|
|
@@ -660,7 +662,7 @@ export const AuthConfigSchema = Type.Object({
|
|
|
660
662
|
codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
|
|
661
663
|
maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
|
|
662
664
|
resendCooldownSec: Type.Integer({ default: 60, minimum: 0, maximum: 600 }),
|
|
663
|
-
//
|
|
665
|
+
// (2026-09-10) test recipients — an OTP / step-up / email-
|
|
664
666
|
// claim request whose address matches an entry sends NO mail and returns
|
|
665
667
|
// the code as `test_code` (audit auth.otp.test_issued). Entries: an exact
|
|
666
668
|
// email, a `*@domain` glob, or a +E.164 number (accepted for the SMS
|
|
@@ -671,11 +673,11 @@ export const AuthConfigSchema = Type.Object({
|
|
|
671
673
|
Type.String({ minLength: 3, maxLength: 320 }), { maxItems: 20 },
|
|
672
674
|
)),
|
|
673
675
|
})),
|
|
674
|
-
// Anonymous (guest) sign-in
|
|
676
|
+
// Anonymous (guest) sign-in. OPTIONAL bag = 1 leaf.
|
|
675
677
|
anonymous: Type.Optional(Type.Object({
|
|
676
678
|
enabled: Type.Boolean({ default: false }),
|
|
677
679
|
})),
|
|
678
|
-
// Org claims embedded in session JWTs at mint/refresh
|
|
680
|
+
// Org claims embedded in session JWTs at mint/refresh:
|
|
679
681
|
// when enabled, auth-v1 fetches the user's active-org membership from orgs
|
|
680
682
|
// over the EDGE and embeds { org_id, role, perms[] } as the `org` claim.
|
|
681
683
|
// Fail-open: an orgs outage mints WITHOUT claims (sign-in never breaks).
|
|
@@ -684,23 +686,22 @@ export const AuthConfigSchema = Type.Object({
|
|
|
684
686
|
orgClaims: Type.Optional(Type.Object({
|
|
685
687
|
enabled: Type.Boolean({ default: false }),
|
|
686
688
|
})),
|
|
687
|
-
//
|
|
689
|
+
// Note: the earlier `redirects` block was DROPPED entirely —
|
|
688
690
|
// it had zero consumers (grep-verified: no worker reads config.redirects) and
|
|
689
691
|
// its leaf was spent on the methods.{apple,facebook} toggles the shipped
|
|
690
|
-
//
|
|
692
|
+
// social sign-in flow actually gates on. Re-adding it requires headroom or a
|
|
691
693
|
// collapse elsewhere.
|
|
692
694
|
//
|
|
693
|
-
// Account-security controls (
|
|
694
|
-
// §4b). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
|
|
695
|
+
// Account-security controls (2026-09-10; guide ch. 6, auth). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
|
|
695
696
|
// absent (existing manifests fold byte-identically) and every control is
|
|
696
697
|
// opt-in:
|
|
697
698
|
// • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
|
|
698
699
|
// sign-in + OTP verify (429 account_locked + Retry-After); a bounded
|
|
699
|
-
// counter
|
|
700
|
+
// stored counter survives restarts.
|
|
700
701
|
// • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
|
|
701
702
|
// chars leave the worker, never the password) at sign-up / reset-confirm;
|
|
702
703
|
// fail-OPEN on network error → 422 password_breached.
|
|
703
|
-
// • captchaSecretRef — a
|
|
704
|
+
// • captchaSecretRef — a secret-store ref (feature 'auth') holding the
|
|
704
705
|
// Turnstile secret; when set, sign-up / OTP request / magic-link request
|
|
705
706
|
// require `captcha_token` (403 captcha_failed otherwise).
|
|
706
707
|
// • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
|
|
@@ -725,24 +726,24 @@ export const AuthConfigSchema = Type.Object({
|
|
|
725
726
|
});
|
|
726
727
|
/** The security bag as persisted (present ⇒ leaf defaults applied). */
|
|
727
728
|
export type AuthSecurityConfig = NonNullable<Static<typeof AuthConfigSchema>['security']>;
|
|
728
|
-
/** The
|
|
729
|
+
/** The generic OIDC / SSO issuer block (`providers.oidc`). */
|
|
729
730
|
export type AuthOidcConfig = Static<typeof OidcProviderSchema>;
|
|
730
|
-
// Leaves (
|
|
731
|
+
// Leaves (2026-09-10): enabled(1) + methods(1, optional bag —
|
|
731
732
|
// was 6) + providers(1, optional bag holding the four google/github/apple/
|
|
732
|
-
// facebook credential blocks + the
|
|
733
|
+
// facebook credential blocks + the `oidc` issuer block) + session(1, optional bag) + password(1,
|
|
733
734
|
// optional bag) + emailVerification(1) + magicLink(1, optional bag) + otp(1, optional bag)
|
|
734
735
|
// + anonymous(1, optional bag) + orgClaims(1, optional bag) + security(1,
|
|
735
|
-
// optional bag) + registryAdopt(1
|
|
736
|
+
// optional bag) + registryAdopt(1) = 12. Cap = 15 — 3 leaves of
|
|
736
737
|
// headroom. Knobs added inside an existing bag (session.maxConcurrent,
|
|
737
738
|
// otp.testRecipients, magicLink.resendCooldownSec, the security sub-bags)
|
|
738
739
|
// never move the count.
|
|
739
740
|
|
|
740
741
|
export type AuthConfig = Static<typeof AuthConfigSchema>;
|
|
741
742
|
|
|
742
|
-
// ── DECLARED API STATE (
|
|
743
|
+
// ── DECLARED API STATE (2026-09-23) ──────────────────────────────────────────
|
|
743
744
|
// A rate-limit policy is a ROW in the feature's own KV store, written by
|
|
744
745
|
// POST/PUT/DELETE /v1/rate-limits/policies. It used to be API state OUTSIDE
|
|
745
|
-
// vxil.config (
|
|
746
|
+
// vxil.config (by the old rule "one writer per datum, and the writer is
|
|
746
747
|
// the route"), which made a second environment non-reproducible — every tenant
|
|
747
748
|
// ended up with an `ensure-rate-limit-policies.ts` + a nightly assert. The
|
|
748
749
|
// rule is unchanged — one writer per datum — but the writer is now THE
|
|
@@ -793,7 +794,7 @@ export type RateLimitsConfig = Static<typeof RateLimitsConfigSchema>;
|
|
|
793
794
|
|
|
794
795
|
export const FilesConfigSchema = Type.Object({
|
|
795
796
|
enabled: Type.Boolean({ default: true }),
|
|
796
|
-
//
|
|
797
|
+
// `bucketRef` was DELETED (2026-09-11): the object-storage bucket is a platform
|
|
797
798
|
// binding on files-v1, never tenant-selectable, and no code ever read the
|
|
798
799
|
// leaf — declaring it invited "point files at my own bucket", which vxil does
|
|
799
800
|
// not offer. A persisted manifest that still carries it folds (Value.Clean).
|
|
@@ -802,8 +803,8 @@ export const FilesConfigSchema = Type.Object({
|
|
|
802
803
|
quotas: Type.Object(
|
|
803
804
|
{
|
|
804
805
|
// maximum caps are a defense-in-depth ceiling on tenant-editable storage —
|
|
805
|
-
// object storage is cheap but the
|
|
806
|
-
// 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
|
|
806
|
+
// object storage is cheap but the database-resident metadata + abuse aren't
|
|
807
|
+
// (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
|
|
807
808
|
// (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
|
|
808
809
|
// tenant tier threaded to files-v1 (plan tiers).
|
|
809
810
|
maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
|
|
@@ -813,7 +814,7 @@ export const FilesConfigSchema = Type.Object({
|
|
|
813
814
|
{ default: {} },
|
|
814
815
|
),
|
|
815
816
|
allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
|
|
816
|
-
// (
|
|
817
|
+
// (2026-09-11) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
|
|
817
818
|
// there is no malware/content scanner in files-v1 (it was a "V1.5" placeholder
|
|
818
819
|
// neither leaf was ever read), so the knob promised quarantine that never
|
|
819
820
|
// happened. Re-declare it in the same change as a real scanner, not before.
|
|
@@ -824,7 +825,7 @@ export const FilesConfigSchema = Type.Object({
|
|
|
824
825
|
},
|
|
825
826
|
{ default: {} },
|
|
826
827
|
),
|
|
827
|
-
// Wave-2 extensions (
|
|
828
|
+
// Wave-2 extensions (guide ch. 6, files: OCR + TTL) — the merge of
|
|
828
829
|
// workers/files-v1/src/ext.ts FilesExtensionsConfigSchema promised by its
|
|
829
830
|
// 'wiring phase' comment. Each is an OPTIONAL bag (= ONE leaf per the cap
|
|
830
831
|
// rule); files-v1 already reads both defensively (FilesConfigWithExt), so
|
|
@@ -842,21 +843,25 @@ export const FilesConfigSchema = Type.Object({
|
|
|
842
843
|
[Type.Literal('gcv'), Type.Literal('textract'), Type.Literal('azure-di'), Type.Literal('mock')],
|
|
843
844
|
{ default: 'mock' },
|
|
844
845
|
),
|
|
845
|
-
// provider key is BYO +
|
|
846
|
-
// config flag. keyRef names the
|
|
846
|
+
// provider key is BYO + encrypted in the tenant's secret store — NOT a
|
|
847
|
+
// config flag. keyRef names the stored secret (like ai's keyRefs).
|
|
847
848
|
keyRef: Type.Optional(Type.String()),
|
|
848
849
|
asyncOverJobs: Type.Boolean({ default: true }), // large/multi-page → jobs
|
|
849
850
|
boundingBoxes: Type.Boolean({ default: false }),
|
|
850
851
|
})),
|
|
852
|
+
// Public asset delivery (2026-10-03, guide ch. 6 files): publish
|
|
853
|
+
// an object to the cache-forever public host (POST /v1/files/{id}/publish).
|
|
854
|
+
// ONE optional bag: { enabled, corsOrigins, variants }. The published-bytes
|
|
855
|
+
// ceiling is a PLAN line (control-plane plans.ts), never a tenant knob.
|
|
856
|
+
publicAssets: Type.Optional(PublicAssetsConfigSchema),
|
|
851
857
|
});
|
|
852
|
-
// Leaves: 9 + ttl(1
|
|
853
|
-
// (was 14 —
|
|
858
|
+
// Leaves: 9 + ttl(1) + extractText(1) + publicAssets(1, optional bags) = 12. Cap = 15.
|
|
859
|
+
// (was 14 — 2026-09-11 deleted the inert bucketRef leaf and the inert contentScan bag.)
|
|
854
860
|
|
|
855
861
|
export type FilesConfig = Static<typeof FilesConfigSchema>;
|
|
856
862
|
|
|
857
863
|
// webhooks-out (wishlist feature): outbound event fan-out over the jobs delivery
|
|
858
|
-
// engine. Subscriptions live in their own
|
|
859
|
-
// per datum); config is just the capability gate + a cap.
|
|
864
|
+
// engine. Subscriptions live in their own store (one writer per datum); config is just the capability gate + a cap.
|
|
860
865
|
export const DeclaredWebhookSubscriptionSchema = Type.Object({
|
|
861
866
|
target_url: Type.String({ minLength: 9, maxLength: 2000 }),
|
|
862
867
|
event_prefixes: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 100 }), { maxItems: 20 })),
|
|
@@ -867,14 +872,14 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
867
872
|
enabled: Type.Boolean({ default: true }),
|
|
868
873
|
maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
869
874
|
maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
870
|
-
// FAILURE-ALERT DIGEST
|
|
875
|
+
// FAILURE-ALERT DIGEST. Outbound subscriptions are the real-time
|
|
871
876
|
// channel; this is the "nobody is consuming them yet" fallback — a periodic
|
|
872
877
|
// e-mail summary of the tenant's FAILURE-class audit events (the level:
|
|
873
878
|
// 'failure' rows of the generated event catalog). Read by
|
|
874
879
|
// workers/control-plane/src/alertDigest.ts on the minute cron.
|
|
875
880
|
//
|
|
876
|
-
// RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this
|
|
877
|
-
//
|
|
881
|
+
// RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this decision is
|
|
882
|
+
// recorded here the way RateLimitsConfigSchema records its own: an
|
|
878
883
|
// arbitrary `to` would turn vxil's own sending identity into a relay for
|
|
879
884
|
// tenant-authored content and open a PII egress path out of the audit trail.
|
|
880
885
|
// The digest goes to the OWNER-role dashboard accounts of the project (cap
|
|
@@ -884,7 +889,7 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
884
889
|
//
|
|
885
890
|
// An Optional object bag counts as ONE leaf (the countLeaves rule).
|
|
886
891
|
//
|
|
887
|
-
// `digestMinutes: 0` is IMMEDIATE (
|
|
892
|
+
// `digestMinutes: 0` is IMMEDIATE (2026-09-23): the
|
|
888
893
|
// pass runs every minute for the tenant and mails the `error`-level failure
|
|
889
894
|
// rows that landed since its last mail — at most one mail per minute, still
|
|
890
895
|
// to the owner accounts. 1–4 are clamped up to 5 by the reader.
|
|
@@ -893,11 +898,11 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
893
898
|
minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
|
|
894
899
|
digestMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 1440 }),
|
|
895
900
|
})),
|
|
896
|
-
// DECLARED API STATE (
|
|
901
|
+
// DECLARED API STATE (2026-09-23): the tenant's OUTBOUND
|
|
897
902
|
// subscriptions as config. Keyed by `target_url` — the only stable identity a
|
|
898
903
|
// subscription has (there is no name column). A changed prefix set is an
|
|
899
904
|
// in-place update — PATCH /v1/webhooks/subscriptions/:subId, same sub_id and
|
|
900
|
-
// cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge
|
|
905
|
+
// cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge the live subscriptions
|
|
901
906
|
// onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
|
|
902
907
|
// reported (deleted only under --allow-destructive). Rows on the platform's
|
|
903
908
|
// signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
|
|
@@ -922,16 +927,16 @@ export const CommentsConfigSchema = Type.Object({
|
|
|
922
927
|
|
|
923
928
|
export type CommentsConfig = Static<typeof CommentsConfigSchema>;
|
|
924
929
|
|
|
925
|
-
// content feature (
|
|
930
|
+
// content feature (guide ch. 4): flags govern LIMITS, never the
|
|
926
931
|
// content model — the model itself is data (cms.collections / cms.fields via
|
|
927
|
-
// the REST surface). versioning/localization/publicRead land later
|
|
932
|
+
// the REST surface). versioning/localization/publicRead land later.
|
|
928
933
|
export const CmsConfigSchema = Type.Object({
|
|
929
934
|
enabled: Type.Boolean({ default: true }),
|
|
930
935
|
draftPublish: Type.Boolean({ default: true }),
|
|
931
|
-
// cms end-user default-deny fail-safe (
|
|
936
|
+
// cms end-user default-deny fail-safe (guide ch. 4). When ON,
|
|
932
937
|
// a VERIFIED end-user key (owner-scope mode) is DENIED access to any
|
|
933
938
|
// collection that declares no owner_field — `403 server_only` on read AND
|
|
934
|
-
// write (
|
|
939
|
+
// write (2026-09-20; reads used to be a 404) — instead of the
|
|
935
940
|
// default tenant-wide-shared behavior. Server-caller mode is a
|
|
936
941
|
// byte-for-byte no-op. Default OFF preserves today's shared semantics
|
|
937
942
|
// (owner.int.test.ts's shared-collection invariant). A collection that DOES
|
|
@@ -953,7 +958,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
953
958
|
},
|
|
954
959
|
{ default: {} },
|
|
955
960
|
),
|
|
956
|
-
// cms ENRICHMENT (
|
|
961
|
+
// cms ENRICHMENT (guide ch. 4): the read-time relation budget. The worker
|
|
957
962
|
// clamps via resolveRelationsConfig (enrich.ts) with the SAME defaults +
|
|
958
963
|
// hard ceilings, so an out-of-range value can never widen the bound.
|
|
959
964
|
relations: Type.Object(
|
|
@@ -993,7 +998,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
993
998
|
}),
|
|
994
999
|
),
|
|
995
1000
|
),
|
|
996
|
-
// Declarative relational read-models (
|
|
1001
|
+
// Declarative relational read-models (guide ch. 4).
|
|
997
1002
|
// Each is a NAMED, closed-grammar aggregate/rank spec, optionally
|
|
998
1003
|
// materialized to a rollup collection on the EXISTING jobs cron (the
|
|
999
1004
|
// fn-cron:* reconciler idiom → cms-rollup:* schedules). Grammar is validated
|
|
@@ -1004,7 +1009,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
1004
1009
|
Type.Object({
|
|
1005
1010
|
collection: Type.String({ maxLength: 64 }),
|
|
1006
1011
|
kind: Type.Union([Type.Literal('aggregate'), Type.Literal('rank')]),
|
|
1007
|
-
// the
|
|
1012
|
+
// the aggregate/rank query body minus limit — Type.Unknown so Value.Clean keeps it
|
|
1008
1013
|
// (the functions `signature` idiom); shape checked by the cross-field rule.
|
|
1009
1014
|
spec: Type.Unknown(),
|
|
1010
1015
|
materialize: Type.Optional(Type.Object({
|
|
@@ -1039,7 +1044,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
1039
1044
|
|
|
1040
1045
|
export type CmsConfig = Static<typeof CmsConfigSchema>;
|
|
1041
1046
|
|
|
1042
|
-
// mcp feature (
|
|
1047
|
+
// mcp feature (guide ch. 10): the aggregation surface's own knobs. Default-enabled —
|
|
1043
1048
|
// a tenant with no mcp config row still gets the aggregated tool list.
|
|
1044
1049
|
export const McpConfigSchema = Type.Object({
|
|
1045
1050
|
enabled: Type.Boolean({ default: true }),
|
|
@@ -1048,7 +1053,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
1048
1053
|
{ default: 'all' },
|
|
1049
1054
|
),
|
|
1050
1055
|
allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
|
|
1051
|
-
// (
|
|
1056
|
+
// (2026-09-11) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
|
|
1052
1057
|
// a "dashboard-UI hint only" that no dashboard ever read, and key minting is a
|
|
1053
1058
|
// control-plane concern independent of MCP exposure — per-agent keys already
|
|
1054
1059
|
// work for every tenant, gated by nothing here.
|
|
@@ -1064,7 +1069,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
1064
1069
|
{ default: {} },
|
|
1065
1070
|
),
|
|
1066
1071
|
|
|
1067
|
-
// Tenant-authored CUSTOM tools (
|
|
1072
|
+
// Tenant-authored CUSTOM tools (guide ch. 10): name → tenant-owned https
|
|
1068
1073
|
// endpoint. mcp-v1 lists each as `custom_<name>` and POSTs the tool
|
|
1069
1074
|
// arguments to `url`, HMAC-signed with the per-tenant key from
|
|
1070
1075
|
// GET /v1/mcp/signing-secret (X-Vxil-Mcp-Signature; the caller's vxil bearer
|
|
@@ -1084,7 +1089,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
1084
1089
|
}),
|
|
1085
1090
|
)),
|
|
1086
1091
|
|
|
1087
|
-
// Config-declared MCP PROMPTS (
|
|
1092
|
+
// Config-declared MCP PROMPTS (guide ch. 10): name → template with
|
|
1088
1093
|
// {{placeholder}} interpolation. Served verbatim by mcp-v1 prompts/list +
|
|
1089
1094
|
// prompts/get. ONE Type.Record leaf; placeholder ↔ arguments consistency is
|
|
1090
1095
|
// a cross-field rule below.
|
|
@@ -1104,7 +1109,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
1104
1109
|
// Leaves: 8 by countLeaves (enabled, exposureLevel, allowToolList,
|
|
1105
1110
|
// rateLimits.toolCallsPerMin, branding.serverName, branding.serverInstructions
|
|
1106
1111
|
// = 6, + customTools + prompts as ONE Type.Record leaf each). Cap = 15.
|
|
1107
|
-
// (was 9 —
|
|
1112
|
+
// (was 9 — 2026-09-11 deleted the inert scopedKey.perAgentKeys leaf.)
|
|
1108
1113
|
|
|
1109
1114
|
export type McpConfig = Static<typeof McpConfigSchema>;
|
|
1110
1115
|
|
|
@@ -1136,7 +1141,7 @@ export const OrgsConfigSchema = Type.Object({
|
|
|
1136
1141
|
|
|
1137
1142
|
export type OrgsConfig = Static<typeof OrgsConfigSchema>;
|
|
1138
1143
|
|
|
1139
|
-
// activity-feed feature (
|
|
1144
|
+
// activity-feed feature (guide ch. 6, activity-feed): a GetStream-class activity-
|
|
1140
1145
|
// streams engine + a Knock/Novu-class in-app notification FEED. Flags govern
|
|
1141
1146
|
// the fan-out throttle, the follow/aggregation caps, and the cross-channel /
|
|
1142
1147
|
// realtime gates — NEVER the verb vocabulary or the personalized ranker (the
|
|
@@ -1156,8 +1161,8 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1156
1161
|
Type.Literal('aggregated'),
|
|
1157
1162
|
Type.Literal('notification'),
|
|
1158
1163
|
]),
|
|
1159
|
-
aggregation: Type.Optional(Type.String()), // group-format rule
|
|
1160
|
-
ranking: Type.Optional(Type.String()), // 'chronological' | 'decay'
|
|
1164
|
+
aggregation: Type.Optional(Type.String()), // group-format rule; required for aggregated/notification
|
|
1165
|
+
ranking: Type.Optional(Type.String()), // 'chronological' | 'decay'; flat-only; default chronological
|
|
1161
1166
|
}),
|
|
1162
1167
|
{
|
|
1163
1168
|
default: {
|
|
@@ -1175,7 +1180,7 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1175
1180
|
{
|
|
1176
1181
|
celebrityThreshold: Type.Integer({ default: 10_000, minimum: 0 }), // ≥ → pull (read-side); < → push (write-side)
|
|
1177
1182
|
maxFanoutPerJob: Type.Integer({ default: 1000, minimum: 1, maximum: 10_000 }), // follower batch size per jobs task
|
|
1178
|
-
maxConcurrentTasks: Type.Integer({ default: 20, minimum: 1, maximum: 1000 }), // per-tenant in-flight cap (
|
|
1183
|
+
maxConcurrentTasks: Type.Integer({ default: 20, minimum: 1, maximum: 1000 }), // per-tenant in-flight cap (local throttle)
|
|
1179
1184
|
pendingCeiling: Type.Integer({ default: 50_000, minimum: 1 }), // pending-fan-out-depth back-pressure ceiling
|
|
1180
1185
|
},
|
|
1181
1186
|
{ default: {} },
|
|
@@ -1189,7 +1194,7 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1189
1194
|
{ default: {} },
|
|
1190
1195
|
),
|
|
1191
1196
|
|
|
1192
|
-
// (
|
|
1197
|
+
// (2026-09-11) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
|
|
1193
1198
|
// or returned a per-group activity LIST — an aggregated read returns the group
|
|
1194
1199
|
// rollup (activity_count/actor_count/last_actor), so there was never an N to
|
|
1195
1200
|
// bound and no code read the leaf. Re-declare it with a group-detail route.
|
|
@@ -1203,7 +1208,7 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1203
1208
|
|
|
1204
1209
|
crossChannel: Type.Object(
|
|
1205
1210
|
{
|
|
1206
|
-
enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger
|
|
1211
|
+
enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger
|
|
1207
1212
|
digestCadence: Type.Union(
|
|
1208
1213
|
[Type.Literal('off'), Type.Literal('hourly'), Type.Literal('daily')],
|
|
1209
1214
|
{ default: 'off' },
|
|
@@ -1212,7 +1217,7 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1212
1217
|
{ default: {} },
|
|
1213
1218
|
),
|
|
1214
1219
|
|
|
1215
|
-
// (
|
|
1220
|
+
// (2026-09-11) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
|
|
1216
1221
|
// rate-limiter binding and never read it, so the declared per-tenant write
|
|
1217
1222
|
// burst was enforced by nothing (the edge front-door limiter and the
|
|
1218
1223
|
// per-tenant request meter are the real bounds). Re-declare it together with
|
|
@@ -1222,11 +1227,11 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1222
1227
|
// maxFanoutPerJob, maxConcurrentTasks, pendingCeiling}(+4=6), follow.{copyLimit,
|
|
1223
1228
|
// maxFollowing}(+2=8), realtime.enabled(9), crossChannel.{enabled, digestCadence}(+2=11).
|
|
1224
1229
|
// countLeaves → 11. Cap = 15. (feedGroups is Type.Record → patternProperties, ONE leaf.)
|
|
1225
|
-
// (was 13 —
|
|
1230
|
+
// (was 13 — 2026-09-11 deleted the inert aggregation and rateLimit bags.)
|
|
1226
1231
|
|
|
1227
1232
|
export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
|
|
1228
1233
|
|
|
1229
|
-
// vector-search feature (
|
|
1234
|
+
// vector-search feature (guide ch. 6, vector-search). Re-declared here to match the
|
|
1230
1235
|
// schema the worker EXPORTS from workers/vector-search-v1/src/config.ts — the
|
|
1231
1236
|
// control plane validates writes against this shared copy (the same
|
|
1232
1237
|
// one-definition / two-consumers note as the other features above; this package owns
|
|
@@ -1235,7 +1240,7 @@ export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
|
|
|
1235
1240
|
// synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
|
|
1236
1241
|
export const VectorSearchConfigSchema = Type.Object({
|
|
1237
1242
|
enabled: Type.Boolean({ default: true }),
|
|
1238
|
-
// 'auto' resolves to the default managed vector backend for the tier
|
|
1243
|
+
// 'auto' resolves to the default managed vector backend for the tier.
|
|
1239
1244
|
backend: Type.Union(
|
|
1240
1245
|
[Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')],
|
|
1241
1246
|
{ default: 'auto' },
|
|
@@ -1293,7 +1298,7 @@ export const VectorSearchConfigSchema = Type.Object({
|
|
|
1293
1298
|
),
|
|
1294
1299
|
model: Type.Optional(Type.String({ maxLength: 128 })), // cohere 'rerank-v3.5' / voyage 'rerank-2'
|
|
1295
1300
|
topN: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 200 })),
|
|
1296
|
-
apiKeyRef: Type.Optional(Type.String({ maxLength: 128 })), // 'secret:<name>'
|
|
1301
|
+
apiKeyRef: Type.Optional(Type.String({ maxLength: 128 })), // 'secret:<name>' in the tenant's secret store
|
|
1297
1302
|
}),
|
|
1298
1303
|
),
|
|
1299
1304
|
// Config-driven auto-embedding sync from cms collections: the control plane
|
|
@@ -1326,12 +1331,12 @@ export const VectorSearchConfigSchema = Type.Object({
|
|
|
1326
1331
|
|
|
1327
1332
|
export type VectorSearchConfig = Static<typeof VectorSearchConfigSchema>;
|
|
1328
1333
|
|
|
1329
|
-
// ai feature (
|
|
1334
|
+
// ai feature (guide ch. 6, ai). Re-declared to match workers/ai-v1/src/core.ts's
|
|
1330
1335
|
// exported AiConfigSchema. vxil owns the call SCAFFOLDING (routing, streaming,
|
|
1331
1336
|
// token accounting, caching, the reserve→settle budget); the tenant owns the
|
|
1332
1337
|
// intelligence (prompt TEMPLATES are config-as-code, stored/rendered but never
|
|
1333
1338
|
// authored). 'mock' is the deterministic default until a BYO key is provisioned;
|
|
1334
|
-
// the real providers route via
|
|
1339
|
+
// the real providers route via secret-store keyRefs.
|
|
1335
1340
|
export const AI_TEMPLATE_NAME_PATTERN = '^[a-zA-Z0-9_.\\-]+$';
|
|
1336
1341
|
export const AI_MAX_DECLARED_TEMPLATES = 50;
|
|
1337
1342
|
export const DeclaredAiTemplateSchema = Type.Object({
|
|
@@ -1352,7 +1357,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1352
1357
|
Type.Literal('gemini'), Type.Literal('azure'), Type.Literal('openrouter')],
|
|
1353
1358
|
{ default: 'mock' },
|
|
1354
1359
|
),
|
|
1355
|
-
// BYO keyRefs →
|
|
1360
|
+
// BYO keyRefs → the tenant's encrypted secret store. The block is NOT
|
|
1356
1361
|
// optional (the worker declares it plain), so its three optional refs each count
|
|
1357
1362
|
// as a leaf. The nested blocks carry `default: {}` (this package's convention)
|
|
1358
1363
|
// so Value.Default materializes them + recurses into the leaf defaults when a
|
|
@@ -1364,7 +1369,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1364
1369
|
geminiKeyRef: Type.Optional(Type.String()),
|
|
1365
1370
|
// The openai-compatible extension surface — ONE optional object = ONE config
|
|
1366
1371
|
// leaf (countLeaves collapses optional objects; 15-leaf cap discipline).
|
|
1367
|
-
// compat.openrouterKeyRef: BYO OpenRouter key (
|
|
1372
|
+
// compat.openrouterKeyRef: BYO OpenRouter key (a secret-store ref).
|
|
1368
1373
|
// compat.openaiBaseUrl: point the openai adapter at ANY openai-compatible
|
|
1369
1374
|
// host (DeepSeek, vLLM, an Azure-compatible proxy). Public-https validated
|
|
1370
1375
|
// at config WRITE (publicHttpsUrlError below) AND at USE (@vxil/runtime
|
|
@@ -1384,7 +1389,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1384
1389
|
tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
|
|
1385
1390
|
consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
|
|
1386
1391
|
}, { default: {} }),
|
|
1387
|
-
// `streaming` became an OPTIONAL bag (3 leaves → 1, the
|
|
1392
|
+
// `streaming` became an OPTIONAL bag (3 leaves → 1, the `retry` trick)
|
|
1388
1393
|
// on 2026-09-23 to fund the declared `templates[]` below. It KEEPS
|
|
1389
1394
|
// `default: {}`, so Value.Default still materializes
|
|
1390
1395
|
// streaming.{enabled,replayBufferFrames,flushMs} into every persisted
|
|
@@ -1393,12 +1398,11 @@ export const AiConfigSchema = Type.Object({
|
|
|
1393
1398
|
// here is optional.
|
|
1394
1399
|
streaming: Type.Optional(Type.Object({
|
|
1395
1400
|
enabled: Type.Boolean({ default: true }),
|
|
1396
|
-
replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), //
|
|
1397
|
-
flushMs: Type.Integer({ default: 50, minimum: 0 }), //
|
|
1401
|
+
replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), // replay ring-buffer depth
|
|
1402
|
+
flushMs: Type.Integer({ default: 50, minimum: 0 }), // token→frame coalesce window
|
|
1398
1403
|
}, { default: {} })),
|
|
1399
|
-
// DECLARED API STATE (
|
|
1400
|
-
// prompt templates as config. vxil STORES + versions, never authors (
|
|
1401
|
-
// §0) — declaring them here changes WHO writes the row (the repository, via
|
|
1404
|
+
// DECLARED API STATE (2026-09-23): the tenant's stored
|
|
1405
|
+
// prompt templates as config. vxil STORES + versions, never authors — declaring them here changes WHO writes the row (the repository, via
|
|
1402
1406
|
// `vxil push`), not what vxil does with it. Converged by CONTENT: the
|
|
1403
1407
|
// control-plane hashes each declared entry (@vxil/runtime
|
|
1404
1408
|
// aiTemplateContentSha256) against the `content_sha256` GET /v1/ai/templates
|
|
@@ -1407,7 +1411,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1407
1411
|
// Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
|
|
1408
1412
|
// name). Stored templates the config does not declare are reported and left
|
|
1409
1413
|
// in place — RETIRED (soft: hidden from list + render, history kept) only
|
|
1410
|
-
// under --allow-destructive (
|
|
1414
|
+
// under --allow-destructive (2026-10-01). Bounded to 50 entries: the
|
|
1411
1415
|
// manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
|
|
1412
1416
|
templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
|
|
1413
1417
|
});
|
|
@@ -1420,7 +1424,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1420
1424
|
|
|
1421
1425
|
export type AiConfig = Static<typeof AiConfigSchema>;
|
|
1422
1426
|
|
|
1423
|
-
// rag feature (
|
|
1427
|
+
// rag feature (guide ch. 6, rag). Re-declared to match workers/rag-v1/src/config.ts's
|
|
1424
1428
|
// exported RagConfigSchema. rag owns the PIPELINE knobs only — retrieval budget,
|
|
1425
1429
|
// context budget + strategy + tokenizer, the citation/stream gates — NEVER the
|
|
1426
1430
|
// prompt, the synthesis, or relevance tuning (the tenant's `ai` template owns those).
|
|
@@ -1444,7 +1448,7 @@ export const RagConfigSchema = Type.Object({
|
|
|
1444
1448
|
{ default: {} },
|
|
1445
1449
|
),
|
|
1446
1450
|
// declarative per-metadata-field relevance boosts applied in rag AFTER
|
|
1447
|
-
// retrieval, BEFORE minScore/budget/grounding (
|
|
1451
|
+
// retrieval, BEFORE minScore/budget/grounding (guide ch. 6, rag). ONE Type.Record
|
|
1448
1452
|
// leaf (the activity-feed feedGroups precedent).
|
|
1449
1453
|
boosts: Type.Record(Type.String(), Type.Union([
|
|
1450
1454
|
Type.Object({
|
|
@@ -1459,13 +1463,13 @@ export const RagConfigSchema = Type.Object({
|
|
|
1459
1463
|
]), { default: {} }),
|
|
1460
1464
|
context: Type.Object(
|
|
1461
1465
|
{
|
|
1462
|
-
// bounded context budget — enforced via the
|
|
1466
|
+
// bounded context budget — enforced via the tokenizer, BEFORE the ai call.
|
|
1463
1467
|
maxTokens: Type.Integer({ default: 4000, minimum: 1, maximum: 1_000_000 }),
|
|
1464
1468
|
strategy: Type.Union([Type.Literal('topk'), Type.Literal('mmr')], {
|
|
1465
1469
|
default: 'topk',
|
|
1466
1470
|
}),
|
|
1467
1471
|
// 'provider' = the resolved ai provider's tokenizer; 'heuristic' = portable
|
|
1468
|
-
// ~chars/4 with a safety margin
|
|
1472
|
+
// ~chars/4 with a safety margin.
|
|
1469
1473
|
tokenizer: Type.Union([Type.Literal('provider'), Type.Literal('heuristic')], {
|
|
1470
1474
|
default: 'provider',
|
|
1471
1475
|
}),
|
|
@@ -1484,7 +1488,7 @@ export const RagConfigSchema = Type.Object({
|
|
|
1484
1488
|
|
|
1485
1489
|
export type RagConfig = Static<typeof RagConfigSchema>;
|
|
1486
1490
|
|
|
1487
|
-
// payments feature (
|
|
1491
|
+
// payments feature (guide ch. 6, payments). Re-declared to match the schema the
|
|
1488
1492
|
// worker EXPORTS from workers/payments-v1/src/core.ts — the control plane
|
|
1489
1493
|
// validates writes against this shared copy (one-definition / two-consumers,
|
|
1490
1494
|
// like the other features). Two co-equal pillars: (A) provider payments (the
|
|
@@ -1503,9 +1507,9 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1503
1507
|
Type.Literal('revenuecat'), Type.Literal('paypal')],
|
|
1504
1508
|
{ default: 'mock' },
|
|
1505
1509
|
),
|
|
1506
|
-
// BYO-key credential blocks →
|
|
1510
|
+
// BYO-key credential blocks → the tenant's encrypted secret store.
|
|
1507
1511
|
// Each OPTIONAL object counts as ONE leaf (the tenant's decision is
|
|
1508
|
-
// "configure it or not", not each inner ref —
|
|
1512
|
+
// "configure it or not", not each inner ref — guide ch. 6, auth).
|
|
1509
1513
|
stripe: Type.Optional(Type.Object({
|
|
1510
1514
|
secretKeyRef: Type.String(),
|
|
1511
1515
|
webhookSecretRef: Type.String(),
|
|
@@ -1520,25 +1524,24 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1520
1524
|
projectId: Type.String(),
|
|
1521
1525
|
publicSdkKey: Type.String(),
|
|
1522
1526
|
secretApiKeyRef: Type.String(),
|
|
1523
|
-
// Per-tenant webhook secret ref (
|
|
1527
|
+
// Per-tenant webhook secret ref (secret store). Inbound RevenueCat
|
|
1524
1528
|
// webhooks are verified against THIS ref and nothing else: there is no
|
|
1525
1529
|
// platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
|
|
1526
|
-
// (that fallback WAS
|
|
1527
|
-
// frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
|
|
1530
|
+
// (that fallback WAS a cross-tenant webhook-forgery vector; a CI gate now
|
|
1528
1531
|
// permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
|
|
1529
1532
|
// Optional at the SCHEMA level only — leaving it unset does not disable
|
|
1530
1533
|
// verification, it fails CLOSED: every delivery is 401 bad_signature with a
|
|
1531
1534
|
// `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
|
|
1532
1535
|
// webhookSecretRef.
|
|
1533
1536
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
1534
|
-
// Environment integrity
|
|
1537
|
+
// Environment integrity. RevenueCat posts SANDBOX
|
|
1535
1538
|
// and PRODUCTION events to the SAME webhook with the same auth header, so a
|
|
1536
1539
|
// sandbox purchase would otherwise fold into production entitlements. A
|
|
1537
1540
|
// sandbox event is persisted as outcome 'rejected_environment' (200, never
|
|
1538
1541
|
// folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
|
|
1539
1542
|
// environments by signing secret / API base, so only RC carries this knob.
|
|
1540
1543
|
acceptSandbox: Type.Boolean({ default: false }),
|
|
1541
|
-
// (2026-10-01
|
|
1544
|
+
// (2026-10-01) Store-review purchases on a PRODUCTION tenant:
|
|
1542
1545
|
// the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
|
|
1543
1546
|
// whose subject (and, for a TRANSFER, every source user) is listed here
|
|
1544
1547
|
// folds — recorded `environment: 'sandbox'` on the delivery, the
|
|
@@ -1559,9 +1562,9 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1559
1562
|
// Where a provider-hosted flow sends the payer back (Stripe billing-portal
|
|
1560
1563
|
// return, PayPal approval return/cancel) — the TENANT's own app URL,
|
|
1561
1564
|
// https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
|
|
1562
|
-
// hardcoded host (
|
|
1565
|
+
// hardcoded host (2026-07-10: the old fallback pointed at a dead apex).
|
|
1563
1566
|
returnUrl: Type.Optional(Type.String({ pattern: '^https://', maxLength: 512 })),
|
|
1564
|
-
// NB (
|
|
1567
|
+
// NB (2026-09-11): the former `prices.catalogRef` leaf was DELETED —
|
|
1565
1568
|
// it named nothing (prices resolve from ledger.priceMap; no code path ever
|
|
1566
1569
|
// read it). A persisted manifest that still carries `prices` folds:
|
|
1567
1570
|
// validateFeatureConfig's Value.Clean strips the stray key.
|
|
@@ -1579,11 +1582,11 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1579
1582
|
// product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
|
|
1580
1583
|
// `boosts` Record-of-Union precedent) with two rule shapes:
|
|
1581
1584
|
// { creditType, amount, period } a credit grant (the original rule)
|
|
1582
|
-
// { tier, durationDays } (2026-09-25
|
|
1585
|
+
// { tier, durationDays } (2026-09-25) a TIME-BOXED
|
|
1583
1586
|
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
1584
1587
|
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
1585
1588
|
// STACKS behind the user's live same-tier manual rows that have an end
|
|
1586
|
-
// (earlier passes AND comp grants since 2026-09-25
|
|
1589
|
+
// (earlier passes AND comp grants since 2026-09-25 — never a row
|
|
1587
1590
|
// linked to the same charge, an open-ended grant or a provider
|
|
1588
1591
|
// subscription) and is ENDED by that charge's full refund / chargeback.
|
|
1589
1592
|
// No defaults in either shape, so an existing manifest is
|
|
@@ -1591,7 +1594,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1591
1594
|
productMap: Type.Record(Type.String(), Type.Union([
|
|
1592
1595
|
Type.Object({
|
|
1593
1596
|
creditType: Type.String({ minLength: 1 }),
|
|
1594
|
-
amount: Type.Integer({ minimum: 1 }), //
|
|
1597
|
+
amount: Type.Integer({ minimum: 1 }), // a grant only ADDS
|
|
1595
1598
|
period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
|
|
1596
1599
|
Type.Literal('annual')]),
|
|
1597
1600
|
}),
|
|
@@ -1602,13 +1605,13 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1602
1605
|
])),
|
|
1603
1606
|
tierMap: Type.Record(Type.String(), Type.Object({ // tier → entitlement/quota/grant
|
|
1604
1607
|
entitlements: Type.Array(Type.String()),
|
|
1605
|
-
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), //
|
|
1606
|
-
rank: Type.Optional(Type.Integer({ minimum: 0 })), // precedence for the multi-sub fold
|
|
1608
|
+
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // no negative quota
|
|
1609
|
+
rank: Type.Optional(Type.Integer({ minimum: 0 })), // precedence for the multi-sub fold
|
|
1607
1610
|
grants: Type.Optional(Type.Array(Type.Object({
|
|
1608
1611
|
creditType: Type.String({ minLength: 1 }),
|
|
1609
|
-
amount: Type.Integer({ minimum: 1 }), //
|
|
1612
|
+
amount: Type.Integer({ minimum: 1 }), // a grant only ADDS
|
|
1610
1613
|
period: Type.String(),
|
|
1611
|
-
// (2026-10-01
|
|
1614
|
+
// (2026-10-01) 'add' (the reader's default, today's
|
|
1612
1615
|
// behaviour) ADDS `amount` each period; 'reset' makes the period's
|
|
1613
1616
|
// grant REPLACE what is left: the unspent available balance of
|
|
1614
1617
|
// `creditType` is written off as one `expire` ledger row and `amount`
|
|
@@ -1624,7 +1627,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1624
1627
|
// fold (refoldEntitlements WHERE tier IS NOT NULL) reflects the subscription.
|
|
1625
1628
|
priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
1626
1629
|
autoRefundOnJobFailure: Type.Boolean({ default: true }), // consume(jobId) reverses on DLQ/timeout
|
|
1627
|
-
// Grace window
|
|
1630
|
+
// Grace window: a `past_due` subscription stays
|
|
1628
1631
|
// entitled for this many days AFTER its current_period_end (the dunning
|
|
1629
1632
|
// window the provider is retrying inside). 0 = today's behaviour (a past_due
|
|
1630
1633
|
// row is never entitled). ONE predicate in the fold — NOT a dunning ladder:
|
|
@@ -1632,8 +1635,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1632
1635
|
grace: Type.Optional(Type.Object({
|
|
1633
1636
|
pastDueDays: Type.Integer({ default: 0, minimum: 0, maximum: 90 }),
|
|
1634
1637
|
})),
|
|
1635
|
-
// Opt-in period-end enforcement (
|
|
1636
|
-
// option a). ABSENT (the default) = today's behaviour: a subscription whose
|
|
1638
|
+
// Opt-in period-end enforcement. ABSENT (the default) = today's behaviour: a subscription whose
|
|
1637
1639
|
// current_period_end passed with no provider event stays entitled forever
|
|
1638
1640
|
// (the provider is the only clock). PRESENT = the nightly reconcile sweep
|
|
1639
1641
|
// flips an `active`/`trialing` row whose current_period_end + slackHours
|
|
@@ -1668,10 +1670,10 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1668
1670
|
// (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
|
|
1669
1671
|
unmappedProduct: Type.Optional(Type.Union([Type.Literal('error'), Type.Literal('ignore')])),
|
|
1670
1672
|
})),
|
|
1671
|
-
// NB
|
|
1673
|
+
// NB: the former `webhooks.forwardToTenantUrl` leaf
|
|
1672
1674
|
// was DELETED — it had zero readers (never forwarded anything). Outbound
|
|
1673
|
-
// delivery of payments state changes rides the
|
|
1674
|
-
//
|
|
1675
|
+
// delivery of payments state changes rides the audit stream → outbound
|
|
1676
|
+
// webhooks: subscribe to the `payments.` event prefix (guide ch. 6, payments).
|
|
1675
1677
|
});
|
|
1676
1678
|
// Leaves: enabled(1), provider(2), stripe?(3), paddle?(4), revenuecat?(5),
|
|
1677
1679
|
// paypal?(6), returnUrl(7), defaults.{currency,trialDays}(+2=9), ledger?(10).
|
|
@@ -1683,24 +1685,24 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1683
1685
|
|
|
1684
1686
|
export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
|
|
1685
1687
|
|
|
1686
|
-
// functions feature (
|
|
1688
|
+
// functions feature (guide ch. 8). Tenant-deployed backend edge
|
|
1687
1689
|
// functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
|
|
1688
1690
|
// scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
|
|
1689
1691
|
// bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
|
|
1690
1692
|
// never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
|
|
1691
1693
|
// (cms.hooks / payments.ledger precedent), so any number of deployed functions
|
|
1692
|
-
// never grows the flag cap.
|
|
1694
|
+
// never grows the flag cap. Tenant code on the platform — paid, tier-walled,
|
|
1693
1695
|
// opt-in (enabled defaults to false), egress-guarded.
|
|
1694
1696
|
export const FunctionsConfigSchema = Type.Object({
|
|
1695
1697
|
enabled: Type.Boolean({ default: false }),
|
|
1696
|
-
// (
|
|
1697
|
-
// is
|
|
1698
|
+
// (2026-09-11) `runtime` ('isolate' | 'container') was DELETED: the container lane
|
|
1699
|
+
// is not built, nothing read the leaf, and accepting 'container' silently
|
|
1698
1700
|
// ran the isolate anyway. It comes back with the lane, not before.
|
|
1699
1701
|
defaultLimits: Type.Object(
|
|
1700
1702
|
{
|
|
1701
1703
|
// cpuMs is the ONLY per-dispatch limit the managed runtime accepts and the
|
|
1702
1704
|
// only one anything reads (functions-v1 meter.ts + the dispatch cap).
|
|
1703
|
-
// (
|
|
1705
|
+
// (2026-09-11) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
|
|
1704
1706
|
// runtime and not tenant-selectable, and no wall-clock abort was ever
|
|
1705
1707
|
// applied — a declared 10s default that nothing enforced.
|
|
1706
1708
|
cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 300_000 }),
|
|
@@ -1751,7 +1753,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1751
1753
|
source: Type.Optional(Type.String()), // webhook/queue: source/queue id
|
|
1752
1754
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
1753
1755
|
event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
|
|
1754
|
-
//
|
|
1756
|
+
// (2026-09-25) the per-binding opt-in to re-delivery on
|
|
1755
1757
|
// queue / webhook / cmsHook / authHook (the cross-field rule
|
|
1756
1758
|
// rejects it on http / cron). Absent = the ACK-200 default. The
|
|
1757
1759
|
// receiver answers a failed attempt as an enveloped 503 (ladder)
|
|
@@ -1778,7 +1780,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1778
1780
|
// the settings it was deployed and tested with, not today's. Absent =
|
|
1779
1781
|
// the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
|
|
1780
1782
|
// strings on purpose (the deploy writes them from the one constant).
|
|
1781
|
-
// cpuMs (
|
|
1783
|
+
// cpuMs (2026-10-01): the per-invoke CPU limit the script was
|
|
1782
1784
|
// uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
|
|
1783
1785
|
// cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
|
|
1784
1786
|
// rewrites it after a tier change. Absent = the platform default.
|
|
@@ -1819,10 +1821,9 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1819
1821
|
// egress guard as an outbound parameter. NOT an invocation
|
|
1820
1822
|
// budget: the INVOCATION is bounded by the platform's own
|
|
1821
1823
|
// FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
|
|
1822
|
-
// bigger per-fetch budget widens with you
|
|
1823
|
-
// (
|
|
1824
|
-
//
|
|
1825
|
-
// per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
|
|
1824
|
+
// bigger per-fetch budget widens with you.
|
|
1825
|
+
// (`memoryMb` is deleted — memory is fixed by the managed runtime and is
|
|
1826
|
+
// not a per-dispatch option; a dispatch takes { cpuMs, subRequests }.
|
|
1826
1827
|
// Value.Clean strips it from an old config, so such a config still loads
|
|
1827
1828
|
// and `vxil plan --explain` marks the key DROPPED.)
|
|
1828
1829
|
limits: Type.Optional(
|
|
@@ -1832,7 +1833,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1832
1833
|
}),
|
|
1833
1834
|
),
|
|
1834
1835
|
enabled: Type.Optional(Type.Boolean()),
|
|
1835
|
-
// Level-1 typed I/O (
|
|
1836
|
+
// Level-1 typed I/O (guide ch. 8): the declared input/output
|
|
1836
1837
|
// contract, persisted by the deploy body so ONLINE `vxil gen` emits the
|
|
1837
1838
|
// same typed fn client as --offline. Opaque JSON-schema-ish payloads —
|
|
1838
1839
|
// the CLI's lowerSig lowers them; the platform never interprets them.
|
|
@@ -1848,7 +1849,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1848
1849
|
// functions(4, the Type.Record bag → ONE leaf; bindings[]/scriptRef/scopes/
|
|
1849
1850
|
// secrets/egressAllow/limits/enabled/signature are DATA inside the MAP value
|
|
1850
1851
|
// and never move the count, like cms.hooks). countLeaves → 4. Cap = 15.
|
|
1851
|
-
// (was 7 —
|
|
1852
|
+
// (was 7 — 2026-09-11 deleted the inert runtime, defaultLimits.timeoutMs and
|
|
1852
1853
|
// defaultLimits.memoryMb leaves.)
|
|
1853
1854
|
export type FunctionsConfig = Static<typeof FunctionsConfigSchema>;
|
|
1854
1855
|
|
|
@@ -1934,7 +1935,7 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
1934
1935
|
{ default: {} },
|
|
1935
1936
|
),
|
|
1936
1937
|
|
|
1937
|
-
// (
|
|
1938
|
+
// (2026-09-11) the `escalation` bag ({enabled, handler, notifyTemplate}) was
|
|
1938
1939
|
// DELETED: the human hand-off it declared was never built — copilot-v1 read
|
|
1939
1940
|
// none of the three leaves, so a tenant who turned it on got silence. The
|
|
1940
1941
|
// shipped hand-off path is a tenant function on the conversation events.
|
|
@@ -1942,7 +1943,7 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
1942
1943
|
// ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
|
|
1943
1944
|
limits: Type.Object({
|
|
1944
1945
|
consumeCredits: Type.Boolean({ default: false }),
|
|
1945
|
-
// (
|
|
1946
|
+
// (2026-09-11) `tokensPerUserPerDay` was DELETED here: token accounting is
|
|
1946
1947
|
// delegated to ai-v1 (this bag's own doctrine) and only `ai`'s
|
|
1947
1948
|
// limits.tokensPerUserPerDay is enforced — the copilot twin read nothing.
|
|
1948
1949
|
}, { default: {} }),
|
|
@@ -1958,7 +1959,7 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
1958
1959
|
// Leaves: enabled(1), agents(2 — Type.Record MAP, ONE leaf),
|
|
1959
1960
|
// limits.consumeCredits(3), widget.{enabled,requireAuth,allowedOrigins,theme}
|
|
1960
1961
|
// (+4=7). countLeaves → 7. Cap = 15.
|
|
1961
|
-
// (was 9 —
|
|
1962
|
+
// (was 9 — 2026-09-11 deleted the inert escalation bag and limits.tokensPerUserPerDay.)
|
|
1962
1963
|
|
|
1963
1964
|
export type CopilotConfig = Static<typeof CopilotConfigSchema>;
|
|
1964
1965
|
|
|
@@ -1986,11 +1987,11 @@ export const FEATURE_SCHEMAS: Record<string, TSchema> = {
|
|
|
1986
1987
|
|
|
1987
1988
|
export const CONFIG_FLAG_CAP = 15;
|
|
1988
1989
|
|
|
1989
|
-
/** Counts leaf flags in a TypeBox object schema
|
|
1990
|
-
* Per
|
|
1990
|
+
/** Counts leaf flags in a TypeBox object schema.
|
|
1991
|
+
* Per guide ch. 6, auth: an OPTIONAL object (e.g. a provider credential
|
|
1991
1992
|
* block) counts as ONE flag — the tenant's decision is "configure it or
|
|
1992
1993
|
* not", not each inner ref. */
|
|
1993
|
-
/** The auth lifecycle events an `authHook` binding may name
|
|
1994
|
+
/** The auth lifecycle events an `authHook` binding may name — the
|
|
1994
1995
|
* closed union `packages/config` types as AuthHookEvent; the control-plane
|
|
1995
1996
|
* reconciler maps each to its `auth.<event>` audit-event prefix. */
|
|
1996
1997
|
export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'] as const;
|
|
@@ -2003,7 +2004,7 @@ export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
|
|
|
2003
2004
|
* else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
|
|
2004
2005
|
* to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
|
|
2005
2006
|
* 'beforeDelete' silently subscribed the function to creates AND updates.
|
|
2006
|
-
*
|
|
2007
|
+
* A CI gate pins every copy to this list. */
|
|
2007
2008
|
export const CMS_HOOK_EVENTS = ['beforeCreate', 'beforeUpdate', 'beforeWrite'] as const;
|
|
2008
2009
|
export type CmsHookEvent = (typeof CMS_HOOK_EVENTS)[number];
|
|
2009
2010
|
|
|
@@ -2015,7 +2016,7 @@ export function cmsHookEventError(event: unknown): string | null {
|
|
|
2015
2016
|
return `a 'cmsHook' binding's event must be one of ${CMS_HOOK_EVENTS.join(' | ')} (or omitted = beforeWrite), not ${JSON.stringify(event)}`;
|
|
2016
2017
|
}
|
|
2017
2018
|
|
|
2018
|
-
/**
|
|
2019
|
+
/** The OTP test-recipient entry grammar (shared by the validator and auth-v1's
|
|
2019
2020
|
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
2020
2021
|
export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
|
|
2021
2022
|
export const TEST_RECIPIENT_GLOB_RE = /^\*@[^\s@*]+\.[^\s@*]+$/;
|
|
@@ -2078,7 +2079,7 @@ export function setKnownMcpTools(names: readonly string[]): void {
|
|
|
2078
2079
|
}
|
|
2079
2080
|
|
|
2080
2081
|
// ── mcp custom-tool / prompt caps + the pure public-https check ──────────────
|
|
2081
|
-
// (
|
|
2082
|
+
// (guide ch. 10) Caps are deliberately tighter than the 200-tool listing cap:
|
|
2082
2083
|
// each custom tool is a platform-signed egress target, so the bag stays small.
|
|
2083
2084
|
export const MCP_MAX_CUSTOM_TOOLS = 32;
|
|
2084
2085
|
export const MCP_MAX_PROMPTS = 32;
|
|
@@ -2136,7 +2137,7 @@ function publicHttpsUrlError(url: string): string | null {
|
|
|
2136
2137
|
* validation below and the control-plane deploy clamp. */
|
|
2137
2138
|
export const DENY_FUNCTION_SCOPES = new Set(['admin', '*', 'features:write', 'functions:write', 'secrets:write']);
|
|
2138
2139
|
|
|
2139
|
-
// ── declared API state — config-write validators
|
|
2140
|
+
// ── declared API state — config-write validators ────────────────────────────
|
|
2140
2141
|
/** `{var}` names of a rate-limit key template (rate-limits-v1 core.ts
|
|
2141
2142
|
* templateVars parity). */
|
|
2142
2143
|
export function rlTemplateVars(template: string): string[] {
|
|
@@ -2219,6 +2220,13 @@ export function validateDeclaredAiTemplates(templates: readonly DeclaredAiTempla
|
|
|
2219
2220
|
return errs;
|
|
2220
2221
|
}
|
|
2221
2222
|
|
|
2223
|
+
function badVariantPresetNames(raw: unknown): string[] {
|
|
2224
|
+
const variants = (raw as { publicAssets?: { variants?: unknown } } | null)?.publicAssets?.variants;
|
|
2225
|
+
if (!variants || typeof variants !== 'object' || Array.isArray(variants)) return [];
|
|
2226
|
+
const re = new RegExp(VARIANT_PRESET_NAME_PATTERN);
|
|
2227
|
+
return Object.keys(variants).filter((k) => !re.test(k));
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2222
2230
|
export function validateFeatureConfig(feature: string, raw: unknown): ConfigValidation {
|
|
2223
2231
|
const schema = FEATURE_SCHEMAS[feature];
|
|
2224
2232
|
if (!schema) {
|
|
@@ -2227,13 +2235,25 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2227
2235
|
if (countLeaves(schema) > CONFIG_FLAG_CAP) {
|
|
2228
2236
|
return { ok: false, errors: [`schema for '${feature}' exceeds the ${CONFIG_FLAG_CAP}-flag cap`] };
|
|
2229
2237
|
}
|
|
2238
|
+
// files.publicAssets.variants is a pattern-keyed record: Value.Clean below
|
|
2239
|
+
// would silently DROP a preset whose name is not URL-safe, and the tenant
|
|
2240
|
+
// would wonder why `/v/My Thumb` 404s. Refuse it by name instead.
|
|
2241
|
+
if (feature === 'files') {
|
|
2242
|
+
const bad = badVariantPresetNames(raw);
|
|
2243
|
+
if (bad.length > 0) {
|
|
2244
|
+
return {
|
|
2245
|
+
ok: false,
|
|
2246
|
+
errors: bad.map((n) => `/publicAssets/variants/${n}: a preset name is lower-case letters, numbers, '-' or '_' (at most 32 characters, starting with a letter or number)`),
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2230
2250
|
// Apply defaults to a clone, then STRIP any property the schema does not
|
|
2231
|
-
// declare, then check. Value.Clean makes the validator TOTAL
|
|
2251
|
+
// declare, then check. Value.Clean makes the validator TOTAL:
|
|
2232
2252
|
// the schemas are open Type.Object()s, so without it Value.Check passes on —
|
|
2233
2253
|
// and putConfig would persist — arbitrary unknown keys. Clean runs AFTER
|
|
2234
2254
|
// Default so materialized nested defaults survive but stray top-level/nested
|
|
2235
2255
|
// keys are dropped. This also removes the CLI dry-run idempotency drift
|
|
2236
|
-
//
|
|
2256
|
+
// too: plan/push diff the same cleaned manifest the server stores.
|
|
2237
2257
|
const withDefaults = Value.Clean(
|
|
2238
2258
|
schema,
|
|
2239
2259
|
Value.Default(schema, Value.Clone(raw)),
|
|
@@ -2277,7 +2297,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2277
2297
|
}
|
|
2278
2298
|
// Cross-field rule: a REAL payments provider needs its credential block; the
|
|
2279
2299
|
// 'mock' provider (the deterministic default) stays zero-config so the whole
|
|
2280
|
-
// ledger path is testable without real keys (
|
|
2300
|
+
// ledger path is testable without real keys (guide ch. 6, payments).
|
|
2281
2301
|
if (feature === 'payments') {
|
|
2282
2302
|
const v = withDefaults as {
|
|
2283
2303
|
provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
|
|
@@ -2299,7 +2319,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2299
2319
|
}
|
|
2300
2320
|
// A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
|
|
2301
2321
|
// the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
|
|
2302
|
-
// user, running vxil-billed functions for free
|
|
2322
|
+
// user, running vxil-billed functions for free. Rejected at write
|
|
2303
2323
|
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
2304
2324
|
const ledgerErrs: string[] = [];
|
|
2305
2325
|
const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
@@ -2309,7 +2329,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2309
2329
|
`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
2310
2330
|
);
|
|
2311
2331
|
}
|
|
2312
|
-
// (2026-09-25
|
|
2332
|
+
// (2026-09-25) an entitlement rule must name a declared tier — the
|
|
2313
2333
|
// write-time mirror of the runtime's unknown-tier refusal (a purchase for
|
|
2314
2334
|
// a tier nobody declared would land the delivery `error`).
|
|
2315
2335
|
if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
|
|
@@ -2360,7 +2380,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2360
2380
|
);
|
|
2361
2381
|
}
|
|
2362
2382
|
}
|
|
2363
|
-
//
|
|
2383
|
+
// An UNMAPPED price silently revoked a paying
|
|
2364
2384
|
// customer (priceMap miss → tier NULL → refold excluded the row). The
|
|
2365
2385
|
// reducer now stamps such an event outcome 'error' (reprocessable), and
|
|
2366
2386
|
// this lint catches the config half at `vxil push` time: every priceMap
|
|
@@ -2411,7 +2431,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2411
2431
|
];
|
|
2412
2432
|
if (rmErrors.length) return { ok: false, errors: rmErrors.slice(0, 10) };
|
|
2413
2433
|
}
|
|
2414
|
-
// Cross-field rules: mcp custom tools + prompts (
|
|
2434
|
+
// Cross-field rules: mcp custom tools + prompts (guide ch. 10). The URL
|
|
2415
2435
|
// check here is a PURE mirror of @vxil/runtime assertPublicHttpsUrl (this
|
|
2416
2436
|
// package is typebox-only) — the authoritative runtime guard re-runs in
|
|
2417
2437
|
// mcp-v1 at call time; this gate rejects obviously-internal targets BEFORE
|
|
@@ -2477,7 +2497,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2477
2497
|
const tplErrs = validateDeclaredAiTemplates(v.templates);
|
|
2478
2498
|
if (tplErrs.length) return { ok: false, errors: tplErrs.slice(0, 10) };
|
|
2479
2499
|
}
|
|
2480
|
-
// Cross-field rules: declared API state
|
|
2500
|
+
// Cross-field rules: declared API state. Each list is
|
|
2481
2501
|
// converged by NAME/URL, so a duplicate key is ambiguous and refused at push;
|
|
2482
2502
|
// the per-item rules mirror the feature route's own validation so a declared
|
|
2483
2503
|
// entry can never be one the converge would 422 on.
|
|
@@ -2491,7 +2511,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2491
2511
|
const errs = validateDeclaredWebhookSubscriptions(v.subscriptions, v.maxSubscriptions);
|
|
2492
2512
|
if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
|
|
2493
2513
|
}
|
|
2494
|
-
// Cross-field rule: auth otp.testRecipients
|
|
2514
|
+
// Cross-field rule: auth otp.testRecipients — every entry must be an
|
|
2495
2515
|
// email, an `*@domain` glob, or a +E.164 number; anything else would never
|
|
2496
2516
|
// match and silently do nothing.
|
|
2497
2517
|
if (feature === 'auth') {
|
|
@@ -2547,7 +2567,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2547
2567
|
if (b.overlap !== undefined && b.kind !== 'cron') {
|
|
2548
2568
|
errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
|
|
2549
2569
|
}
|
|
2550
|
-
//
|
|
2570
|
+
// retry is an opt-in for the platform-delivered event lanes only —
|
|
2551
2571
|
// an http invoke returns its real status to its caller, and a cron
|
|
2552
2572
|
// tick's retry would overlap the next tick.
|
|
2553
2573
|
if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
|
|
@@ -2560,7 +2580,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2560
2580
|
// at write time, never a silent created+updated subscription.
|
|
2561
2581
|
const cmsEventErr = b.kind === 'cmsHook' ? cmsHookEventError(b.event) : null;
|
|
2562
2582
|
if (cmsEventErr) errs.push(`/functions/${name}/bindings/${i}: ${cmsEventErr}`);
|
|
2563
|
-
// authHook: a CLOSED event union
|
|
2583
|
+
// authHook: a CLOSED event union — reject typos at write time
|
|
2564
2584
|
// so a binding never silently subscribes to nothing.
|
|
2565
2585
|
if (b.kind === 'authHook' && b.event !== undefined && !(AUTH_HOOK_EVENTS as readonly string[]).includes(b.event)) {
|
|
2566
2586
|
errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be one of ${AUTH_HOOK_EVENTS.join(' | ')} (or omitted = user.created)`);
|