@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/dist/index.js
CHANGED
|
@@ -33,6 +33,84 @@ export const RESERVED_CREDIT_TYPES = new Set(['fn_cpu_ms']);
|
|
|
33
33
|
export function isReservedCreditType(creditType) {
|
|
34
34
|
return RESERVED_CREDIT_TYPES.has(creditType);
|
|
35
35
|
}
|
|
36
|
+
// ── notifications template catalog (D4 per-locale overrides) ─────────────────
|
|
37
|
+
// The SHIPPED template ids and the placeholders each body may interpolate. The
|
|
38
|
+
// renderer itself lives in workers/notifications-v1/src/templates.ts (templates
|
|
39
|
+
// are code, not rows); this table is the CONFIG-TIME half so a `vxil push` that
|
|
40
|
+
// overrides a template with a typo'd `{{placholder}}` is rejected at write time
|
|
41
|
+
// instead of silently rendering an empty string into a customer's email.
|
|
42
|
+
// Parity with the real renderer is asserted by the worker's templates.test.ts.
|
|
43
|
+
export const NOTIFICATION_TEMPLATE_PLACEHOLDERS = {
|
|
44
|
+
'magic-link': ['url', 'expires_minutes'],
|
|
45
|
+
'otp-code': ['code', 'expires_minutes'],
|
|
46
|
+
welcome: ['app_name', 'first_name', 'first_name_greeting'],
|
|
47
|
+
transactional: ['subject', 'paragraph', 'cta_label', 'cta_url'],
|
|
48
|
+
};
|
|
49
|
+
/** Per-override caps (D4). Bodies are emails, not documents. */
|
|
50
|
+
export const NOTIF_OVERRIDE_SUBJECT_MAX = 500;
|
|
51
|
+
export const NOTIF_OVERRIDE_BODY_MAX = 20_000;
|
|
52
|
+
/** Whole-map caps: keeps one manifest (and the KV row every send reads) small. */
|
|
53
|
+
export const NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE = 20;
|
|
54
|
+
export const NOTIF_OVERRIDE_MAX_BYTES = 64 * 1024;
|
|
55
|
+
/** `{{ placeholder }}` occurrences in one override body (the worker renderer's
|
|
56
|
+
* grammar verbatim — templates.ts `interpolate`). */
|
|
57
|
+
const NOTIF_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
58
|
+
/** Same permissive BCP-47-ish shape the worker's auto-locale resolver accepts. */
|
|
59
|
+
const NOTIF_LOCALE_RE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}$/;
|
|
60
|
+
/**
|
|
61
|
+
* D4 — validate `templates.overrides` at CONFIG-WRITE time. Returns [] when the
|
|
62
|
+
* bag is absent or clean. Every rule fails LOUD rather than shipping something
|
|
63
|
+
* that silently renders wrong in a customer's inbox:
|
|
64
|
+
* • overrides present with `allowOverride:false` → rejected (never inert);
|
|
65
|
+
* • an unknown template id / locale tag → rejected;
|
|
66
|
+
* • an unknown `{{placeholder}}` in subject/html/text → rejected (the class of
|
|
67
|
+
* typo that renders as an empty string in a live email);
|
|
68
|
+
* • size caps (per-locale count, whole-map bytes) → rejected.
|
|
69
|
+
* Pure, so it is unit-tested without a control plane.
|
|
70
|
+
*/
|
|
71
|
+
export function validateNotificationOverrides(templates) {
|
|
72
|
+
const overrides = templates?.overrides;
|
|
73
|
+
if (!overrides || Object.keys(overrides).length === 0)
|
|
74
|
+
return [];
|
|
75
|
+
const errs = [];
|
|
76
|
+
if (templates?.allowOverride !== true) {
|
|
77
|
+
errs.push('/templates/overrides: set templates.allowOverride: true to use per-locale template overrides (an override map with allowOverride:false would never be rendered)');
|
|
78
|
+
}
|
|
79
|
+
const bytes = new TextEncoder().encode(JSON.stringify(overrides)).byteLength;
|
|
80
|
+
if (bytes > NOTIF_OVERRIDE_MAX_BYTES) {
|
|
81
|
+
errs.push(`/templates/overrides: ${bytes} bytes exceeds the ${NOTIF_OVERRIDE_MAX_BYTES}-byte cap`);
|
|
82
|
+
}
|
|
83
|
+
for (const [templateId, byLocale] of Object.entries(overrides)) {
|
|
84
|
+
const allowed = NOTIFICATION_TEMPLATE_PLACEHOLDERS[templateId];
|
|
85
|
+
if (!allowed) {
|
|
86
|
+
errs.push(`/templates/overrides/${templateId}: unknown template id (one of: ${Object.keys(NOTIFICATION_TEMPLATE_PLACEHOLDERS).join(', ')})`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const locales = Object.keys(byLocale ?? {});
|
|
90
|
+
if (locales.length > NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE) {
|
|
91
|
+
errs.push(`/templates/overrides/${templateId}: ${locales.length} locales exceeds the ${NOTIF_OVERRIDE_MAX_LOCALES_PER_TEMPLATE}-per-template cap`);
|
|
92
|
+
}
|
|
93
|
+
for (const locale of locales) {
|
|
94
|
+
if (!NOTIF_LOCALE_RE.test(locale)) {
|
|
95
|
+
errs.push(`/templates/overrides/${templateId}/${locale}: not a BCP-47 locale tag (e.g. 'en-GB')`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const variant = byLocale[locale];
|
|
99
|
+
for (const part of ['subject', 'html', 'text']) {
|
|
100
|
+
const body = variant[part];
|
|
101
|
+
if (typeof body !== 'string')
|
|
102
|
+
continue;
|
|
103
|
+
for (const m of body.matchAll(NOTIF_PLACEHOLDER_RE)) {
|
|
104
|
+
const key = m[1];
|
|
105
|
+
if (!allowed.includes(key)) {
|
|
106
|
+
errs.push(`/templates/overrides/${templateId}/${locale}/${part}: unknown placeholder '{{${key}}}' (allowed: ${allowed.join(', ')})`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return errs;
|
|
113
|
+
}
|
|
36
114
|
export const NotificationsConfigSchema = Type.Object({
|
|
37
115
|
enabled: Type.Boolean({ default: true }),
|
|
38
116
|
fromEmail: Type.String({ format: 'email' }),
|
|
@@ -73,7 +151,29 @@ export const NotificationsConfigSchema = Type.Object({
|
|
|
73
151
|
perDay: Type.Integer({ default: 100000 }),
|
|
74
152
|
perTenantSec: Type.Integer({ default: 50 }),
|
|
75
153
|
}, { default: {} }),
|
|
76
|
-
templates
|
|
154
|
+
// `templates` became an OPTIONAL bag (1 leaf, the same M21 trick `retry`
|
|
155
|
+
// uses) to fund the D4 per-locale `overrides` map WITHOUT moving the count:
|
|
156
|
+
// countLeaves scores an Optional object as ONE. `default: {}` is KEPT, so
|
|
157
|
+
// Value.Default still materializes `templates.allowOverride` into every
|
|
158
|
+
// persisted manifest exactly as before — zero behavioral delta; only the TS
|
|
159
|
+
// type is optional (workers read via core.ts `templatesOf()`).
|
|
160
|
+
templates: Type.Optional(Type.Object({
|
|
161
|
+
allowOverride: Type.Boolean({ default: false }),
|
|
162
|
+
// D4 (2026-09-10) — TENANT-AUTHORED PER-LOCALE OVERRIDES of the SHIPPED
|
|
163
|
+
// template ids, as config DATA (a Record = 1 leaf, catalog size never
|
|
164
|
+
// moves the count). Shape: { [templateId]: { [locale]: { subject,
|
|
165
|
+
// html?, text? } } }. Same escaped `{{placeholder}}` grammar as the
|
|
166
|
+
// built-ins — the worker reuses ONE renderer, so escaping/URL-scheme
|
|
167
|
+
// sanitisation are identical and there is no raw-output syntax.
|
|
168
|
+
// validateFeatureConfig rejects: an unknown template id, an unknown
|
|
169
|
+
// placeholder, a malformed locale tag, an over-size body, and (fail
|
|
170
|
+
// LOUD, never silently inert) overrides present with allowOverride:false.
|
|
171
|
+
overrides: Type.Optional(Type.Record(Type.String(), Type.Record(Type.String(), Type.Object({
|
|
172
|
+
subject: Type.String({ minLength: 1, maxLength: NOTIF_OVERRIDE_SUBJECT_MAX }),
|
|
173
|
+
html: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
174
|
+
text: Type.Optional(Type.String({ maxLength: NOTIF_OVERRIDE_BODY_MAX })),
|
|
175
|
+
})))),
|
|
176
|
+
}, { default: {} })),
|
|
77
177
|
/** in-app inbox channel (send with channel: 'inbox' | 'both') */
|
|
78
178
|
inboxEnabled: Type.Boolean({ default: false }),
|
|
79
179
|
/** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
|
|
@@ -168,16 +268,62 @@ const AppleRefsSchema = Type.Object({
|
|
|
168
268
|
// Value.Clean stripping the field out of PUT /v1/config/auth.
|
|
169
269
|
bundleIds: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
|
|
170
270
|
});
|
|
271
|
+
// RB-2 — the generic OIDC / SSO bridge (roadmap §4.6.B, §1B.5; features/auth.md
|
|
272
|
+
// §6.7). ONE tenant-supplied issuer, declared INSIDE the existing `providers`
|
|
273
|
+
// bag so it spends NO leaf (an Optional object bag = ONE leaf; auth stays 11).
|
|
274
|
+
// Declarative + opt-in: the block's PRESENCE enables the `oidc` provider (no
|
|
275
|
+
// methods.* toggle — a `default:false` flag would re-materialize every published
|
|
276
|
+
// manifest, breaking the D3 byte-identical fold). The worker discovers the
|
|
277
|
+
// endpoints + JWKS from `{issuer}/.well-known/openid-configuration` and verifies
|
|
278
|
+
// iss (byte-equal) / aud (= clientId) / nonce / exp / iat strictly, fail-closed.
|
|
279
|
+
// `clientId` is NOT a secret (it rides every authorize URL); `clientSecretRef`
|
|
280
|
+
// is a tenant_secrets POINTER (feature 'auth'), never the value. A SAML IdP
|
|
281
|
+
// plugs in through a broker (Okta / Entra / Auth0 / WorkOS) that speaks OIDC —
|
|
282
|
+
// there is deliberately NO native SAML.
|
|
283
|
+
const OidcProviderSchema = Type.Object({
|
|
284
|
+
// https URL, no query/fragment (the OIDC issuer identifier). Compared
|
|
285
|
+
// BYTE-EQUAL to the discovery document's `issuer` and the id_token `iss`.
|
|
286
|
+
issuer: Type.String({ minLength: 12, maxLength: 512, pattern: '^https://[^\\s?#]+$' }),
|
|
287
|
+
clientId: Type.String({ minLength: 1, maxLength: 512 }),
|
|
288
|
+
clientSecretRef: Type.String({ minLength: 1, maxLength: 200 }),
|
|
289
|
+
// authorize-request scopes; `openid` is always added by the worker. Default
|
|
290
|
+
// (absent) = openid email profile.
|
|
291
|
+
scopes: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 64, pattern: '^[\\x21-\\x7e]+$' }), { maxItems: 16 })),
|
|
292
|
+
// claim NAMES to read (absent = the standard `email` / `name`; `roles` has
|
|
293
|
+
// no default — no claim named ⇒ no role claims are minted).
|
|
294
|
+
claims: Type.Optional(Type.Object({
|
|
295
|
+
email: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
296
|
+
name: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
297
|
+
roles: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
298
|
+
})),
|
|
299
|
+
// when non-empty, the asserted email's domain MUST be listed (fail-closed:
|
|
300
|
+
// no email ⇒ refused) → 403 oidc_domain_not_allowed; a listed domain also
|
|
301
|
+
// marks the email verified (the tenant declared the issuer authoritative).
|
|
302
|
+
allowedDomains: Type.Optional(Type.Array(Type.String({ minLength: 3, maxLength: 253, pattern: '^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$' }), { maxItems: 32 })),
|
|
303
|
+
// default true: an identity whose VERIFIED email matches an existing user is
|
|
304
|
+
// linked to it (the shipped §6.4 rule). false: link only by the stable
|
|
305
|
+
// (issuer, sub) anchor; a matching email that is not yet linked → 409.
|
|
306
|
+
autoLink: Type.Optional(Type.Boolean()),
|
|
307
|
+
});
|
|
171
308
|
export const AuthConfigSchema = Type.Object({
|
|
172
309
|
enabled: Type.Boolean({ default: true }),
|
|
173
|
-
|
|
310
|
+
// D3 (auth wave 2026-09-10): the six method toggles collapsed into ONE
|
|
311
|
+
// Optional bag (6 leaves → 1, countLeaves counts an Optional object as ONE) —
|
|
312
|
+
// the notifications `retry`/`broadcast` precedent. It KEEPS `default: {}`,
|
|
313
|
+
// which Value.Default still materializes, so every persisted manifest carries
|
|
314
|
+
// methods.{emailPassword,magicLink,google,github,apple,facebook} with the
|
|
315
|
+
// SAME keys and defaults as before — byte-identical folds for existing
|
|
316
|
+
// tenants (index.test.ts "D3 fold bytes"). Only the TS type is now optional;
|
|
317
|
+
// the worker's gate() normalizes an absent bag to the defaults so every
|
|
318
|
+
// reader (config.methods.<flag>) is unchanged.
|
|
319
|
+
methods: Type.Optional(Type.Object({
|
|
174
320
|
emailPassword: Type.Boolean({ default: true }),
|
|
175
321
|
magicLink: Type.Boolean({ default: true }),
|
|
176
322
|
google: Type.Boolean({ default: false }),
|
|
177
323
|
github: Type.Boolean({ default: false }),
|
|
178
324
|
apple: Type.Boolean({ default: false }),
|
|
179
325
|
facebook: Type.Boolean({ default: false }),
|
|
180
|
-
}, { default: {} }),
|
|
326
|
+
}, { default: {} })),
|
|
181
327
|
// Per-provider BYO credential refs. ONE optional bag (= ONE leaf per the cap
|
|
182
328
|
// rule) keyed by provider, so the four provider blocks (and any future one)
|
|
183
329
|
// never inflate the flag count — the prior shape spent a leaf per top-level
|
|
@@ -195,6 +341,8 @@ export const AuthConfigSchema = Type.Object({
|
|
|
195
341
|
github: Type.Optional(OAuthRefsSchema),
|
|
196
342
|
apple: Type.Optional(AppleRefsSchema),
|
|
197
343
|
facebook: Type.Optional(OAuthRefsSchema),
|
|
344
|
+
// RB-2: the generic OIDC / SSO issuer (presence = enabled; see above)
|
|
345
|
+
oidc: Type.Optional(OidcProviderSchema),
|
|
198
346
|
})),
|
|
199
347
|
// session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
|
|
200
348
|
// the OTP/anonymous/orgClaims wave — the `{ default: {} }` keeps the inner
|
|
@@ -204,6 +352,12 @@ export const AuthConfigSchema = Type.Object({
|
|
|
204
352
|
session: Type.Optional(Type.Object({
|
|
205
353
|
ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
|
|
206
354
|
refreshTtlDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
|
|
355
|
+
// A1 (auth wave 2026-09-10): concurrent-session cap per user with
|
|
356
|
+
// TAKE-OVER — a new sign-in revokes the OLDEST sessions past the cap
|
|
357
|
+
// (revoked_reason 'device_cap', edge cache written) and reports them as
|
|
358
|
+
// `took_over: [session_id…]`. Optional WITHOUT a default so existing
|
|
359
|
+
// manifests fold byte-identically; absent/0 = unlimited (today).
|
|
360
|
+
maxConcurrent: Type.Optional(Type.Integer({ minimum: 0, maximum: 100 })),
|
|
207
361
|
}, { default: {} })),
|
|
208
362
|
password: Type.Optional(Type.Object({
|
|
209
363
|
minLength: Type.Integer({ default: 8, minimum: 6, maximum: 128 }),
|
|
@@ -223,6 +377,14 @@ export const AuthConfigSchema = Type.Object({
|
|
|
223
377
|
codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
|
|
224
378
|
maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
|
|
225
379
|
resendCooldownSec: Type.Integer({ default: 60, minimum: 0, maximum: 600 }),
|
|
380
|
+
// F4-29 (auth wave 2026-09-10): test recipients — an OTP / step-up / email-
|
|
381
|
+
// claim request whose address matches an entry sends NO mail and returns
|
|
382
|
+
// the code as `test_code` (audit auth.otp.test_issued). Entries: an exact
|
|
383
|
+
// email, a `*@domain` glob, or a +E.164 number (accepted for the SMS
|
|
384
|
+
// channel; inert for email identifiers). ≤20; Optional (no default) so
|
|
385
|
+
// existing manifests fold byte-identically. Store-review / CI accounts
|
|
386
|
+
// only — never a real user's address.
|
|
387
|
+
testRecipients: Type.Optional(Type.Array(Type.String({ minLength: 3, maxLength: 320 }), { maxItems: 20 })),
|
|
226
388
|
})),
|
|
227
389
|
// Anonymous (guest) sign-in (roadmap Tier-0). OPTIONAL bag = 1 leaf.
|
|
228
390
|
anonymous: Type.Optional(Type.Object({
|
|
@@ -242,6 +404,33 @@ export const AuthConfigSchema = Type.Object({
|
|
|
242
404
|
// its leaf was spent on the methods.{apple,facebook} toggles the shipped
|
|
243
405
|
// §6.4 sign-in flow actually gates on. Re-adding it requires headroom or a
|
|
244
406
|
// collapse elsewhere.
|
|
407
|
+
//
|
|
408
|
+
// Account-security controls (auth wave 2026-09-10, P0-3; features/auth.md
|
|
409
|
+
// §4b). ONE optional bag = 1 leaf; NO `default: {}` so an absent bag stays
|
|
410
|
+
// absent (existing manifests fold byte-identically) and every control is
|
|
411
|
+
// opt-in:
|
|
412
|
+
// • lockout — present ⇒ per-(tenant, identifier) failure lockout on password
|
|
413
|
+
// sign-in + OTP verify (429 account_locked + Retry-After); a bounded
|
|
414
|
+
// counter row in auth.lockouts (migration 0088) survives restarts.
|
|
415
|
+
// • breachedPasswords — HIBP k-anonymity range check (first 5 SHA-1 hex
|
|
416
|
+
// chars leave the worker, never the password) at sign-up / reset-confirm;
|
|
417
|
+
// fail-OPEN on network error → 422 password_breached.
|
|
418
|
+
// • captchaSecretRef — a tenant_secrets ref (feature 'auth') holding the
|
|
419
|
+
// Turnstile secret; when set, sign-up / OTP request / magic-link request
|
|
420
|
+
// require `captcha_token` (403 captcha_failed otherwise).
|
|
421
|
+
// • allowedRedirectOrigins — when non-empty, EVERY caller-supplied
|
|
422
|
+
// redirect/return URL (magic link, password reset, email verification,
|
|
423
|
+
// OAuth start) must match one origin exactly → 422 redirect_not_allowed.
|
|
424
|
+
security: Type.Optional(Type.Object({
|
|
425
|
+
lockout: Type.Optional(Type.Object({
|
|
426
|
+
maxFailures: Type.Integer({ default: 10, minimum: 3, maximum: 100 }),
|
|
427
|
+
windowMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
|
|
428
|
+
lockMinutes: Type.Integer({ default: 15, minimum: 1, maximum: 1440 }),
|
|
429
|
+
})),
|
|
430
|
+
breachedPasswords: Type.Boolean({ default: false }),
|
|
431
|
+
captchaSecretRef: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
|
|
432
|
+
allowedRedirectOrigins: Type.Optional(Type.Array(Type.String({ minLength: 8, maxLength: 253, pattern: '^https://[^/?#\\s]+$' }), { maxItems: 32 })),
|
|
433
|
+
})),
|
|
245
434
|
});
|
|
246
435
|
export const RateLimitsConfigSchema = Type.Object({
|
|
247
436
|
enabled: Type.Boolean({ default: true }),
|
|
@@ -252,7 +441,10 @@ export const RateLimitsConfigSchema = Type.Object({
|
|
|
252
441
|
});
|
|
253
442
|
export const FilesConfigSchema = Type.Object({
|
|
254
443
|
enabled: Type.Boolean({ default: true }),
|
|
255
|
-
|
|
444
|
+
// (F8-54) `bucketRef` was DELETED: the object-storage bucket is a platform
|
|
445
|
+
// binding on files-v1, never tenant-selectable, and no code ever read the
|
|
446
|
+
// leaf — declaring it invited "point files at my own bucket", which vxil does
|
|
447
|
+
// not offer. A persisted manifest that still carries it folds (Value.Clean).
|
|
256
448
|
uploadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
|
|
257
449
|
downloadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
|
|
258
450
|
quotas: Type.Object({
|
|
@@ -260,16 +452,16 @@ export const FilesConfigSchema = Type.Object({
|
|
|
260
452
|
// object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
|
|
261
453
|
// 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
|
|
262
454
|
// (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
|
|
263
|
-
// tenant tier threaded to files-v1 (
|
|
455
|
+
// tenant tier threaded to files-v1 (plan tiers).
|
|
264
456
|
maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
|
|
265
457
|
maxTotalBytes: Type.Integer({ default: 10 * 1024 * 1024 * 1024, minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 }),
|
|
266
458
|
maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
|
|
267
459
|
}, { default: {} }),
|
|
268
460
|
allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
|
|
269
|
-
contentScan
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
461
|
+
// (F8-54) the `contentScan` bag ({enabled, quarantineOnFail}) was DELETED:
|
|
462
|
+
// there is no malware/content scanner in files-v1 (it was a "V1.5" placeholder
|
|
463
|
+
// neither leaf was ever read), so the knob promised quarantine that never
|
|
464
|
+
// happened. Re-declare it in the same change as a real scanner, not before.
|
|
273
465
|
sharedLinks: Type.Object({
|
|
274
466
|
enabled: Type.Boolean({ default: true }),
|
|
275
467
|
maxTtl: Type.Integer({ default: 7 * 24 * 3600 }),
|
|
@@ -344,7 +536,7 @@ export const CmsConfigSchema = Type.Object({
|
|
|
344
536
|
maxExpandFields: Type.Integer({ default: 5, minimum: 1, maximum: 25 }),
|
|
345
537
|
maxComputedFieldsPerCollection: Type.Integer({ default: 10, minimum: 1, maximum: 50 }),
|
|
346
538
|
}, { default: {} }),
|
|
347
|
-
// Code-based lifecycle hooks (docs/
|
|
539
|
+
// Code-based lifecycle hooks (https://vxil.com/docs/guide/07-validation-and-hooks):
|
|
348
540
|
// a tenant-authored SAFE expression. Write events (beforeCreate/beforeUpdate/
|
|
349
541
|
// beforeWrite) run in-transaction — `validate` rejects the write, `derive`
|
|
350
542
|
// computes a persisted field. Read events shape the RESPONSE only: `validate`
|
|
@@ -407,8 +599,10 @@ export const McpConfigSchema = Type.Object({
|
|
|
407
599
|
enabled: Type.Boolean({ default: true }),
|
|
408
600
|
exposureLevel: Type.Union([Type.Literal('all'), Type.Literal('read-only'), Type.Literal('custom')], { default: 'all' }),
|
|
409
601
|
allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
|
|
410
|
-
|
|
411
|
-
|
|
602
|
+
// (F8-54) the `scopedKey.perAgentKeys` leaf was DELETED: it was documented as
|
|
603
|
+
// a "dashboard-UI hint only" that no dashboard ever read, and key minting is a
|
|
604
|
+
// control-plane concern independent of MCP exposure — per-agent keys already
|
|
605
|
+
// work for every tenant, gated by nothing here.
|
|
412
606
|
rateLimits: Type.Object({ toolCallsPerMin: Type.Integer({ default: 60, minimum: 1, maximum: 6000 }) }, { default: {} }),
|
|
413
607
|
branding: Type.Object({
|
|
414
608
|
serverName: Type.String({ default: 'Vxil' }),
|
|
@@ -499,9 +693,10 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
499
693
|
copyLimit: Type.Integer({ default: 100, minimum: 0, maximum: 1000 }), // backfill budget on follow
|
|
500
694
|
maxFollowing: Type.Integer({ default: 10_000, minimum: 0 }), // per-feed following cap
|
|
501
695
|
}, { default: {} }),
|
|
502
|
-
aggregation:
|
|
503
|
-
|
|
504
|
-
|
|
696
|
+
// (F8-54) the `aggregation.maxGroupActivities` leaf was DELETED: nothing kept
|
|
697
|
+
// or returned a per-group activity LIST — an aggregated read returns the group
|
|
698
|
+
// rollup (activity_count/actor_count/last_actor), so there was never an N to
|
|
699
|
+
// bound and no code read the leaf. Re-declare it with a group-detail route.
|
|
505
700
|
realtime: Type.Object({
|
|
506
701
|
enabled: Type.Boolean({ default: true }), // live new-activity + count push over the realtime ChannelDO
|
|
507
702
|
}, { default: {} }),
|
|
@@ -509,9 +704,11 @@ export const ActivityFeedConfigSchema = Type.Object({
|
|
|
509
704
|
enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger (§10)
|
|
510
705
|
digestCadence: Type.Union([Type.Literal('off'), Type.Literal('hourly'), Type.Literal('daily')], { default: 'off' }), // digest roll-up window (feed owns the roll-up, rides notifications' live single-send)
|
|
511
706
|
}, { default: {} }),
|
|
512
|
-
rateLimit:
|
|
513
|
-
|
|
514
|
-
|
|
707
|
+
// (F8-54) the `rateLimit.addPerSec` leaf was DELETED: activity-feed-v1 has no
|
|
708
|
+
// rate-limiter binding and never read it, so the declared per-tenant write
|
|
709
|
+
// burst was enforced by nothing (the edge front-door limiter and the
|
|
710
|
+
// per-tenant request meter are the real bounds). Re-declare it together with
|
|
711
|
+
// the binding that enforces it.
|
|
515
712
|
});
|
|
516
713
|
// vector-search feature (features/vector-search.md §4). Re-declared here to match the
|
|
517
714
|
// schema the worker EXPORTS from workers/vector-search-v1/src/config.ts — the
|
|
@@ -710,6 +907,13 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
710
907
|
// of the platform PROVIDER_WEBHOOK_SECRET — closes the multi-tenant RC
|
|
711
908
|
// webhook-forgery vector (mirrors stripe/paddle webhookSecretRef).
|
|
712
909
|
webhookSecretRef: Type.Optional(Type.String()),
|
|
910
|
+
// Environment integrity (money-path wave F1-4/D5). RevenueCat posts SANDBOX
|
|
911
|
+
// and PRODUCTION events to the SAME webhook with the same auth header, so a
|
|
912
|
+
// sandbox purchase would otherwise fold into production entitlements. A
|
|
913
|
+
// sandbox event is persisted as outcome 'rejected_environment' (200, never
|
|
914
|
+
// folded) unless the tenant opts in here. Stripe/Paddle/PayPal separate
|
|
915
|
+
// environments by signing secret / API base, so only RC carries this knob.
|
|
916
|
+
acceptSandbox: Type.Boolean({ default: false }),
|
|
713
917
|
})),
|
|
714
918
|
paypal: Type.Optional(Type.Object({
|
|
715
919
|
clientIdRef: Type.String(),
|
|
@@ -722,8 +926,10 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
722
926
|
// https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
|
|
723
927
|
// hardcoded host (audit 2026-07-10: the old fallback pointed at a dead apex).
|
|
724
928
|
returnUrl: Type.Optional(Type.String({ pattern: '^https://', maxLength: 512 })),
|
|
725
|
-
|
|
726
|
-
|
|
929
|
+
// NB (F8-54, 2026-09-11): the former `prices.catalogRef` leaf was DELETED —
|
|
930
|
+
// it named nothing (prices resolve from ledger.priceMap; no code path ever
|
|
931
|
+
// read it). A persisted manifest that still carries `prices` folds:
|
|
932
|
+
// validateFeatureConfig's Value.Clean strips the stray key.
|
|
727
933
|
defaults: Type.Object({
|
|
728
934
|
currency: Type.String({ default: 'usd' }),
|
|
729
935
|
trialDays: Type.Integer({ default: 0, minimum: 0, maximum: 365 }),
|
|
@@ -754,8 +960,31 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
754
960
|
// fold (refoldEntitlements WHERE tier IS NOT NULL) reflects the subscription.
|
|
755
961
|
priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
756
962
|
autoRefundOnJobFailure: Type.Boolean({ default: true }), // consume(jobId) reverses on DLQ/timeout
|
|
963
|
+
// Grace window (money-path wave F1-7): a `past_due` subscription stays
|
|
964
|
+
// entitled for this many days AFTER its current_period_end (the dunning
|
|
965
|
+
// window the provider is retrying inside). 0 = today's behaviour (a past_due
|
|
966
|
+
// row is never entitled). ONE predicate in the fold — NOT a dunning ladder:
|
|
967
|
+
// no retries, no emails. Lives inside the ledger bag (no new top-level leaf).
|
|
968
|
+
grace: Type.Optional(Type.Object({
|
|
969
|
+
pastDueDays: Type.Integer({ default: 0, minimum: 0, maximum: 90 }),
|
|
970
|
+
})),
|
|
971
|
+
// Opt-in period-end enforcement (money-path operations wave, decision D2
|
|
972
|
+
// option a). ABSENT (the default) = today's behaviour: a subscription whose
|
|
973
|
+
// current_period_end passed with no provider event stays entitled forever
|
|
974
|
+
// (the provider is the only clock). PRESENT = the hourly reconcile sweep
|
|
975
|
+
// flips an `active`/`trialing` row whose current_period_end + slackHours
|
|
976
|
+
// has passed with no renewal to status 'lapsed' (+ refold, +
|
|
977
|
+
// payments.subscription.lapsed); a later provider renewal event revives it
|
|
978
|
+
// (the two-clocks revive rule). Never a manual row without `until`. Lives
|
|
979
|
+
// inside the ledger bag (no new top-level leaf).
|
|
980
|
+
enforcePeriodEnd: Type.Optional(Type.Object({
|
|
981
|
+
slackHours: Type.Integer({ default: 72, minimum: 0, maximum: 720 }),
|
|
982
|
+
})),
|
|
757
983
|
})),
|
|
758
|
-
|
|
984
|
+
// NB (money-path wave F3-19): the former `webhooks.forwardToTenantUrl` leaf
|
|
985
|
+
// was DELETED — it had zero readers (never forwarded anything). Outbound
|
|
986
|
+
// delivery of payments state changes rides the audit_event → webhooks-out
|
|
987
|
+
// spine: subscribe to the `payments.` event prefix (payments.md §7b).
|
|
759
988
|
});
|
|
760
989
|
// functions feature (vxil-functions-design §4.c). Tenant-deployed backend edge
|
|
761
990
|
// functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
|
|
@@ -767,12 +996,16 @@ export const PaymentsConfigSchema = Type.Object({
|
|
|
767
996
|
// opt-in (enabled defaults to false), egress-guarded.
|
|
768
997
|
export const FunctionsConfigSchema = Type.Object({
|
|
769
998
|
enabled: Type.Boolean({ default: false }),
|
|
770
|
-
//
|
|
771
|
-
|
|
999
|
+
// (F8-54) `runtime` ('isolate' | 'container') was DELETED: the container lane
|
|
1000
|
+
// is design-only, nothing read the leaf, and accepting 'container' silently
|
|
1001
|
+
// ran the isolate anyway. It comes back with the lane, not before.
|
|
772
1002
|
defaultLimits: Type.Object({
|
|
1003
|
+
// cpuMs is the ONLY per-dispatch limit the managed runtime accepts and the
|
|
1004
|
+
// only one anything reads (functions-v1 meter.ts + the dispatch cap).
|
|
1005
|
+
// (F8-54) `timeoutMs` and `memoryMb` were DELETED: memory is fixed by the
|
|
1006
|
+
// runtime and not tenant-selectable, and no wall-clock abort was ever
|
|
1007
|
+
// applied — a declared 10s default that nothing enforced.
|
|
773
1008
|
cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 300_000 }),
|
|
774
|
-
timeoutMs: Type.Integer({ default: 10_000, minimum: 50, maximum: 300_000 }),
|
|
775
|
-
memoryMb: Type.Integer({ default: 128, minimum: 64, maximum: 1024 }),
|
|
776
1009
|
}, { default: {} }),
|
|
777
1010
|
// Reserve→settle CPU-ms metering into the payments credit ledger (the
|
|
778
1011
|
// recover-by-price tier, live-prep "functions metering"). When enabled the
|
|
@@ -811,9 +1044,9 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
811
1044
|
collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
|
|
812
1045
|
event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
|
|
813
1046
|
}), { maxItems: 8 })),
|
|
814
|
-
scriptRef: Type.String(), // content-hashed
|
|
1047
|
+
scriptRef: Type.String(), // content-hashed hosted-script name: fn-<tenant>-<name>-<sha>
|
|
815
1048
|
scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
|
|
816
|
-
secrets: Type.Optional(Type.Array(Type.String())), //
|
|
1049
|
+
secrets: Type.Optional(Type.Array(Type.String())), // names of tenant secrets injected at invoke time
|
|
817
1050
|
egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
|
|
818
1051
|
limits: Type.Optional(Type.Object({
|
|
819
1052
|
cpuMs: Type.Integer(),
|
|
@@ -831,7 +1064,7 @@ export const FunctionsConfigSchema = Type.Object({
|
|
|
831
1064
|
})),
|
|
832
1065
|
}))),
|
|
833
1066
|
});
|
|
834
|
-
// copilot feature (docs/copilot-agents
|
|
1067
|
+
// copilot feature (https://vxil.com/docs/guide/11-copilot-agents). Re-declared to match
|
|
835
1068
|
// workers/copilot-v1/src/config.ts's exported CopilotConfigSchema (one shape,
|
|
836
1069
|
// two consumers — keep the two definitions byte-identical). A thin COMPOSITION
|
|
837
1070
|
// layer over ai + vector-search + rag + mcp: it owns the AGENT layer (persona,
|
|
@@ -895,16 +1128,16 @@ export const CopilotConfigSchema = Type.Object({
|
|
|
895
1128
|
guestToolAllow: Type.Optional(Type.Array(Type.String({ maxLength: 64 }), { maxItems: 16, default: [] })),
|
|
896
1129
|
})),
|
|
897
1130
|
}), { default: {} }),
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
notifyTemplate: Type.Optional(Type.String({ maxLength: 128 })),
|
|
903
|
-
})),
|
|
1131
|
+
// (F8-54) the `escalation` bag ({enabled, handler, notifyTemplate}) was
|
|
1132
|
+
// DELETED: the human hand-off it declared was never built — copilot-v1 read
|
|
1133
|
+
// none of the three leaves, so a tenant who turned it on got silence. The
|
|
1134
|
+
// shipped hand-off path is a tenant function on the conversation events.
|
|
904
1135
|
// ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
|
|
905
1136
|
limits: Type.Object({
|
|
906
1137
|
consumeCredits: Type.Boolean({ default: false }),
|
|
907
|
-
|
|
1138
|
+
// (F8-54) `tokensPerUserPerDay` was DELETED here: token accounting is
|
|
1139
|
+
// delegated to ai-v1 (this bag's own doctrine) and only `ai`'s
|
|
1140
|
+
// limits.tokensPerUserPerDay is enforced — the copilot twin read nothing.
|
|
908
1141
|
}, { default: {} }),
|
|
909
1142
|
// ── widget / embed (the Stage-3 public chat surface) ──────────────────────
|
|
910
1143
|
widget: Type.Object({
|
|
@@ -940,6 +1173,36 @@ export const CONFIG_FLAG_CAP = 15;
|
|
|
940
1173
|
* Per features/auth.md §4: an OPTIONAL object (e.g. a provider credential
|
|
941
1174
|
* block) counts as ONE flag — the tenant's decision is "configure it or
|
|
942
1175
|
* not", not each inner ref. */
|
|
1176
|
+
/** The auth lifecycle events an `authHook` binding may name (F4-30) — the
|
|
1177
|
+
* closed union `packages/config` types as AuthHookEvent; the control-plane
|
|
1178
|
+
* reconciler maps each to its `auth.<event>` audit-event prefix. */
|
|
1179
|
+
export const AUTH_HOOK_EVENTS = ['user.created', 'session.created', 'session.revoked', 'signin.failure'];
|
|
1180
|
+
/** F4-29 test-recipient entry grammar (shared by the validator and auth-v1's
|
|
1181
|
+
* matcher): an exact email, a `*@domain` glob, or a +E.164 phone number. */
|
|
1182
|
+
export const TEST_RECIPIENT_EMAIL_RE = /^[^\s@*]+@[^\s@]+\.[^\s@]+$/;
|
|
1183
|
+
export const TEST_RECIPIENT_GLOB_RE = /^\*@[^\s@*]+\.[^\s@*]+$/;
|
|
1184
|
+
export const TEST_RECIPIENT_E164_RE = /^\+[1-9]\d{1,14}$/;
|
|
1185
|
+
export function isTestRecipientPattern(entry) {
|
|
1186
|
+
return TEST_RECIPIENT_EMAIL_RE.test(entry) || TEST_RECIPIENT_GLOB_RE.test(entry) || TEST_RECIPIENT_E164_RE.test(entry);
|
|
1187
|
+
}
|
|
1188
|
+
/** True iff `identifier` (an email today) matches one configured entry:
|
|
1189
|
+
* exact (case-insensitive) or the `*@domain` glob. +E.164 entries never match
|
|
1190
|
+
* an email. */
|
|
1191
|
+
export function matchesTestRecipient(identifier, entries) {
|
|
1192
|
+
if (!entries || entries.length === 0)
|
|
1193
|
+
return false;
|
|
1194
|
+
const id = identifier.trim().toLowerCase();
|
|
1195
|
+
const at = id.lastIndexOf('@');
|
|
1196
|
+
const domain = at >= 0 ? id.slice(at + 1) : null;
|
|
1197
|
+
return entries.some((raw) => {
|
|
1198
|
+
const e = raw.trim().toLowerCase();
|
|
1199
|
+
if (TEST_RECIPIENT_GLOB_RE.test(e))
|
|
1200
|
+
return domain !== null && domain === e.slice(2);
|
|
1201
|
+
if (TEST_RECIPIENT_E164_RE.test(e))
|
|
1202
|
+
return id === e;
|
|
1203
|
+
return id === e;
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
943
1206
|
export function countLeaves(schema) {
|
|
944
1207
|
const t = schema;
|
|
945
1208
|
if (t.type === 'object' && t.properties) {
|
|
@@ -1053,6 +1316,9 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1053
1316
|
errors: ["/resendApiKeyRef: required when provider is 'resend' (omit it for provider 'mock')"],
|
|
1054
1317
|
};
|
|
1055
1318
|
}
|
|
1319
|
+
const overrideErrs = validateNotificationOverrides(v.templates);
|
|
1320
|
+
if (overrideErrs.length)
|
|
1321
|
+
return { ok: false, errors: overrideErrs.slice(0, 10) };
|
|
1056
1322
|
}
|
|
1057
1323
|
// Cross-field rule: a REAL payments provider needs its credential block; the
|
|
1058
1324
|
// 'mock' provider (the deterministic default) stays zero-config so the whole
|
|
@@ -1086,6 +1352,32 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1086
1352
|
}
|
|
1087
1353
|
});
|
|
1088
1354
|
}
|
|
1355
|
+
// Money-path wave F1-3 (D3): an UNMAPPED price silently revoked a paying
|
|
1356
|
+
// customer (priceMap miss → tier NULL → refold excluded the row). The
|
|
1357
|
+
// reducer now stamps such an event outcome 'error' (reprocessable), and
|
|
1358
|
+
// this lint catches the config half at `vxil push` time: every priceMap
|
|
1359
|
+
// value must be a tierMap key, and explicitly-declared tier ranks must be
|
|
1360
|
+
// unique (two tiers at the same rank make the multi-sub fold's winner
|
|
1361
|
+
// order-dependent). Omitted ranks (default 0) are NOT checked — rejecting
|
|
1362
|
+
// rank-less legacy configs on push would break existing tenants.
|
|
1363
|
+
const tierKeys = new Set(Object.keys(v.ledger?.tierMap ?? {}));
|
|
1364
|
+
for (const [priceRef, tier] of Object.entries(v.ledger?.priceMap ?? {})) {
|
|
1365
|
+
if (!tierKeys.has(tier)) {
|
|
1366
|
+
ledgerErrs.push(`/ledger/priceMap/${priceRef}: '${tier}' is not a tierMap key (a webhook for this price would fold to an unknown tier)`);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
const rankOwner = new Map();
|
|
1370
|
+
for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
|
|
1371
|
+
if (typeof rule.rank !== 'number')
|
|
1372
|
+
continue;
|
|
1373
|
+
const prior = rankOwner.get(rule.rank);
|
|
1374
|
+
if (prior !== undefined) {
|
|
1375
|
+
ledgerErrs.push(`/ledger/tierMap/${tier}/rank: rank ${rule.rank} is already used by tier '${prior}' (tier ranks must be unique)`);
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
rankOwner.set(rule.rank, tier);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1089
1381
|
if (ledgerErrs.length)
|
|
1090
1382
|
return { ok: false, errors: ledgerErrs.slice(0, 10) };
|
|
1091
1383
|
}
|
|
@@ -1170,6 +1462,16 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1170
1462
|
return { ok: false, errors: [`/providers/compat/openaiBaseUrl: ${urlErr}`] };
|
|
1171
1463
|
}
|
|
1172
1464
|
}
|
|
1465
|
+
// Cross-field rule: auth otp.testRecipients (F4-29) — every entry must be an
|
|
1466
|
+
// email, an `*@domain` glob, or a +E.164 number; anything else would never
|
|
1467
|
+
// match and silently do nothing.
|
|
1468
|
+
if (feature === 'auth') {
|
|
1469
|
+
const v = withDefaults;
|
|
1470
|
+
const bad = (v.otp?.testRecipients ?? []).filter((e) => !isTestRecipientPattern(e));
|
|
1471
|
+
if (bad.length) {
|
|
1472
|
+
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`) };
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1173
1475
|
// Cross-field rule: functions. Reject privilege-escalating scopes and malformed
|
|
1174
1476
|
// bindings at config-write time (the deploy pipeline additionally asserts the
|
|
1175
1477
|
// scriptRef sha is uploaded + clamps scopes via DENY_FUNCTION_SCOPES). Bindings
|
|
@@ -1194,10 +1496,10 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1194
1496
|
if (b.kind === 'cmsHook' && !b.collection) {
|
|
1195
1497
|
errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
|
|
1196
1498
|
}
|
|
1197
|
-
// authHook:
|
|
1198
|
-
//
|
|
1199
|
-
if (b.kind === 'authHook' && b.event !== undefined && b.event
|
|
1200
|
-
errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be
|
|
1499
|
+
// authHook: a CLOSED event union (F4-30) — reject typos at write time
|
|
1500
|
+
// so a binding never silently subscribes to nothing.
|
|
1501
|
+
if (b.kind === 'authHook' && b.event !== undefined && !AUTH_HOOK_EVENTS.includes(b.event)) {
|
|
1502
|
+
errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be one of ${AUTH_HOOK_EVENTS.join(' | ')} (or omitted = user.created)`);
|
|
1201
1503
|
}
|
|
1202
1504
|
});
|
|
1203
1505
|
// Manifest-bloat guard: the signature is opaque DATA carried inside the
|
|
@@ -1209,7 +1511,7 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1209
1511
|
if (errs.length)
|
|
1210
1512
|
return { ok: false, errors: errs.slice(0, 10) };
|
|
1211
1513
|
}
|
|
1212
|
-
// Cross-field rules: copilot (docs/copilot-agents
|
|
1514
|
+
// Cross-field rules: copilot (https://vxil.com/docs/guide/11-copilot-agents). Pure
|
|
1213
1515
|
// rules only — cross-feature resolutions (collection existence, directiveRef
|
|
1214
1516
|
// → ai.templates) are deferred to runtime, which fails soft with the
|
|
1215
1517
|
// capability-surface envelope. Tool-NAME existence runs only when the mcp
|
|
@@ -1249,16 +1551,13 @@ export function validateFeatureConfig(feature, raw) {
|
|
|
1249
1551
|
if (v.widget?.requireAuth === false && !anyGuestAgent) {
|
|
1250
1552
|
errs.push('/widget/requireAuth: false requires at least one agent with guardrails.allowGuest: true');
|
|
1251
1553
|
}
|
|
1252
|
-
if (v.escalation?.handler === 'notifications' && !v.escalation.notifyTemplate) {
|
|
1253
|
-
errs.push("/escalation/notifyTemplate: required when handler is 'notifications'");
|
|
1254
|
-
}
|
|
1255
1554
|
if (errs.length)
|
|
1256
1555
|
return { ok: false, errors: errs.slice(0, 10) };
|
|
1257
1556
|
}
|
|
1258
1557
|
return { ok: true, errors: [], value: withDefaults };
|
|
1259
1558
|
}
|
|
1260
1559
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1261
|
-
// PLANNER GROUNDING (
|
|
1560
|
+
// PLANNER GROUNDING (https://vxil.com/docs/guide/10-agents-and-mcp).
|
|
1262
1561
|
// FEATURE_KEYS is the literal feature list the planner catalog is projected
|
|
1263
1562
|
// from; the planner-catalog CI gate asserts set-equality with
|
|
1264
1563
|
// Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
|
package/dist/readmodels.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// cms-rel B5/E config-write gates (docs/
|
|
1
|
+
// cms-rel B5/E config-write gates (read models + transactions — https://vxil.com/docs/guide/04-data-with-cms)
|
|
2
2
|
// — the hooks.ts sibling: PURE structural validation of the `readModels` and
|
|
3
3
|
// `cdc` bags at config-write time (the anti-malice-gate pattern). The grammar
|
|
4
4
|
// (fn/rank allow-lists, arity caps, window bounds, dotted-term shape) is
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vxil/feature-configs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "The per-feature configuration schemas and validators behind vxil.config.ts (published for @vxil/cli and @vxil/config).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://vxil.com",
|
|
7
7
|
"repository": {
|