@vxil/feature-configs 0.2.0 → 0.3.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/README.md +58 -0
- package/dist/hooks.d.ts +1 -1
- package/dist/hooks.js +4 -4
- package/dist/index.d.ts +99 -33
- package/dist/index.js +343 -44
- package/dist/readmodels.js +1 -1
- package/package.json +2 -2
- package/src/hooks.ts +4 -4
- package/src/index.test.ts +411 -26
- package/src/index.ts +418 -99
- package/src/readmodels.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -38,6 +38,91 @@ export function isReservedCreditType(creditType: string): boolean {
|
|
|
38
38
|
return RESERVED_CREDIT_TYPES.has(creditType);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// ── notifications template catalog (D4 per-locale overrides) ─────────────────
|
|
42
|
+
// The SHIPPED template ids and the placeholders each body may interpolate. The
|
|
43
|
+
// renderer itself lives in workers/notifications-v1/src/templates.ts (templates
|
|
44
|
+
// are code, not rows); this table is the CONFIG-TIME half so a `vxil push` that
|
|
45
|
+
// overrides a template with a typo'd `{{placholder}}` is rejected at write time
|
|
46
|
+
// instead of silently rendering an empty string into a customer's email.
|
|
47
|
+
// Parity with the real renderer is asserted by the worker's templates.test.ts.
|
|
48
|
+
export const NOTIFICATION_TEMPLATE_PLACEHOLDERS: Readonly<Record<string, readonly string[]>> = {
|
|
49
|
+
'magic-link': ['url', 'expires_minutes'],
|
|
50
|
+
'otp-code': ['code', 'expires_minutes'],
|
|
51
|
+
welcome: ['app_name', 'first_name', 'first_name_greeting'],
|
|
52
|
+
transactional: ['subject', 'paragraph', 'cta_label', 'cta_url'],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Per-override caps (D4). Bodies are emails, not documents. */
|
|
56
|
+
export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
|
|
57
|
+
export const NOTIF_OVERRIDE_BODY_MAX = 20_000;
|
|
58
|
+
/** Whole-map caps: keeps one manifest (and the KV row every send reads) small. */
|
|
59
|
+
export const NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE = 20;
|
|
60
|
+
export const NOTIF_OVERRIDE_MAX_BYTES = 64 * 1024;
|
|
61
|
+
|
|
62
|
+
/** `{{ placeholder }}` occurrences in one override body (the worker renderer's
|
|
63
|
+
* grammar verbatim — templates.ts `interpolate`). */
|
|
64
|
+
const NOTIF_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
65
|
+
/** Same permissive BCP-47-ish shape the worker's auto-locale resolver accepts. */
|
|
66
|
+
const NOTIF_LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}$/;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* D4 — validate `templates.overrides` at CONFIG-WRITE time. Returns [] when the
|
|
70
|
+
* bag is absent or clean. Every rule fails LOUD rather than shipping something
|
|
71
|
+
* that silently renders wrong in a customer's inbox:
|
|
72
|
+
* • overrides present with `allowOverride:false` → rejected (never inert);
|
|
73
|
+
* • an unknown template id / locale tag → rejected;
|
|
74
|
+
* • an unknown `{{placeholder}}` in subject/html/text → rejected (the class of
|
|
75
|
+
* typo that renders as an empty string in a live email);
|
|
76
|
+
* • size caps (per-locale count, whole-map bytes) → rejected.
|
|
77
|
+
* Pure, so it is unit-tested without a control plane.
|
|
78
|
+
*/
|
|
79
|
+
export function validateNotificationOverrides(
|
|
80
|
+
templates: {
|
|
81
|
+
allowOverride?: boolean;
|
|
82
|
+
overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
|
|
83
|
+
} | undefined,
|
|
84
|
+
): string[] {
|
|
85
|
+
const overrides = templates?.overrides;
|
|
86
|
+
if (!overrides || Object.keys(overrides).length === 0) return [];
|
|
87
|
+
const errs: string[] = [];
|
|
88
|
+
if (templates?.allowOverride !== true) {
|
|
89
|
+
errs.push('/templates/overrides: set templates.allowOverride: true to use per-locale template overrides (an override map with allowOverride:false would never be rendered)');
|
|
90
|
+
}
|
|
91
|
+
const bytes = new TextEncoder().encode(JSON.stringify(overrides)).byteLength;
|
|
92
|
+
if (bytes > NOTIF_OVERRIDE_MAX_BYTES) {
|
|
93
|
+
errs.push(`/templates/overrides: ${bytes} bytes exceeds the ${NOTIF_OVERRIDE_MAX_BYTES}-byte cap`);
|
|
94
|
+
}
|
|
95
|
+
for (const [templateId, byLocale] of Object.entries(overrides)) {
|
|
96
|
+
const allowed = NOTIFICATION_TEMPLATE_PLACEHOLDERS[templateId];
|
|
97
|
+
if (!allowed) {
|
|
98
|
+
errs.push(`/templates/overrides/${templateId}: unknown template id (one of: ${Object.keys(NOTIFICATION_TEMPLATE_PLACEHOLDERS).join(', ')})`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const locales = Object.keys(byLocale ?? {});
|
|
102
|
+
if (locales.length > NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE) {
|
|
103
|
+
errs.push(`/templates/overrides/${templateId}: ${locales.length} locales exceeds the ${NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE}-per-template cap`);
|
|
104
|
+
}
|
|
105
|
+
for (const locale of locales) {
|
|
106
|
+
if (!NOTIF_LOCALE_RE.test(locale)) {
|
|
107
|
+
errs.push(`/templates/overrides/${templateId}/${locale}: not a BCP-47 locale tag (e.g. 'en-GB')`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const variant = byLocale![locale]!;
|
|
111
|
+
for (const part of ['subject', 'html', 'text'] as const) {
|
|
112
|
+
const body = variant[part];
|
|
113
|
+
if (typeof body !== 'string') continue;
|
|
114
|
+
for (const m of body.matchAll(NOTIF_PLACEHOLDER_RE)) {
|
|
115
|
+
const key = m[1]!;
|
|
116
|
+
if (!allowed.includes(key)) {
|
|
117
|
+
errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${allowed.join(', ')})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return errs;
|
|
124
|
+
}
|
|
125
|
+
|
|
41
126
|
export const NotificationsConfigSchema = Type.Object({
|
|
42
127
|
enabled: Type.Boolean({ default: true }),
|
|
43
128
|
fromEmail: Type.String({ format: 'email' }),
|
|
@@ -87,10 +172,35 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
87
172
|
},
|
|
88
173
|
{ default: {} },
|
|
89
174
|
),
|
|
90
|
-
templates
|
|
91
|
-
|
|
175
|
+
// `templates` became an OPTIONAL bag (1 leaf, the same M21 trick `retry`
|
|
176
|
+
// uses) to fund the D4 per-locale `overrides` map WITHOUT moving the count:
|
|
177
|
+
// countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
|
|
178
|
+
// Value.Default still materializes `templates.allowOverride` into every
|
|
179
|
+
// persisted manifest exactly as before — zero behavioral delta; only the TS
|
|
180
|
+
// type is optional (workers read via core.ts `templatesOf()`).
|
|
181
|
+
templates: Type.Optional(Type.Object(
|
|
182
|
+
{
|
|
183
|
+
allowOverride: Type.Boolean({ default: false }),
|
|
184
|
+
// D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
|
|
185
|
+
// template ids, as config DATA (a Record = 1 leaf, catalog size never
|
|
186
|
+
// moves the count). Shape: { [templateId]: { [locale]: { subject,
|
|
187
|
+
// html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
|
|
188
|
+
// built-ins — the worker reuses ONE renderer, so escaping/URL-scheme
|
|
189
|
+
// sanitisation are identical and there is no raw-output syntax.
|
|
190
|
+
// validateFeatureConfig rejects: an unknown template id, an unknown
|
|
191
|
+
// placeholder, a malformed locale tag, an over-size body, and (fail
|
|
192
|
+
// LOUD, never silently inert) overrides present with allowOverride:false.
|
|
193
|
+
overrides: Type.Optional(Type.Record(
|
|
194
|
+
Type.String(),
|
|
195
|
+
Type.Record(Type.String(), Type.Object({
|
|
196
|
+
subject: Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX }),
|
|
197
|
+
html: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
198
|
+
text: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
199
|
+
})),
|
|
200
|
+
)),
|
|
201
|
+
},
|
|
92
202
|
{ default: {} },
|
|
93
|
-
),
|
|
203
|
+
)),
|
|
94
204
|
/** in-app inbox channel (send with channel: 'inbox' | 'both') */
|
|
95
205
|
inboxEnabled: Type.Boolean({ default: false }),
|
|
96
206
|
/** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
|
|
@@ -118,8 +228,11 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
118
228
|
// Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef,
|
|
119
229
|
// webhookSecretRef, provider, defaultLocale, retry (Optional bag = 1),
|
|
120
230
|
// suppression.softBounceThreshold, rateLimit.perDay, rateLimit.perTenantSec,
|
|
121
|
-
// templates
|
|
122
|
-
//
|
|
231
|
+
// templates (Optional bag = 1 — was templates.allowOverride, collapsed 2026-09-10
|
|
232
|
+
// to fund the D4 `overrides` map at zero net cost), inboxEnabled,
|
|
233
|
+
// broadcast (Optional bag = 1) → 15. Cap = 15 — AT the cap; the next flag must
|
|
234
|
+
// collapse something. (`templates.overrides` is a Record MAP inside the ONE
|
|
235
|
+
// optional templates leaf — DATA, not a flag — so catalog size never moves it.)
|
|
123
236
|
|
|
124
237
|
export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
|
|
125
238
|
/** The §11b.5 broadcast bag as persisted (present ⇒ leaf defaults applied). */
|
|
@@ -214,10 +327,61 @@ const AppleRefsSchema = Type.Object({
|
|
|
214
327
|
// Value.Clean stripping the field out of PUT /v1/config/auth.
|
|
215
328
|
bundleIds: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
|
|
216
329
|
});
|
|
330
|
+
// RB-2 — the generic OIDC / SSO bridge (roadmap §4.6.B, §1B.5; features/auth.md
|
|
331
|
+
// §6.7). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
|
|
332
|
+
// bag so it spends NO leaf (an Optional object bag = ONE leaf; auth stays 11).
|
|
333
|
+
// Declarative + opt-in: the block's PRESENCE enables the `oidc` provider (no
|
|
334
|
+
// methods.* toggle — a `default:false` flag would re-materialize every published
|
|
335
|
+
// manifest, breaking the D3 byte-identical fold). The worker discovers the
|
|
336
|
+
// endpoints + JWKS from `{issuer}/.well-known/openid-configuration` and verifies
|
|
337
|
+
// iss (byte-equal) / aud (= clientId) / nonce / exp / iat strictly, fail-closed.
|
|
338
|
+
// `clientId` is NOT a secret (it rides every authorize URL); `clientSecretRef`
|
|
339
|
+
// is a tenant_secrets POINTER (feature 'auth'), never the value. A SAML IdP
|
|
340
|
+
// plugs in through a broker (Okta / Entra / Auth0 / WorkOS) that speaks OIDC —
|
|
341
|
+
// there is deliberately NO native SAML.
|
|
342
|
+
const OidcProviderSchema = Type.Object({
|
|
343
|
+
// https URL, no query/fragment (the OIDC issuer identifier). Compared
|
|
344
|
+
// BYTE-EQUAL to the discovery document's `issuer` and the id_token `iss`.
|
|
345
|
+
issuer: Type.String({ minLength: 12, maxLength: 512, pattern: '^https://[^\\s?#]+$' }),
|
|
346
|
+
clientId: Type.String({ minLength: 1, maxLength: 512 }),
|
|
347
|
+
clientSecretRef: Type.String({ minLength: 1, maxLength: 200 }),
|
|
348
|
+
// authorize-request scopes; `openid` is always added by the worker. Default
|
|
349
|
+
// (absent) = openid email profile.
|
|
350
|
+
scopes: Type.Optional(Type.Array(
|
|
351
|
+
Type.String({ minLength: 1, maxLength: 64, pattern: '^[\\x21-\\x7e]+$' }), { maxItems: 16 },
|
|
352
|
+
)),
|
|
353
|
+
// claim NAMES to read (absent = the standard `email` / `name`; `roles` has
|
|
354
|
+
// no default — no claim named ⇒ no role claims are minted).
|
|
355
|
+
claims: Type.Optional(Type.Object({
|
|
356
|
+
email: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
357
|
+
name: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
358
|
+
roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
359
|
+
})),
|
|
360
|
+
// when non-empty, the asserted email's domain MUST be listed (fail-closed:
|
|
361
|
+
// no email ⇒ refused) → 403 oidc_domain_not_allowed; a listed domain also
|
|
362
|
+
// marks the email verified (the tenant declared the issuer authoritative).
|
|
363
|
+
allowedDomains: Type.Optional(Type.Array(
|
|
364
|
+
Type.String({ minLength: 3, maxLength: 253, pattern: '^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$' }),
|
|
365
|
+
{ maxItems: 32 },
|
|
366
|
+
)),
|
|
367
|
+
// default true: an identity whose VERIFIED email matches an existing user is
|
|
368
|
+
// linked to it (the shipped §6.4 rule). false: link only by the stable
|
|
369
|
+
// (issuer, sub) anchor; a matching email that is not yet linked → 409.
|
|
370
|
+
autoLink: Type.Optional(Type.Boolean()),
|
|
371
|
+
});
|
|
217
372
|
|
|
218
373
|
export const AuthConfigSchema = Type.Object({
|
|
219
374
|
enabled: Type.Boolean({ default: true }),
|
|
220
|
-
|
|
375
|
+
// D3 (auth wave 2026-09-10): the six method toggles collapsed into ONE
|
|
376
|
+
// Optional bag (6 leaves → 1, countLeaves counts an Optional object as ONE) —
|
|
377
|
+
// the notifications `retry`/`broadcast` precedent. It KEEPS `default: {}`,
|
|
378
|
+
// which Value.Default still materializes, so every persisted manifest carries
|
|
379
|
+
// methods.{emailPassword,magicLink,google,github,apple,facebook} with the
|
|
380
|
+
// SAME keys and defaults as before — byte-identical folds for existing
|
|
381
|
+
// tenants (index.test.ts "D3 fold bytes"). Only the TS type is now optional;
|
|
382
|
+
// the worker's gate() normalizes an absent bag to the defaults so every
|
|
383
|
+
// reader (config.methods.<flag>) is unchanged.
|
|
384
|
+
methods: Type.Optional(Type.Object(
|
|
221
385
|
{
|
|
222
386
|
emailPassword: Type.Boolean({ default: true }),
|
|
223
387
|
magicLink: Type.Boolean({ default: true }),
|
|
@@ -227,7 +391,7 @@ export const AuthConfigSchema = Type.Object({
|
|
|
227
391
|
facebook: Type.Boolean({ default: false }),
|
|
228
392
|
},
|
|
229
393
|
{ default: {} },
|
|
230
|
-
),
|
|
394
|
+
)),
|
|
231
395
|
// Per-provider BYO credential refs. ONE optional bag (= ONE leaf per the cap
|
|
232
396
|
// rule) keyed by provider, so the four provider blocks (and any future one)
|
|
233
397
|
// never inflate the flag count — the prior shape spent a leaf per top-level
|
|
@@ -245,6 +409,8 @@ export const AuthConfigSchema = Type.Object({
|
|
|
245
409
|
github: Type.Optional(OAuthRefsSchema),
|
|
246
410
|
apple: Type.Optional(AppleRefsSchema),
|
|
247
411
|
facebook: Type.Optional(OAuthRefsSchema),
|
|
412
|
+
// RB-2: the generic OIDC / SSO issuer (presence = enabled; see above)
|
|
413
|
+
oidc: Type.Optional(OidcProviderSchema),
|
|
248
414
|
})),
|
|
249
415
|
// session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
|
|
250
416
|
// the OTP/anonymous/orgClaims wave — the `{ default: {} }` keeps the inner
|
|
@@ -255,6 +421,12 @@ export const AuthConfigSchema = Type.Object({
|
|
|
255
421
|
{
|
|
256
422
|
ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
|
|
257
423
|
refreshTtlDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
|
|
424
|
+
// A1 (auth wave 2026-09-10): concurrent-session cap per user with
|
|
425
|
+
// TAKE-OVER — a new sign-in revokes the OLDEST sessions past the cap
|
|
426
|
+
// (revoked_reason 'device_cap', edge cache written) and reports them as
|
|
427
|
+
// `took_over: [session_id…]`. Optional WITHOUT a default so existing
|
|
428
|
+
// manifests fold byte-identically; absent/0 = unlimited (today).
|
|
429
|
+
maxConcurrent: Type.Optional(Type.Integer({ minimum: 0, maximum: 100 })),
|
|
258
430
|
},
|
|
259
431
|
{ default: {} },
|
|
260
432
|
)),
|
|
@@ -285,6 +457,16 @@ export const AuthConfigSchema = Type.Object({
|
|
|
285
457
|
codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
|
|
286
458
|
maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
|
|
287
459
|
resendCooldownSec: Type.Integer({ default: 60, minimum: 0, maximum: 600 }),
|
|
460
|
+
// F4-29 (auth wave 2026-09-10): test recipients — an OTP / step-up / email-
|
|
461
|
+
// claim request whose address matches an entry sends NO mail and returns
|
|
462
|
+
// the code as `test_code` (audit auth.otp.test_issued). Entries: an exact
|
|
463
|
+
// email, a `*@domain` glob, or a +E.164 number (accepted for the SMS
|
|
464
|
+
// channel; inert for email identifiers). ≤20; Optional (no default) so
|
|
465
|
+
// existing manifests fold byte-identically. Store-review / CI accounts
|
|
466
|
+
// only — never a real user's address.
|
|
467
|
+
testRecipients: Type.Optional(Type.Array(
|
|
468
|
+
Type.String({ minLength: 3, maxLength: 320 }), { maxItems: 20 },
|
|
469
|
+
)),
|
|
288
470
|
})),
|
|
289
471
|
// Anonymous (guest) sign-in (roadmap Tier-0). OPTIONAL bag = 1 leaf.
|
|
290
472
|
anonymous: Type.Optional(Type.Object({
|
|
@@ -304,15 +486,49 @@ export const AuthConfigSchema = Type.Object({
|
|
|
304
486
|
// its leaf was spent on the methods.{apple,facebook} toggles the shipped
|
|
305
487
|
// §6.4 sign-in flow actually gates on. Re-adding it requires headroom or a
|
|
306
488
|
// collapse elsewhere.
|
|
489
|
+
//
|
|
490
|
+
// Account-security controls (auth wave 2026-09-10, P0-3; features/auth.md
|
|
491
|
+
// §4b). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
|
|
492
|
+
// absent (existing manifests fold byte-identically) and every control is
|
|
493
|
+
// opt-in:
|
|
494
|
+
// • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
|
|
495
|
+
// sign-in + OTP verify (429 account_locked + Retry-After); a bounded
|
|
496
|
+
// counter row in auth.lockouts (migration 0088) survives restarts.
|
|
497
|
+
// • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
|
|
498
|
+
// chars leave the worker, never the password) at sign-up / reset-confirm;
|
|
499
|
+
// fail-OPEN on network error → 422 password_breached.
|
|
500
|
+
// • captchaSecretRef — a tenant_secrets ref (feature 'auth') holding the
|
|
501
|
+
// Turnstile secret; when set, sign-up / OTP request / magic-link request
|
|
502
|
+
// require `captcha_token` (403 captcha_failed otherwise).
|
|
503
|
+
// • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
|
|
504
|
+
// redirect/return URL (magic link, password reset, email verification,
|
|
505
|
+
// OAuth start) must match one origin exactly → 422 redirect_not_allowed.
|
|
506
|
+
security: Type.Optional(Type.Object({
|
|
507
|
+
lockout: Type.Optional(Type.Object({
|
|
508
|
+
maxFailures: Type.Integer({ default: 10, minimum: 3, maximum: 100 }),
|
|
509
|
+
windowMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
|
|
510
|
+
lockMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
|
|
511
|
+
})),
|
|
512
|
+
breachedPasswords: Type.Boolean({ default: false }),
|
|
513
|
+
captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
|
|
514
|
+
allowedRedirectOrigins: Type.Optional(Type.Array(
|
|
515
|
+
Type.String({ minLength: 8, maxLength: 253, pattern: '^https://[^/?#\\s]+$' }),
|
|
516
|
+
{ maxItems: 32 },
|
|
517
|
+
)),
|
|
518
|
+
})),
|
|
307
519
|
});
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
520
|
+
/** The security bag as persisted (present ⇒ leaf defaults applied). */
|
|
521
|
+
export type AuthSecurityConfig = NonNullable<Static<typeof AuthConfigSchema>['security']>;
|
|
522
|
+
/** The RB-2 generic OIDC / SSO issuer block (`providers.oidc`). */
|
|
523
|
+
export type AuthOidcConfig = Static<typeof OidcProviderSchema>;
|
|
524
|
+
// Leaves (auth wave 2026-09-10, D3): enabled(1) + methods(1, optional bag —
|
|
525
|
+
// was 6) + providers(1, optional bag holding the four google/github/apple/
|
|
526
|
+
// facebook credential blocks + the RB-2 `oidc` issuer block) + session(1, optional bag) + password(1,
|
|
527
|
+
// optional bag) + emailVerification(1) + magicLink(1) + otp(1, optional bag)
|
|
528
|
+
// + anonymous(1, optional bag) + orgClaims(1, optional bag) + security(1,
|
|
529
|
+
// optional bag) = 11. Cap = 15 — 4 leaves of headroom. Knobs added inside an
|
|
530
|
+
// existing bag (session.maxConcurrent, otp.testRecipients, the security
|
|
531
|
+
// sub-bags) never move the count.
|
|
316
532
|
|
|
317
533
|
export type AuthConfig = Static<typeof AuthConfigSchema>;
|
|
318
534
|
|
|
@@ -336,7 +552,10 @@ export type RateLimitsConfig = Static<typeof RateLimitsConfigSchema>;
|
|
|
336
552
|
|
|
337
553
|
export const FilesConfigSchema = Type.Object({
|
|
338
554
|
enabled: Type.Boolean({ default: true }),
|
|
339
|
-
|
|
555
|
+
// (F8-54) `bucketRef` was DELETED: the object-storage bucket is a platform
|
|
556
|
+
// binding on files-v1, never tenant-selectable, and no code ever read the
|
|
557
|
+
// leaf — declaring it invited "point files at my own bucket", which vxil does
|
|
558
|
+
// not offer. A persisted manifest that still carries it folds (Value.Clean).
|
|
340
559
|
uploadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
|
|
341
560
|
downloadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
|
|
342
561
|
quotas: Type.Object(
|
|
@@ -345,7 +564,7 @@ export const FilesConfigSchema = Type.Object({
|
|
|
345
564
|
// object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
|
|
346
565
|
// 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
|
|
347
566
|
// (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
|
|
348
|
-
// tenant tier threaded to files-v1 (
|
|
567
|
+
// tenant tier threaded to files-v1 (plan tiers).
|
|
349
568
|
maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
|
|
350
569
|
maxTotalBytes: Type.Integer({ default: 10 * 1024 * 1024 * 1024, minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 }),
|
|
351
570
|
maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
|
|
@@ -353,13 +572,10 @@ export const FilesConfigSchema = Type.Object({
|
|
|
353
572
|
{ default: {} },
|
|
354
573
|
),
|
|
355
574
|
allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
|
|
356
|
-
contentScan
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
},
|
|
361
|
-
{ default: {} },
|
|
362
|
-
),
|
|
575
|
+
// (F8-54) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
|
|
576
|
+
// there is no malware/content scanner in files-v1 (it was a "V1.5" placeholder
|
|
577
|
+
// neither leaf was ever read), so the knob promised quarantine that never
|
|
578
|
+
// happened. Re-declare it in the same change as a real scanner, not before.
|
|
363
579
|
sharedLinks: Type.Object(
|
|
364
580
|
{
|
|
365
581
|
enabled: Type.Boolean({ default: true }),
|
|
@@ -392,7 +608,8 @@ export const FilesConfigSchema = Type.Object({
|
|
|
392
608
|
boundingBoxes: Type.Boolean({ default: false }),
|
|
393
609
|
})),
|
|
394
610
|
});
|
|
395
|
-
// Leaves:
|
|
611
|
+
// Leaves: 9 + ttl(1, optional bag) + extractText(1, optional bag) = 11. Cap = 15.
|
|
612
|
+
// (was 14 — F8-54 deleted the inert bucketRef leaf and the inert contentScan bag.)
|
|
396
613
|
|
|
397
614
|
export type FilesConfig = Static<typeof FilesConfigSchema>;
|
|
398
615
|
|
|
@@ -461,7 +678,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
461
678
|
},
|
|
462
679
|
{ default: {} },
|
|
463
680
|
),
|
|
464
|
-
// Code-based lifecycle hooks (docs/
|
|
681
|
+
// Code-based lifecycle hooks (https://vxil.com/docs/guide/07-validation-and-hooks):
|
|
465
682
|
// a tenant-authored SAFE expression. Write events (beforeCreate/beforeUpdate/
|
|
466
683
|
// beforeWrite) run in-transaction — `validate` rejects the write, `derive`
|
|
467
684
|
// computes a persisted field. Read events shape the RESPONSE only: `validate`
|
|
@@ -545,10 +762,10 @@ export const McpConfigSchema = Type.Object({
|
|
|
545
762
|
{ default: 'all' },
|
|
546
763
|
),
|
|
547
764
|
allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
|
|
548
|
-
scopedKey:
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
765
|
+
// (F8-54) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
|
|
766
|
+
// a "dashboard-UI hint only" that no dashboard ever read, and key minting is a
|
|
767
|
+
// control-plane concern independent of MCP exposure — per-agent keys already
|
|
768
|
+
// work for every tenant, gated by nothing here.
|
|
552
769
|
rateLimits: Type.Object(
|
|
553
770
|
{ toolCallsPerMin: Type.Integer({ default: 60, minimum: 1, maximum: 6000 }) },
|
|
554
771
|
{ default: {} },
|
|
@@ -598,10 +815,10 @@ export const McpConfigSchema = Type.Object({
|
|
|
598
815
|
}),
|
|
599
816
|
)),
|
|
600
817
|
});
|
|
601
|
-
// Leaves:
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
818
|
+
// Leaves: 8 by countLeaves (enabled, exposureLevel, allowToolList,
|
|
819
|
+
// rateLimits.toolCallsPerMin, branding.serverName, branding.serverInstructions
|
|
820
|
+
// = 6, + customTools + prompts as ONE Type.Record leaf each). Cap = 15.
|
|
821
|
+
// (was 9 — F8-54 deleted the inert scopedKey.perAgentKeys leaf.)
|
|
605
822
|
|
|
606
823
|
export type McpConfig = Static<typeof McpConfigSchema>;
|
|
607
824
|
|
|
@@ -686,12 +903,10 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
686
903
|
{ default: {} },
|
|
687
904
|
),
|
|
688
905
|
|
|
689
|
-
aggregation:
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
{ default: {} },
|
|
694
|
-
),
|
|
906
|
+
// (F8-54) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
|
|
907
|
+
// or returned a per-group activity LIST — an aggregated read returns the group
|
|
908
|
+
// rollup (activity_count/actor_count/last_actor), so there was never an N to
|
|
909
|
+
// bound and no code read the leaf. Re-declare it with a group-detail route.
|
|
695
910
|
|
|
696
911
|
realtime: Type.Object(
|
|
697
912
|
{
|
|
@@ -711,18 +926,17 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
711
926
|
{ default: {} },
|
|
712
927
|
),
|
|
713
928
|
|
|
714
|
-
rateLimit:
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
),
|
|
929
|
+
// (F8-54) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
|
|
930
|
+
// rate-limiter binding and never read it, so the declared per-tenant write
|
|
931
|
+
// burst was enforced by nothing (the edge front-door limiter and the
|
|
932
|
+
// per-tenant request meter are the real bounds). Re-declare it together with
|
|
933
|
+
// the binding that enforces it.
|
|
720
934
|
});
|
|
721
935
|
// Leaves: enabled(1), feedGroups(1, the Record bag → ONE leaf), fanout.{celebrityThreshold,
|
|
722
936
|
// maxFanoutPerJob, maxConcurrentTasks, pendingCeiling}(+4=6), follow.{copyLimit,
|
|
723
|
-
// maxFollowing}(+2=8),
|
|
724
|
-
//
|
|
725
|
-
//
|
|
937
|
+
// maxFollowing}(+2=8), realtime.enabled(9), crossChannel.{enabled, digestCadence}(+2=11).
|
|
938
|
+
// countLeaves → 11. Cap = 15. (feedGroups is Type.Record → patternProperties, ONE leaf.)
|
|
939
|
+
// (was 13 — F8-54 deleted the inert aggregation and rateLimit bags.)
|
|
726
940
|
|
|
727
941
|
export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
|
|
728
942
|
|
|
@@ -995,6 +1209,13 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
995
1209
|
// of the platform PROVIDER_WEBHOOK_SECRET — closes the multi-tenant RC
|
|
996
1210
|
// webhook-forgery vector (mirrors stripe/paddle webhookSecretRef).
|
|
997
1211
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
1212
|
+
// Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
|
|
1213
|
+
// and PRODUCTION events to the SAME webhook with the same auth header, so a
|
|
1214
|
+
// sandbox purchase would otherwise fold into production entitlements. A
|
|
1215
|
+
// sandbox event is persisted as outcome 'rejected_environment' (200, never
|
|
1216
|
+
// folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
|
|
1217
|
+
// environments by signing secret / API base, so only RC carries this knob.
|
|
1218
|
+
acceptSandbox: Type.Boolean({ default: false }),
|
|
998
1219
|
})),
|
|
999
1220
|
paypal: Type.Optional(Type.Object({
|
|
1000
1221
|
clientIdRef: Type.String(),
|
|
@@ -1007,10 +1228,10 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1007
1228
|
// https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
|
|
1008
1229
|
// hardcoded host (audit 2026-07-10: the old fallback pointed at a dead apex).
|
|
1009
1230
|
returnUrl: Type.Optional(Type.String({ pattern: '^https://', maxLength: 512 })),
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1231
|
+
// NB (F8-54, 2026-09-11): the former `prices.catalogRef` leaf was DELETED —
|
|
1232
|
+
// it named nothing (prices resolve from ledger.priceMap; no code path ever
|
|
1233
|
+
// read it). A persisted manifest that still carries `prices` folds:
|
|
1234
|
+
// validateFeatureConfig's Value.Clean strips the stray key.
|
|
1014
1235
|
defaults: Type.Object(
|
|
1015
1236
|
{
|
|
1016
1237
|
currency: Type.String({ default: 'usd' }),
|
|
@@ -1044,17 +1265,39 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
1044
1265
|
// fold (refoldEntitlements WHERE tier IS NOT NULL) reflects the subscription.
|
|
1045
1266
|
priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
1046
1267
|
autoRefundOnJobFailure: Type.Boolean({ default: true }), // consume(jobId) reverses on DLQ/timeout
|
|
1268
|
+
// Grace window (money-path wave F1-7): a `past_due` subscription stays
|
|
1269
|
+
// entitled for this many days AFTER its current_period_end (the dunning
|
|
1270
|
+
// window the provider is retrying inside). 0 = today's behaviour (a past_due
|
|
1271
|
+
// row is never entitled). ONE predicate in the fold — NOT a dunning ladder:
|
|
1272
|
+
// no retries, no emails. Lives inside the ledger bag (no new top-level leaf).
|
|
1273
|
+
grace: Type.Optional(Type.Object({
|
|
1274
|
+
pastDueDays: Type.Integer({ default: 0, minimum: 0, maximum: 90 }),
|
|
1275
|
+
})),
|
|
1276
|
+
// Opt-in period-end enforcement (money-path operations wave, decision D2
|
|
1277
|
+
// option a). ABSENT (the default) = today's behaviour: a subscription whose
|
|
1278
|
+
// current_period_end passed with no provider event stays entitled forever
|
|
1279
|
+
// (the provider is the only clock). PRESENT = the hourly reconcile sweep
|
|
1280
|
+
// flips an `active`/`trialing` row whose current_period_end + slackHours
|
|
1281
|
+
// has passed with no renewal to status 'lapsed' (+ refold, +
|
|
1282
|
+
// payments.subscription.lapsed); a later provider renewal event revives it
|
|
1283
|
+
// (the two-clocks revive rule). Never a manual row without `until`. Lives
|
|
1284
|
+
// inside the ledger bag (no new top-level leaf).
|
|
1285
|
+
enforcePeriodEnd: Type.Optional(Type.Object({
|
|
1286
|
+
slackHours: Type.Integer({ default: 72, minimum: 0, maximum: 720 }),
|
|
1287
|
+
})),
|
|
1047
1288
|
})),
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
)
|
|
1289
|
+
// NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
|
|
1290
|
+
// was DELETED — it had zero readers (never forwarded anything). Outbound
|
|
1291
|
+
// delivery of payments state changes rides the audit_event → webhooks-out
|
|
1292
|
+
// spine: subscribe to the `payments.` event prefix (payments.md §7b).
|
|
1052
1293
|
});
|
|
1053
1294
|
// Leaves: enabled(1), provider(2), stripe?(3), paddle?(4), revenuecat?(5),
|
|
1054
|
-
// paypal?(6),
|
|
1055
|
-
//
|
|
1056
|
-
// (productMap / tierMap / priceMap
|
|
1057
|
-
// ledger leaf — DATA
|
|
1295
|
+
// paypal?(6), returnUrl(7), defaults.{currency,trialDays}(+2=9), ledger?(10).
|
|
1296
|
+
// countLeaves → 10. Cap = 15. (prices.catalogRef was deleted 2026-09-11.)
|
|
1297
|
+
// (productMap / tierMap / priceMap / grace / enforcePeriodEnd are inside the
|
|
1298
|
+
// ONE optional ledger leaf — DATA + two nested optional bags — so catalog size
|
|
1299
|
+
// never moves the count; revenuecat.acceptSandbox is inside the ONE optional
|
|
1300
|
+
// revenuecat leaf.)
|
|
1058
1301
|
|
|
1059
1302
|
export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
|
|
1060
1303
|
|
|
@@ -1068,13 +1311,17 @@ export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
|
|
|
1068
1311
|
// opt-in (enabled defaults to false), egress-guarded.
|
|
1069
1312
|
export const FunctionsConfigSchema = Type.Object({
|
|
1070
1313
|
enabled: Type.Boolean({ default: false }),
|
|
1071
|
-
//
|
|
1072
|
-
|
|
1314
|
+
// (F8-54) `runtime` ('isolate' | 'container') was DELETED: the container lane
|
|
1315
|
+
// is design-only, nothing read the leaf, and accepting 'container' silently
|
|
1316
|
+
// ran the isolate anyway. It comes back with the lane, not before.
|
|
1073
1317
|
defaultLimits: Type.Object(
|
|
1074
1318
|
{
|
|
1319
|
+
// cpuMs is the ONLY per-dispatch limit the managed runtime accepts and the
|
|
1320
|
+
// only one anything reads (functions-v1 meter.ts + the dispatch cap).
|
|
1321
|
+
// (F8-54) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
|
|
1322
|
+
// runtime and not tenant-selectable, and no wall-clock abort was ever
|
|
1323
|
+
// applied — a declared 10s default that nothing enforced.
|
|
1075
1324
|
cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 300_000 }),
|
|
1076
|
-
timeoutMs: Type.Integer({ default: 10_000, minimum: 50, maximum: 300_000 }),
|
|
1077
|
-
memoryMb: Type.Integer({ default: 128, minimum: 64, maximum: 1024 }),
|
|
1078
1325
|
},
|
|
1079
1326
|
{ default: {} },
|
|
1080
1327
|
),
|
|
@@ -1126,9 +1373,9 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1126
1373
|
{ maxItems: 8 },
|
|
1127
1374
|
),
|
|
1128
1375
|
),
|
|
1129
|
-
scriptRef: Type.String(), // content-hashed
|
|
1376
|
+
scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
|
|
1130
1377
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
1131
|
-
secrets: Type.Optional(Type.Array(Type.String())), //
|
|
1378
|
+
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
1132
1379
|
egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
|
|
1133
1380
|
limits: Type.Optional(
|
|
1134
1381
|
Type.Object({
|
|
@@ -1150,14 +1397,15 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
1150
1397
|
),
|
|
1151
1398
|
),
|
|
1152
1399
|
});
|
|
1153
|
-
// Leaves: enabled(1),
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1400
|
+
// Leaves: enabled(1), defaultLimits.cpuMs(2), metering.enabled(3),
|
|
1401
|
+
// functions(4, the Type.Record bag → ONE leaf; bindings[]/scriptRef/scopes/
|
|
1402
|
+
// secrets/egressAllow/limits/enabled/signature are DATA inside the MAP value
|
|
1403
|
+
// and never move the count, like cms.hooks). countLeaves → 4. Cap = 15.
|
|
1404
|
+
// (was 7 — F8-54 deleted the inert runtime, defaultLimits.timeoutMs and
|
|
1405
|
+
// defaultLimits.memoryMb leaves.)
|
|
1158
1406
|
export type FunctionsConfig = Static<typeof FunctionsConfigSchema>;
|
|
1159
1407
|
|
|
1160
|
-
// copilot feature (docs/copilot-agents
|
|
1408
|
+
// copilot feature (https://vxil.com/docs/guide/11-copilot-agents). Re-declared to match
|
|
1161
1409
|
// workers/copilot-v1/src/config.ts's exported CopilotConfigSchema (one shape,
|
|
1162
1410
|
// two consumers — keep the two definitions byte-identical). A thin COMPOSITION
|
|
1163
1411
|
// layer over ai + vector-search + rag + mcp: it owns the AGENT layer (persona,
|
|
@@ -1239,20 +1487,17 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
1239
1487
|
{ default: {} },
|
|
1240
1488
|
),
|
|
1241
1489
|
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
[Type.Literal('comments'), Type.Literal('notifications')],
|
|
1247
|
-
{ default: 'comments' },
|
|
1248
|
-
),
|
|
1249
|
-
notifyTemplate: Type.Optional(Type.String({ maxLength: 128 })),
|
|
1250
|
-
})),
|
|
1490
|
+
// (F8-54) the `escalation` bag ({enabled, handler, notifyTemplate}) was
|
|
1491
|
+
// DELETED: the human hand-off it declared was never built — copilot-v1 read
|
|
1492
|
+
// none of the three leaves, so a tenant who turned it on got silence. The
|
|
1493
|
+
// shipped hand-off path is a tenant function on the conversation events.
|
|
1251
1494
|
|
|
1252
1495
|
// ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
|
|
1253
1496
|
limits: Type.Object({
|
|
1254
1497
|
consumeCredits: Type.Boolean({ default: false }),
|
|
1255
|
-
|
|
1498
|
+
// (F8-54) `tokensPerUserPerDay` was DELETED here: token accounting is
|
|
1499
|
+
// delegated to ai-v1 (this bag's own doctrine) and only `ai`'s
|
|
1500
|
+
// limits.tokensPerUserPerDay is enforced — the copilot twin read nothing.
|
|
1256
1501
|
}, { default: {} }),
|
|
1257
1502
|
|
|
1258
1503
|
// ── widget / embed (the Stage-3 public chat surface) ──────────────────────
|
|
@@ -1263,10 +1508,10 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
1263
1508
|
theme: Type.Optional(Type.String({ maxLength: 32 })),
|
|
1264
1509
|
}, { default: {} }),
|
|
1265
1510
|
});
|
|
1266
|
-
// Leaves: enabled(1), agents(2 — Type.Record MAP, ONE leaf),
|
|
1267
|
-
//
|
|
1268
|
-
// (+
|
|
1269
|
-
//
|
|
1511
|
+
// Leaves: enabled(1), agents(2 — Type.Record MAP, ONE leaf),
|
|
1512
|
+
// limits.consumeCredits(3), widget.{enabled,requireAuth,allowedOrigins,theme}
|
|
1513
|
+
// (+4=7). countLeaves → 7. Cap = 15.
|
|
1514
|
+
// (was 9 — F8-54 deleted the inert escalation bag and limits.tokensPerUserPerDay.)
|
|
1270
1515
|
|
|
1271
1516
|
export type CopilotConfig = Static<typeof CopilotConfigSchema>;
|
|
1272
1517
|
|
|
@@ -1298,6 +1543,36 @@ export const CONFIG_FLAG_CAP = 15;
|
|
|
1298
1543
|
* Per features/auth.md §4: an OPTIONAL object (e.g. a provider credential
|
|
1299
1544
|
* block) counts as ONE flag — the tenant's decision is "configure it or
|
|
1300
1545
|
* not", not each inner ref. */
|
|
1546
|
+
/** The auth lifecycle events an `authHook` binding may name (F4-30) — the
|
|
1547
|
+
* closed union `packages/config` types as AuthHookEvent; the control-plane
|
|
1548
|
+
* reconciler maps each to its `auth.<event>` audit-event prefix. */
|
|
1549
|
+
export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'] as const;
|
|
1550
|
+
export type AuthHookEvent = (typeof AUTH_HOOK_EVENTS)[number];
|
|
1551
|
+
|
|
1552
|
+
/** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
|
|
1553
|
+
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
1554
|
+
export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
|
|
1555
|
+
export const TEST_RECIPIENT_GLOB_RE = /^\*@[^\s@*]+\.[^\s@*]+$/;
|
|
1556
|
+
export const TEST_RECIPIENT_E164_RE = /^\+[1-9]\d{1,14}$/;
|
|
1557
|
+
export function isTestRecipientPattern(entry: string): boolean {
|
|
1558
|
+
return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry) || TEST_RECIPIENT_E164_RE.test(entry);
|
|
1559
|
+
}
|
|
1560
|
+
/** True iff `identifier` (an email today) matches one configured entry:
|
|
1561
|
+
* exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
|
|
1562
|
+
* an email. */
|
|
1563
|
+
export function matchesTestRecipient(identifier: string, entries: readonly string[] | undefined): boolean {
|
|
1564
|
+
if (!entries || entries.length === 0) return false;
|
|
1565
|
+
const id = identifier.trim().toLowerCase();
|
|
1566
|
+
const at = id.lastIndexOf('@');
|
|
1567
|
+
const domain = at >= 0 ? id.slice(at + 1) : null;
|
|
1568
|
+
return entries.some((raw) => {
|
|
1569
|
+
const e = raw.trim().toLowerCase();
|
|
1570
|
+
if (TEST_RECIPIENT_GLOB_RE.test(e)) return domain !== null && domain === e.slice(2);
|
|
1571
|
+
if (TEST_RECIPIENT_E164_RE.test(e)) return id === e;
|
|
1572
|
+
return id === e;
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1301
1576
|
export function countLeaves(schema: TSchema): number {
|
|
1302
1577
|
const t = schema as unknown as {
|
|
1303
1578
|
type?: string;
|
|
@@ -1423,13 +1698,22 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1423
1698
|
// Cross-field rule: notifications only needs a Resend key for the real
|
|
1424
1699
|
// provider. The `mock` provider (and inbox-only setups) stay zero-config.
|
|
1425
1700
|
if (feature === 'notifications') {
|
|
1426
|
-
const v = withDefaults as {
|
|
1701
|
+
const v = withDefaults as {
|
|
1702
|
+
provider?: string;
|
|
1703
|
+
resendApiKeyRef?: string;
|
|
1704
|
+
templates?: {
|
|
1705
|
+
allowOverride?: boolean;
|
|
1706
|
+
overrides?: Record<string, Record<string, { subject?: string; html?: string; text?: string }>>;
|
|
1707
|
+
};
|
|
1708
|
+
};
|
|
1427
1709
|
if (v.provider === 'resend' && !v.resendApiKeyRef) {
|
|
1428
1710
|
return {
|
|
1429
1711
|
ok: false,
|
|
1430
1712
|
errors: ["/resendApiKeyRef: required when provider is 'resend' (omit it for provider 'mock')"],
|
|
1431
1713
|
};
|
|
1432
1714
|
}
|
|
1715
|
+
const overrideErrs = validateNotificationOverrides(v.templates);
|
|
1716
|
+
if (overrideErrs.length) return { ok: false, errors: overrideErrs.slice(0, 10) };
|
|
1433
1717
|
}
|
|
1434
1718
|
// Cross-field rule: a REAL payments provider needs its credential block; the
|
|
1435
1719
|
// 'mock' provider (the deterministic default) stays zero-config so the whole
|
|
@@ -1439,7 +1723,8 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1439
1723
|
provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown;
|
|
1440
1724
|
ledger?: {
|
|
1441
1725
|
productMap?: Record<string, { creditType?: string }>;
|
|
1442
|
-
tierMap?: Record<string, { grants?: Array<{ creditType?: string }> }>;
|
|
1726
|
+
tierMap?: Record<string, { rank?: number; grants?: Array<{ creditType?: string }> }>;
|
|
1727
|
+
priceMap?: Record<string, string>;
|
|
1443
1728
|
};
|
|
1444
1729
|
};
|
|
1445
1730
|
const needsBlock: Record<string, 'stripe' | 'paddle' | 'revenuecat'> = {
|
|
@@ -1473,6 +1758,34 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1473
1758
|
}
|
|
1474
1759
|
});
|
|
1475
1760
|
}
|
|
1761
|
+
// Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
|
|
1762
|
+
// customer (priceMap miss → tier NULL → refold excluded the row). The
|
|
1763
|
+
// reducer now stamps such an event outcome 'error' (reprocessable), and
|
|
1764
|
+
// this lint catches the config half at `vxil push` time: every priceMap
|
|
1765
|
+
// value must be a tierMap key, and explicitly-declared tier ranks must be
|
|
1766
|
+
// unique (two tiers at the same rank make the multi-sub fold's winner
|
|
1767
|
+
// order-dependent). Omitted ranks (default 0) are NOT checked — rejecting
|
|
1768
|
+
// rank-less legacy configs on push would break existing tenants.
|
|
1769
|
+
const tierKeys = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
1770
|
+
for (const [priceRef, tier] of Object.entries(v.ledger?.priceMap ?? {})) {
|
|
1771
|
+
if (!tierKeys.has(tier)) {
|
|
1772
|
+
ledgerErrs.push(
|
|
1773
|
+
`/ledger/priceMap/${priceRef}: '${tier}' is not a tierMap key (a webhook for this price would fold to an unknown tier)`,
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
const rankOwner = new Map<number, string>();
|
|
1778
|
+
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
1779
|
+
if (typeof rule.rank !== 'number') continue;
|
|
1780
|
+
const prior = rankOwner.get(rule.rank);
|
|
1781
|
+
if (prior !== undefined) {
|
|
1782
|
+
ledgerErrs.push(
|
|
1783
|
+
`/ledger/tierMap/${tier}/rank: rank ${rule.rank} is already used by tier '${prior}' (tier ranks must be unique)`,
|
|
1784
|
+
);
|
|
1785
|
+
} else {
|
|
1786
|
+
rankOwner.set(rule.rank, tier);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1476
1789
|
if (ledgerErrs.length) return { ok: false, errors: ledgerErrs.slice(0, 10) };
|
|
1477
1790
|
}
|
|
1478
1791
|
// Cross-field rule: CMS lifecycle hooks. Each hook's expression is parsed and
|
|
@@ -1557,6 +1870,16 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1557
1870
|
if (urlErr) return { ok: false, errors: [`/providers/compat/openaiBaseUrl: ${urlErr}`] };
|
|
1558
1871
|
}
|
|
1559
1872
|
}
|
|
1873
|
+
// Cross-field rule: auth otp.testRecipients (F4-29) — every entry must be an
|
|
1874
|
+
// email, an `*@domain` glob, or a +E.164 number; anything else would never
|
|
1875
|
+
// match and silently do nothing.
|
|
1876
|
+
if (feature === 'auth') {
|
|
1877
|
+
const v = withDefaults as { otp?: { testRecipients?: string[] } };
|
|
1878
|
+
const bad = (v.otp?.testRecipients ?? []).filter((e) => !isTestRecipientPattern(e));
|
|
1879
|
+
if (bad.length) {
|
|
1880
|
+
return { ok: false, errors: bad.slice(0, 10).map((e) => `/otp/testRecipients: '${e}' is not an email, an *@domain glob, or a +E.164 number`) };
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1560
1883
|
// Cross-field rule: functions. Reject privilege-escalating scopes and malformed
|
|
1561
1884
|
// bindings at config-write time (the deploy pipeline additionally asserts the
|
|
1562
1885
|
// scriptRef sha is uploaded + clamps scopes via DENY_FUNCTION_SCOPES). Bindings
|
|
@@ -1585,10 +1908,10 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1585
1908
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
1586
1909
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
1587
1910
|
}
|
|
1588
|
-
// authHook:
|
|
1589
|
-
//
|
|
1590
|
-
if (b.kind === 'authHook' && b.event !== undefined && b.event
|
|
1591
|
-
errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be
|
|
1911
|
+
// authHook: a CLOSED event union (F4-30) — reject typos at write time
|
|
1912
|
+
// so a binding never silently subscribes to nothing.
|
|
1913
|
+
if (b.kind === 'authHook' && b.event !== undefined && !(AUTH_HOOK_EVENTS as readonly string[]).includes(b.event)) {
|
|
1914
|
+
errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be one of ${AUTH_HOOK_EVENTS.join(' | ')} (or omitted = user.created)`);
|
|
1592
1915
|
}
|
|
1593
1916
|
});
|
|
1594
1917
|
// Manifest-bloat guard: the signature is opaque DATA carried inside the
|
|
@@ -1599,7 +1922,7 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1599
1922
|
}
|
|
1600
1923
|
if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
|
|
1601
1924
|
}
|
|
1602
|
-
// Cross-field rules: copilot (docs/copilot-agents
|
|
1925
|
+
// Cross-field rules: copilot (https://vxil.com/docs/guide/11-copilot-agents). Pure
|
|
1603
1926
|
// rules only — cross-feature resolutions (collection existence, directiveRef
|
|
1604
1927
|
// → ai.templates) are deferred to runtime, which fails soft with the
|
|
1605
1928
|
// capability-surface envelope. Tool-NAME existence runs only when the mcp
|
|
@@ -1610,7 +1933,6 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1610
1933
|
actions?: { mode?: string; allow?: Record<string, unknown> };
|
|
1611
1934
|
guardrails?: { allowGuest?: boolean; guestToolAllow?: string[] };
|
|
1612
1935
|
}>;
|
|
1613
|
-
escalation?: { handler?: string; notifyTemplate?: string };
|
|
1614
1936
|
widget?: { requireAuth?: boolean };
|
|
1615
1937
|
};
|
|
1616
1938
|
const errs: string[] = [];
|
|
@@ -1645,16 +1967,13 @@ export function validateFeatureConfig(feature: string, raw: unknown): ConfigVali
|
|
|
1645
1967
|
if (v.widget?.requireAuth === false && !anyGuestAgent) {
|
|
1646
1968
|
errs.push('/widget/requireAuth: false requires at least one agent with guardrails.allowGuest: true');
|
|
1647
1969
|
}
|
|
1648
|
-
if (v.escalation?.handler === 'notifications' && !v.escalation.notifyTemplate) {
|
|
1649
|
-
errs.push("/escalation/notifyTemplate: required when handler is 'notifications'");
|
|
1650
|
-
}
|
|
1651
1970
|
if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
|
|
1652
1971
|
}
|
|
1653
1972
|
return { ok: true, errors: [], value: withDefaults };
|
|
1654
1973
|
}
|
|
1655
1974
|
|
|
1656
1975
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1657
|
-
// PLANNER GROUNDING (
|
|
1976
|
+
// PLANNER GROUNDING (https://vxil.com/docs/guide/10-agents-and-mcp).
|
|
1658
1977
|
// FEATURE_KEYS is the literal feature list the planner catalog is projected
|
|
1659
1978
|
// from; the planner-catalog CI gate asserts set-equality with
|
|
1660
1979
|
// Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
|