@vxil/feature-configs 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apiState.d.ts +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 +36 -15
- package/dist/index.js +265 -175
- 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 +284 -188
- package/src/readmodels.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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';
|
|
@@ -15,7 +15,7 @@ export * from './hooks.js';
|
|
|
15
15
|
// Re-export the cms-rel read-model/cdc config gates (the pure-mirror split:
|
|
16
16
|
// grammar validated here at config-write; field existence at runtime).
|
|
17
17
|
export * from './readmodels.js';
|
|
18
|
-
// Re-export the DECLARED API STATE planner
|
|
18
|
+
// Re-export the DECLARED API STATE planner: pure
|
|
19
19
|
// declared-vs-live reconciliation, shared by the control-plane apply path and
|
|
20
20
|
// the CLI (`vxil plan/diff/push`), so the two push paths can never diverge.
|
|
21
21
|
export * from './apiState.js';
|
|
@@ -29,7 +29,7 @@ if (!FormatRegistry.Has('email')) {
|
|
|
29
29
|
FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
// ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth
|
|
32
|
+
// ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth ─────────────────
|
|
33
33
|
// `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
|
|
34
34
|
// functions "recover-by-price" meter). It must NEVER be grantable or consumable
|
|
35
35
|
// by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
|
|
@@ -40,7 +40,7 @@ if (!FormatRegistry.Has('email')) {
|
|
|
40
40
|
// config-write refusal share ONE list. payments-v1/core.ts re-exports these.
|
|
41
41
|
export const RESERVED_CREDIT_TYPES: ReadonlySet<string> = new Set<string>(['fn_cpu_ms']);
|
|
42
42
|
|
|
43
|
-
/**
|
|
43
|
+
/** A function binding's `retry.maxAttempts` ceiling, and the
|
|
44
44
|
* binding kinds that may carry `retry` — the platform-delivered event lanes
|
|
45
45
|
* (an http invoke returns its own status; a cron tick's retry would overlap
|
|
46
46
|
* the next tick). Read by the schema, the deploy clamp and the CLI. */
|
|
@@ -53,7 +53,7 @@ export function isReservedCreditType(creditType: string): boolean {
|
|
|
53
53
|
return RESERVED_CREDIT_TYPES.has(creditType);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// ── notifications template catalog (
|
|
56
|
+
// ── notifications template catalog (per-locale overrides) ────────────────────
|
|
57
57
|
// The SHIPPED template ids and the placeholders each body may interpolate. The
|
|
58
58
|
// renderer itself lives in workers/notifications-v1/src/templates.ts (templates
|
|
59
59
|
// are code, not rows); this table is the CONFIG-TIME half so a `vxil push` that
|
|
@@ -80,7 +80,7 @@ export const NOTIFICATION_TEMPLATE_SLOT_PLACEHOLDERS: Readonly<Record<string, re
|
|
|
80
80
|
transactional: ['cta'],
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
-
/** Per-override caps
|
|
83
|
+
/** Per-override caps. Bodies are emails, not documents. */
|
|
84
84
|
export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
|
|
85
85
|
export const NOTIF_OVERRIDE_BODY_MAX = 20_000;
|
|
86
86
|
/** Whole-map caps: keeps one manifest (and the KV row every send reads) small. */
|
|
@@ -94,7 +94,7 @@ const NOTIF_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
|
94
94
|
const NOTIF_LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}$/;
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
97
|
+
* Validate `templates.overrides` at CONFIG-WRITE time. Returns [] when the
|
|
98
98
|
* bag is absent or clean. Every rule fails LOUD rather than shipping something
|
|
99
99
|
* that silently renders wrong in a customer's inbox:
|
|
100
100
|
* • overrides present with `allowOverride:false` → rejected (never inert);
|
|
@@ -189,12 +189,12 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
189
189
|
// need no email account, so the mock path is zero-config. A cross-field
|
|
190
190
|
// check in validateFeatureConfig requires it only when provider === 'resend'.
|
|
191
191
|
resendApiKeyRef: Type.Optional(Type.String()),
|
|
192
|
-
// Optional per-tenant Resend/Svix ENDPOINT secret ref (
|
|
193
|
-
//
|
|
192
|
+
// Optional per-tenant Resend/Svix ENDPOINT secret ref (a pointer into the
|
|
193
|
+
// tenant's encrypted secret store — same store as resendApiKeyRef).
|
|
194
194
|
// When set, inbound Resend webhooks are verified with THIS tenant's secret
|
|
195
195
|
// instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
|
|
196
196
|
// to the tenant so a signed event for tenant A can never validate at tenant
|
|
197
|
-
// B's webhook URL (
|
|
197
|
+
// B's webhook URL (mirrors payments revenuecat.webhookSecretRef).
|
|
198
198
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
199
199
|
// 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP;
|
|
200
200
|
// 'ses' (2026-09-19) is the second real provider — Amazon SES v2, BYO IAM
|
|
@@ -206,7 +206,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
206
206
|
// cap rule — funded by folding `rateLimit` into an Optional bag below);
|
|
207
207
|
// NO `default: {}` so an absent bag stays absent (the auth `security`
|
|
208
208
|
// precedent). Both refs are `secret:<name>` POINTERS into
|
|
209
|
-
//
|
|
209
|
+
// the tenant's encrypted secret store (feature='notifications') —
|
|
210
210
|
// exactly how resendApiKeyRef resolves; the region is plain config (not a
|
|
211
211
|
// secret) and is pattern-pinned to the AWS region grammar so a typo fails
|
|
212
212
|
// at `vxil push` instead of as a DNS error on the first send. Required
|
|
@@ -228,7 +228,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
228
228
|
// nested objects carry `default: {}` so Value.Default can materialize them
|
|
229
229
|
// and then recurse into the leaf defaults.
|
|
230
230
|
// `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
|
|
231
|
-
// Optional object as ONE) to fund `broadcast` below (
|
|
231
|
+
// Optional object as ONE) to fund `broadcast` below (2026-07-18).
|
|
232
232
|
// It KEEPS `default: {}`, which Value.Default still materializes — so every
|
|
233
233
|
// persisted manifest carries retry.{maxAttempts,backoff} exactly as before
|
|
234
234
|
// (zero behavioral delta); only the TS type is now optional (workers read
|
|
@@ -246,7 +246,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
246
246
|
{ softBounceThreshold: Type.Integer({ default: 3 }) },
|
|
247
247
|
{ default: {} },
|
|
248
248
|
),
|
|
249
|
-
// `rateLimit` became an OPTIONAL bag (2 leaves → 1, the
|
|
249
|
+
// `rateLimit` became an OPTIONAL bag (2 leaves → 1, the `retry` trick)
|
|
250
250
|
// on 2026-09-19 to fund the `ses` credential bag above. It KEEPS
|
|
251
251
|
// `default: {}`, so Value.Default still materializes
|
|
252
252
|
// rateLimit.{perDay,perTenantSec} into every persisted manifest exactly as
|
|
@@ -259,8 +259,8 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
259
259
|
},
|
|
260
260
|
{ default: {} },
|
|
261
261
|
)),
|
|
262
|
-
// `templates` became an OPTIONAL bag (1 leaf, the same
|
|
263
|
-
// uses) to fund the
|
|
262
|
+
// `templates` became an OPTIONAL bag (1 leaf, the same trick `retry`
|
|
263
|
+
// uses) to fund the per-locale `overrides` map WITHOUT moving the count:
|
|
264
264
|
// countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
|
|
265
265
|
// Value.Default still materializes `templates.allowOverride` into every
|
|
266
266
|
// persisted manifest exactly as before — zero behavioral delta; only the TS
|
|
@@ -268,7 +268,7 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
268
268
|
templates: Type.Optional(Type.Object(
|
|
269
269
|
{
|
|
270
270
|
allowOverride: Type.Boolean({ default: false }),
|
|
271
|
-
//
|
|
271
|
+
// (2026-09-10) TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
|
|
272
272
|
// template ids, as config DATA (a Record = 1 leaf, catalog size never
|
|
273
273
|
// moves the count). Shape: { [templateId]: { [locale]: { subject?,
|
|
274
274
|
// html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
|
|
@@ -297,10 +297,10 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
297
297
|
)),
|
|
298
298
|
/** in-app inbox channel (send with channel: 'inbox' | 'both') */
|
|
299
299
|
inboxEnabled: Type.Boolean({ default: false }),
|
|
300
|
-
/**
|
|
300
|
+
/** Broadcast campaigns channel. Optional bag (= 1 leaf): absent means
|
|
301
301
|
* disabled; per-campaign quiet_hours / freq_cap overrides live on the
|
|
302
302
|
* notifications.campaigns ROW (tenant data), not here. Folded into the
|
|
303
|
-
* canonical schema 2026-07-18 (
|
|
303
|
+
* canonical schema 2026-07-18 (Value.Clean previously STRIPPED the
|
|
304
304
|
* worker-local extension, so campaigns 403'd via the real config path). */
|
|
305
305
|
broadcast: Type.Optional(Type.Object({
|
|
306
306
|
enabled: Type.Boolean({ default: false }),
|
|
@@ -324,28 +324,26 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
324
324
|
// retry (Optional bag = 1), suppression.softBounceThreshold,
|
|
325
325
|
// rateLimit (Optional bag = 1 — was rateLimit.{perDay,perTenantSec}, collapsed
|
|
326
326
|
// 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
|
|
327
|
+
// templates.allowOverride, collapsed 2026-09-10 to fund the `overrides` map
|
|
328
328
|
// at zero net cost), inboxEnabled, broadcast (Optional bag = 1) → 15. Cap = 15 —
|
|
329
329
|
// AT the cap; the next flag must collapse something. (`templates.overrides` is
|
|
330
330
|
// a Record MAP inside the ONE optional templates leaf — DATA, not a flag — so
|
|
331
331
|
// catalog size never moves it.)
|
|
332
332
|
|
|
333
333
|
export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
|
|
334
|
-
/** The
|
|
334
|
+
/** The broadcast bag as persisted (present ⇒ leaf defaults applied). */
|
|
335
335
|
export type BroadcastConfig = NonNullable<NotificationsConfig['broadcast']>;
|
|
336
336
|
|
|
337
337
|
// ── jobs `generation` block: the ONE declaration of its defaults and bounds ──
|
|
338
|
-
// (
|
|
339
|
-
// copy"). The JobsConfigSchema `generation` leaf reads these for its
|
|
338
|
+
// (2026-09-25 — one copy of the generation defaults, not two). The JobsConfigSchema `generation` leaf reads these for its
|
|
340
339
|
// `default` / `minimum` / `maximum`, and workers/jobs-v1/src/generation.ts
|
|
341
340
|
// imports them (jobs-v1 already depends on @vxil/feature-configs; this package
|
|
342
341
|
// has no @vxil/types dependency, so the shared value lives here — the
|
|
343
342
|
// 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.
|
|
343
|
+
// feature-configs unit test pins schema ↔ constant, and a CI gate pins that no
|
|
344
|
+
// second object-literal declaration of the constant reappears anywhere.
|
|
347
345
|
|
|
348
|
-
/** Generation-lifecycle config defaults (
|
|
346
|
+
/** Generation-lifecycle config defaults (guide ch. 6, jobs). */
|
|
349
347
|
export const GENERATION_DEFAULTS = {
|
|
350
348
|
/** per-tenant in-flight generation cap (separate budget from queue jobs) */
|
|
351
349
|
maxConcurrent: 20,
|
|
@@ -399,7 +397,7 @@ export const JobsConfigSchema = Type.Object({
|
|
|
399
397
|
{ maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
|
|
400
398
|
{ default: {} },
|
|
401
399
|
),
|
|
402
|
-
//
|
|
400
|
+
// Generation lifecycle knobs (guide ch. 6, jobs). The defaults and bounds are
|
|
403
401
|
// declared ONCE, as GENERATION_DEFAULTS / GENERATION_BOUNDS below this schema
|
|
404
402
|
// (2026-09-25): jobs-v1's resolveGenerationConfig imports them and clamps a
|
|
405
403
|
// pre-fold manifest to the same numbers — a hand-mirrored copy used to live
|
|
@@ -414,14 +412,14 @@ export const JobsConfigSchema = Type.Object({
|
|
|
414
412
|
maxTimeoutMs: Type.Integer({ default: GENERATION_DEFAULTS.maxTimeoutMs, minimum: GENERATION_BOUNDS.maxTimeoutMs.min, maximum: GENERATION_BOUNDS.maxTimeoutMs.max }),
|
|
415
413
|
/** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
|
|
416
414
|
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
|
-
*
|
|
415
|
+
/** MANDATORY per-hold cap on a generation `reserve_credits.amount`
|
|
416
|
+
* (guide ch. 6, jobs). Every requested amount is CLAMPED to this (never rejected) — a
|
|
419
417
|
* conservative default so an untrusted deployed function that carries a
|
|
420
418
|
* reserve block can never hold more than a bounded amount per run without
|
|
421
419
|
* any tenant action. */
|
|
422
420
|
maxReserveCredits: Type.Integer({ default: GENERATION_DEFAULTS.maxReserveCredits, minimum: GENERATION_BOUNDS.maxReserveCredits.min, maximum: GENERATION_BOUNDS.maxReserveCredits.max }),
|
|
423
421
|
/** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
|
|
424
|
-
* reserve holds across all in-flight generation runs (
|
|
422
|
+
* reserve holds across all in-flight generation runs (guide ch. 6, jobs): a
|
|
425
423
|
* reserve whose amount would push the tenant's outstanding-holds total over
|
|
426
424
|
* this is rejected 429, so a runaway function cannot hold every user at
|
|
427
425
|
* once. Defaulted so no tenant action is required to be safe. */
|
|
@@ -435,43 +433,42 @@ export const JobsConfigSchema = Type.Object({
|
|
|
435
433
|
export type JobsConfig = Static<typeof JobsConfigSchema>;
|
|
436
434
|
|
|
437
435
|
// One social-provider's BYO credential block. The *Ref fields are POINTERS into
|
|
438
|
-
//
|
|
439
|
-
//
|
|
436
|
+
// the tenant's encrypted secret store — never raw secrets (guide ch. 6, auth).
|
|
437
|
+
// google/github/facebook share the clientId/secret shape; apple is distinct
|
|
440
438
|
// (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
|
|
439
|
+
// the .p8, so it carries servicesId/teamId/keyId + a p8 keyRef).
|
|
442
440
|
const OAuthRefsSchema = Type.Object({
|
|
443
441
|
// clientId/secret refs are OPTIONAL at the schema layer: a tenant may stage a
|
|
444
442
|
// partial block, and the worker enforces presence at use (→ 501 if missing),
|
|
445
443
|
// matching the worker's OAuthProviderRefs shape (core.ts). They stay POINTERS
|
|
446
|
-
// into
|
|
444
|
+
// into the tenant's secret store — never raw secrets (guide ch. 6, auth).
|
|
447
445
|
clientIdRef: Type.Optional(Type.String()),
|
|
448
446
|
clientSecretRef: Type.Optional(Type.String()),
|
|
449
447
|
// extra native-aud allow-list entries (iOS/web client ids that differ from the
|
|
450
|
-
// primary clientIdRef) — also
|
|
448
|
+
// primary clientIdRef) — also secret-store refs (guide ch. 6, auth).
|
|
451
449
|
audRefs: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
|
|
452
450
|
});
|
|
453
451
|
const AppleRefsSchema = Type.Object({
|
|
454
452
|
servicesId: Type.String(), // the OAuth client_id / native aud (NOT a secret)
|
|
455
453
|
teamId: Type.String(),
|
|
456
454
|
keyId: Type.String(),
|
|
457
|
-
p8KeyRef: Type.String(), //
|
|
455
|
+
p8KeyRef: Type.String(), // secret-store ref → the encrypted .p8 PEM
|
|
458
456
|
// extra native-aud allow-list entries: genuine iOS ASAuthorization id_tokens
|
|
459
|
-
// carry the app BUNDLE ID as aud, not the Services ID (
|
|
457
|
+
// carry the app BUNDLE ID as aud, not the Services ID (guide ch. 6, auth). Plain
|
|
460
458
|
// config values — bundle ids are not secrets. The worker already honors them
|
|
461
459
|
// (oauthCore.ts nativeAudAllowList); declaring them here is what stops
|
|
462
460
|
// Value.Clean stripping the field out of PUT /v1/config/auth.
|
|
463
461
|
bundleIds: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
|
|
464
462
|
});
|
|
465
|
-
//
|
|
466
|
-
// §6.7). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
|
|
463
|
+
// The generic OIDC / SSO bridge (guide ch. 6, auth). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
|
|
467
464
|
// bag so it spends NO leaf (an Optional object bag = ONE leaf; auth stays 11).
|
|
468
465
|
// Declarative + opt-in: the block's PRESENCE enables the `oidc` provider (no
|
|
469
466
|
// methods.* toggle — a `default:false` flag would re-materialize every published
|
|
470
|
-
// manifest, breaking the
|
|
467
|
+
// manifest, breaking the byte-identical fold). The worker discovers the
|
|
471
468
|
// endpoints + JWKS from `{issuer}/.well-known/openid-configuration` and verifies
|
|
472
469
|
// iss (byte-equal) / aud (= clientId) / nonce / exp / iat strictly, fail-closed.
|
|
473
470
|
// `clientId` is NOT a secret (it rides every authorize URL); `clientSecretRef`
|
|
474
|
-
// is a
|
|
471
|
+
// is a secret-store POINTER (feature 'auth'), never the value. A SAML IdP
|
|
475
472
|
// plugs in through a broker (Okta / Entra / Auth0 / WorkOS) that speaks OIDC —
|
|
476
473
|
// there is deliberately NO native SAML.
|
|
477
474
|
const OidcProviderSchema = Type.Object({
|
|
@@ -500,7 +497,7 @@ const OidcProviderSchema = Type.Object({
|
|
|
500
497
|
{ maxItems: 32 },
|
|
501
498
|
)),
|
|
502
499
|
// default true: an identity whose VERIFIED email matches an existing user is
|
|
503
|
-
// linked to it (the shipped
|
|
500
|
+
// linked to it (the shipped social sign-in rule). false: link only by the stable
|
|
504
501
|
// (issuer, sub) anchor; a matching email that is not yet linked → 409.
|
|
505
502
|
autoLink: Type.Optional(Type.Boolean()),
|
|
506
503
|
});
|
|
@@ -510,7 +507,7 @@ const OidcProviderSchema = Type.Object({
|
|
|
510
507
|
// on every caller-supplied `redirect_url` / `redirect_uri` (core.ts
|
|
511
508
|
// `isAllowedRedirectUrl`) must agree on WHICH schemes a sign-in link may be
|
|
512
509
|
// delivered to, or a tenant could allow-list an origin the worker then refuses
|
|
513
|
-
// (or the reverse). The rule (
|
|
510
|
+
// (or the reverse). The rule (2026-09-19): `https://` on any
|
|
514
511
|
// host, plus PLAIN `http://` ONLY on the two loopback names — `localhost` and
|
|
515
512
|
// `127.0.0.1`, any port — so a local dev server can complete a magic link
|
|
516
513
|
// without a staging origin. Any other `http://` (a LAN host, `*.local`, a
|
|
@@ -521,7 +518,7 @@ const OidcProviderSchema = Type.Object({
|
|
|
521
518
|
// with THIS predicate, and auth-v1 calls it on the parsed URL — the parity test
|
|
522
519
|
// in index.test.ts / redirectRule.test.ts holds the two together. The CLI's
|
|
523
520
|
// production promotion gate still refuses any `http://` origin on a
|
|
524
|
-
// production push
|
|
521
|
+
// production push, so a loopback entry is a DEV-tenant affair.
|
|
525
522
|
export const REDIRECT_ORIGIN_PATTERN =
|
|
526
523
|
'^(https://[^/?#\\s]+|http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?)$';
|
|
527
524
|
|
|
@@ -547,13 +544,13 @@ export function isAllowedRedirectOriginEntry(entry: string): boolean {
|
|
|
547
544
|
|
|
548
545
|
export const AuthConfigSchema = Type.Object({
|
|
549
546
|
enabled: Type.Boolean({ default: true }),
|
|
550
|
-
//
|
|
547
|
+
// (2026-09-10) the six method toggles collapsed into ONE
|
|
551
548
|
// Optional bag (6 leaves → 1, countLeaves counts an Optional object as ONE) —
|
|
552
549
|
// the notifications `retry`/`broadcast` precedent. It KEEPS `default: {}`,
|
|
553
550
|
// which Value.Default still materializes, so every persisted manifest carries
|
|
554
551
|
// methods.{emailPassword,magicLink,google,github,apple,facebook} with the
|
|
555
552
|
// SAME keys and defaults as before — byte-identical folds for existing
|
|
556
|
-
// tenants (index.test.ts
|
|
553
|
+
// tenants (index.test.ts pins the fold bytes). Only the TS type is now optional;
|
|
557
554
|
// the worker's gate() normalizes an absent bag to the defaults so every
|
|
558
555
|
// reader (config.methods.<flag>) is unchanged.
|
|
559
556
|
methods: Type.Optional(Type.Object(
|
|
@@ -571,12 +568,12 @@ export const AuthConfigSchema = Type.Object({
|
|
|
571
568
|
// rule) keyed by provider, so the four provider blocks (and any future one)
|
|
572
569
|
// never inflate the flag count — the prior shape spent a leaf per top-level
|
|
573
570
|
// 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
|
|
571
|
+
// for the bag) and EXTENDS the accepted providers to apple + facebook:
|
|
575
572
|
// • google/github/facebook → { clientIdRef, clientSecretRef, audRefs? }
|
|
576
573
|
// • apple → { servicesId, teamId, keyId, p8KeyRef }
|
|
577
|
-
// All *Ref fields are
|
|
574
|
+
// All *Ref fields are secret-store POINTERS, never raw secrets. The
|
|
578
575
|
// worker reads config.providers?.{google,github,apple,facebook} (oauthCore.ts).
|
|
579
|
-
// The
|
|
576
|
+
// The runtime sign-in flow for apple/facebook is SHIPPED in oauthCore.ts
|
|
580
577
|
// (id_token/access_token verification + ES256 Apple client_secret minting);
|
|
581
578
|
// methods.{apple,facebook} above are the enable toggles it gates on.
|
|
582
579
|
providers: Type.Optional(Type.Object({
|
|
@@ -584,11 +581,11 @@ export const AuthConfigSchema = Type.Object({
|
|
|
584
581
|
github: Type.Optional(OAuthRefsSchema),
|
|
585
582
|
apple: Type.Optional(AppleRefsSchema),
|
|
586
583
|
facebook: Type.Optional(OAuthRefsSchema),
|
|
587
|
-
//
|
|
584
|
+
// the generic OIDC / SSO issuer (presence = enabled; see above)
|
|
588
585
|
oidc: Type.Optional(OidcProviderSchema),
|
|
589
586
|
})),
|
|
590
587
|
// session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
|
|
591
|
-
// the OTP/anonymous/orgClaims
|
|
588
|
+
// the OTP/anonymous/orgClaims release — the `{ default: {} }` keeps the inner
|
|
592
589
|
// defaults materializing on publish, so the worker still reads fully-populated
|
|
593
590
|
// manifests; its `config.session?.ttlMinutes ?? 60` fallbacks cover sparse
|
|
594
591
|
// hand-built manifests only.
|
|
@@ -596,7 +593,7 @@ export const AuthConfigSchema = Type.Object({
|
|
|
596
593
|
{
|
|
597
594
|
ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
|
|
598
595
|
refreshTtlDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
|
|
599
|
-
//
|
|
596
|
+
// (2026-09-10) concurrent-session cap per user with
|
|
600
597
|
// TAKE-OVER — a new sign-in revokes the OLDEST sessions past the cap
|
|
601
598
|
// (revoked_reason 'device_cap', edge cache written) and reports them as
|
|
602
599
|
// `took_over: [session_id…]`. Optional WITHOUT a default so existing
|
|
@@ -612,15 +609,15 @@ export const AuthConfigSchema = Type.Object({
|
|
|
612
609
|
},
|
|
613
610
|
{ default: {} },
|
|
614
611
|
)),
|
|
615
|
-
//
|
|
612
|
+
// Note: emailVerification.tokenTtlHours was DROPPED —
|
|
616
613
|
// consumer-less (grep-verified: only this schema + dist mentioned it; the
|
|
617
614
|
// verify-email token flow it would bound was never built). Same precedent as
|
|
618
|
-
// the removed `redirects` block below. Its leaf funds the OTP
|
|
615
|
+
// the removed `redirects` block below. Its leaf funds the OTP sign-in bag.
|
|
619
616
|
emailVerification: Type.Object(
|
|
620
617
|
{ required: Type.Boolean({ default: false }) },
|
|
621
618
|
{ default: {} },
|
|
622
619
|
),
|
|
623
|
-
//
|
|
620
|
+
// (2026-09-18) magicLink is now an OPTIONAL bag (= ONE leaf however
|
|
624
621
|
// many knobs it holds — the methods/session/password precedent) so the
|
|
625
622
|
// request cooldown could land without spending a second leaf. It KEEPS
|
|
626
623
|
// `default: {}`, so Value.Default still materializes
|
|
@@ -629,7 +626,7 @@ export const AuthConfigSchema = Type.Object({
|
|
|
629
626
|
magicLink: Type.Optional(Type.Object(
|
|
630
627
|
{
|
|
631
628
|
tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }),
|
|
632
|
-
//
|
|
629
|
+
// the per-(tenant, identifier) magic-link REQUEST cooldown — the
|
|
633
630
|
// `otp.resendCooldownSec` twin, same bounds so the two knobs read the
|
|
634
631
|
// same. A second request for the same address inside the window is a 429
|
|
635
632
|
// `magic_link_rate_limited` with a Retry-After header. Type.Optional with
|
|
@@ -640,19 +637,19 @@ export const AuthConfigSchema = Type.Object({
|
|
|
640
637
|
},
|
|
641
638
|
{ default: {} },
|
|
642
639
|
)),
|
|
643
|
-
//
|
|
640
|
+
// Identity continuity (2026-09-18) — OPT-IN registry adoption. When
|
|
644
641
|
// true, a sign-in by a method that PROVES control of the address — magic
|
|
645
642
|
// link, email OTP, or OAuth with a provider-verified address — reuses the id
|
|
646
643
|
// of the one matching pre-registered end-user (POST /v1/users) that has no
|
|
647
644
|
// account yet, instead of minting a fresh `user_<ulid>` and leaving the
|
|
648
645
|
// tenant with two records for one person. Password SIGN-UP never adopts: it
|
|
649
|
-
// proves nothing about the address (
|
|
646
|
+
// proves nothing about the address (guide ch. 6, auth).
|
|
650
647
|
// OFF by default, and Type.Optional with NO default: adoption means whoever
|
|
651
648
|
// proves control of a pre-registered address becomes that record — a change
|
|
652
649
|
// of security semantics for a tenant that bulk-imports contacts, whose
|
|
653
650
|
// addresses vxil never verified.
|
|
654
651
|
registryAdopt: Type.Optional(Type.Boolean()),
|
|
655
|
-
// Email OTP sign-in
|
|
652
|
+
// Email OTP sign-in + the knobs step-up re-auth shares.
|
|
656
653
|
// OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
|
|
657
654
|
// otp?.enabled === true).
|
|
658
655
|
otp: Type.Optional(Type.Object({
|
|
@@ -660,7 +657,7 @@ export const AuthConfigSchema = Type.Object({
|
|
|
660
657
|
codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
|
|
661
658
|
maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
|
|
662
659
|
resendCooldownSec: Type.Integer({ default: 60, minimum: 0, maximum: 600 }),
|
|
663
|
-
//
|
|
660
|
+
// (2026-09-10) test recipients — an OTP / step-up / email-
|
|
664
661
|
// claim request whose address matches an entry sends NO mail and returns
|
|
665
662
|
// the code as `test_code` (audit auth.otp.test_issued). Entries: an exact
|
|
666
663
|
// email, a `*@domain` glob, or a +E.164 number (accepted for the SMS
|
|
@@ -671,11 +668,11 @@ export const AuthConfigSchema = Type.Object({
|
|
|
671
668
|
Type.String({ minLength: 3, maxLength: 320 }), { maxItems: 20 },
|
|
672
669
|
)),
|
|
673
670
|
})),
|
|
674
|
-
// Anonymous (guest) sign-in
|
|
671
|
+
// Anonymous (guest) sign-in. OPTIONAL bag = 1 leaf.
|
|
675
672
|
anonymous: Type.Optional(Type.Object({
|
|
676
673
|
enabled: Type.Boolean({ default: false }),
|
|
677
674
|
})),
|
|
678
|
-
// Org claims embedded in session JWTs at mint/refresh
|
|
675
|
+
// Org claims embedded in session JWTs at mint/refresh:
|
|
679
676
|
// when enabled, auth-v1 fetches the user's active-org membership from orgs
|
|
680
677
|
// over the EDGE and embeds { org_id, role, perms[] } as the `org` claim.
|
|
681
678
|
// Fail-open: an orgs outage mints WITHOUT claims (sign-in never breaks).
|
|
@@ -684,23 +681,22 @@ export const AuthConfigSchema = Type.Object({
|
|
|
684
681
|
orgClaims: Type.Optional(Type.Object({
|
|
685
682
|
enabled: Type.Boolean({ default: false }),
|
|
686
683
|
})),
|
|
687
|
-
//
|
|
684
|
+
// Note: the earlier `redirects` block was DROPPED entirely —
|
|
688
685
|
// it had zero consumers (grep-verified: no worker reads config.redirects) and
|
|
689
686
|
// its leaf was spent on the methods.{apple,facebook} toggles the shipped
|
|
690
|
-
//
|
|
687
|
+
// social sign-in flow actually gates on. Re-adding it requires headroom or a
|
|
691
688
|
// collapse elsewhere.
|
|
692
689
|
//
|
|
693
|
-
// Account-security controls (
|
|
694
|
-
// §4b). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
|
|
690
|
+
// Account-security controls (2026-09-10; guide ch. 6, auth). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
|
|
695
691
|
// absent (existing manifests fold byte-identically) and every control is
|
|
696
692
|
// opt-in:
|
|
697
693
|
// • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
|
|
698
694
|
// sign-in + OTP verify (429 account_locked + Retry-After); a bounded
|
|
699
|
-
// counter
|
|
695
|
+
// stored counter survives restarts.
|
|
700
696
|
// • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
|
|
701
697
|
// chars leave the worker, never the password) at sign-up / reset-confirm;
|
|
702
698
|
// fail-OPEN on network error → 422 password_breached.
|
|
703
|
-
// • captchaSecretRef — a
|
|
699
|
+
// • captchaSecretRef — a secret-store ref (feature 'auth') holding the
|
|
704
700
|
// Turnstile secret; when set, sign-up / OTP request / magic-link request
|
|
705
701
|
// require `captcha_token` (403 captcha_failed otherwise).
|
|
706
702
|
// • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
|
|
@@ -725,24 +721,24 @@ export const AuthConfigSchema = Type.Object({
|
|
|
725
721
|
});
|
|
726
722
|
/** The security bag as persisted (present ⇒ leaf defaults applied). */
|
|
727
723
|
export type AuthSecurityConfig = NonNullable<Static<typeof AuthConfigSchema>['security']>;
|
|
728
|
-
/** The
|
|
724
|
+
/** The generic OIDC / SSO issuer block (`providers.oidc`). */
|
|
729
725
|
export type AuthOidcConfig = Static<typeof OidcProviderSchema>;
|
|
730
|
-
// Leaves (
|
|
726
|
+
// Leaves (2026-09-10): enabled(1) + methods(1, optional bag —
|
|
731
727
|
// was 6) + providers(1, optional bag holding the four google/github/apple/
|
|
732
|
-
// facebook credential blocks + the
|
|
728
|
+
// facebook credential blocks + the `oidc` issuer block) + session(1, optional bag) + password(1,
|
|
733
729
|
// optional bag) + emailVerification(1) + magicLink(1, optional bag) + otp(1, optional bag)
|
|
734
730
|
// + anonymous(1, optional bag) + orgClaims(1, optional bag) + security(1,
|
|
735
|
-
// optional bag) + registryAdopt(1
|
|
731
|
+
// optional bag) + registryAdopt(1) = 12. Cap = 15 — 3 leaves of
|
|
736
732
|
// headroom. Knobs added inside an existing bag (session.maxConcurrent,
|
|
737
733
|
// otp.testRecipients, magicLink.resendCooldownSec, the security sub-bags)
|
|
738
734
|
// never move the count.
|
|
739
735
|
|
|
740
736
|
export type AuthConfig = Static<typeof AuthConfigSchema>;
|
|
741
737
|
|
|
742
|
-
// ── DECLARED API STATE (
|
|
738
|
+
// ── DECLARED API STATE (2026-09-23) ──────────────────────────────────────────
|
|
743
739
|
// A rate-limit policy is a ROW in the feature's own KV store, written by
|
|
744
740
|
// POST/PUT/DELETE /v1/rate-limits/policies. It used to be API state OUTSIDE
|
|
745
|
-
// vxil.config (
|
|
741
|
+
// vxil.config (by the old rule "one writer per datum, and the writer is
|
|
746
742
|
// the route"), which made a second environment non-reproducible — every tenant
|
|
747
743
|
// ended up with an `ensure-rate-limit-policies.ts` + a nightly assert. The
|
|
748
744
|
// rule is unchanged — one writer per datum — but the writer is now THE
|
|
@@ -793,7 +789,7 @@ export type RateLimitsConfig = Static<typeof RateLimitsConfigSchema>;
|
|
|
793
789
|
|
|
794
790
|
export const FilesConfigSchema = Type.Object({
|
|
795
791
|
enabled: Type.Boolean({ default: true }),
|
|
796
|
-
//
|
|
792
|
+
// `bucketRef` was DELETED (2026-09-11): the object-storage bucket is a platform
|
|
797
793
|
// binding on files-v1, never tenant-selectable, and no code ever read the
|
|
798
794
|
// leaf — declaring it invited "point files at my own bucket", which vxil does
|
|
799
795
|
// not offer. A persisted manifest that still carries it folds (Value.Clean).
|
|
@@ -802,8 +798,8 @@ export const FilesConfigSchema = Type.Object({
|
|
|
802
798
|
quotas: Type.Object(
|
|
803
799
|
{
|
|
804
800
|
// 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
|
|
801
|
+
// object storage is cheap but the database-resident metadata + abuse aren't
|
|
802
|
+
// (pricing review 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
|
|
807
803
|
// (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
|
|
808
804
|
// tenant tier threaded to files-v1 (plan tiers).
|
|
809
805
|
maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
|
|
@@ -813,7 +809,7 @@ export const FilesConfigSchema = Type.Object({
|
|
|
813
809
|
{ default: {} },
|
|
814
810
|
),
|
|
815
811
|
allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
|
|
816
|
-
// (
|
|
812
|
+
// (2026-09-11) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
|
|
817
813
|
// there is no malware/content scanner in files-v1 (it was a "V1.5" placeholder
|
|
818
814
|
// neither leaf was ever read), so the knob promised quarantine that never
|
|
819
815
|
// happened. Re-declare it in the same change as a real scanner, not before.
|
|
@@ -824,7 +820,7 @@ export const FilesConfigSchema = Type.Object({
|
|
|
824
820
|
},
|
|
825
821
|
{ default: {} },
|
|
826
822
|
),
|
|
827
|
-
// Wave-2 extensions (
|
|
823
|
+
// Wave-2 extensions (guide ch. 6, files: OCR + TTL) — the merge of
|
|
828
824
|
// workers/files-v1/src/ext.ts FilesExtensionsConfigSchema promised by its
|
|
829
825
|
// 'wiring phase' comment. Each is an OPTIONAL bag (= ONE leaf per the cap
|
|
830
826
|
// rule); files-v1 already reads both defensively (FilesConfigWithExt), so
|
|
@@ -842,21 +838,20 @@ export const FilesConfigSchema = Type.Object({
|
|
|
842
838
|
[Type.Literal('gcv'), Type.Literal('textract'), Type.Literal('azure-di'), Type.Literal('mock')],
|
|
843
839
|
{ default: 'mock' },
|
|
844
840
|
),
|
|
845
|
-
// provider key is BYO +
|
|
846
|
-
// config flag. keyRef names the
|
|
841
|
+
// provider key is BYO + encrypted in the tenant's secret store — NOT a
|
|
842
|
+
// config flag. keyRef names the stored secret (like ai's keyRefs).
|
|
847
843
|
keyRef: Type.Optional(Type.String()),
|
|
848
844
|
asyncOverJobs: Type.Boolean({ default: true }), // large/multi-page → jobs
|
|
849
845
|
boundingBoxes: Type.Boolean({ default: false }),
|
|
850
846
|
})),
|
|
851
847
|
});
|
|
852
848
|
// Leaves: 9 + ttl(1, optional bag) + extractText(1, optional bag) = 11. Cap = 15.
|
|
853
|
-
// (was 14 —
|
|
849
|
+
// (was 14 — 2026-09-11 deleted the inert bucketRef leaf and the inert contentScan bag.)
|
|
854
850
|
|
|
855
851
|
export type FilesConfig = Static<typeof FilesConfigSchema>;
|
|
856
852
|
|
|
857
853
|
// 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.
|
|
854
|
+
// engine. Subscriptions live in their own store (one writer per datum); config is just the capability gate + a cap.
|
|
860
855
|
export const DeclaredWebhookSubscriptionSchema = Type.Object({
|
|
861
856
|
target_url: Type.String({ minLength: 9, maxLength: 2000 }),
|
|
862
857
|
event_prefixes: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 100 }), { maxItems: 20 })),
|
|
@@ -867,14 +862,14 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
867
862
|
enabled: Type.Boolean({ default: true }),
|
|
868
863
|
maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
869
864
|
maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
|
|
870
|
-
// FAILURE-ALERT DIGEST
|
|
865
|
+
// FAILURE-ALERT DIGEST. Outbound subscriptions are the real-time
|
|
871
866
|
// channel; this is the "nobody is consuming them yet" fallback — a periodic
|
|
872
867
|
// e-mail summary of the tenant's FAILURE-class audit events (the level:
|
|
873
868
|
// 'failure' rows of the generated event catalog). Read by
|
|
874
869
|
// workers/control-plane/src/alertDigest.ts on the minute cron.
|
|
875
870
|
//
|
|
876
|
-
// RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this
|
|
877
|
-
//
|
|
871
|
+
// RECIPIENTS ARE DELIBERATELY NOT CONFIGURABLE, and this decision is
|
|
872
|
+
// recorded here the way RateLimitsConfigSchema records its own: an
|
|
878
873
|
// arbitrary `to` would turn vxil's own sending identity into a relay for
|
|
879
874
|
// tenant-authored content and open a PII egress path out of the audit trail.
|
|
880
875
|
// The digest goes to the OWNER-role dashboard accounts of the project (cap
|
|
@@ -884,7 +879,7 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
884
879
|
//
|
|
885
880
|
// An Optional object bag counts as ONE leaf (the countLeaves rule).
|
|
886
881
|
//
|
|
887
|
-
// `digestMinutes: 0` is IMMEDIATE (
|
|
882
|
+
// `digestMinutes: 0` is IMMEDIATE (2026-09-23): the
|
|
888
883
|
// pass runs every minute for the tenant and mails the `error`-level failure
|
|
889
884
|
// rows that landed since its last mail — at most one mail per minute, still
|
|
890
885
|
// to the owner accounts. 1–4 are clamped up to 5 by the reader.
|
|
@@ -893,11 +888,11 @@ export const WebhooksConfigSchema = Type.Object({
|
|
|
893
888
|
minLevel: Type.Union([Type.Literal('warn'), Type.Literal('error')], { default: 'error' }),
|
|
894
889
|
digestMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 1440 }),
|
|
895
890
|
})),
|
|
896
|
-
// DECLARED API STATE (
|
|
891
|
+
// DECLARED API STATE (2026-09-23): the tenant's OUTBOUND
|
|
897
892
|
// subscriptions as config. Keyed by `target_url` — the only stable identity a
|
|
898
893
|
// subscription has (there is no name column). A changed prefix set is an
|
|
899
894
|
// in-place update — PATCH /v1/webhooks/subscriptions/:subId, same sub_id and
|
|
900
|
-
// cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge
|
|
895
|
+
// cursor (2026-09-25). `vxil push` / `POST /v1/apply` converge the live subscriptions
|
|
901
896
|
// onto this list (handlers/apiState.ts); undeclared live rows are LEFT and
|
|
902
897
|
// reported (deleted only under --allow-destructive). Rows on the platform's
|
|
903
898
|
// signed function-delivery lanes (/v1/internal/fn/…) are NEVER declared here
|
|
@@ -922,16 +917,16 @@ export const CommentsConfigSchema = Type.Object({
|
|
|
922
917
|
|
|
923
918
|
export type CommentsConfig = Static<typeof CommentsConfigSchema>;
|
|
924
919
|
|
|
925
|
-
// content feature (
|
|
920
|
+
// content feature (guide ch. 4): flags govern LIMITS, never the
|
|
926
921
|
// content model — the model itself is data (cms.collections / cms.fields via
|
|
927
|
-
// the REST surface). versioning/localization/publicRead land later
|
|
922
|
+
// the REST surface). versioning/localization/publicRead land later.
|
|
928
923
|
export const CmsConfigSchema = Type.Object({
|
|
929
924
|
enabled: Type.Boolean({ default: true }),
|
|
930
925
|
draftPublish: Type.Boolean({ default: true }),
|
|
931
|
-
// cms end-user default-deny fail-safe (
|
|
926
|
+
// cms end-user default-deny fail-safe (guide ch. 4). When ON,
|
|
932
927
|
// a VERIFIED end-user key (owner-scope mode) is DENIED access to any
|
|
933
928
|
// collection that declares no owner_field — `403 server_only` on read AND
|
|
934
|
-
// write (
|
|
929
|
+
// write (2026-09-20; reads used to be a 404) — instead of the
|
|
935
930
|
// default tenant-wide-shared behavior. Server-caller mode is a
|
|
936
931
|
// byte-for-byte no-op. Default OFF preserves today's shared semantics
|
|
937
932
|
// (owner.int.test.ts's shared-collection invariant). A collection that DOES
|
|
@@ -953,7 +948,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
953
948
|
},
|
|
954
949
|
{ default: {} },
|
|
955
950
|
),
|
|
956
|
-
// cms ENRICHMENT (
|
|
951
|
+
// cms ENRICHMENT (guide ch. 4): the read-time relation budget. The worker
|
|
957
952
|
// clamps via resolveRelationsConfig (enrich.ts) with the SAME defaults +
|
|
958
953
|
// hard ceilings, so an out-of-range value can never widen the bound.
|
|
959
954
|
relations: Type.Object(
|
|
@@ -993,7 +988,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
993
988
|
}),
|
|
994
989
|
),
|
|
995
990
|
),
|
|
996
|
-
// Declarative relational read-models (
|
|
991
|
+
// Declarative relational read-models (guide ch. 4).
|
|
997
992
|
// Each is a NAMED, closed-grammar aggregate/rank spec, optionally
|
|
998
993
|
// materialized to a rollup collection on the EXISTING jobs cron (the
|
|
999
994
|
// fn-cron:* reconciler idiom → cms-rollup:* schedules). Grammar is validated
|
|
@@ -1004,7 +999,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
1004
999
|
Type.Object({
|
|
1005
1000
|
collection: Type.String({ maxLength: 64 }),
|
|
1006
1001
|
kind: Type.Union([Type.Literal('aggregate'), Type.Literal('rank')]),
|
|
1007
|
-
// the
|
|
1002
|
+
// the aggregate/rank query body minus limit — Type.Unknown so Value.Clean keeps it
|
|
1008
1003
|
// (the functions `signature` idiom); shape checked by the cross-field rule.
|
|
1009
1004
|
spec: Type.Unknown(),
|
|
1010
1005
|
materialize: Type.Optional(Type.Object({
|
|
@@ -1039,7 +1034,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
1039
1034
|
|
|
1040
1035
|
export type CmsConfig = Static<typeof CmsConfigSchema>;
|
|
1041
1036
|
|
|
1042
|
-
// mcp feature (
|
|
1037
|
+
// mcp feature (guide ch. 10): the aggregation surface's own knobs. Default-enabled —
|
|
1043
1038
|
// a tenant with no mcp config row still gets the aggregated tool list.
|
|
1044
1039
|
export const McpConfigSchema = Type.Object({
|
|
1045
1040
|
enabled: Type.Boolean({ default: true }),
|
|
@@ -1048,7 +1043,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
1048
1043
|
{ default: 'all' },
|
|
1049
1044
|
),
|
|
1050
1045
|
allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
|
|
1051
|
-
// (
|
|
1046
|
+
// (2026-09-11) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
|
|
1052
1047
|
// a "dashboard-UI hint only" that no dashboard ever read, and key minting is a
|
|
1053
1048
|
// control-plane concern independent of MCP exposure — per-agent keys already
|
|
1054
1049
|
// work for every tenant, gated by nothing here.
|
|
@@ -1064,7 +1059,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
1064
1059
|
{ default: {} },
|
|
1065
1060
|
),
|
|
1066
1061
|
|
|
1067
|
-
// Tenant-authored CUSTOM tools (
|
|
1062
|
+
// Tenant-authored CUSTOM tools (guide ch. 10): name → tenant-owned https
|
|
1068
1063
|
// endpoint. mcp-v1 lists each as `custom_<name>` and POSTs the tool
|
|
1069
1064
|
// arguments to `url`, HMAC-signed with the per-tenant key from
|
|
1070
1065
|
// GET /v1/mcp/signing-secret (X-Vxil-Mcp-Signature; the caller's vxil bearer
|
|
@@ -1084,7 +1079,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
1084
1079
|
}),
|
|
1085
1080
|
)),
|
|
1086
1081
|
|
|
1087
|
-
// Config-declared MCP PROMPTS (
|
|
1082
|
+
// Config-declared MCP PROMPTS (guide ch. 10): name → template with
|
|
1088
1083
|
// {{placeholder}} interpolation. Served verbatim by mcp-v1 prompts/list +
|
|
1089
1084
|
// prompts/get. ONE Type.Record leaf; placeholder ↔ arguments consistency is
|
|
1090
1085
|
// a cross-field rule below.
|
|
@@ -1104,7 +1099,7 @@ export const McpConfigSchema = Type.Object({
|
|
|
1104
1099
|
// Leaves: 8 by countLeaves (enabled, exposureLevel, allowToolList,
|
|
1105
1100
|
// rateLimits.toolCallsPerMin, branding.serverName, branding.serverInstructions
|
|
1106
1101
|
// = 6, + customTools + prompts as ONE Type.Record leaf each). Cap = 15.
|
|
1107
|
-
// (was 9 —
|
|
1102
|
+
// (was 9 — 2026-09-11 deleted the inert scopedKey.perAgentKeys leaf.)
|
|
1108
1103
|
|
|
1109
1104
|
export type McpConfig = Static<typeof McpConfigSchema>;
|
|
1110
1105
|
|
|
@@ -1136,7 +1131,7 @@ export const OrgsConfigSchema = Type.Object({
|
|
|
1136
1131
|
|
|
1137
1132
|
export type OrgsConfig = Static<typeof OrgsConfigSchema>;
|
|
1138
1133
|
|
|
1139
|
-
// activity-feed feature (
|
|
1134
|
+
// activity-feed feature (guide ch. 6, activity-feed): a GetStream-class activity-
|
|
1140
1135
|
// streams engine + a Knock/Novu-class in-app notification FEED. Flags govern
|
|
1141
1136
|
// the fan-out throttle, the follow/aggregation caps, and the cross-channel /
|
|
1142
1137
|
// realtime gates — NEVER the verb vocabulary or the personalized ranker (the
|
|
@@ -1156,8 +1151,8 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1156
1151
|
Type.Literal('aggregated'),
|
|
1157
1152
|
Type.Literal('notification'),
|
|
1158
1153
|
]),
|
|
1159
|
-
aggregation: Type.Optional(Type.String()), // group-format rule
|
|
1160
|
-
ranking: Type.Optional(Type.String()), // 'chronological' | 'decay'
|
|
1154
|
+
aggregation: Type.Optional(Type.String()), // group-format rule; required for aggregated/notification
|
|
1155
|
+
ranking: Type.Optional(Type.String()), // 'chronological' | 'decay'; flat-only; default chronological
|
|
1161
1156
|
}),
|
|
1162
1157
|
{
|
|
1163
1158
|
default: {
|
|
@@ -1175,7 +1170,7 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1175
1170
|
{
|
|
1176
1171
|
celebrityThreshold: Type.Integer({ default: 10_000, minimum: 0 }), // ≥ → pull (read-side); < → push (write-side)
|
|
1177
1172
|
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 (
|
|
1173
|
+
maxConcurrentTasks: Type.Integer({ default: 20, minimum: 1, maximum: 1000 }), // per-tenant in-flight cap (local throttle)
|
|
1179
1174
|
pendingCeiling: Type.Integer({ default: 50_000, minimum: 1 }), // pending-fan-out-depth back-pressure ceiling
|
|
1180
1175
|
},
|
|
1181
1176
|
{ default: {} },
|
|
@@ -1189,7 +1184,7 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1189
1184
|
{ default: {} },
|
|
1190
1185
|
),
|
|
1191
1186
|
|
|
1192
|
-
// (
|
|
1187
|
+
// (2026-09-11) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
|
|
1193
1188
|
// or returned a per-group activity LIST — an aggregated read returns the group
|
|
1194
1189
|
// rollup (activity_count/actor_count/last_actor), so there was never an N to
|
|
1195
1190
|
// bound and no code read the leaf. Re-declare it with a group-detail route.
|
|
@@ -1203,7 +1198,7 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1203
1198
|
|
|
1204
1199
|
crossChannel: Type.Object(
|
|
1205
1200
|
{
|
|
1206
|
-
enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger
|
|
1201
|
+
enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger
|
|
1207
1202
|
digestCadence: Type.Union(
|
|
1208
1203
|
[Type.Literal('off'), Type.Literal('hourly'), Type.Literal('daily')],
|
|
1209
1204
|
{ default: 'off' },
|
|
@@ -1212,7 +1207,7 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1212
1207
|
{ default: {} },
|
|
1213
1208
|
),
|
|
1214
1209
|
|
|
1215
|
-
// (
|
|
1210
|
+
// (2026-09-11) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
|
|
1216
1211
|
// rate-limiter binding and never read it, so the declared per-tenant write
|
|
1217
1212
|
// burst was enforced by nothing (the edge front-door limiter and the
|
|
1218
1213
|
// per-tenant request meter are the real bounds). Re-declare it together with
|
|
@@ -1222,11 +1217,11 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
1222
1217
|
// maxFanoutPerJob, maxConcurrentTasks, pendingCeiling}(+4=6), follow.{copyLimit,
|
|
1223
1218
|
// maxFollowing}(+2=8), realtime.enabled(9), crossChannel.{enabled, digestCadence}(+2=11).
|
|
1224
1219
|
// countLeaves → 11. Cap = 15. (feedGroups is Type.Record → patternProperties, ONE leaf.)
|
|
1225
|
-
// (was 13 —
|
|
1220
|
+
// (was 13 — 2026-09-11 deleted the inert aggregation and rateLimit bags.)
|
|
1226
1221
|
|
|
1227
1222
|
export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
|
|
1228
1223
|
|
|
1229
|
-
// vector-search feature (
|
|
1224
|
+
// vector-search feature (guide ch. 6, vector-search). Re-declared here to match the
|
|
1230
1225
|
// schema the worker EXPORTS from workers/vector-search-v1/src/config.ts — the
|
|
1231
1226
|
// control plane validates writes against this shared copy (the same
|
|
1232
1227
|
// one-definition / two-consumers note as the other features above; this package owns
|
|
@@ -1235,7 +1230,7 @@ export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
|
|
|
1235
1230
|
// synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
|
|
1236
1231
|
export const VectorSearchConfigSchema = Type.Object({
|
|
1237
1232
|
enabled: Type.Boolean({ default: true }),
|
|
1238
|
-
// 'auto' resolves to the default managed vector backend for the tier
|
|
1233
|
+
// 'auto' resolves to the default managed vector backend for the tier.
|
|
1239
1234
|
backend: Type.Union(
|
|
1240
1235
|
[Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')],
|
|
1241
1236
|
{ default: 'auto' },
|
|
@@ -1293,7 +1288,7 @@ export const VectorSearchConfigSchema = Type.Object({
|
|
|
1293
1288
|
),
|
|
1294
1289
|
model: Type.Optional(Type.String({ maxLength: 128 })), // cohere 'rerank-v3.5' / voyage 'rerank-2'
|
|
1295
1290
|
topN: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 200 })),
|
|
1296
|
-
apiKeyRef: Type.Optional(Type.String({ maxLength: 128 })), // 'secret:<name>'
|
|
1291
|
+
apiKeyRef: Type.Optional(Type.String({ maxLength: 128 })), // 'secret:<name>' in the tenant's secret store
|
|
1297
1292
|
}),
|
|
1298
1293
|
),
|
|
1299
1294
|
// Config-driven auto-embedding sync from cms collections: the control plane
|
|
@@ -1326,12 +1321,12 @@ export const VectorSearchConfigSchema = Type.Object({
|
|
|
1326
1321
|
|
|
1327
1322
|
export type VectorSearchConfig = Static<typeof VectorSearchConfigSchema>;
|
|
1328
1323
|
|
|
1329
|
-
// ai feature (
|
|
1324
|
+
// ai feature (guide ch. 6, ai). Re-declared to match workers/ai-v1/src/core.ts's
|
|
1330
1325
|
// exported AiConfigSchema. vxil owns the call SCAFFOLDING (routing, streaming,
|
|
1331
1326
|
// token accounting, caching, the reserve→settle budget); the tenant owns the
|
|
1332
1327
|
// intelligence (prompt TEMPLATES are config-as-code, stored/rendered but never
|
|
1333
1328
|
// authored). 'mock' is the deterministic default until a BYO key is provisioned;
|
|
1334
|
-
// the real providers route via
|
|
1329
|
+
// the real providers route via secret-store keyRefs.
|
|
1335
1330
|
export const AI_TEMPLATE_NAME_PATTERN = '^[a-zA-Z0-9_.\\-]+$';
|
|
1336
1331
|
export const AI_MAX_DECLARED_TEMPLATES = 50;
|
|
1337
1332
|
export const DeclaredAiTemplateSchema = Type.Object({
|
|
@@ -1352,7 +1347,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1352
1347
|
Type.Literal('gemini'), Type.Literal('azure'), Type.Literal('openrouter')],
|
|
1353
1348
|
{ default: 'mock' },
|
|
1354
1349
|
),
|
|
1355
|
-
// BYO keyRefs →
|
|
1350
|
+
// BYO keyRefs → the tenant's encrypted secret store. The block is NOT
|
|
1356
1351
|
// optional (the worker declares it plain), so its three optional refs each count
|
|
1357
1352
|
// as a leaf. The nested blocks carry `default: {}` (this package's convention)
|
|
1358
1353
|
// so Value.Default materializes them + recurses into the leaf defaults when a
|
|
@@ -1364,7 +1359,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1364
1359
|
geminiKeyRef: Type.Optional(Type.String()),
|
|
1365
1360
|
// The openai-compatible extension surface — ONE optional object = ONE config
|
|
1366
1361
|
// leaf (countLeaves collapses optional objects; 15-leaf cap discipline).
|
|
1367
|
-
// compat.openrouterKeyRef: BYO OpenRouter key (
|
|
1362
|
+
// compat.openrouterKeyRef: BYO OpenRouter key (a secret-store ref).
|
|
1368
1363
|
// compat.openaiBaseUrl: point the openai adapter at ANY openai-compatible
|
|
1369
1364
|
// host (DeepSeek, vLLM, an Azure-compatible proxy). Public-https validated
|
|
1370
1365
|
// at config WRITE (publicHttpsUrlError below) AND at USE (@vxil/runtime
|
|
@@ -1384,7 +1379,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1384
1379
|
tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
|
|
1385
1380
|
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
1381
|
}, { default: {} }),
|
|
1387
|
-
// `streaming` became an OPTIONAL bag (3 leaves → 1, the
|
|
1382
|
+
// `streaming` became an OPTIONAL bag (3 leaves → 1, the `retry` trick)
|
|
1388
1383
|
// on 2026-09-23 to fund the declared `templates[]` below. It KEEPS
|
|
1389
1384
|
// `default: {}`, so Value.Default still materializes
|
|
1390
1385
|
// streaming.{enabled,replayBufferFrames,flushMs} into every persisted
|
|
@@ -1393,20 +1388,20 @@ export const AiConfigSchema = Type.Object({
|
|
|
1393
1388
|
// here is optional.
|
|
1394
1389
|
streaming: Type.Optional(Type.Object({
|
|
1395
1390
|
enabled: Type.Boolean({ default: true }),
|
|
1396
|
-
replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), //
|
|
1397
|
-
flushMs: Type.Integer({ default: 50, minimum: 0 }), //
|
|
1391
|
+
replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), // replay ring-buffer depth
|
|
1392
|
+
flushMs: Type.Integer({ default: 50, minimum: 0 }), // token→frame coalesce window
|
|
1398
1393
|
}, { 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
|
|
1394
|
+
// DECLARED API STATE (2026-09-23): the tenant's stored
|
|
1395
|
+
// prompt templates as config. vxil STORES + versions, never authors — declaring them here changes WHO writes the row (the repository, via
|
|
1402
1396
|
// `vxil push`), not what vxil does with it. Converged by CONTENT: the
|
|
1403
1397
|
// control-plane hashes each declared entry (@vxil/runtime
|
|
1404
1398
|
// aiTemplateContentSha256) against the `content_sha256` GET /v1/ai/templates
|
|
1405
1399
|
// stamps on the latest version, and POSTs a new version ONLY when the content
|
|
1406
1400
|
// differs — so a push is idempotent and versions stay monotonic per name.
|
|
1407
1401
|
// Item shape mirrors ai-v1 core.ts TemplateBody exactly (`template` is the
|
|
1408
|
-
// name). Stored templates the config does not declare are reported
|
|
1409
|
-
//
|
|
1402
|
+
// name). Stored templates the config does not declare are reported and left
|
|
1403
|
+
// in place — RETIRED (soft: hidden from list + render, history kept) only
|
|
1404
|
+
// under --allow-destructive (2026-10-01). Bounded to 50 entries: the
|
|
1410
1405
|
// manifest rides the 1 MiB config body cap. An Optional ARRAY is ONE leaf.
|
|
1411
1406
|
templates: Type.Optional(Type.Array(DeclaredAiTemplateSchema, { maxItems: AI_MAX_DECLARED_TEMPLATES })),
|
|
1412
1407
|
});
|
|
@@ -1419,7 +1414,7 @@ export const AiConfigSchema = Type.Object({
|
|
|
1419
1414
|
|
|
1420
1415
|
export type AiConfig = Static<typeof AiConfigSchema>;
|
|
1421
1416
|
|
|
1422
|
-
// rag feature (
|
|
1417
|
+
// rag feature (guide ch. 6, rag). Re-declared to match workers/rag-v1/src/config.ts's
|
|
1423
1418
|
// exported RagConfigSchema. rag owns the PIPELINE knobs only — retrieval budget,
|
|
1424
1419
|
// context budget + strategy + tokenizer, the citation/stream gates — NEVER the
|
|
1425
1420
|
// prompt, the synthesis, or relevance tuning (the tenant's `ai` template owns those).
|
|
@@ -1443,7 +1438,7 @@ export const RagConfigSchema = Type.Object({
|
|
|
1443
1438
|
{ default: {} },
|
|
1444
1439
|
),
|
|
1445
1440
|
// declarative per-metadata-field relevance boosts applied in rag AFTER
|
|
1446
|
-
// retrieval, BEFORE minScore/budget/grounding (
|
|
1441
|
+
// retrieval, BEFORE minScore/budget/grounding (guide ch. 6, rag). ONE Type.Record
|
|
1447
1442
|
// leaf (the activity-feed feedGroups precedent).
|
|
1448
1443
|
boosts: Type.Record(Type.String(), Type.Union([
|
|
1449
1444
|
Type.Object({
|
|
@@ -1458,13 +1453,13 @@ export const RagConfigSchema = Type.Object({
|
|
|
1458
1453
|
]), { default: {} }),
|
|
1459
1454
|
context: Type.Object(
|
|
1460
1455
|
{
|
|
1461
|
-
// bounded context budget — enforced via the
|
|
1456
|
+
// bounded context budget — enforced via the tokenizer, BEFORE the ai call.
|
|
1462
1457
|
maxTokens: Type.Integer({ default: 4000, minimum: 1, maximum: 1_000_000 }),
|
|
1463
1458
|
strategy: Type.Union([Type.Literal('topk'), Type.Literal('mmr')], {
|
|
1464
1459
|
default: 'topk',
|
|
1465
1460
|
}),
|
|
1466
1461
|
// 'provider' = the resolved ai provider's tokenizer; 'heuristic' = portable
|
|
1467
|
-
// ~chars/4 with a safety margin
|
|
1462
|
+
// ~chars/4 with a safety margin.
|
|
1468
1463
|
tokenizer: Type.Union([Type.Literal('provider'), Type.Literal('heuristic')], {
|
|
1469
1464
|
default: 'provider',
|
|
1470
1465
|
}),
|
|
@@ -1483,7 +1478,7 @@ export const RagConfigSchema = Type.Object({
|
|
|
1483
1478
|
|
|
1484
1479
|
export type RagConfig = Static<typeof RagConfigSchema>;
|
|
1485
1480
|
|
|
1486
|
-
// payments feature (
|
|
1481
|
+
// payments feature (guide ch. 6, payments). Re-declared to match the schema the
|
|
1487
1482
|
// worker EXPORTS from workers/payments-v1/src/core.ts — the control plane
|
|
1488
1483
|
// validates writes against this shared copy (one-definition / two-consumers,
|
|
1489
1484
|
// like the other features). Two co-equal pillars: (A) provider payments (the
|
|
@@ -1502,9 +1497,9 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1502
1497
|
Type.Literal('revenuecat'), Type.Literal('paypal')],
|
|
1503
1498
|
{ default: 'mock' },
|
|
1504
1499
|
),
|
|
1505
|
-
// BYO-key credential blocks →
|
|
1500
|
+
// BYO-key credential blocks → the tenant's encrypted secret store.
|
|
1506
1501
|
// Each OPTIONAL object counts as ONE leaf (the tenant's decision is
|
|
1507
|
-
// "configure it or not", not each inner ref —
|
|
1502
|
+
// "configure it or not", not each inner ref — guide ch. 6, auth).
|
|
1508
1503
|
stripe: Type.Optional(Type.Object({
|
|
1509
1504
|
secretKeyRef: Type.String(),
|
|
1510
1505
|
webhookSecretRef: Type.String(),
|
|
@@ -1519,24 +1514,34 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1519
1514
|
projectId: Type.String(),
|
|
1520
1515
|
publicSdkKey: Type.String(),
|
|
1521
1516
|
secretApiKeyRef: Type.String(),
|
|
1522
|
-
// Per-tenant webhook secret ref (
|
|
1517
|
+
// Per-tenant webhook secret ref (secret store). Inbound RevenueCat
|
|
1523
1518
|
// webhooks are verified against THIS ref and nothing else: there is no
|
|
1524
1519
|
// platform-wide PROVIDER_WEBHOOK_SECRET fallback for a real payment provider
|
|
1525
|
-
// (that fallback WAS
|
|
1526
|
-
// frozen out by tests/ci/src/provider-webhook-secret-fallback.test.ts, which
|
|
1520
|
+
// (that fallback WAS a cross-tenant webhook-forgery vector; a CI gate now
|
|
1527
1521
|
// permits `secrets.webhookSecret` only in makeProvider's mock/default arm).
|
|
1528
1522
|
// Optional at the SCHEMA level only — leaving it unset does not disable
|
|
1529
1523
|
// verification, it fails CLOSED: every delivery is 401 bad_signature with a
|
|
1530
1524
|
// `sig_failed` row that can never be reprocessed. Mirrors stripe/paddle
|
|
1531
1525
|
// webhookSecretRef.
|
|
1532
1526
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
1533
|
-
// Environment integrity
|
|
1527
|
+
// Environment integrity. RevenueCat posts SANDBOX
|
|
1534
1528
|
// and PRODUCTION events to the SAME webhook with the same auth header, so a
|
|
1535
1529
|
// sandbox purchase would otherwise fold into production entitlements. A
|
|
1536
1530
|
// sandbox event is persisted as outcome 'rejected_environment' (200, never
|
|
1537
1531
|
// folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
|
|
1538
1532
|
// environments by signing secret / API base, so only RC carries this knob.
|
|
1539
1533
|
acceptSandbox: Type.Boolean({ default: false }),
|
|
1534
|
+
// (2026-10-01) Store-review purchases on a PRODUCTION tenant:
|
|
1535
|
+
// the reviewer accounts' RevenueCat `app_user_id`s (≤ 20). A SANDBOX event
|
|
1536
|
+
// whose subject (and, for a TRANSFER, every source user) is listed here
|
|
1537
|
+
// folds — recorded `environment: 'sandbox'` on the delivery, the
|
|
1538
|
+
// subscription and the charge, so it stays out of revenue — while every
|
|
1539
|
+
// other sandbox event is still `rejected_environment`. The narrow,
|
|
1540
|
+
// production-safe alternative to `acceptSandbox` (which folds EVERY
|
|
1541
|
+
// TestFlight purchase and is refused by the CLI's production promotion
|
|
1542
|
+
// gate). No default on purpose (Value.Default would materialize it into
|
|
1543
|
+
// every RC manifest); absent = an empty list.
|
|
1544
|
+
sandboxUsers: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 256 }), { maxItems: 20 })),
|
|
1540
1545
|
})),
|
|
1541
1546
|
paypal: Type.Optional(Type.Object({
|
|
1542
1547
|
clientIdRef: Type.String(),
|
|
@@ -1547,9 +1552,9 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1547
1552
|
// Where a provider-hosted flow sends the payer back (Stripe billing-portal
|
|
1548
1553
|
// return, PayPal approval return/cancel) — the TENANT's own app URL,
|
|
1549
1554
|
// https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
|
|
1550
|
-
// hardcoded host (
|
|
1555
|
+
// hardcoded host (2026-07-10: the old fallback pointed at a dead apex).
|
|
1551
1556
|
returnUrl: Type.Optional(Type.String({ pattern: '^https://', maxLength: 512 })),
|
|
1552
|
-
// NB (
|
|
1557
|
+
// NB (2026-09-11): the former `prices.catalogRef` leaf was DELETED —
|
|
1553
1558
|
// it named nothing (prices resolve from ledger.priceMap; no code path ever
|
|
1554
1559
|
// read it). A persisted manifest that still carries `prices` folds:
|
|
1555
1560
|
// validateFeatureConfig's Value.Clean strips the stray key.
|
|
@@ -1567,16 +1572,19 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1567
1572
|
// product_id → what the purchase GRANTS. ONE Type.Record leaf (the rag
|
|
1568
1573
|
// `boosts` Record-of-Union precedent) with two rule shapes:
|
|
1569
1574
|
// { creditType, amount, period } a credit grant (the original rule)
|
|
1570
|
-
// { tier, durationDays } (2026-09-25
|
|
1575
|
+
// { tier, durationDays } (2026-09-25) a TIME-BOXED
|
|
1571
1576
|
// ENTITLEMENT: the buyer gets `tier` (a tierMap key — cross-checked
|
|
1572
1577
|
// below) for `durationDays`, as a charge-linked manual-style row that
|
|
1573
|
-
// STACKS
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1578
|
+
// STACKS behind the user's live same-tier manual rows that have an end
|
|
1579
|
+
// (earlier passes AND comp grants since 2026-09-25 — never a row
|
|
1580
|
+
// linked to the same charge, an open-ended grant or a provider
|
|
1581
|
+
// subscription) and is ENDED by that charge's full refund / chargeback.
|
|
1582
|
+
// No defaults in either shape, so an existing manifest is
|
|
1583
|
+
// byte-identical after Value.Default.
|
|
1576
1584
|
productMap: Type.Record(Type.String(), Type.Union([
|
|
1577
1585
|
Type.Object({
|
|
1578
1586
|
creditType: Type.String({ minLength: 1 }),
|
|
1579
|
-
amount: Type.Integer({ minimum: 1 }), //
|
|
1587
|
+
amount: Type.Integer({ minimum: 1 }), // a grant only ADDS
|
|
1580
1588
|
period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
|
|
1581
1589
|
Type.Literal('annual')]),
|
|
1582
1590
|
}),
|
|
@@ -1587,12 +1595,20 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1587
1595
|
])),
|
|
1588
1596
|
tierMap: Type.Record(Type.String(), Type.Object({ // tier → entitlement/quota/grant
|
|
1589
1597
|
entitlements: Type.Array(Type.String()),
|
|
1590
|
-
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), //
|
|
1591
|
-
rank: Type.Optional(Type.Integer({ minimum: 0 })), // precedence for the multi-sub fold
|
|
1598
|
+
quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // no negative quota
|
|
1599
|
+
rank: Type.Optional(Type.Integer({ minimum: 0 })), // precedence for the multi-sub fold
|
|
1592
1600
|
grants: Type.Optional(Type.Array(Type.Object({
|
|
1593
1601
|
creditType: Type.String({ minLength: 1 }),
|
|
1594
|
-
amount: Type.Integer({ minimum: 1 }), //
|
|
1602
|
+
amount: Type.Integer({ minimum: 1 }), // a grant only ADDS
|
|
1595
1603
|
period: Type.String(),
|
|
1604
|
+
// (2026-10-01) 'add' (the reader's default, today's
|
|
1605
|
+
// behaviour) ADDS `amount` each period; 'reset' makes the period's
|
|
1606
|
+
// grant REPLACE what is left: the unspent available balance of
|
|
1607
|
+
// `creditType` is written off as one `expire` ledger row and `amount`
|
|
1608
|
+
// granted, so the user starts every period with exactly `amount`
|
|
1609
|
+
// (credits held by an in-flight job stay held). No schema default (it
|
|
1610
|
+
// would materialize into every manifest carrying grants).
|
|
1611
|
+
mode: Type.Optional(Type.Union([Type.Literal('add'), Type.Literal('reset')])),
|
|
1596
1612
|
}))),
|
|
1597
1613
|
})),
|
|
1598
1614
|
// provider price/plan id → tier. A real subscription webhook carries the
|
|
@@ -1601,7 +1617,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1601
1617
|
// fold (refoldEntitlements WHERE tier IS NOT NULL) reflects the subscription.
|
|
1602
1618
|
priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
1603
1619
|
autoRefundOnJobFailure: Type.Boolean({ default: true }), // consume(jobId) reverses on DLQ/timeout
|
|
1604
|
-
// Grace window
|
|
1620
|
+
// Grace window: a `past_due` subscription stays
|
|
1605
1621
|
// entitled for this many days AFTER its current_period_end (the dunning
|
|
1606
1622
|
// window the provider is retrying inside). 0 = today's behaviour (a past_due
|
|
1607
1623
|
// row is never entitled). ONE predicate in the fold — NOT a dunning ladder:
|
|
@@ -1609,8 +1625,7 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1609
1625
|
grace: Type.Optional(Type.Object({
|
|
1610
1626
|
pastDueDays: Type.Integer({ default: 0, minimum: 0, maximum: 90 }),
|
|
1611
1627
|
})),
|
|
1612
|
-
// Opt-in period-end enforcement (
|
|
1613
|
-
// option a). ABSENT (the default) = today's behaviour: a subscription whose
|
|
1628
|
+
// Opt-in period-end enforcement. ABSENT (the default) = today's behaviour: a subscription whose
|
|
1614
1629
|
// current_period_end passed with no provider event stays entitled forever
|
|
1615
1630
|
// (the provider is the only clock). PRESENT = the nightly reconcile sweep
|
|
1616
1631
|
// flips an `active`/`trialing` row whose current_period_end + slackHours
|
|
@@ -1645,10 +1660,10 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1645
1660
|
// (`config.ledger?.unmappedProduct ?? 'error'`), which is the one definition.
|
|
1646
1661
|
unmappedProduct: Type.Optional(Type.Union([Type.Literal('error'), Type.Literal('ignore')])),
|
|
1647
1662
|
})),
|
|
1648
|
-
// NB
|
|
1663
|
+
// NB: the former `webhooks.forwardToTenantUrl` leaf
|
|
1649
1664
|
// was DELETED — it had zero readers (never forwarded anything). Outbound
|
|
1650
|
-
// delivery of payments state changes rides the
|
|
1651
|
-
//
|
|
1665
|
+
// delivery of payments state changes rides the audit stream → outbound
|
|
1666
|
+
// webhooks: subscribe to the `payments.` event prefix (guide ch. 6, payments).
|
|
1652
1667
|
});
|
|
1653
1668
|
// Leaves: enabled(1), provider(2), stripe?(3), paddle?(4), revenuecat?(5),
|
|
1654
1669
|
// paypal?(6), returnUrl(7), defaults.{currency,trialDays}(+2=9), ledger?(10).
|
|
@@ -1660,24 +1675,24 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1660
1675
|
|
|
1661
1676
|
export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
|
|
1662
1677
|
|
|
1663
|
-
// functions feature (
|
|
1678
|
+
// functions feature (guide ch. 8). Tenant-deployed backend edge
|
|
1664
1679
|
// functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
|
|
1665
1680
|
// scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
|
|
1666
1681
|
// bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
|
|
1667
1682
|
// never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
|
|
1668
1683
|
// (cms.hooks / payments.ledger precedent), so any number of deployed functions
|
|
1669
|
-
// never grows the flag cap.
|
|
1684
|
+
// never grows the flag cap. Tenant code on the platform — paid, tier-walled,
|
|
1670
1685
|
// opt-in (enabled defaults to false), egress-guarded.
|
|
1671
1686
|
export const FunctionsConfigSchema = Type.Object({
|
|
1672
1687
|
enabled: Type.Boolean({ default: false }),
|
|
1673
|
-
// (
|
|
1674
|
-
// is
|
|
1688
|
+
// (2026-09-11) `runtime` ('isolate' | 'container') was DELETED: the container lane
|
|
1689
|
+
// is not built, nothing read the leaf, and accepting 'container' silently
|
|
1675
1690
|
// ran the isolate anyway. It comes back with the lane, not before.
|
|
1676
1691
|
defaultLimits: Type.Object(
|
|
1677
1692
|
{
|
|
1678
1693
|
// cpuMs is the ONLY per-dispatch limit the managed runtime accepts and the
|
|
1679
1694
|
// only one anything reads (functions-v1 meter.ts + the dispatch cap).
|
|
1680
|
-
// (
|
|
1695
|
+
// (2026-09-11) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
|
|
1681
1696
|
// runtime and not tenant-selectable, and no wall-clock abort was ever
|
|
1682
1697
|
// applied — a declared 10s default that nothing enforced.
|
|
1683
1698
|
cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 300_000 }),
|
|
@@ -1728,7 +1743,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1728
1743
|
source: Type.Optional(Type.String()), // webhook/queue: source/queue id
|
|
1729
1744
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
1730
1745
|
event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
|
|
1731
|
-
//
|
|
1746
|
+
// (2026-09-25) the per-binding opt-in to re-delivery on
|
|
1732
1747
|
// queue / webhook / cmsHook / authHook (the cross-field rule
|
|
1733
1748
|
// rejects it on http / cron). Absent = the ACK-200 default. The
|
|
1734
1749
|
// receiver answers a failed attempt as an enveloped 503 (ladder)
|
|
@@ -1737,6 +1752,10 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1737
1752
|
retry: Type.Optional(Type.Object({
|
|
1738
1753
|
maxAttempts: Type.Integer({ minimum: 1, maximum: FN_RETRY_MAX_ATTEMPTS }),
|
|
1739
1754
|
})),
|
|
1755
|
+
// W7 (2026-10-01): cron only — 'skip' = a due tick fires nothing
|
|
1756
|
+
// while the previous tick's run is still open (queued / running /
|
|
1757
|
+
// retrying / waiting / delayed). Absent = 'allow' (every tick runs).
|
|
1758
|
+
overlap: Type.Optional(Type.Union([Type.Literal('allow'), Type.Literal('skip')])),
|
|
1740
1759
|
}),
|
|
1741
1760
|
{ maxItems: 8 },
|
|
1742
1761
|
),
|
|
@@ -1745,6 +1764,23 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1745
1764
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
1746
1765
|
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
1747
1766
|
egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
|
|
1767
|
+
// R1 (2026-10-01): the runtime settings this function's script was
|
|
1768
|
+
// UPLOADED with — server-SET by the deploy pipeline (never authored),
|
|
1769
|
+
// read back by the rollback re-upload so a restored script runs under
|
|
1770
|
+
// the settings it was deployed and tested with, not today's. Absent =
|
|
1771
|
+
// the legacy settings (@vxil/types FUNCTIONS_RUNTIME_LEGACY). Unbounded
|
|
1772
|
+
// strings on purpose (the deploy writes them from the one constant).
|
|
1773
|
+
// cpuMs (2026-10-01): the per-invoke CPU limit the script was
|
|
1774
|
+
// uploaded with (limits.cpu_ms = min(declared limits.cpuMs, the tier's
|
|
1775
|
+
// cpuMsPerInvoke, FN_MAX_CPU_MS)) — server-set; the nightly plan pass
|
|
1776
|
+
// rewrites it after a tier change. Absent = the platform default.
|
|
1777
|
+
runtime: Type.Optional(
|
|
1778
|
+
Type.Object({
|
|
1779
|
+
compatibilityDate: Type.String(),
|
|
1780
|
+
compatibilityFlags: Type.Optional(Type.Array(Type.String())),
|
|
1781
|
+
cpuMs: Type.Optional(Type.Number()),
|
|
1782
|
+
}),
|
|
1783
|
+
),
|
|
1748
1784
|
// Per-function resource declarations. BOTH members are OPTIONAL (the bag
|
|
1749
1785
|
// used to REQUIRE all three, a latent 422 the moment anything sent it —
|
|
1750
1786
|
// nothing ever did, because the CLI never carried it) and BOTH are
|
|
@@ -1775,10 +1811,9 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1775
1811
|
// egress guard as an outbound parameter. NOT an invocation
|
|
1776
1812
|
// budget: the INVOCATION is bounded by the platform's own
|
|
1777
1813
|
// FN_MAX_INVOKE_MS deadline (default >= 5 min), which a
|
|
1778
|
-
// bigger per-fetch budget widens with you
|
|
1779
|
-
// (
|
|
1780
|
-
//
|
|
1781
|
-
// per-dispatch option; the WfP dispatch bag takes { cpuMs, subRequests }.
|
|
1814
|
+
// bigger per-fetch budget widens with you.
|
|
1815
|
+
// (`memoryMb` is deleted — memory is fixed by the managed runtime and is
|
|
1816
|
+
// not a per-dispatch option; a dispatch takes { cpuMs, subRequests }.
|
|
1782
1817
|
// Value.Clean strips it from an old config, so such a config still loads
|
|
1783
1818
|
// and `vxil plan --explain` marks the key DROPPED.)
|
|
1784
1819
|
limits: Type.Optional(
|
|
@@ -1788,7 +1823,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1788
1823
|
}),
|
|
1789
1824
|
),
|
|
1790
1825
|
enabled: Type.Optional(Type.Boolean()),
|
|
1791
|
-
// Level-1 typed I/O (
|
|
1826
|
+
// Level-1 typed I/O (guide ch. 8): the declared input/output
|
|
1792
1827
|
// contract, persisted by the deploy body so ONLINE `vxil gen` emits the
|
|
1793
1828
|
// same typed fn client as --offline. Opaque JSON-schema-ish payloads —
|
|
1794
1829
|
// the CLI's lowerSig lowers them; the platform never interprets them.
|
|
@@ -1804,7 +1839,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1804
1839
|
// functions(4, the Type.Record bag → ONE leaf; bindings[]/scriptRef/scopes/
|
|
1805
1840
|
// secrets/egressAllow/limits/enabled/signature are DATA inside the MAP value
|
|
1806
1841
|
// and never move the count, like cms.hooks). countLeaves → 4. Cap = 15.
|
|
1807
|
-
// (was 7 —
|
|
1842
|
+
// (was 7 — 2026-09-11 deleted the inert runtime, defaultLimits.timeoutMs and
|
|
1808
1843
|
// defaultLimits.memoryMb leaves.)
|
|
1809
1844
|
export type FunctionsConfig = Static<typeof FunctionsConfigSchema>;
|
|
1810
1845
|
|
|
@@ -1890,7 +1925,7 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
1890
1925
|
{ default: {} },
|
|
1891
1926
|
),
|
|
1892
1927
|
|
|
1893
|
-
// (
|
|
1928
|
+
// (2026-09-11) the `escalation` bag ({enabled, handler, notifyTemplate}) was
|
|
1894
1929
|
// DELETED: the human hand-off it declared was never built — copilot-v1 read
|
|
1895
1930
|
// none of the three leaves, so a tenant who turned it on got silence. The
|
|
1896
1931
|
// shipped hand-off path is a tenant function on the conversation events.
|
|
@@ -1898,7 +1933,7 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
1898
1933
|
// ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
|
|
1899
1934
|
limits: Type.Object({
|
|
1900
1935
|
consumeCredits: Type.Boolean({ default: false }),
|
|
1901
|
-
// (
|
|
1936
|
+
// (2026-09-11) `tokensPerUserPerDay` was DELETED here: token accounting is
|
|
1902
1937
|
// delegated to ai-v1 (this bag's own doctrine) and only `ai`'s
|
|
1903
1938
|
// limits.tokensPerUserPerDay is enforced — the copilot twin read nothing.
|
|
1904
1939
|
}, { default: {} }),
|
|
@@ -1914,7 +1949,7 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
1914
1949
|
// Leaves: enabled(1), agents(2 — Type.Record MAP, ONE leaf),
|
|
1915
1950
|
// limits.consumeCredits(3), widget.{enabled,requireAuth,allowedOrigins,theme}
|
|
1916
1951
|
// (+4=7). countLeaves → 7. Cap = 15.
|
|
1917
|
-
// (was 9 —
|
|
1952
|
+
// (was 9 — 2026-09-11 deleted the inert escalation bag and limits.tokensPerUserPerDay.)
|
|
1918
1953
|
|
|
1919
1954
|
export type CopilotConfig = Static<typeof CopilotConfigSchema>;
|
|
1920
1955
|
|
|
@@ -1942,17 +1977,36 @@ export const FEATURE_SCHEMAS: Record<string, TSchema> = {
|
|
|
1942
1977
|
|
|
1943
1978
|
export const CONFIG_FLAG_CAP = 15;
|
|
1944
1979
|
|
|
1945
|
-
/** Counts leaf flags in a TypeBox object schema
|
|
1946
|
-
* Per
|
|
1980
|
+
/** Counts leaf flags in a TypeBox object schema.
|
|
1981
|
+
* Per guide ch. 6, auth: an OPTIONAL object (e.g. a provider credential
|
|
1947
1982
|
* block) counts as ONE flag — the tenant's decision is "configure it or
|
|
1948
1983
|
* not", not each inner ref. */
|
|
1949
|
-
/** The auth lifecycle events an `authHook` binding may name
|
|
1984
|
+
/** The auth lifecycle events an `authHook` binding may name — the
|
|
1950
1985
|
* closed union `packages/config` types as AuthHookEvent; the control-plane
|
|
1951
1986
|
* reconciler maps each to its `auth.<event>` audit-event prefix. */
|
|
1952
1987
|
export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'] as const;
|
|
1953
1988
|
export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
|
|
1954
1989
|
|
|
1955
|
-
/**
|
|
1990
|
+
/** The events a `cmsHook` function binding may name — the closed union
|
|
1991
|
+
* `packages/config` types on the `cmsHook` trigger, and the keys of the
|
|
1992
|
+
* functions-v1 receiver's CMS_HOOK_EVENTS_FOR (beforeCreate → created,
|
|
1993
|
+
* beforeUpdate → updated, beforeWrite → both). Omitted = beforeWrite. Anything
|
|
1994
|
+
* else is refused at config write (E-CMSHOOK, 2026-10-01): an unknown name used
|
|
1995
|
+
* to fall back to beforeWrite at delivery, so a typo like 'afterCreate' or
|
|
1996
|
+
* 'beforeDelete' silently subscribed the function to creates AND updates.
|
|
1997
|
+
* A CI gate pins every copy to this list. */
|
|
1998
|
+
export const CMS_HOOK_EVENTS = ['beforeCreate', 'beforeUpdate', 'beforeWrite'] as const;
|
|
1999
|
+
export type CmsHookEvent = (typeof CMS_HOOK_EVENTS)[number];
|
|
2000
|
+
|
|
2001
|
+
/** The ONE refusal text for an unknown cmsHook binding event (the config
|
|
2002
|
+
* validator and the deploy route's early 422 both use it). */
|
|
2003
|
+
export function cmsHookEventError(event: unknown): string | null {
|
|
2004
|
+
if (event === undefined) return null;
|
|
2005
|
+
if (typeof event === 'string' && (CMS_HOOK_EVENTS as readonly string[]).includes(event)) return null;
|
|
2006
|
+
return `a 'cmsHook' binding's event must be one of ${CMS_HOOK_EVENTS.join(' | ')} (or omitted = beforeWrite), not ${JSON.stringify(event)}`;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/** The OTP test-recipient entry grammar (shared by the validator and auth-v1's
|
|
1956
2010
|
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
1957
2011
|
export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
|
|
1958
2012
|
export const TEST_RECIPIENT_GLOB_RE = /^\*@[^\s@*]+\.[^\s@*]+$/;
|
|
@@ -2015,7 +2069,7 @@ export function setKnownMcpTools(names: readonly string[]): void {
|
|
|
2015
2069
|
}
|
|
2016
2070
|
|
|
2017
2071
|
// ── mcp custom-tool / prompt caps + the pure public-https check ──────────────
|
|
2018
|
-
// (
|
|
2072
|
+
// (guide ch. 10) Caps are deliberately tighter than the 200-tool listing cap:
|
|
2019
2073
|
// each custom tool is a platform-signed egress target, so the bag stays small.
|
|
2020
2074
|
export const MCP_MAX_CUSTOM_TOOLS = 32;
|
|
2021
2075
|
export const MCP_MAX_PROMPTS = 32;
|
|
@@ -2073,7 +2127,7 @@ function publicHttpsUrlError(url: string): string | null {
|
|
|
2073
2127
|
* validation below and the control-plane deploy clamp. */
|
|
2074
2128
|
export const DENY_FUNCTION_SCOPES = new Set(['admin', '*', 'features:write', 'functions:write', 'secrets:write']);
|
|
2075
2129
|
|
|
2076
|
-
// ── declared API state — config-write validators
|
|
2130
|
+
// ── declared API state — config-write validators ────────────────────────────
|
|
2077
2131
|
/** `{var}` names of a rate-limit key template (rate-limits-v1 core.ts
|
|
2078
2132
|
* templateVars parity). */
|
|
2079
2133
|
export function rlTemplateVars(template: string): string[] {
|
|
@@ -2165,12 +2219,12 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2165
2219
|
return { ok: false, errors: [`schema for '${feature}' exceeds the ${CONFIG_FLAG_CAP}-flag cap`] };
|
|
2166
2220
|
}
|
|
2167
2221
|
// Apply defaults to a clone, then STRIP any property the schema does not
|
|
2168
|
-
// declare, then check. Value.Clean makes the validator TOTAL
|
|
2222
|
+
// declare, then check. Value.Clean makes the validator TOTAL:
|
|
2169
2223
|
// the schemas are open Type.Object()s, so without it Value.Check passes on —
|
|
2170
2224
|
// and putConfig would persist — arbitrary unknown keys. Clean runs AFTER
|
|
2171
2225
|
// Default so materialized nested defaults survive but stray top-level/nested
|
|
2172
2226
|
// keys are dropped. This also removes the CLI dry-run idempotency drift
|
|
2173
|
-
//
|
|
2227
|
+
// too: plan/push diff the same cleaned manifest the server stores.
|
|
2174
2228
|
const withDefaults = Value.Clean(
|
|
2175
2229
|
schema,
|
|
2176
2230
|
Value.Default(schema, Value.Clone(raw)),
|
|
@@ -2214,13 +2268,13 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2214
2268
|
}
|
|
2215
2269
|
// Cross-field rule: a REAL payments provider needs its credential block; the
|
|
2216
2270
|
// 'mock' provider (the deterministic default) stays zero-config so the whole
|
|
2217
|
-
// ledger path is testable without real keys (
|
|
2271
|
+
// ledger path is testable without real keys (guide ch. 6, payments).
|
|
2218
2272
|
if (feature === 'payments') {
|
|
2219
2273
|
const v = withDefaults as {
|
|
2220
2274
|
provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
|
|
2221
2275
|
ledger?: {
|
|
2222
2276
|
productMap?: Record<string, { creditType?: string; tier?: string }>;
|
|
2223
|
-
tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
|
|
2277
|
+
tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string; period?: string; mode?: string }> }>;
|
|
2224
2278
|
priceMap?: Record<string, string>;
|
|
2225
2279
|
};
|
|
2226
2280
|
};
|
|
@@ -2236,7 +2290,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2236
2290
|
}
|
|
2237
2291
|
// A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
|
|
2238
2292
|
// the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
|
|
2239
|
-
// user, running vxil-billed functions for free
|
|
2293
|
+
// user, running vxil-billed functions for free. Rejected at write
|
|
2240
2294
|
// time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
|
|
2241
2295
|
const ledgerErrs: string[] = [];
|
|
2242
2296
|
const tierKeysForProducts = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
@@ -2246,7 +2300,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2246
2300
|
`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
2247
2301
|
);
|
|
2248
2302
|
}
|
|
2249
|
-
// (2026-09-25
|
|
2303
|
+
// (2026-09-25) an entitlement rule must name a declared tier — the
|
|
2250
2304
|
// write-time mirror of the runtime's unknown-tier refusal (a purchase for
|
|
2251
2305
|
// a tier nobody declared would land the delivery `error`).
|
|
2252
2306
|
if (rule.tier !== undefined && !tierKeysForProducts.has(rule.tier)) {
|
|
@@ -2260,9 +2314,44 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2260
2314
|
`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`,
|
|
2261
2315
|
);
|
|
2262
2316
|
}
|
|
2317
|
+
// (W9) 'reset' replaces the balance EACH PERIOD — a one-time grant has
|
|
2318
|
+
// no period to reset, and a second grant of the same credit type in the
|
|
2319
|
+
// same tier would be wiped (or wipe it) depending on array order.
|
|
2320
|
+
if (g.mode === 'reset') {
|
|
2321
|
+
if (g.period === 'once') {
|
|
2322
|
+
ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/mode: 'reset' needs a recurring period (monthly or annual), not 'once'`);
|
|
2323
|
+
}
|
|
2324
|
+
const twin = (rule.grants ?? []).findIndex((o, j) => j !== i && o.creditType === g.creditType);
|
|
2325
|
+
if (twin >= 0) {
|
|
2326
|
+
ledgerErrs.push(
|
|
2327
|
+
`/ledger/tierMap/${tier}/grants/${i}/mode: a 'reset' grant must be the only grant of '${g.creditType}' in the tier (grants/${twin} also grants it)`,
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2263
2331
|
});
|
|
2264
2332
|
}
|
|
2265
|
-
//
|
|
2333
|
+
// (2026-10-01 review) A 'reset' grant writes off the WHOLE available balance
|
|
2334
|
+
// of its credit type each period — purchased credits of that type included.
|
|
2335
|
+
// A credit pack of a reset type would lose paid value at the next renewal,
|
|
2336
|
+
// so it is refused here: keep packs on their own type and spend in order
|
|
2337
|
+
// (`credit_types: ['plan', 'topup']`).
|
|
2338
|
+
const resetBy = new Map<string, string>();
|
|
2339
|
+
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
2340
|
+
(rule.grants ?? []).forEach((g, i) => {
|
|
2341
|
+
if (g.mode === 'reset' && g.creditType && !resetBy.has(g.creditType)) {
|
|
2342
|
+
resetBy.set(g.creditType, `/ledger/tierMap/${tier}/grants/${i}`);
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
|
|
2347
|
+
const by = rule.creditType ? resetBy.get(rule.creditType) : undefined;
|
|
2348
|
+
if (by) {
|
|
2349
|
+
ledgerErrs.push(
|
|
2350
|
+
`/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`,
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
// An UNMAPPED price silently revoked a paying
|
|
2266
2355
|
// customer (priceMap miss → tier NULL → refold excluded the row). The
|
|
2267
2356
|
// reducer now stamps such an event outcome 'error' (reprocessable), and
|
|
2268
2357
|
// this lint catches the config half at `vxil push` time: every priceMap
|
|
@@ -2313,7 +2402,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2313
2402
|
];
|
|
2314
2403
|
if (rmErrors.length) return { ok: false, errors: rmErrors.slice(0, 10) };
|
|
2315
2404
|
}
|
|
2316
|
-
// Cross-field rules: mcp custom tools + prompts (
|
|
2405
|
+
// Cross-field rules: mcp custom tools + prompts (guide ch. 10). The URL
|
|
2317
2406
|
// check here is a PURE mirror of @vxil/runtime assertPublicHttpsUrl (this
|
|
2318
2407
|
// package is typebox-only) — the authoritative runtime guard re-runs in
|
|
2319
2408
|
// mcp-v1 at call time; this gate rejects obviously-internal targets BEFORE
|
|
@@ -2379,7 +2468,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2379
2468
|
const tplErrs = validateDeclaredAiTemplates(v.templates);
|
|
2380
2469
|
if (tplErrs.length) return { ok: false, errors: tplErrs.slice(0, 10) };
|
|
2381
2470
|
}
|
|
2382
|
-
// Cross-field rules: declared API state
|
|
2471
|
+
// Cross-field rules: declared API state. Each list is
|
|
2383
2472
|
// converged by NAME/URL, so a duplicate key is ambiguous and refused at push;
|
|
2384
2473
|
// the per-item rules mirror the feature route's own validation so a declared
|
|
2385
2474
|
// entry can never be one the converge would 422 on.
|
|
@@ -2393,7 +2482,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2393
2482
|
const errs = validateDeclaredWebhookSubscriptions(v.subscriptions, v.maxSubscriptions);
|
|
2394
2483
|
if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
|
|
2395
2484
|
}
|
|
2396
|
-
// Cross-field rule: auth otp.testRecipients
|
|
2485
|
+
// Cross-field rule: auth otp.testRecipients — every entry must be an
|
|
2397
2486
|
// email, an `*@domain` glob, or a +E.164 number; anything else would never
|
|
2398
2487
|
// match and silently do nothing.
|
|
2399
2488
|
if (feature === 'auth') {
|
|
@@ -2430,7 +2519,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2430
2519
|
string,
|
|
2431
2520
|
{
|
|
2432
2521
|
scriptRef?: string; scopes?: string[]; signature?: unknown;
|
|
2433
|
-
bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number } }>;
|
|
2522
|
+
bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string; retry?: { maxAttempts?: number }; overlap?: string }>;
|
|
2434
2523
|
}
|
|
2435
2524
|
>;
|
|
2436
2525
|
};
|
|
@@ -2446,7 +2535,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2446
2535
|
if (b.kind === 'cron' && !b.schedule) {
|
|
2447
2536
|
errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
|
|
2448
2537
|
}
|
|
2449
|
-
|
|
2538
|
+
if (b.overlap !== undefined && b.kind !== 'cron') {
|
|
2539
|
+
errs.push(`/functions/${name}/bindings/${i}: 'overlap' applies to cron bindings only (not '${b.kind}')`);
|
|
2540
|
+
}
|
|
2541
|
+
// retry is an opt-in for the platform-delivered event lanes only —
|
|
2450
2542
|
// an http invoke returns its real status to its caller, and a cron
|
|
2451
2543
|
// tick's retry would overlap the next tick.
|
|
2452
2544
|
if (b.retry !== undefined && !FN_RETRY_BINDING_KINDS.has(b.kind)) {
|
|
@@ -2455,7 +2547,11 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
2455
2547
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
2456
2548
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
2457
2549
|
}
|
|
2458
|
-
//
|
|
2550
|
+
// E-CMSHOOK: the CLOSED cmsHook event union — an unknown name is a 422
|
|
2551
|
+
// at write time, never a silent created+updated subscription.
|
|
2552
|
+
const cmsEventErr = b.kind === 'cmsHook' ? cmsHookEventError(b.event) : null;
|
|
2553
|
+
if (cmsEventErr) errs.push(`/functions/${name}/bindings/${i}: ${cmsEventErr}`);
|
|
2554
|
+
// authHook: a CLOSED event union — reject typos at write time
|
|
2459
2555
|
// so a binding never silently subscribes to nothing.
|
|
2460
2556
|
if (b.kind === 'authHook' && b.event !== undefined && !(AUTH_HOOK_EVENTS as readonly string[]).includes(b.event)) {
|
|
2461
2557
|
errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be one of ${AUTH_HOOK_EVENTS.join(' | ')} (or omitted = user.created)`);
|