@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/dist/index.js
CHANGED
|
@@ -1,32 +1,37 @@
|
|
|
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 } 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
10
|
import { isFnTriggerSubscriptionUrl } from './apiState.js';
|
|
11
|
+
import { PublicAssetsConfigSchema, VARIANT_PRESET_NAME_PATTERN } from './publicAssets.js';
|
|
11
12
|
// Re-export the CMS lifecycle-hook engine so feature workers (cms-v1, runtime
|
|
12
13
|
// eval) and the control plane (config-time validation) share one definition.
|
|
13
14
|
export * from './hooks.js';
|
|
14
15
|
// Re-export the cms-rel read-model/cdc config gates (the pure-mirror split:
|
|
15
16
|
// grammar validated here at config-write; field existence at runtime).
|
|
16
17
|
export * from './readmodels.js';
|
|
17
|
-
// Re-export the DECLARED API STATE planner
|
|
18
|
+
// Re-export the DECLARED API STATE planner: pure
|
|
18
19
|
// declared-vs-live reconciliation, shared by the control-plane apply path and
|
|
19
20
|
// the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
|
|
20
21
|
export * from './apiState.js';
|
|
21
22
|
// Key-order-insensitive JSON: the one declared-vs-stored manifest compare
|
|
22
23
|
// (control-plane shallowDiff, CLI diffManifest).
|
|
23
24
|
export * from './canonicalJson.js';
|
|
25
|
+
// Public asset delivery (files.publicAssets): the publishable content-type
|
|
26
|
+
// allowlist, the URL/key shape and the variant presets — shared by files-v1
|
|
27
|
+
// (publish) and files-cdn (serve), which never import each other.
|
|
28
|
+
export * from './publicAssets.js';
|
|
24
29
|
// TypeBox validates `format:` only for registered formats — register the ones
|
|
25
30
|
// our schemas use (pragmatic RFC-lite email check; providers do the real one).
|
|
26
31
|
if (!FormatRegistry.Has('email')) {
|
|
27
32
|
FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
|
|
28
33
|
}
|
|
29
|
-
// ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth
|
|
34
|
+
// ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth ─────────────────
|
|
30
35
|
// `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
|
|
31
36
|
// functions "recover-by-price" meter). It must NEVER be grantable or consumable
|
|
32
37
|
// by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
|
|
@@ -36,7 +41,7 @@ if (!FormatRegistry.Has('email')) {
|
|
|
36
41
|
// config-write gate AND payments-v1 import) so the runtime choke point and the
|
|
37
42
|
// config-write refusal share ONE list. payments-v1/core.ts re-exports these.
|
|
38
43
|
export const RESERVED_CREDIT_TYPES = new Set(['fn_cpu_ms']);
|
|
39
|
-
/**
|
|
44
|
+
/** A function binding's `retry.maxAttempts` ceiling, and the
|
|
40
45
|
* binding kinds that may carry `retry` — the platform-delivered event lanes
|
|
41
46
|
* (an http invoke returns its own status; a cron tick's retry would overlap
|
|
42
47
|
* the next tick). Read by the schema, the deploy clamp and the CLI. */
|
|
@@ -47,7 +52,7 @@ export const FN_RETRY_BINDING_KINDS = new Set(['queue', 'webhook', 'cmsHook', 'a
|
|
|
47
52
|
export function isReservedCreditType(creditType) {
|
|
48
53
|
return RESERVED_CREDIT_TYPES.has(creditType);
|
|
49
54
|
}
|
|
50
|
-
// ── notifications template catalog (
|
|
55
|
+
// ── notifications template catalog (per-locale overrides) ────────────────────
|
|
51
56
|
// The SHIPPED template ids and the placeholders each body may interpolate. The
|
|
52
57
|
// renderer itself lives in workers/notifications-v1/src/templates.ts (templates
|
|
53
58
|
// are code, not rows); this table is the CONFIG-TIME half so a `vxil push` that
|
|
@@ -72,7 +77,7 @@ export const NOTIFICATION_TEMPLATE_PLACEHOLDERS = {
|
|
|
72
77
|
export const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS = {
|
|
73
78
|
transactional: ['cta'],
|
|
74
79
|
};
|
|
75
|
-
/** Per-override caps
|
|
80
|
+
/** Per-override caps. Bodies are emails, not documents. */
|
|
76
81
|
export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
|
|
77
82
|
export const NOTIF_OVERRIDE_BODY_MAX = 20_000;
|
|
78
83
|
/** Whole-map caps: keeps one manifest (and the KV row every send reads) small. */
|
|
@@ -84,7 +89,7 @@ const NOTIF_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
|
84
89
|
/** Same permissive BCP-47-ish shape the worker's auto-locale resolver accepts. */
|
|
85
90
|
const NOTIF_LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}$/;
|
|
86
91
|
/**
|
|
87
|
-
*
|
|
92
|
+
* Validate `templates.overrides` at CONFIG-WRITE time. Returns [] when the
|
|
88
93
|
* bag is absent or clean. Every rule fails LOUD rather than shipping something
|
|
89
94
|
* that silently renders wrong in a customer's inbox:
|
|
90
95
|
* • overrides present with `allowOverride:false` → rejected (never inert);
|
|
@@ -173,12 +178,12 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
173
178
|
// need no email account, so the mock path is zero-config. A cross-field
|
|
174
179
|
// check in validateFeatureConfig requires it only when provider === 'resend'.
|
|
175
180
|
resendApiKeyRef: Type.Optional(Type.String()),
|
|
176
|
-
// Optional per-tenant Resend/Svix ENDPOINT secret ref (
|
|
177
|
-
//
|
|
181
|
+
// Optional per-tenant Resend/Svix ENDPOINT secret ref (a pointer into the
|
|
182
|
+
// tenant's encrypted secret store — same store as resendApiKeyRef).
|
|
178
183
|
// When set, inbound Resend webhooks are verified with THIS tenant's secret
|
|
179
184
|
// instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
|
|
180
185
|
// to the tenant so a signed event for tenant A can never validate at tenant
|
|
181
|
-
// B's webhook URL (
|
|
186
|
+
// B's webhook URL (mirrors payments revenuecat.webhookSecretRef).
|
|
182
187
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
183
188
|
// 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP;
|
|
184
189
|
// 'ses' (2026-09-19) is the second real provider — Amazon SES v2, BYO IAM
|
|
@@ -190,7 +195,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
190
195
|
// cap rule — funded by folding `rateLimit` into an Optional bag below);
|
|
191
196
|
// NO `default: {}` so an absent bag stays absent (the auth `security`
|
|
192
197
|
// precedent). Both refs are `secret:<name>` POINTERS into
|
|
193
|
-
//
|
|
198
|
+
// the tenant's encrypted secret store (feature='notifications') —
|
|
194
199
|
// exactly how resendApiKeyRef resolves; the region is plain config (not a
|
|
195
200
|
// secret) and is pattern-pinned to the AWS region grammar so a typo fails
|
|
196
201
|
// at `vxil push` instead of as a DNS error on the first send. Required
|
|
@@ -212,7 +217,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
212
217
|
// nested objects carry `default: {}` so Value.Default can materialize them
|
|
213
218
|
// and then recurse into the leaf defaults.
|
|
214
219
|
// `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
|
|
215
|
-
// Optional object as ONE) to fund `broadcast` below (
|
|
220
|
+
// Optional object as ONE) to fund `broadcast` below (2026-07-18).
|
|
216
221
|
// It KEEPS `default: {}`, which Value.Default still materializes — so every
|
|
217
222
|
// persisted manifest carries retry.{maxAttempts,backoff} exactly as before
|
|
218
223
|
// (zero behavioral delta); only the TS type is now optional (workers read
|
|
@@ -224,7 +229,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
224
229
|
}),
|
|
225
230
|
}, { default: {} })),
|
|
226
231
|
suppression: Type.Object({ softBounceThreshold: Type.Integer({ default: 3 }) }, { default: {} }),
|
|
227
|
-
// `rateLimit` became an OPTIONAL bag (2 leaves → 1, the
|
|
232
|
+
// `rateLimit` became an OPTIONAL bag (2 leaves → 1, the `retry` trick)
|
|
228
233
|
// on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
|
|
229
234
|
// `default: {}`, so Value.Default still materializes
|
|
230
235
|
// rateLimit.{perDay,perTenantSec} into every persisted manifest exactly as
|
|
@@ -234,15 +239,15 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
234
239
|
perDay: Type.Integer({ default: 100000 }),
|
|
235
240
|
perTenantSec: Type.Integer({ default: 50 }),
|
|
236
241
|
}, { default: {} })),
|
|
237
|
-
// `templates` became an OPTIONAL bag (1 leaf, the same
|
|
238
|
-
// uses) to fund the
|
|
242
|
+
// `templates` became an OPTIONAL bag (1 leaf, the same trick `retry`
|
|
243
|
+
// uses) to fund the per-locale `overrides` map WITHOUT moving the count:
|
|
239
244
|
// countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
|
|
240
245
|
// Value.Default still materializes `templates.allowOverride` into every
|
|
241
246
|
// persisted manifest exactly as before — zero behavioral delta; only the TS
|
|
242
247
|
// type is optional (workers read via core.ts `templatesOf()`).
|
|
243
248
|
templates: Type.Optional(Type.Object({
|
|
244
249
|
allowOverride: Type.Boolean({ default: false }),
|
|
245
|
-
//
|
|
250
|
+
// (2026-09-10) TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
|
|
246
251
|
// template ids, as config DATA (a Record = 1 leaf, catalog size never
|
|
247
252
|
// moves the count). Shape: { [templateId]: { [locale]: { subject?,
|
|
248
253
|
// html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
|
|
@@ -267,10 +272,10 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
267
272
|
}, { default: {} })),
|
|
268
273
|
/** in-app inbox channel (send with channel: 'inbox' | 'both') */
|
|
269
274
|
inboxEnabled: Type.Boolean({ default: false }),
|
|
270
|
-
/**
|
|
275
|
+
/** Broadcast campaigns channel. Optional bag (= 1 leaf): absent means
|
|
271
276
|
* disabled; per-campaign quiet_hours / freq_cap overrides live on the
|
|
272
277
|
* notifications.campaigns ROW (tenant data), not here. Folded into the
|
|
273
|
-
* canonical schema 2026-07-18 (
|
|
278
|
+
* canonical schema 2026-07-18 (Value.Clean previously STRIPPED the
|
|
274
279
|
* worker-local extension, so campaigns 403'd via the real config path). */
|
|
275
280
|
broadcast: Type.Optional(Type.Object({
|
|
276
281
|
enabled: Type.Boolean({ default: false }),
|
|
@@ -290,16 +295,14 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
290
295
|
})),
|
|
291
296
|
});
|
|
292
297
|
// ── jobs `generation` block: the ONE declaration of its defaults and bounds ──
|
|
293
|
-
// (
|
|
294
|
-
// copy"). The JobsConfigSchema `generation` leaf reads these for its
|
|
298
|
+
// (2026-09-25 — one copy of the generation defaults, not two). The JobsConfigSchema `generation` leaf reads these for its
|
|
295
299
|
// `default` / `minimum` / `maximum`, and workers/jobs-v1/src/generation.ts
|
|
296
300
|
// imports them (jobs-v1 already depends on @vxil/feature-configs; this package
|
|
297
301
|
// has no @vxil/types dependency, so the shared value lives here — the
|
|
298
302
|
// RESERVED_CREDIT_TYPES precedent). A future edit changes one object; the
|
|
299
|
-
// feature-configs unit test pins schema ↔ constant, and
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
/** Generation-lifecycle config defaults (jobs.md §11 / §5 ≤15-flag budget). */
|
|
303
|
+
// feature-configs unit test pins schema ↔ constant, and a CI gate pins that no
|
|
304
|
+
// second object-literal declaration of the constant reappears anywhere.
|
|
305
|
+
/** Generation-lifecycle config defaults (guide ch. 6, jobs). */
|
|
303
306
|
export const GENERATION_DEFAULTS = {
|
|
304
307
|
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
305
308
|
maxConcurrent: 20,
|
|
@@ -339,7 +342,7 @@ export const JobsConfigSchema = Type.Object({
|
|
|
339
342
|
dlqDailyQuota: Type.Integer({ default: 0, minimum: 0, maximum: 100_000 }),
|
|
340
343
|
schedules: Type.Object({ maxPerTenant: Type.Integer({ default: 50, minimum: 1, maximum: 1000 }) }, { default: {} }),
|
|
341
344
|
concurrency: Type.Object({ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) }, { default: {} }),
|
|
342
|
-
//
|
|
345
|
+
// Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
|
|
343
346
|
// declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
|
|
344
347
|
// (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
|
|
345
348
|
// pre-fold manifest to the same numbers — a hand-mirrored copy used to live
|
|
@@ -353,14 +356,14 @@ export const JobsConfigSchema = Type.Object({
|
|
|
353
356
|
maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
|
|
354
357
|
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
355
358
|
pollMaxAttempts: Type.Integer({ default: GENERATION_DEFAULTS.pollMaxAttempts, minimum: GENERATION_BOUNDS.pollMaxAttempts.min, maximum: GENERATION_BOUNDS.pollMaxAttempts.max }),
|
|
356
|
-
/** MANDATORY per-hold cap on a generation `reserve_credits.amount`
|
|
357
|
-
*
|
|
359
|
+
/** MANDATORY per-hold cap on a generation `reserve_credits.amount`
|
|
360
|
+
* (guide ch. 6, jobs). Every requested amount is CLAMPED to this (never rejected) — a
|
|
358
361
|
* conservative default so an untrusted deployed function that carries a
|
|
359
362
|
* reserve block can never hold more than a bounded amount per run without
|
|
360
363
|
* any tenant action. */
|
|
361
364
|
maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
|
|
362
365
|
/** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
|
|
363
|
-
* reserve holds across all in-flight generation runs (
|
|
366
|
+
* reserve holds across all in-flight generation runs (guide ch. 6, jobs): a
|
|
364
367
|
* reserve whose amount would push the tenant's outstanding-holds total over
|
|
365
368
|
* this is rejected 429, so a runaway function cannot hold every user at
|
|
366
369
|
* once. Defaulted so no tenant action is required to be safe. */
|
|
@@ -368,43 +371,42 @@ export const JobsConfigSchema = Type.Object({
|
|
|
368
371
|
}, { default: {} }),
|
|
369
372
|
});
|
|
370
373
|
// One social-provider's BYO credential block. The *Ref fields are POINTERS into
|
|
371
|
-
//
|
|
372
|
-
//
|
|
374
|
+
// the tenant's encrypted secret store — never raw secrets (guide ch. 6, auth).
|
|
375
|
+
// google/github/facebook share the clientId/secret shape; apple is distinct
|
|
373
376
|
// (Sign in with Apple has no static secret — it mints an ES256 client_secret from
|
|
374
|
-
// the .p8, so it carries servicesId/teamId/keyId + a p8 keyRef
|
|
377
|
+
// the .p8, so it carries servicesId/teamId/keyId + a p8 keyRef).
|
|
375
378
|
const OAuthRefsSchema = Type.Object({
|
|
376
379
|
// clientId/secret refs are OPTIONAL at the schema layer: a tenant may stage a
|
|
377
380
|
// partial block, and the worker enforces presence at use (→ 501 if missing),
|
|
378
381
|
// matching the worker's OAuthProviderRefs shape (core.ts). They stay POINTERS
|
|
379
|
-
// into
|
|
382
|
+
// into the tenant's secret store — never raw secrets (guide ch. 6, auth).
|
|
380
383
|
clientIdRef: Type.Optional(Type.String()),
|
|
381
384
|
clientSecretRef: Type.Optional(Type.String()),
|
|
382
385
|
// extra native-aud allow-list entries (iOS/web client ids that differ from the
|
|
383
|
-
// primary clientIdRef) — also
|
|
386
|
+
// primary clientIdRef) — also secret-store refs (guide ch. 6, auth).
|
|
384
387
|
audRefs: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
|
|
385
388
|
});
|
|
386
389
|
const AppleRefsSchema = Type.Object({
|
|
387
390
|
servicesId: Type.String(), // the OAuth client_id / native aud (NOT a secret)
|
|
388
391
|
teamId: Type.String(),
|
|
389
392
|
keyId: Type.String(),
|
|
390
|
-
p8KeyRef: Type.String(), //
|
|
393
|
+
p8KeyRef: Type.String(), // secret-store ref → the encrypted .p8 PEM
|
|
391
394
|
// extra native-aud allow-list entries: genuine iOS ASAuthorization id_tokens
|
|
392
|
-
// carry the app BUNDLE ID as aud, not the Services ID (
|
|
395
|
+
// carry the app BUNDLE ID as aud, not the Services ID (guide ch. 6, auth). Plain
|
|
393
396
|
// config values — bundle ids are not secrets. The worker already honors them
|
|
394
397
|
// (oauthCore.ts nativeAudAllowList); declaring them here is what stops
|
|
395
398
|
// Value.Clean stripping the field out of PUT /v1/config/auth.
|
|
396
399
|
bundleIds: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
|
|
397
400
|
});
|
|
398
|
-
//
|
|
399
|
-
// §6.7). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
|
|
401
|
+
// The generic OIDC / SSO bridge (guide ch. 6, auth). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
|
|
400
402
|
// bag so it spends NO leaf (an Optional object bag = ONE leaf; auth stays 11).
|
|
401
403
|
// Declarative + opt-in: the block's PRESENCE enables the `oidc` provider (no
|
|
402
404
|
// methods.* toggle — a `default:false` flag would re-materialize every published
|
|
403
|
-
// manifest, breaking the
|
|
405
|
+
// manifest, breaking the byte-identical fold). The worker discovers the
|
|
404
406
|
// endpoints + JWKS from `{issuer}/.well-known/openid-configuration` and verifies
|
|
405
407
|
// iss (byte-equal) / aud (= clientId) / nonce / exp / iat strictly, fail-closed.
|
|
406
408
|
// `clientId` is NOT a secret (it rides every authorize URL); `clientSecretRef`
|
|
407
|
-
// is a
|
|
409
|
+
// is a secret-store POINTER (feature 'auth'), never the value. A SAML IdP
|
|
408
410
|
// plugs in through a broker (Okta / Entra / Auth0 / WorkOS) that speaks OIDC —
|
|
409
411
|
// there is deliberately NO native SAML.
|
|
410
412
|
const OidcProviderSchema = Type.Object({
|
|
@@ -428,7 +430,7 @@ const OidcProviderSchema = Type.Object({
|
|
|
428
430
|
// marks the email verified (the tenant declared the issuer authoritative).
|
|
429
431
|
allowedDomains: Type.Optional(Type.Array(Type.String({ minLength: 3, maxLength: 253, pattern: '^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$' }), { maxItems: 32 })),
|
|
430
432
|
// default true: an identity whose VERIFIED email matches an existing user is
|
|
431
|
-
// linked to it (the shipped
|
|
433
|
+
// linked to it (the shipped social sign-in rule). false: link only by the stable
|
|
432
434
|
// (issuer, sub) anchor; a matching email that is not yet linked → 409.
|
|
433
435
|
autoLink: Type.Optional(Type.Boolean()),
|
|
434
436
|
});
|
|
@@ -437,7 +439,7 @@ const OidcProviderSchema = Type.Object({
|
|
|
437
439
|
// on every caller-supplied `redirect_url` / `redirect_uri` (core.ts
|
|
438
440
|
// `isAllowedRedirectUrl`) must agree on WHICH schemes a sign-in link may be
|
|
439
441
|
// delivered to, or a tenant could allow-list an origin the worker then refuses
|
|
440
|
-
// (or the reverse). The rule (
|
|
442
|
+
// (or the reverse). The rule (2026-09-19): `https://` on any
|
|
441
443
|
// host, plus PLAIN `http://` ONLY on the two loopback names — `localhost` and
|
|
442
444
|
// `127.0.0.1`, any port — so a local dev server can complete a magic link
|
|
443
445
|
// without a staging origin. Any other `http://` (a LAN host, `*.local`, a
|
|
@@ -448,7 +450,7 @@ const OidcProviderSchema = Type.Object({
|
|
|
448
450
|
// with THIS predicate, and auth-v1 calls it on the parsed URL — the parity test
|
|
449
451
|
// in index.test.ts / redirectRule.test.ts holds the two together. The CLI's
|
|
450
452
|
// production promotion gate still refuses any `http://` origin on a
|
|
451
|
-
// production push
|
|
453
|
+
// production push, so a loopback entry is a DEV-tenant affair.
|
|
452
454
|
export const REDIRECT_ORIGIN_PATTERN = '^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$';
|
|
453
455
|
/** THE scheme rule for a redirect/return URL: https anywhere, http on loopback
|
|
454
456
|
* only. Takes the parsed URL (or any {protocol, hostname} pair) so the config
|
|
@@ -473,13 +475,13 @@ export function isAllowedRedirectOriginEntry(entry) {
|
|
|
473
475
|
}
|
|
474
476
|
export const AuthConfigSchema = Type.Object({
|
|
475
477
|
enabled: Type.Boolean({ default: true }),
|
|
476
|
-
//
|
|
478
|
+
// (2026-09-10) the six method toggles collapsed into ONE
|
|
477
479
|
// Optional bag (6 leaves → 1, countLeaves counts an Optional object as ONE) —
|
|
478
480
|
// the notifications `retry`/`broadcast` precedent. It KEEPS `default: {}`,
|
|
479
481
|
// which Value.Default still materializes, so every persisted manifest carries
|
|
480
482
|
// methods.{emailPassword,magicLink,google,github,apple,facebook} with the
|
|
481
483
|
// SAME keys and defaults as before — byte-identical folds for existing
|
|
482
|
-
// tenants (index.test.ts
|
|
484
|
+
// tenants (index.test.ts pins the fold bytes). Only the TS type is now optional;
|
|
483
485
|
// the worker's gate() normalizes an absent bag to the defaults so every
|
|
484
486
|
// reader (config.methods.<flag>) is unchanged.
|
|
485
487
|
methods: Type.Optional(Type.Object({
|
|
@@ -494,12 +496,12 @@ export const AuthConfigSchema = Type.Object({
|
|
|
494
496
|
// rule) keyed by provider, so the four provider blocks (and any future one)
|
|
495
497
|
// never inflate the flag count — the prior shape spent a leaf per top-level
|
|
496
498
|
// google/github block. This bag REPLACES those two top-level blocks (−2, +1
|
|
497
|
-
// for the bag) and EXTENDS the accepted providers to apple + facebook
|
|
499
|
+
// for the bag) and EXTENDS the accepted providers to apple + facebook:
|
|
498
500
|
// • google/github/facebook → { clientIdRef, clientSecretRef, audRefs? }
|
|
499
501
|
// • apple → { servicesId, teamId, keyId, p8KeyRef }
|
|
500
|
-
// All *Ref fields are
|
|
502
|
+
// All *Ref fields are secret-store POINTERS, never raw secrets. The
|
|
501
503
|
// worker reads config.providers?.{google,github,apple,facebook} (oauthCore.ts).
|
|
502
|
-
// The
|
|
504
|
+
// The runtime sign-in flow for apple/facebook is SHIPPED in oauthCore.ts
|
|
503
505
|
// (id_token/access_token verification + ES256 Apple client_secret minting);
|
|
504
506
|
// methods.{apple,facebook} above are the enable toggles it gates on.
|
|
505
507
|
providers: Type.Optional(Type.Object({
|
|
@@ -507,18 +509,18 @@ export const AuthConfigSchema = Type.Object({
|
|
|
507
509
|
github: Type.Optional(OAuthRefsSchema),
|
|
508
510
|
apple: Type.Optional(AppleRefsSchema),
|
|
509
511
|
facebook: Type.Optional(OAuthRefsSchema),
|
|
510
|
-
//
|
|
512
|
+
// the generic OIDC / SSO issuer (presence = enabled; see above)
|
|
511
513
|
oidc: Type.Optional(OidcProviderSchema),
|
|
512
514
|
})),
|
|
513
515
|
// session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
|
|
514
|
-
// the OTP/anonymous/orgClaims
|
|
516
|
+
// the OTP/anonymous/orgClaims release — the `{ default: {} }` keeps the inner
|
|
515
517
|
// defaults materializing on publish, so the worker still reads fully-populated
|
|
516
518
|
// manifests; its `config.session?.ttlMinutes ?? 60` fallbacks cover sparse
|
|
517
519
|
// hand-built manifests only.
|
|
518
520
|
session: Type.Optional(Type.Object({
|
|
519
521
|
ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
|
|
520
522
|
refreshTtlDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
|
|
521
|
-
//
|
|
523
|
+
// (2026-09-10) concurrent-session cap per user with
|
|
522
524
|
// TAKE-OVER — a new sign-in revokes the OLDEST sessions past the cap
|
|
523
525
|
// (revoked_reason 'device_cap', edge cache written) and reports them as
|
|
524
526
|
// `took_over: [session_id…]`. Optional WITHOUT a default so existing
|
|
@@ -529,12 +531,12 @@ export const AuthConfigSchema = Type.Object({
|
|
|
529
531
|
minLength: Type.Integer({ default: 8, minimum: 6, maximum: 128 }),
|
|
530
532
|
requireMixed: Type.Boolean({ default: false }),
|
|
531
533
|
}, { default: {} })),
|
|
532
|
-
//
|
|
534
|
+
// Note: emailVerification.tokenTtlHours was DROPPED —
|
|
533
535
|
// consumer-less (grep-verified: only this schema + dist mentioned it; the
|
|
534
536
|
// verify-email token flow it would bound was never built). Same precedent as
|
|
535
|
-
// the removed `redirects` block below. Its leaf funds the OTP
|
|
537
|
+
// the removed `redirects` block below. Its leaf funds the OTP sign-in bag.
|
|
536
538
|
emailVerification: Type.Object({ required: Type.Boolean({ default: false }) }, { default: {} }),
|
|
537
|
-
//
|
|
539
|
+
// (2026-09-18) magicLink is now an OPTIONAL bag (= ONE leaf however
|
|
538
540
|
// many knobs it holds — the methods/session/password precedent) so the
|
|
539
541
|
// request cooldown could land without spending a second leaf. It KEEPS
|
|
540
542
|
// `default: {}`, so Value.Default still materializes
|
|
@@ -542,7 +544,7 @@ export const AuthConfigSchema = Type.Object({
|
|
|
542
544
|
// folds byte-identically.
|
|
543
545
|
magicLink: Type.Optional(Type.Object({
|
|
544
546
|
tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
|
|
545
|
-
//
|
|
547
|
+
// the per-(tenant, identifier) magic-link REQUEST cooldown — the
|
|
546
548
|
// `otp.resendCooldownSec` twin, same bounds so the two knobs read the
|
|
547
549
|
// same. A second request for the same address inside the window is a 429
|
|
548
550
|
// `magic_link_rate_limited` with a Retry-After header. Type.Optional with
|
|
@@ -551,19 +553,19 @@ export const AuthConfigSchema = Type.Object({
|
|
|
551
553
|
// auth feature at read time. 0 disables the cooldown.
|
|
552
554
|
resendCooldownSec: Type.Optional(Type.Integer({ minimum: 0, maximum: 600 })),
|
|
553
555
|
}, { default: {} })),
|
|
554
|
-
//
|
|
556
|
+
// Identity continuity (2026-09-18) — OPT-IN registry adoption. When
|
|
555
557
|
// true, a sign-in by a method that PROVES control of the address — magic
|
|
556
558
|
// link, email OTP, or OAuth with a provider-verified address — reuses the id
|
|
557
559
|
// of the one matching pre-registered end-user (POST /v1/users) that has no
|
|
558
560
|
// account yet, instead of minting a fresh `user_<ulid>` and leaving the
|
|
559
561
|
// tenant with two records for one person. Password SIGN-UP never adopts: it
|
|
560
|
-
// proves nothing about the address (
|
|
562
|
+
// proves nothing about the address (guide ch. 6, auth).
|
|
561
563
|
// OFF by default, and Type.Optional with NO default: adoption means whoever
|
|
562
564
|
// proves control of a pre-registered address becomes that record — a change
|
|
563
565
|
// of security semantics for a tenant that bulk-imports contacts, whose
|
|
564
566
|
// addresses vxil never verified.
|
|
565
567
|
registryAdopt: Type.Optional(Type.Boolean()),
|
|
566
|
-
// Email OTP sign-in
|
|
568
|
+
// Email OTP sign-in + the knobs step-up re-auth shares.
|
|
567
569
|
// OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
|
|
568
570
|
// otp?.enabled === true).
|
|
569
571
|
otp: Type.Optional(Type.Object({
|
|
@@ -571,7 +573,7 @@ export const AuthConfigSchema = Type.Object({
|
|
|
571
573
|
codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
|
|
572
574
|
maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
|
|
573
575
|
resendCooldownSec: Type.Integer({ default: 60, minimum: 0, maximum: 600 }),
|
|
574
|
-
//
|
|
576
|
+
// (2026-09-10) test recipients — an OTP / step-up / email-
|
|
575
577
|
// claim request whose address matches an entry sends NO mail and returns
|
|
576
578
|
// the code as `test_code` (audit auth.otp.test_issued). Entries: an exact
|
|
577
579
|
// email, a `*@domain` glob, or a +E.164 number (accepted for the SMS
|
|
@@ -580,11 +582,11 @@ export const AuthConfigSchema = Type.Object({
|
|
|
580
582
|
// only — never a real user's address.
|
|
581
583
|
testRecipients: Type.Optional(Type.Array(Type.String({ minLength: 3, maxLength: 320 }), { maxItems: 20 })),
|
|
582
584
|
})),
|
|
583
|
-
// Anonymous (guest) sign-in
|
|
585
|
+
// Anonymous (guest) sign-in. OPTIONAL bag = 1 leaf.
|
|
584
586
|
anonymous: Type.Optional(Type.Object({
|
|
585
587
|
enabled: Type.Boolean({ default: false }),
|
|
586
588
|
})),
|
|
587
|
-
// Org claims embedded in session JWTs at mint/refresh
|
|
589
|
+
// Org claims embedded in session JWTs at mint/refresh:
|
|
588
590
|
// when enabled, auth-v1 fetches the user's active-org membership from orgs
|
|
589
591
|
// over the EDGE and embeds { org_id, role, perms[] } as the `org` claim.
|
|
590
592
|
// Fail-open: an orgs outage mints WITHOUT claims (sign-in never breaks).
|
|
@@ -593,23 +595,22 @@ export const AuthConfigSchema = Type.Object({
|
|
|
593
595
|
orgClaims: Type.Optional(Type.Object({
|
|
594
596
|
enabled: Type.Boolean({ default: false }),
|
|
595
597
|
})),
|
|
596
|
-
//
|
|
598
|
+
// Note: the earlier `redirects` block was DROPPED entirely —
|
|
597
599
|
// it had zero consumers (grep-verified: no worker reads config.redirects) and
|
|
598
600
|
// its leaf was spent on the methods.{apple,facebook} toggles the shipped
|
|
599
|
-
//
|
|
601
|
+
// social sign-in flow actually gates on. Re-adding it requires headroom or a
|
|
600
602
|
// collapse elsewhere.
|
|
601
603
|
//
|
|
602
|
-
// Account-security controls (
|
|
603
|
-
// §4b). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
|
|
604
|
+
// Account-security controls (2026-09-10; guide ch. 6, auth). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
|
|
604
605
|
// absent (existing manifests fold byte-identically) and every control is
|
|
605
606
|
// opt-in:
|
|
606
607
|
// • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
|
|
607
608
|
// sign-in + OTP verify (429 account_locked + Retry-After); a bounded
|
|
608
|
-
// counter
|
|
609
|
+
// stored counter survives restarts.
|
|
609
610
|
// • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
|
|
610
611
|
// chars leave the worker, never the password) at sign-up / reset-confirm;
|
|
611
612
|
// fail-OPEN on network error → 422 password_breached.
|
|
612
|
-
// • captchaSecretRef — a
|
|
613
|
+
// • captchaSecretRef — a secret-store ref (feature 'auth') holding the
|
|
613
614
|
// Turnstile secret; when set, sign-up / OTP request / magic-link request
|
|
614
615
|
// require `captcha_token` (403 captcha_failed otherwise).
|
|
615
616
|
// • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
|
|
@@ -629,10 +630,10 @@ export const AuthConfigSchema = Type.Object({
|
|
|
629
630
|
allowedRedirectOrigins: Type.Optional(Type.Array(Type.String({ minLength: 8, maxLength: 253, pattern: REDIRECT_ORIGIN_PATTERN }), { maxItems: 32 })),
|
|
630
631
|
})),
|
|
631
632
|
});
|
|
632
|
-
// ── DECLARED API STATE (
|
|
633
|
+
// ── DECLARED API STATE (2026-09-23) ──────────────────────────────────────────
|
|
633
634
|
// A rate-limit policy is a ROW in the feature's own KV store, written by
|
|
634
635
|
// POST/PUT/DELETE /v1/rate-limits/policies. It used to be API state OUTSIDE
|
|
635
|
-
// vxil.config (
|
|
636
|
+
// vxil.config (by the old rule "one writer per datum, and the writer is
|
|
636
637
|
// the route"), which made a second environment non-reproducible — every tenant
|
|
637
638
|
// ended up with an `ensure-rate-limit-policies.ts` + a nightly assert. The
|
|
638
639
|
// rule is unchanged — one writer per datum — but the writer is now THE
|
|
@@ -670,7 +671,7 @@ export const RateLimitsConfigSchema = Type.Object({
|
|
|
670
671
|
});
|
|
671
672
|
export const FilesConfigSchema = Type.Object({
|
|
672
673
|
enabled: Type.Boolean({ default: true }),
|
|
673
|
-
//
|
|
674
|
+
// `bucketRef` was DELETED (2026-09-11): the object-storage bucket is a platform
|
|
674
675
|
// binding on files-v1, never tenant-selectable, and no code ever read the
|
|
675
676
|
// leaf — declaring it invited "point files at my own bucket", which vxil does
|
|
676
677
|
// not offer. A persisted manifest that still carries it folds (Value.Clean).
|
|
@@ -678,8 +679,8 @@ export const FilesConfigSchema = Type.Object({
|
|
|
678
679
|
downloadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
|
|
679
680
|
quotas: Type.Object({
|
|
680
681
|
// maximum caps are a defense-in-depth ceiling on tenant-editable storage —
|
|
681
|
-
// object storage is cheap but the
|
|
682
|
-
// 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
|
|
682
|
+
// object storage is cheap but the database-resident metadata + abuse aren't
|
|
683
|
+
// (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
|
|
683
684
|
// (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
|
|
684
685
|
// tenant tier threaded to files-v1 (plan tiers).
|
|
685
686
|
maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
|
|
@@ -687,7 +688,7 @@ export const FilesConfigSchema = Type.Object({
|
|
|
687
688
|
maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
|
|
688
689
|
}, { default: {} }),
|
|
689
690
|
allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
|
|
690
|
-
// (
|
|
691
|
+
// (2026-09-11) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
|
|
691
692
|
// there is no malware/content scanner in files-v1 (it was a "V1.5" placeholder
|
|
692
693
|
// neither leaf was ever read), so the knob promised quarantine that never
|
|
693
694
|
// happened. Re-declare it in the same change as a real scanner, not before.
|
|
@@ -695,7 +696,7 @@ export const FilesConfigSchema = Type.Object({
|
|
|
695
696
|
enabled: Type.Boolean({ default: true }),
|
|
696
697
|
maxTtl: Type.Integer({ default: 7 * 24 * 3600 }),
|
|
697
698
|
}, { default: {} }),
|
|
698
|
-
// Wave-2 extensions (
|
|
699
|
+
// Wave-2 extensions (guide ch. 6, files: OCR + TTL) — the merge of
|
|
699
700
|
// workers/files-v1/src/ext.ts FilesExtensionsConfigSchema promised by its
|
|
700
701
|
// 'wiring phase' comment. Each is an OPTIONAL bag (= ONE leaf per the cap
|
|
701
702
|
// rule); files-v1 already reads both defensively (FilesConfigWithExt), so
|
|
@@ -710,16 +711,20 @@ export const FilesConfigSchema = Type.Object({
|
|
|
710
711
|
extractText: Type.Optional(Type.Object({
|
|
711
712
|
enabled: Type.Boolean({ default: false }),
|
|
712
713
|
provider: Type.Union([Type.Literal('gcv'), Type.Literal('textract'), Type.Literal('azure-di'), Type.Literal('mock')], { default: 'mock' }),
|
|
713
|
-
// provider key is BYO +
|
|
714
|
-
// config flag. keyRef names the
|
|
714
|
+
// provider key is BYO + encrypted in the tenant's secret store — NOT a
|
|
715
|
+
// config flag. keyRef names the stored secret (like ai's keyRefs).
|
|
715
716
|
keyRef: Type.Optional(Type.String()),
|
|
716
717
|
asyncOverJobs: Type.Boolean({ default: true }), // large/multi-page → jobs
|
|
717
718
|
boundingBoxes: Type.Boolean({ default: false }),
|
|
718
719
|
})),
|
|
720
|
+
// Public asset delivery (2026-10-03, guide ch. 6 files): publish
|
|
721
|
+
// an object to the cache-forever public host (POST /v1/files/{id}/publish).
|
|
722
|
+
// ONE optional bag: { enabled, corsOrigins, variants }. The published-bytes
|
|
723
|
+
// ceiling is a PLAN line (control-plane plans.ts), never a tenant knob.
|
|
724
|
+
publicAssets: Type.Optional(PublicAssetsConfigSchema),
|
|
719
725
|
});
|
|
720
726
|
// webhooks-out (wishlist feature): outbound event fan-out over the jobs delivery
|
|
721
|
-
// engine. Subscriptions live in their own
|
|
722
|
-
// per datum); config is just the capability gate + a cap.
|
|
727
|
+
// engine. Subscriptions live in their own store (one writer per datum); config is just the capability gate + a cap.
|
|
723
728
|
export const DeclaredWebhookSubscriptionSchema = Type.Object({
|
|
724
729
|
target_url: Type.String({ minLength: 9, maxLength: 2000 }),
|
|
725
730
|
event_prefixes: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 100 }), { maxItems: 20 })),
|
|
@@ -728,14 +733,14 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
728
733
|
enabled: Type.Boolean({ default: true }),
|
|
729
734
|
maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
730
735
|
maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
731
|
-
// FAILURE-ALERT DIGEST
|
|
736
|
+
// FAILURE-ALERT DIGEST. Outbound subscriptions are the real-time
|
|
732
737
|
// channel; this is the "nobody is consuming them yet" fallback — a periodic
|
|
733
738
|
// e-mail summary of the tenant's FAILURE-class audit events (the level:
|
|
734
739
|
// 'failure' rows of the generated event catalog). Read by
|
|
735
740
|
// workers/control-plane/src/alertDigest.ts on the minute cron.
|
|
736
741
|
//
|
|
737
|
-
// RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this
|
|
738
|
-
//
|
|
742
|
+
// RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this decision is
|
|
743
|
+
// recorded here the way RateLimitsConfigSchema records its own: an
|
|
739
744
|
// arbitrary `to` would turn vxil's own sending identity into a relay for
|
|
740
745
|
// tenant-authored content and open a PII egress path out of the audit trail.
|
|
741
746
|
// The digest goes to the OWNER-role dashboard accounts of the project (cap
|
|
@@ -745,7 +750,7 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
745
750
|
//
|
|
746
751
|
// An Optional object bag counts as ONE leaf (the countLeaves rule).
|
|
747
752
|
//
|
|
748
|
-
// `digestMinutes: 0` is IMMEDIATE (
|
|
753
|
+
// `digestMinutes: 0` is IMMEDIATE (2026-09-23): the
|
|
749
754
|
// pass runs every minute for the tenant and mails the `error`-level failure
|
|
750
755
|
// rows that landed since its last mail — at most one mail per minute, still
|
|
751
756
|
// to the owner accounts. 1–4 are clamped up to 5 by the reader.
|
|
@@ -754,11 +759,11 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
754
759
|
minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
|
|
755
760
|
digestMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 1440 }),
|
|
756
761
|
})),
|
|
757
|
-
// DECLARED API STATE (
|
|
762
|
+
// DECLARED API STATE (2026-09-23): the tenant's OUTBOUND
|
|
758
763
|
// subscriptions as config. Keyed by `target_url` — the only stable identity a
|
|
759
764
|
// subscription has (there is no name column). A changed prefix set is an
|
|
760
765
|
// in-place update — PATCH /v1/webhooks/subscriptions/:subId, same sub_id and
|
|
761
|
-
// cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge
|
|
766
|
+
// cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge the live subscriptions
|
|
762
767
|
// onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
|
|
763
768
|
// reported (deleted only under --allow-destructive). Rows on the platform's
|
|
764
769
|
// signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
|
|
@@ -775,16 +780,16 @@ export const CommentsConfigSchema = Type.Object({
|
|
|
775
780
|
/** Author edits allowed this long after posting; 0 disables editing. */
|
|
776
781
|
editWindowMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 10_080 }),
|
|
777
782
|
});
|
|
778
|
-
// content feature (
|
|
783
|
+
// content feature (guide ch. 4): flags govern LIMITS, never the
|
|
779
784
|
// content model — the model itself is data (cms.collections / cms.fields via
|
|
780
|
-
// the REST surface). versioning/localization/publicRead land later
|
|
785
|
+
// the REST surface). versioning/localization/publicRead land later.
|
|
781
786
|
export const CmsConfigSchema = Type.Object({
|
|
782
787
|
enabled: Type.Boolean({ default: true }),
|
|
783
788
|
draftPublish: Type.Boolean({ default: true }),
|
|
784
|
-
// cms end-user default-deny fail-safe (
|
|
789
|
+
// cms end-user default-deny fail-safe (guide ch. 4). When ON,
|
|
785
790
|
// a VERIFIED end-user key (owner-scope mode) is DENIED access to any
|
|
786
791
|
// collection that declares no owner_field — `403 server_only` on read AND
|
|
787
|
-
// write (
|
|
792
|
+
// write (2026-09-20; reads used to be a 404) — instead of the
|
|
788
793
|
// default tenant-wide-shared behavior. Server-caller mode is a
|
|
789
794
|
// byte-for-byte no-op. Default OFF preserves today's shared semantics
|
|
790
795
|
// (owner.int.test.ts's shared-collection invariant). A collection that DOES
|
|
@@ -800,7 +805,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
800
805
|
maxPageSize: Type.Integer({ default: 100, minimum: 1, maximum: 500 }),
|
|
801
806
|
defaultPageSize: Type.Integer({ default: 25, minimum: 1, maximum: 500 }),
|
|
802
807
|
}, { default: {} }),
|
|
803
|
-
// cms ENRICHMENT (
|
|
808
|
+
// cms ENRICHMENT (guide ch. 4): the read-time relation budget. The worker
|
|
804
809
|
// clamps via resolveRelationsConfig (enrich.ts) with the SAME defaults +
|
|
805
810
|
// hard ceilings, so an out-of-range value can never widen the bound.
|
|
806
811
|
relations: Type.Object({
|
|
@@ -832,7 +837,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
832
837
|
message: Type.Optional(Type.String({ maxLength: 200 })),
|
|
833
838
|
enabled: Type.Optional(Type.Boolean()),
|
|
834
839
|
}))),
|
|
835
|
-
// Declarative relational read-models (
|
|
840
|
+
// Declarative relational read-models (guide ch. 4).
|
|
836
841
|
// Each is a NAMED, closed-grammar aggregate/rank spec, optionally
|
|
837
842
|
// materialized to a rollup collection on the EXISTING jobs cron (the
|
|
838
843
|
// fn-cron:* reconciler idiom → cms-rollup:* schedules). Grammar is validated
|
|
@@ -841,7 +846,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
841
846
|
readModels: Type.Optional(Type.Record(Type.String({ maxLength: 64 }), Type.Object({
|
|
842
847
|
collection: Type.String({ maxLength: 64 }),
|
|
843
848
|
kind: Type.Union([Type.Literal('aggregate'), Type.Literal('rank')]),
|
|
844
|
-
// the
|
|
849
|
+
// the aggregate/rank query body minus limit — Type.Unknown so Value.Clean keeps it
|
|
845
850
|
// (the functions `signature` idiom); shape checked by the cross-field rule.
|
|
846
851
|
spec: Type.Unknown(),
|
|
847
852
|
materialize: Type.Optional(Type.Object({
|
|
@@ -865,13 +870,13 @@ export const CmsConfigSchema = Type.Object({
|
|
|
865
870
|
enabled: Type.Optional(Type.Boolean()),
|
|
866
871
|
}))),
|
|
867
872
|
});
|
|
868
|
-
// mcp feature (
|
|
873
|
+
// mcp feature (guide ch. 10): the aggregation surface's own knobs. Default-enabled —
|
|
869
874
|
// a tenant with no mcp config row still gets the aggregated tool list.
|
|
870
875
|
export const McpConfigSchema = Type.Object({
|
|
871
876
|
enabled: Type.Boolean({ default: true }),
|
|
872
877
|
exposureLevel: Type.Union([Type.Literal('all'), Type.Literal('read-only'), Type.Literal('custom')], { default: 'all' }),
|
|
873
878
|
allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
|
|
874
|
-
// (
|
|
879
|
+
// (2026-09-11) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
|
|
875
880
|
// a "dashboard-UI hint only" that no dashboard ever read, and key minting is a
|
|
876
881
|
// control-plane concern independent of MCP exposure — per-agent keys already
|
|
877
882
|
// work for every tenant, gated by nothing here.
|
|
@@ -880,7 +885,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
880
885
|
serverName: Type.String({ default: 'Vxil' }),
|
|
881
886
|
serverInstructions: Type.Optional(Type.String({ maxLength: 4000 })),
|
|
882
887
|
}, { default: {} }),
|
|
883
|
-
// Tenant-authored CUSTOM tools (
|
|
888
|
+
// Tenant-authored CUSTOM tools (guide ch. 10): name → tenant-owned https
|
|
884
889
|
// endpoint. mcp-v1 lists each as `custom_<name>` and POSTs the tool
|
|
885
890
|
// arguments to `url`, HMAC-signed with the per-tenant key from
|
|
886
891
|
// GET /v1/mcp/signing-secret (X-Vxil-Mcp-Signature; the caller's vxil bearer
|
|
@@ -896,7 +901,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
896
901
|
inputSchema: Type.Optional(Type.Unknown()),
|
|
897
902
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, maximum: 20_000 })),
|
|
898
903
|
}))),
|
|
899
|
-
// Config-declared MCP PROMPTS (
|
|
904
|
+
// Config-declared MCP PROMPTS (guide ch. 10): name → template with
|
|
900
905
|
// {{placeholder}} interpolation. Served verbatim by mcp-v1 prompts/list +
|
|
901
906
|
// prompts/get. ONE Type.Record leaf; placeholder ↔ arguments consistency is
|
|
902
907
|
// a cross-field rule below.
|
|
@@ -926,7 +931,7 @@ export const OrgsConfigSchema = Type.Object({
|
|
|
926
931
|
maxMembersPerOrg: Type.Integer({ default: 1000, minimum: 1, maximum: 100_000 }),
|
|
927
932
|
invitationTtlHours: Type.Integer({ default: 168, minimum: 1, maximum: 720 }),
|
|
928
933
|
});
|
|
929
|
-
// activity-feed feature (
|
|
934
|
+
// activity-feed feature (guide ch. 6, activity-feed): a GetStream-class activity-
|
|
930
935
|
// streams engine + a Knock/Novu-class in-app notification FEED. Flags govern
|
|
931
936
|
// the fan-out throttle, the follow/aggregation caps, and the cross-channel /
|
|
932
937
|
// realtime gates — NEVER the verb vocabulary or the personalized ranker (the
|
|
@@ -943,8 +948,8 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
943
948
|
Type.Literal('aggregated'),
|
|
944
949
|
Type.Literal('notification'),
|
|
945
950
|
]),
|
|
946
|
-
aggregation: Type.Optional(Type.String()), // group-format rule
|
|
947
|
-
ranking: Type.Optional(Type.String()), // 'chronological' | 'decay'
|
|
951
|
+
aggregation: Type.Optional(Type.String()), // group-format rule; required for aggregated/notification
|
|
952
|
+
ranking: Type.Optional(Type.String()), // 'chronological' | 'decay'; flat-only; default chronological
|
|
948
953
|
}), {
|
|
949
954
|
default: {
|
|
950
955
|
user: { type: 'flat' },
|
|
@@ -958,14 +963,14 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
958
963
|
fanout: Type.Object({
|
|
959
964
|
celebrityThreshold: Type.Integer({ default: 10_000, minimum: 0 }), // ≥ → pull (read-side); < → push (write-side)
|
|
960
965
|
maxFanoutPerJob: Type.Integer({ default: 1000, minimum: 1, maximum: 10_000 }), // follower batch size per jobs task
|
|
961
|
-
maxConcurrentTasks: Type.Integer({ default: 20, minimum: 1, maximum: 1000 }), // per-tenant in-flight cap (
|
|
966
|
+
maxConcurrentTasks: Type.Integer({ default: 20, minimum: 1, maximum: 1000 }), // per-tenant in-flight cap (local throttle)
|
|
962
967
|
pendingCeiling: Type.Integer({ default: 50_000, minimum: 1 }), // pending-fan-out-depth back-pressure ceiling
|
|
963
968
|
}, { default: {} }),
|
|
964
969
|
follow: Type.Object({
|
|
965
970
|
copyLimit: Type.Integer({ default: 100, minimum: 0, maximum: 1000 }), // backfill budget on follow
|
|
966
971
|
maxFollowing: Type.Integer({ default: 10_000, minimum: 0 }), // per-feed following cap
|
|
967
972
|
}, { default: {} }),
|
|
968
|
-
// (
|
|
973
|
+
// (2026-09-11) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
|
|
969
974
|
// or returned a per-group activity LIST — an aggregated read returns the group
|
|
970
975
|
// rollup (activity_count/actor_count/last_actor), so there was never an N to
|
|
971
976
|
// bound and no code read the leaf. Re-declare it with a group-detail route.
|
|
@@ -973,16 +978,16 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
973
978
|
enabled: Type.Boolean({ default: true }), // live new-activity + count push over the realtime ChannelDO
|
|
974
979
|
}, { default: {} }),
|
|
975
980
|
crossChannel: Type.Object({
|
|
976
|
-
enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger
|
|
981
|
+
enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger
|
|
977
982
|
digestCadence: Type.Union([Type.Literal('off'), Type.Literal('hourly'), Type.Literal('daily')], { default: 'off' }), // digest roll-up window (feed owns the roll-up, rides notifications' live single-send)
|
|
978
983
|
}, { default: {} }),
|
|
979
|
-
// (
|
|
984
|
+
// (2026-09-11) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
|
|
980
985
|
// rate-limiter binding and never read it, so the declared per-tenant write
|
|
981
986
|
// burst was enforced by nothing (the edge front-door limiter and the
|
|
982
987
|
// per-tenant request meter are the real bounds). Re-declare it together with
|
|
983
988
|
// the binding that enforces it.
|
|
984
989
|
});
|
|
985
|
-
// vector-search feature (
|
|
990
|
+
// vector-search feature (guide ch. 6, vector-search). Re-declared here to match the
|
|
986
991
|
// schema the worker EXPORTS from workers/vector-search-v1/src/config.ts — the
|
|
987
992
|
// control plane validates writes against this shared copy (the same
|
|
988
993
|
// one-definition / two-consumers note as the other features above; this package owns
|
|
@@ -991,7 +996,7 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
991
996
|
// synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
|
|
992
997
|
export const VectorSearchConfigSchema = Type.Object({
|
|
993
998
|
enabled: Type.Boolean({ default: true }),
|
|
994
|
-
// 'auto' resolves to the default managed vector backend for the tier
|
|
999
|
+
// 'auto' resolves to the default managed vector backend for the tier.
|
|
995
1000
|
backend: Type.Union([Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')], { default: 'auto' }),
|
|
996
1001
|
// 'byov'/'mock' need NO provider key (zero-config default); openai/cohere read a
|
|
997
1002
|
// BYO key from tenant secrets via apiKeyRef (encrypted at rest).
|
|
@@ -1025,7 +1030,7 @@ export const VectorSearchConfigSchema = Type.Object({
|
|
|
1025
1030
|
provider: Type.Union([Type.Literal('mock'), Type.Literal('cohere'), Type.Literal('voyage')]),
|
|
1026
1031
|
model: Type.Optional(Type.String({ maxLength: 128 })), // cohere 'rerank-v3.5' / voyage 'rerank-2'
|
|
1027
1032
|
topN: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 200 })),
|
|
1028
|
-
apiKeyRef: Type.Optional(Type.String({ maxLength: 128 })), // 'secret:<name>'
|
|
1033
|
+
apiKeyRef: Type.Optional(Type.String({ maxLength: 128 })), // 'secret:<name>' in the tenant's secret store
|
|
1029
1034
|
})),
|
|
1030
1035
|
// Config-driven auto-embedding sync from cms collections: the control plane
|
|
1031
1036
|
// reconciles one `vs-sync:` jobs schedule per entry on every config commit;
|
|
@@ -1042,12 +1047,12 @@ export const VectorSearchConfigSchema = Type.Object({
|
|
|
1042
1047
|
cron: Type.Optional(Type.String({ pattern: '^\\S+ \\S+ \\S+ \\S+ \\S+$' })),
|
|
1043
1048
|
}), { maxItems: 8 })),
|
|
1044
1049
|
});
|
|
1045
|
-
// ai feature (
|
|
1050
|
+
// ai feature (guide ch. 6, ai). Re-declared to match workers/ai-v1/src/core.ts's
|
|
1046
1051
|
// exported AiConfigSchema. vxil owns the call SCAFFOLDING (routing, streaming,
|
|
1047
1052
|
// token accounting, caching, the reserve→settle budget); the tenant owns the
|
|
1048
1053
|
// intelligence (prompt TEMPLATES are config-as-code, stored/rendered but never
|
|
1049
1054
|
// authored). 'mock' is the deterministic default until a BYO key is provisioned;
|
|
1050
|
-
// the real providers route via
|
|
1055
|
+
// the real providers route via secret-store keyRefs.
|
|
1051
1056
|
export const AI_TEMPLATE_NAME_PATTERN = '^[a-zA-Z0-9_.\\-]+$';
|
|
1052
1057
|
export const AI_MAX_DECLARED_TEMPLATES = 50;
|
|
1053
1058
|
export const DeclaredAiTemplateSchema = Type.Object({
|
|
@@ -1063,7 +1068,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1063
1068
|
// meta-provider (BYO key under providers.compat.openrouterKeyRef).
|
|
1064
1069
|
defaultProvider: Type.Union([Type.Literal('mock'), Type.Literal('openai'), Type.Literal('anthropic'),
|
|
1065
1070
|
Type.Literal('gemini'), Type.Literal('azure'), Type.Literal('openrouter')], { default: 'mock' }),
|
|
1066
|
-
// BYO keyRefs →
|
|
1071
|
+
// BYO keyRefs → the tenant's encrypted secret store. The block is NOT
|
|
1067
1072
|
// optional (the worker declares it plain), so its three optional refs each count
|
|
1068
1073
|
// as a leaf. The nested blocks carry `default: {}` (this package's convention)
|
|
1069
1074
|
// so Value.Default materializes them + recurses into the leaf defaults when a
|
|
@@ -1075,7 +1080,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1075
1080
|
geminiKeyRef: Type.Optional(Type.String()),
|
|
1076
1081
|
// The openai-compatible extension surface — ONE optional object = ONE config
|
|
1077
1082
|
// leaf (countLeaves collapses optional objects; 15-leaf cap discipline).
|
|
1078
|
-
// compat.openrouterKeyRef: BYO OpenRouter key (
|
|
1083
|
+
// compat.openrouterKeyRef: BYO OpenRouter key (a secret-store ref).
|
|
1079
1084
|
// compat.openaiBaseUrl: point the openai adapter at ANY openai-compatible
|
|
1080
1085
|
// host (DeepSeek, vLLM, an Azure-compatible proxy). Public-https validated
|
|
1081
1086
|
// at config WRITE (publicHttpsUrlError below) AND at USE (@vxil/runtime
|
|
@@ -1095,7 +1100,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1095
1100
|
tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
|
|
1096
1101
|
consumeCredits: Type.Boolean({ default: false }), // LIVE: reserve→settle against the payments credit ledger (a job-routed generation reserves pre-generation and 402s insufficient_credits)
|
|
1097
1102
|
}, { default: {} }),
|
|
1098
|
-
// `streaming` became an OPTIONAL bag (3 leaves → 1, the
|
|
1103
|
+
// `streaming` became an OPTIONAL bag (3 leaves → 1, the `retry` trick)
|
|
1099
1104
|
// on 2026-09-23 to fund the declared `templates[]` below. It KEEPS
|
|
1100
1105
|
// `default: {}`, so Value.Default still materializes
|
|
1101
1106
|
// streaming.{enabled,replayBufferFrames,flushMs} into every persisted
|
|
@@ -1104,12 +1109,11 @@ export const AiConfigSchema = Type.Object({
|
|
|
1104
1109
|
// here is optional.
|
|
1105
1110
|
streaming: Type.Optional(Type.Object({
|
|
1106
1111
|
enabled: Type.Boolean({ default: true }),
|
|
1107
|
-
replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), //
|
|
1108
|
-
flushMs: Type.Integer({ default: 50, minimum: 0 }), //
|
|
1112
|
+
replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), // replay ring-buffer depth
|
|
1113
|
+
flushMs: Type.Integer({ default: 50, minimum: 0 }), // token→frame coalesce window
|
|
1109
1114
|
}, { default: {} })),
|
|
1110
|
-
// DECLARED API STATE (
|
|
1111
|
-
// prompt templates as config. vxil STORES + versions, never authors (
|
|
1112
|
-
// §0) — declaring them here changes WHO writes the row (the repository, via
|
|
1115
|
+
// DECLARED API STATE (2026-09-23): the tenant's stored
|
|
1116
|
+
// prompt templates as config. vxil STORES + versions, never authors — declaring them here changes WHO writes the row (the repository, via
|
|
1113
1117
|
// `vxil push`), not what vxil does with it. Converged by CONTENT: the
|
|
1114
1118
|
// control-plane hashes each declared entry (@vxil/runtime
|
|
1115
1119
|
// aiTemplateContentSha256) against the `content_sha256` GET /v1/ai/templates
|
|
@@ -1118,11 +1122,11 @@ export const AiConfigSchema = Type.Object({
|
|
|
1118
1122
|
// Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
|
|
1119
1123
|
// name). Stored templates the config does not declare are reported and left
|
|
1120
1124
|
// in place — RETIRED (soft: hidden from list + render, history kept) only
|
|
1121
|
-
// under --allow-destructive (
|
|
1125
|
+
// under --allow-destructive (2026-10-01). Bounded to 50 entries: the
|
|
1122
1126
|
// manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
|
|
1123
1127
|
templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
|
|
1124
1128
|
});
|
|
1125
|
-
// rag feature (
|
|
1129
|
+
// rag feature (guide ch. 6, rag). Re-declared to match workers/rag-v1/src/config.ts's
|
|
1126
1130
|
// exported RagConfigSchema. rag owns the PIPELINE knobs only — retrieval budget,
|
|
1127
1131
|
// context budget + strategy + tokenizer, the citation/stream gates — NEVER the
|
|
1128
1132
|
// prompt, the synthesis, or relevance tuning (the tenant's `ai` template owns those).
|
|
@@ -1140,7 +1144,7 @@ export const RagConfigSchema = Type.Object({
|
|
|
1140
1144
|
rerank: Type.Boolean({ default: false }),
|
|
1141
1145
|
}, { default: {} }),
|
|
1142
1146
|
// declarative per-metadata-field relevance boosts applied in rag AFTER
|
|
1143
|
-
// retrieval, BEFORE minScore/budget/grounding (
|
|
1147
|
+
// retrieval, BEFORE minScore/budget/grounding (guide ch. 6, rag). ONE Type.Record
|
|
1144
1148
|
// leaf (the activity-feed feedGroups precedent).
|
|
1145
1149
|
boosts: Type.Record(Type.String(), Type.Union([
|
|
1146
1150
|
Type.Object({
|
|
@@ -1154,13 +1158,13 @@ export const RagConfigSchema = Type.Object({
|
|
|
1154
1158
|
}),
|
|
1155
1159
|
]), { default: {} }),
|
|
1156
1160
|
context: Type.Object({
|
|
1157
|
-
// bounded context budget — enforced via the
|
|
1161
|
+
// bounded context budget — enforced via the tokenizer, BEFORE the ai call.
|
|
1158
1162
|
maxTokens: Type.Integer({ default: 4000, minimum: 1, maximum: 1_000_000 }),
|
|
1159
1163
|
strategy: Type.Union([Type.Literal('topk'), Type.Literal('mmr')], {
|
|
1160
1164
|
default: 'topk',
|
|
1161
1165
|
}),
|
|
1162
1166
|
// 'provider' = the resolved ai provider's tokenizer; 'heuristic' = portable
|
|
1163
|
-
// ~chars/4 with a safety margin
|
|
1167
|
+
// ~chars/4 with a safety margin.
|
|
1164
1168
|
tokenizer: Type.Union([Type.Literal('provider'), Type.Literal('heuristic')], {
|
|
1165
1169
|
default: 'provider',
|
|
1166
1170
|
}),
|
|
@@ -1170,7 +1174,7 @@ export const RagConfigSchema = Type.Object({
|
|
|
1170
1174
|
citations: Type.Boolean({ default: true }),
|
|
1171
1175
|
streaming: Type.Boolean({ default: true }),
|
|
1172
1176
|
});
|
|
1173
|
-
// payments feature (
|
|
1177
|
+
// payments feature (guide ch. 6, payments). Re-declared to match the schema the
|
|
1174
1178
|
// worker EXPORTS from workers/payments-v1/src/core.ts — the control plane
|
|
1175
1179
|
// validates writes against this shared copy (one-definition / two-consumers,
|
|
1176
1180
|
// like the other features). Two co-equal pillars: (A) provider payments (the
|
|
@@ -1186,9 +1190,9 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1186
1190
|
// events) so the ENTIRE ledger path is testable WITHOUT real provider keys.
|
|
1187
1191
|
provider: Type.Union([Type.Literal('mock'), Type.Literal('stripe'), Type.Literal('paddle'),
|
|
1188
1192
|
Type.Literal('revenuecat'), Type.Literal('paypal')], { default: 'mock' }),
|
|
1189
|
-
// BYO-key credential blocks →
|
|
1193
|
+
// BYO-key credential blocks → the tenant's encrypted secret store.
|
|
1190
1194
|
// Each OPTIONAL object counts as ONE leaf (the tenant's decision is
|
|
1191
|
-
// "configure it or not", not each inner ref —
|
|
1195
|
+
// "configure it or not", not each inner ref — guide ch. 6, auth).
|
|
1192
1196
|
stripe: Type.Optional(Type.Object({
|
|
1193
1197
|
secretKeyRef: Type.String(),
|
|
1194
1198
|
webhookSecretRef: Type.String(),
|
|
@@ -1203,25 +1207,24 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1203
1207
|
projectId: Type.String(),
|
|
1204
1208
|
publicSdkKey: Type.String(),
|
|
1205
1209
|
secretApiKeyRef: Type.String(),
|
|
1206
|
-
// Per-tenant webhook secret ref (
|
|
1210
|
+
// Per-tenant webhook secret ref (secret store). Inbound RevenueCat
|
|
1207
1211
|
// webhooks are verified against THIS ref and nothing else: there is no
|
|
1208
1212
|
// platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
|
|
1209
|
-
// (that fallback WAS
|
|
1210
|
-
// frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
|
|
1213
|
+
// (that fallback WAS a cross-tenant webhook-forgery vector; a CI gate now
|
|
1211
1214
|
// permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
|
|
1212
1215
|
// Optional at the SCHEMA level only — leaving it unset does not disable
|
|
1213
1216
|
// verification, it fails CLOSED: every delivery is 401 bad_signature with a
|
|
1214
1217
|
// `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
|
|
1215
1218
|
// webhookSecretRef.
|
|
1216
1219
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
1217
|
-
// Environment integrity
|
|
1220
|
+
// Environment integrity. RevenueCat posts SANDBOX
|
|
1218
1221
|
// and PRODUCTION events to the SAME webhook with the same auth header, so a
|
|
1219
1222
|
// sandbox purchase would otherwise fold into production entitlements. A
|
|
1220
1223
|
// sandbox event is persisted as outcome 'rejected_environment' (200, never
|
|
1221
1224
|
// folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
|
|
1222
1225
|
// environments by signing secret / API base, so only RC carries this knob.
|
|
1223
1226
|
acceptSandbox: Type.Boolean({ default: false }),
|
|
1224
|
-
// (2026-10-01
|
|
1227
|
+
// (2026-10-01) Store-review purchases on a PRODUCTION tenant:
|
|
1225
1228
|
// the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
|
|
1226
1229
|
// whose subject (and, for a TRANSFER, every source user) is listed here
|
|
1227
1230
|
// folds — recorded `environment: 'sandbox'` on the delivery, the
|
|
@@ -1242,9 +1245,9 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1242
1245
|
// Where a provider-hosted flow sends the payer back (Stripe billing-portal
|
|
1243
1246
|
// return, PayPal approval return/cancel) — the TENANT's own app URL,
|
|
1244
1247
|
// https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
|
|
1245
|
-
// hardcoded host (
|
|
1248
|
+
// hardcoded host (2026-07-10: the old fallback pointed at a dead apex).
|
|
1246
1249
|
returnUrl: Type.Optional(Type.String({ pattern: '^https://', maxLength: 512 })),
|
|
1247
|
-
// NB (
|
|
1250
|
+
// NB (2026-09-11): the former `prices.catalogRef` leaf was DELETED —
|
|
1248
1251
|
// it named nothing (prices resolve from ledger.priceMap; no code path ever
|
|
1249
1252
|
// read it). A persisted manifest that still carries `prices` folds:
|
|
1250
1253
|
// validateFeatureConfig's Value.Clean strips the stray key.
|
|
@@ -1259,11 +1262,11 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1259
1262
|
// product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
|
|
1260
1263
|
// `boosts` Record-of-Union precedent) with two rule shapes:
|
|
1261
1264
|
// { creditType, amount, period } a credit grant (the original rule)
|
|
1262
|
-
// { tier, durationDays } (2026-09-25
|
|
1265
|
+
// { tier, durationDays } (2026-09-25) a TIME-BOXED
|
|
1263
1266
|
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
1264
1267
|
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
1265
1268
|
// STACKS behind the user's live same-tier manual rows that have an end
|
|
1266
|
-
// (earlier passes AND comp grants since 2026-09-25
|
|
1269
|
+
// (earlier passes AND comp grants since 2026-09-25 — never a row
|
|
1267
1270
|
// linked to the same charge, an open-ended grant or a provider
|
|
1268
1271
|
// subscription) and is ENDED by that charge's full refund / chargeback.
|
|
1269
1272
|
// No defaults in either shape, so an existing manifest is
|
|
@@ -1271,7 +1274,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1271
1274
|
productMap: Type.Record(Type.String(), Type.Union([
|
|
1272
1275
|
Type.Object({
|
|
1273
1276
|
creditType: Type.String({ minLength: 1 }),
|
|
1274
|
-
amount: Type.Integer({ minimum: 1 }), //
|
|
1277
|
+
amount: Type.Integer({ minimum: 1 }), // a grant only ADDS
|
|
1275
1278
|
period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
|
|
1276
1279
|
Type.Literal('annual')]),
|
|
1277
1280
|
}),
|
|
@@ -1282,13 +1285,13 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1282
1285
|
])),
|
|
1283
1286
|
tierMap: Type.Record(Type.String(), Type.Object({
|
|
1284
1287
|
entitlements: Type.Array(Type.String()),
|
|
1285
|
-
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), //
|
|
1286
|
-
rank: Type.Optional(Type.Integer({ minimum: 0 })), // precedence for the multi-sub fold
|
|
1288
|
+
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // no negative quota
|
|
1289
|
+
rank: Type.Optional(Type.Integer({ minimum: 0 })), // precedence for the multi-sub fold
|
|
1287
1290
|
grants: Type.Optional(Type.Array(Type.Object({
|
|
1288
1291
|
creditType: Type.String({ minLength: 1 }),
|
|
1289
|
-
amount: Type.Integer({ minimum: 1 }), //
|
|
1292
|
+
amount: Type.Integer({ minimum: 1 }), // a grant only ADDS
|
|
1290
1293
|
period: Type.String(),
|
|
1291
|
-
// (2026-10-01
|
|
1294
|
+
// (2026-10-01) 'add' (the reader's default, today's
|
|
1292
1295
|
// behaviour) ADDS `amount` each period; 'reset' makes the period's
|
|
1293
1296
|
// grant REPLACE what is left: the unspent available balance of
|
|
1294
1297
|
// `creditType` is written off as one `expire` ledger row and `amount`
|
|
@@ -1304,7 +1307,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1304
1307
|
// fold (refoldEntitlements WHERE tier IS NOT NULL) reflects the subscription.
|
|
1305
1308
|
priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
1306
1309
|
autoRefundOnJobFailure: Type.Boolean({ default: true }), // consume(jobId) reverses on DLQ/timeout
|
|
1307
|
-
// Grace window
|
|
1310
|
+
// Grace window: a `past_due` subscription stays
|
|
1308
1311
|
// entitled for this many days AFTER its current_period_end (the dunning
|
|
1309
1312
|
// window the provider is retrying inside). 0 = today's behaviour (a past_due
|
|
1310
1313
|
// row is never entitled). ONE predicate in the fold — NOT a dunning ladder:
|
|
@@ -1312,8 +1315,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1312
1315
|
grace: Type.Optional(Type.Object({
|
|
1313
1316
|
pastDueDays: Type.Integer({ default: 0, minimum: 0, maximum: 90 }),
|
|
1314
1317
|
})),
|
|
1315
|
-
// Opt-in period-end enforcement (
|
|
1316
|
-
// option a). ABSENT (the default) = today's behaviour: a subscription whose
|
|
1318
|
+
// Opt-in period-end enforcement. ABSENT (the default) = today's behaviour: a subscription whose
|
|
1317
1319
|
// current_period_end passed with no provider event stays entitled forever
|
|
1318
1320
|
// (the provider is the only clock). PRESENT = the nightly reconcile sweep
|
|
1319
1321
|
// flips an `active`/`trialing` row whose current_period_end + slackHours
|
|
@@ -1348,28 +1350,28 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1348
1350
|
// (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
|
|
1349
1351
|
unmappedProduct: Type.Optional(Type.Union([Type.Literal('error'), Type.Literal('ignore')])),
|
|
1350
1352
|
})),
|
|
1351
|
-
// NB
|
|
1353
|
+
// NB: the former `webhooks.forwardToTenantUrl` leaf
|
|
1352
1354
|
// was DELETED — it had zero readers (never forwarded anything). Outbound
|
|
1353
|
-
// delivery of payments state changes rides the
|
|
1354
|
-
//
|
|
1355
|
+
// delivery of payments state changes rides the audit stream → outbound
|
|
1356
|
+
// webhooks: subscribe to the `payments.` event prefix (guide ch. 6, payments).
|
|
1355
1357
|
});
|
|
1356
|
-
// functions feature (
|
|
1358
|
+
// functions feature (guide ch. 8). Tenant-deployed backend edge
|
|
1357
1359
|
// functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
|
|
1358
1360
|
// scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
|
|
1359
1361
|
// bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
|
|
1360
1362
|
// never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
|
|
1361
1363
|
// (cms.hooks / payments.ledger precedent), so any number of deployed functions
|
|
1362
|
-
// never grows the flag cap.
|
|
1364
|
+
// never grows the flag cap. Tenant code on the platform — paid, tier-walled,
|
|
1363
1365
|
// opt-in (enabled defaults to false), egress-guarded.
|
|
1364
1366
|
export const FunctionsConfigSchema = Type.Object({
|
|
1365
1367
|
enabled: Type.Boolean({ default: false }),
|
|
1366
|
-
// (
|
|
1367
|
-
// is
|
|
1368
|
+
// (2026-09-11) `runtime` ('isolate' | 'container') was DELETED: the container lane
|
|
1369
|
+
// is not built, nothing read the leaf, and accepting 'container' silently
|
|
1368
1370
|
// ran the isolate anyway. It comes back with the lane, not before.
|
|
1369
1371
|
defaultLimits: Type.Object({
|
|
1370
1372
|
// cpuMs is the ONLY per-dispatch limit the managed runtime accepts and the
|
|
1371
1373
|
// only one anything reads (functions-v1 meter.ts + the dispatch cap).
|
|
1372
|
-
// (
|
|
1374
|
+
// (2026-09-11) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
|
|
1373
1375
|
// runtime and not tenant-selectable, and no wall-clock abort was ever
|
|
1374
1376
|
// applied — a declared 10s default that nothing enforced.
|
|
1375
1377
|
cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 300_000 }),
|
|
@@ -1410,7 +1412,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1410
1412
|
source: Type.Optional(Type.String()), // webhook/queue: source/queue id
|
|
1411
1413
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
1412
1414
|
event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
|
|
1413
|
-
//
|
|
1415
|
+
// (2026-09-25) the per-binding opt-in to re-delivery on
|
|
1414
1416
|
// queue / webhook / cmsHook / authHook (the cross-field rule
|
|
1415
1417
|
// rejects it on http / cron). Absent = the ACK-200 default. The
|
|
1416
1418
|
// receiver answers a failed attempt as an enveloped 503 (ladder)
|
|
@@ -1434,7 +1436,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1434
1436
|
// the settings it was deployed and tested with, not today's. Absent =
|
|
1435
1437
|
// the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
|
|
1436
1438
|
// strings on purpose (the deploy writes them from the one constant).
|
|
1437
|
-
// cpuMs (
|
|
1439
|
+
// cpuMs (2026-10-01): the per-invoke CPU limit the script was
|
|
1438
1440
|
// uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
|
|
1439
1441
|
// cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
|
|
1440
1442
|
// rewrites it after a tier change. Absent = the platform default.
|
|
@@ -1473,10 +1475,9 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1473
1475
|
// egress guard as an outbound parameter. NOT an invocation
|
|
1474
1476
|
// budget: the INVOCATION is bounded by the platform's own
|
|
1475
1477
|
// FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
|
|
1476
|
-
// bigger per-fetch budget widens with you
|
|
1477
|
-
// (
|
|
1478
|
-
//
|
|
1479
|
-
// per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
|
|
1478
|
+
// bigger per-fetch budget widens with you.
|
|
1479
|
+
// (`memoryMb` is deleted — memory is fixed by the managed runtime and is
|
|
1480
|
+
// not a per-dispatch option; a dispatch takes { cpuMs, subRequests }.
|
|
1480
1481
|
// Value.Clean strips it from an old config, so such a config still loads
|
|
1481
1482
|
// and `vxil plan --explain` marks the key DROPPED.)
|
|
1482
1483
|
limits: Type.Optional(Type.Object({
|
|
@@ -1484,7 +1485,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1484
1485
|
timeoutMs: Type.Optional(Type.Integer()),
|
|
1485
1486
|
})),
|
|
1486
1487
|
enabled: Type.Optional(Type.Boolean()),
|
|
1487
|
-
// Level-1 typed I/O (
|
|
1488
|
+
// Level-1 typed I/O (guide ch. 8): the declared input/output
|
|
1488
1489
|
// contract, persisted by the deploy body so ONLINE `vxil gen` emits the
|
|
1489
1490
|
// same typed fn client as --offline. Opaque JSON-schema-ish payloads —
|
|
1490
1491
|
// the CLI's lowerSig lowers them; the platform never interprets them.
|
|
@@ -1558,14 +1559,14 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
1558
1559
|
guestToolAllow: Type.Optional(Type.Array(Type.String({ maxLength: 64 }), { maxItems: 16, default: [] })),
|
|
1559
1560
|
})),
|
|
1560
1561
|
}), { default: {} }),
|
|
1561
|
-
// (
|
|
1562
|
+
// (2026-09-11) the `escalation` bag ({enabled, handler, notifyTemplate}) was
|
|
1562
1563
|
// DELETED: the human hand-off it declared was never built — copilot-v1 read
|
|
1563
1564
|
// none of the three leaves, so a tenant who turned it on got silence. The
|
|
1564
1565
|
// shipped hand-off path is a tenant function on the conversation events.
|
|
1565
1566
|
// ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
|
|
1566
1567
|
limits: Type.Object({
|
|
1567
1568
|
consumeCredits: Type.Boolean({ default: false }),
|
|
1568
|
-
// (
|
|
1569
|
+
// (2026-09-11) `tokensPerUserPerDay` was DELETED here: token accounting is
|
|
1569
1570
|
// delegated to ai-v1 (this bag's own doctrine) and only `ai`'s
|
|
1570
1571
|
// limits.tokensPerUserPerDay is enforced — the copilot twin read nothing.
|
|
1571
1572
|
}, { default: {} }),
|
|
@@ -1599,11 +1600,11 @@ export const FEATURE_SCHEMAS = {
|
|
|
1599
1600
|
copilot: CopilotConfigSchema,
|
|
1600
1601
|
};
|
|
1601
1602
|
export const CONFIG_FLAG_CAP = 15;
|
|
1602
|
-
/** Counts leaf flags in a TypeBox object schema
|
|
1603
|
-
* Per
|
|
1603
|
+
/** Counts leaf flags in a TypeBox object schema.
|
|
1604
|
+
* Per guide ch. 6, auth: an OPTIONAL object (e.g. a provider credential
|
|
1604
1605
|
* block) counts as ONE flag — the tenant's decision is "configure it or
|
|
1605
1606
|
* not", not each inner ref. */
|
|
1606
|
-
/** The auth lifecycle events an `authHook` binding may name
|
|
1607
|
+
/** The auth lifecycle events an `authHook` binding may name — the
|
|
1607
1608
|
* closed union `packages/config` types as AuthHookEvent; the control-plane
|
|
1608
1609
|
* reconciler maps each to its `auth.<event>` audit-event prefix. */
|
|
1609
1610
|
export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'];
|
|
@@ -1614,7 +1615,7 @@ export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.rev
|
|
|
1614
1615
|
* else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
|
|
1615
1616
|
* to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
|
|
1616
1617
|
* 'beforeDelete' silently subscribed the function to creates AND updates.
|
|
1617
|
-
*
|
|
1618
|
+
* A CI gate pins every copy to this list. */
|
|
1618
1619
|
export const CMS_HOOK_EVENTS = ['beforeCreate', 'beforeUpdate', 'beforeWrite'];
|
|
1619
1620
|
/** The ONE refusal text for an unknown cmsHook binding event (the config
|
|
1620
1621
|
* validator and the deploy route's early 422 both use it). */
|
|
@@ -1625,7 +1626,7 @@ export function cmsHookEventError(event) {
|
|
|
1625
1626
|
return null;
|
|
1626
1627
|
return `a 'cmsHook' binding's event must be one of ${CMS_HOOK_EVENTS.join(' | ')} (or omitted = beforeWrite), not ${JSON.stringify(event)}`;
|
|
1627
1628
|
}
|
|
1628
|
-
/**
|
|
1629
|
+
/** The OTP test-recipient entry grammar (shared by the validator and auth-v1's
|
|
1629
1630
|
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
1630
1631
|
export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
|
|
1631
1632
|
export const TEST_RECIPIENT_GLOB_RE = /^\*@[^\s@*]+\.[^\s@*]+$/;
|
|
@@ -1678,7 +1679,7 @@ export function setKnownMcpTools(names) {
|
|
|
1678
1679
|
knownMcpTools = new Set(names);
|
|
1679
1680
|
}
|
|
1680
1681
|
// ── mcp custom-tool / prompt caps + the pure public-https check ──────────────
|
|
1681
|
-
// (
|
|
1682
|
+
// (guide ch. 10) Caps are deliberately tighter than the 200-tool listing cap:
|
|
1682
1683
|
// each custom tool is a platform-signed egress target, so the bag stays small.
|
|
1683
1684
|
export const MCP_MAX_CUSTOM_TOOLS = 32;
|
|
1684
1685
|
export const MCP_MAX_PROMPTS = 32;
|
|
@@ -1734,7 +1735,7 @@ function publicHttpsUrlError(url) {
|
|
|
1734
1735
|
* stays ALLOWED (fn→fn composition). Source of truth for both the config-write
|
|
1735
1736
|
* validation below and the control-plane deploy clamp. */
|
|
1736
1737
|
export const DENY_FUNCTION_SCOPES = new Set(['admin', '*', 'features:write', 'functions:write', 'secrets:write']);
|
|
1737
|
-
// ── declared API state — config-write validators
|
|
1738
|
+
// ── declared API state — config-write validators ────────────────────────────
|
|
1738
1739
|
/** `{var}` names of a rate-limit key template (rate-limits-v1 core.ts
|
|
1739
1740
|
* templateVars parity). */
|
|
1740
1741
|
export function rlTemplateVars(template) {
|
|
@@ -1819,6 +1820,13 @@ export function validateDeclaredAiTemplates(templates) {
|
|
|
1819
1820
|
});
|
|
1820
1821
|
return errs;
|
|
1821
1822
|
}
|
|
1823
|
+
function badVariantPresetNames(raw) {
|
|
1824
|
+
const variants = raw?.publicAssets?.variants;
|
|
1825
|
+
if (!variants || typeof variants !== 'object' || Array.isArray(variants))
|
|
1826
|
+
return [];
|
|
1827
|
+
const re = new RegExp(VARIANT_PRESET_NAME_PATTERN);
|
|
1828
|
+
return Object.keys(variants).filter((k) => !re.test(k));
|
|
1829
|
+
}
|
|
1822
1830
|
export function validateFeatureConfig(feature, raw) {
|
|
1823
1831
|
const schema = FEATURE_SCHEMAS[feature];
|
|
1824
1832
|
if (!schema) {
|
|
@@ -1827,13 +1835,25 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1827
1835
|
if (countLeaves(schema) > CONFIG_FLAG_CAP) {
|
|
1828
1836
|
return { ok: false, errors: [`schema for '${feature}' exceeds the ${CONFIG_FLAG_CAP}-flag cap`] };
|
|
1829
1837
|
}
|
|
1838
|
+
// files.publicAssets.variants is a pattern-keyed record: Value.Clean below
|
|
1839
|
+
// would silently DROP a preset whose name is not URL-safe, and the tenant
|
|
1840
|
+
// would wonder why `/v/My Thumb` 404s. Refuse it by name instead.
|
|
1841
|
+
if (feature === 'files') {
|
|
1842
|
+
const bad = badVariantPresetNames(raw);
|
|
1843
|
+
if (bad.length > 0) {
|
|
1844
|
+
return {
|
|
1845
|
+
ok: false,
|
|
1846
|
+
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)`),
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1830
1850
|
// Apply defaults to a clone, then STRIP any property the schema does not
|
|
1831
|
-
// declare, then check. Value.Clean makes the validator TOTAL
|
|
1851
|
+
// declare, then check. Value.Clean makes the validator TOTAL:
|
|
1832
1852
|
// the schemas are open Type.Object()s, so without it Value.Check passes on —
|
|
1833
1853
|
// and putConfig would persist — arbitrary unknown keys. Clean runs AFTER
|
|
1834
1854
|
// Default so materialized nested defaults survive but stray top-level/nested
|
|
1835
1855
|
// keys are dropped. This also removes the CLI dry-run idempotency drift
|
|
1836
|
-
//
|
|
1856
|
+
// too: plan/push diff the same cleaned manifest the server stores.
|
|
1837
1857
|
const withDefaults = Value.Clean(schema, Value.Default(schema, Value.Clone(raw)));
|
|
1838
1858
|
if (!Value.Check(schema, withDefaults)) {
|
|
1839
1859
|
const errors = [...Value.Errors(schema, withDefaults)].map((e) => `${e.path || '/'}: ${e.message}`);
|
|
@@ -1865,7 +1885,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1865
1885
|
}
|
|
1866
1886
|
// Cross-field rule: a REAL payments provider needs its credential block; the
|
|
1867
1887
|
// 'mock' provider (the deterministic default) stays zero-config so the whole
|
|
1868
|
-
// ledger path is testable without real keys (
|
|
1888
|
+
// ledger path is testable without real keys (guide ch. 6, payments).
|
|
1869
1889
|
if (feature === 'payments') {
|
|
1870
1890
|
const v = withDefaults;
|
|
1871
1891
|
const needsBlock = {
|
|
@@ -1880,7 +1900,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1880
1900
|
}
|
|
1881
1901
|
// A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
|
|
1882
1902
|
// the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
|
|
1883
|
-
// user, running vxil-billed functions for free
|
|
1903
|
+
// user, running vxil-billed functions for free. Rejected at write
|
|
1884
1904
|
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
1885
1905
|
const ledgerErrs = [];
|
|
1886
1906
|
const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
@@ -1888,7 +1908,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1888
1908
|
if (rule.creditType && isReservedCreditType(rule.creditType)) {
|
|
1889
1909
|
ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
|
|
1890
1910
|
}
|
|
1891
|
-
// (2026-09-25
|
|
1911
|
+
// (2026-09-25) an entitlement rule must name a declared tier — the
|
|
1892
1912
|
// write-time mirror of the runtime's unknown-tier refusal (a purchase for
|
|
1893
1913
|
// a tier nobody declared would land the delivery `error`).
|
|
1894
1914
|
if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
|
|
@@ -1933,7 +1953,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1933
1953
|
ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is reset each period by ${by} (mode 'reset' writes off purchased credits of that type) — sell packs on their own credit type and spend with credit_types`);
|
|
1934
1954
|
}
|
|
1935
1955
|
}
|
|
1936
|
-
//
|
|
1956
|
+
// An UNMAPPED price silently revoked a paying
|
|
1937
1957
|
// customer (priceMap miss → tier NULL → refold excluded the row). The
|
|
1938
1958
|
// reducer now stamps such an event outcome 'error' (reprocessable), and
|
|
1939
1959
|
// this lint catches the config half at `vxil push` time: every priceMap
|
|
@@ -1981,7 +2001,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1981
2001
|
if (rmErrors.length)
|
|
1982
2002
|
return { ok: false, errors: rmErrors.slice(0, 10) };
|
|
1983
2003
|
}
|
|
1984
|
-
// Cross-field rules: mcp custom tools + prompts (
|
|
2004
|
+
// Cross-field rules: mcp custom tools + prompts (guide ch. 10). The URL
|
|
1985
2005
|
// check here is a PURE mirror of @vxil/runtime assertPublicHttpsUrl (this
|
|
1986
2006
|
// package is typebox-only) — the authoritative runtime guard re-runs in
|
|
1987
2007
|
// mcp-v1 at call time; this gate rejects obviously-internal targets BEFORE
|
|
@@ -2046,7 +2066,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
2046
2066
|
if (tplErrs.length)
|
|
2047
2067
|
return { ok: false, errors: tplErrs.slice(0, 10) };
|
|
2048
2068
|
}
|
|
2049
|
-
// Cross-field rules: declared API state
|
|
2069
|
+
// Cross-field rules: declared API state. Each list is
|
|
2050
2070
|
// converged by NAME/URL, so a duplicate key is ambiguous and refused at push;
|
|
2051
2071
|
// the per-item rules mirror the feature route's own validation so a declared
|
|
2052
2072
|
// entry can never be one the converge would 422 on.
|
|
@@ -2062,7 +2082,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
2062
2082
|
if (errs.length)
|
|
2063
2083
|
return { ok: false, errors: errs.slice(0, 10) };
|
|
2064
2084
|
}
|
|
2065
|
-
// Cross-field rule: auth otp.testRecipients
|
|
2085
|
+
// Cross-field rule: auth otp.testRecipients — every entry must be an
|
|
2066
2086
|
// email, an `*@domain` glob, or a +E.164 number; anything else would never
|
|
2067
2087
|
// match and silently do nothing.
|
|
2068
2088
|
if (feature === 'auth') {
|
|
@@ -2107,7 +2127,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
2107
2127
|
if (b.overlap !== undefined && b.kind !== 'cron') {
|
|
2108
2128
|
errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
|
|
2109
2129
|
}
|
|
2110
|
-
//
|
|
2130
|
+
// retry is an opt-in for the platform-delivered event lanes only —
|
|
2111
2131
|
// an http invoke returns its real status to its caller, and a cron
|
|
2112
2132
|
// tick's retry would overlap the next tick.
|
|
2113
2133
|
if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
|
|
@@ -2121,7 +2141,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
2121
2141
|
const cmsEventErr = b.kind === 'cmsHook' ? cmsHookEventError(b.event) : null;
|
|
2122
2142
|
if (cmsEventErr)
|
|
2123
2143
|
errs.push(`/functions/${name}/bindings/${i}: ${cmsEventErr}`);
|
|
2124
|
-
// authHook: a CLOSED event union
|
|
2144
|
+
// authHook: a CLOSED event union — reject typos at write time
|
|
2125
2145
|
// so a binding never silently subscribes to nothing.
|
|
2126
2146
|
if (b.kind === 'authHook' && b.event !== undefined && !AUTH_HOOK_EVENTS.includes(b.event)) {
|
|
2127
2147
|
errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be one of ${AUTH_HOOK_EVENTS.join(' | ')} (or omitted = user.created)`);
|