@vxil/feature-configs 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -18,6 +18,99 @@ export * from './readmodels.js';
18
18
  if (!FormatRegistry.Has('email')) {
19
19
  FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
20
20
  }
21
+ // ── RESERVED (vxil-COGS) CREDIT TYPES — single source of truth (audit F2) ─────
22
+ // `fn_cpu_ms` funds vxil's OWN function-compute cost-of-goods (the
23
+ // functions "recover-by-price" meter). It must NEVER be grantable or consumable
24
+ // by a tenant's own `payments:write` key, NOR mapped-in via ledger config — a
25
+ // tenant that self-grants `fn_cpu_ms` (directly, or by routing a product/tier
26
+ // through the webhook/subscription grant reducers) runs vxil-billed function CPU
27
+ // for free. Defined HERE (the typebox-only shared package both the control-plane
28
+ // config-write gate AND payments-v1 import) so the runtime choke point and the
29
+ // config-write refusal share ONE list. payments-v1/core.ts re-exports these.
30
+ export const RESERVED_CREDIT_TYPES = new Set(['fn_cpu_ms']);
31
+ /** True when a credit_type is a vxil-COGS reserved type (grant/consume/config
32
+ * of which is restricted to internal platform machinery). */
33
+ export function isReservedCreditType(creditType) {
34
+ return RESERVED_CREDIT_TYPES.has(creditType);
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
+ }
21
114
  export const NotificationsConfigSchema = Type.Object({
22
115
  enabled: Type.Boolean({ default: true }),
23
116
  fromEmail: Type.String({ format: 'email' }),
@@ -27,27 +120,83 @@ export const NotificationsConfigSchema = Type.Object({
27
120
  // need no email account, so the mock path is zero-config. A cross-field
28
121
  // check in validateFeatureConfig requires it only when provider === 'resend'.
29
122
  resendApiKeyRef: Type.Optional(Type.String()),
123
+ // Optional per-tenant Resend/Svix ENDPOINT secret ref (public.tenant_secrets,
124
+ // envelope-encrypted under KEK_NOTIFICATIONS — same store as resendApiKeyRef).
125
+ // When set, inbound Resend webhooks are verified with THIS tenant's secret
126
+ // instead of the platform-wide PROVIDER_WEBHOOK_SECRET, binding the signature
127
+ // to the tenant so a signed event for tenant A can never validate at tenant
128
+ // B's webhook URL (audit H6 — mirrors payments revenuecat.webhookSecretRef).
129
+ webhookSecretRef: Type.Optional(Type.String()),
30
130
  // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP.
31
131
  provider: Type.Union([Type.Literal('resend'), Type.Literal('mock')], {
32
132
  default: 'resend',
33
133
  }),
34
134
  defaultLocale: Type.String({ default: 'en-US' }),
35
135
  // nested objects carry `default: {}` so Value.Default can materialize them
36
- // and then recurse into the leaf defaults
37
- retry: Type.Object({
136
+ // and then recurse into the leaf defaults.
137
+ // `retry` became an OPTIONAL bag (2 leaves → 1, countLeaves counts an
138
+ // Optional object as ONE) to fund `broadcast` below (M21/#4, 2026-07-18).
139
+ // It KEEPS `default: {}`, which Value.Default still materializes — so every
140
+ // persisted manifest carries retry.{maxAttempts,backoff} exactly as before
141
+ // (zero behavioral delta); only the TS type is now optional (workers read
142
+ // via retryOf()'s fallback).
143
+ retry: Type.Optional(Type.Object({
38
144
  maxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }),
39
145
  backoff: Type.Union([Type.Literal('exponential'), Type.Literal('linear')], {
40
146
  default: 'exponential',
41
147
  }),
42
- }, { default: {} }),
148
+ }, { default: {} })),
43
149
  suppression: Type.Object({ softBounceThreshold: Type.Integer({ default: 3 }) }, { default: {} }),
44
150
  rateLimit: Type.Object({
45
151
  perDay: Type.Integer({ default: 100000 }),
46
152
  perTenantSec: Type.Integer({ default: 50 }),
47
153
  }, { default: {} }),
48
- templates: Type.Object({ allowOverride: Type.Boolean({ default: false }) }, { default: {} }),
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: {} })),
49
177
  /** in-app inbox channel (send with channel: 'inbox' | 'both') */
50
178
  inboxEnabled: Type.Boolean({ default: false }),
179
+ /** §11b.5 broadcast campaigns channel. Optional bag (= 1 leaf): absent means
180
+ * disabled; per-campaign quiet_hours / freq_cap overrides live on the
181
+ * notifications.campaigns ROW (tenant data), not here. Folded into the
182
+ * canonical schema 2026-07-18 (M21/#4 — Value.Clean previously STRIPPED the
183
+ * worker-local extension, so campaigns 403'd via the real config path). */
184
+ broadcast: Type.Optional(Type.Object({
185
+ enabled: Type.Boolean({ default: false }),
186
+ /** tenant-default per-recipient quiet window (defer-not-drop); a
187
+ * per-campaign quiet_hours wins over it. Mirrors the campaigns-row shape
188
+ * (workers/notifications-v1 CampaignBody.quiet_hours). start/end are
189
+ * pattern-pinned to what the worker's parseHhMm actually parses — a
190
+ * looser string ('10pm') would validate but FAIL OPEN at runtime
191
+ * (quiet window silently ignored). */
192
+ defaultQuietHours: Type.Optional(Type.Object({
193
+ tz: Type.String({ minLength: 1, maxLength: 64 }),
194
+ start: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
195
+ end: Type.String({ maxLength: 5, pattern: '^\\d{1,2}:\\d{2}$' }),
196
+ })),
197
+ /** default rolling per-user-per-day campaign-send cap across campaigns */
198
+ freqCapPerUserPerDay: Type.Integer({ default: 5, minimum: 0 }),
199
+ })),
51
200
  });
52
201
  export const JobsConfigSchema = Type.Object({
53
202
  enabled: Type.Boolean({ default: true }),
@@ -119,16 +268,62 @@ const AppleRefsSchema = Type.Object({
119
268
  // Value.Clean stripping the field out of PUT /v1/config/auth.
120
269
  bundleIds: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
121
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
+ });
122
308
  export const AuthConfigSchema = Type.Object({
123
309
  enabled: Type.Boolean({ default: true }),
124
- methods: Type.Object({
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({
125
320
  emailPassword: Type.Boolean({ default: true }),
126
321
  magicLink: Type.Boolean({ default: true }),
127
322
  google: Type.Boolean({ default: false }),
128
323
  github: Type.Boolean({ default: false }),
129
324
  apple: Type.Boolean({ default: false }),
130
325
  facebook: Type.Boolean({ default: false }),
131
- }, { default: {} }),
326
+ }, { default: {} })),
132
327
  // Per-provider BYO credential refs. ONE optional bag (= ONE leaf per the cap
133
328
  // rule) keyed by provider, so the four provider blocks (and any future one)
134
329
  // never inflate the flag count — the prior shape spent a leaf per top-level
@@ -146,6 +341,8 @@ export const AuthConfigSchema = Type.Object({
146
341
  github: Type.Optional(OAuthRefsSchema),
147
342
  apple: Type.Optional(AppleRefsSchema),
148
343
  facebook: Type.Optional(OAuthRefsSchema),
344
+ // RB-2: the generic OIDC / SSO issuer (presence = enabled; see above)
345
+ oidc: Type.Optional(OidcProviderSchema),
149
346
  })),
150
347
  // session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
151
348
  // the OTP/anonymous/orgClaims wave — the `{ default: {} }` keeps the inner
@@ -155,6 +352,12 @@ export const AuthConfigSchema = Type.Object({
155
352
  session: Type.Optional(Type.Object({
156
353
  ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
157
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 })),
158
361
  }, { default: {} })),
159
362
  password: Type.Optional(Type.Object({
160
363
  minLength: Type.Integer({ default: 8, minimum: 6, maximum: 128 }),
@@ -174,6 +377,14 @@ export const AuthConfigSchema = Type.Object({
174
377
  codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
175
378
  maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
176
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 })),
177
388
  })),
178
389
  // Anonymous (guest) sign-in (roadmap Tier-0). OPTIONAL bag = 1 leaf.
179
390
  anonymous: Type.Optional(Type.Object({
@@ -193,6 +404,33 @@ export const AuthConfigSchema = Type.Object({
193
404
  // its leaf was spent on the methods.{apple,facebook} toggles the shipped
194
405
  // §6.4 sign-in flow actually gates on. Re-adding it requires headroom or a
195
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
+ })),
196
434
  });
197
435
  export const RateLimitsConfigSchema = Type.Object({
198
436
  enabled: Type.Boolean({ default: true }),
@@ -203,12 +441,15 @@ export const RateLimitsConfigSchema = Type.Object({
203
441
  });
204
442
  export const FilesConfigSchema = Type.Object({
205
443
  enabled: Type.Boolean({ default: true }),
206
- bucketRef: Type.String({ default: 'vxil-files' }),
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).
207
448
  uploadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
208
449
  downloadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
209
450
  quotas: Type.Object({
210
451
  // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
211
- // R2 is cheap but Neon-resident metadata + abuse aren't (pricing re-audit
452
+ // object storage is cheap but the SQL-database-resident metadata + abuse aren't (pricing re-audit
212
453
  // 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
213
454
  // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
214
455
  // tenant tier threaded to files-v1 (docs/pricing-model-analysis.md §6).
@@ -217,10 +458,10 @@ export const FilesConfigSchema = Type.Object({
217
458
  maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
218
459
  }, { default: {} }),
219
460
  allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
220
- contentScan: Type.Object({
221
- enabled: Type.Boolean({ default: false }), // V1.5
222
- quarantineOnFail: Type.Boolean({ default: true }),
223
- }, { default: {} }),
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.
224
465
  sharedLinks: Type.Object({
225
466
  enabled: Type.Boolean({ default: true }),
226
467
  maxTtl: Type.Integer({ default: 7 * 24 * 3600 }),
@@ -338,7 +579,7 @@ export const CmsConfigSchema = Type.Object({
338
579
  enabled: Type.Optional(Type.Boolean()),
339
580
  }))),
340
581
  // Realtime CDC bridge (cms-rel E): cms writes auto-publish a change event to
341
- // a realtime channel — config-only rewiring of postgres_changes-style subs.
582
+ // a realtime channel — config-only rewiring of database-change-event-style subs.
342
583
  // Fire-and-forget via waitUntil; at-most-once (guaranteed delivery stays
343
584
  // webhooks-out / functions cms-hook). ONE Type.Record leaf.
344
585
  cdc: Type.Optional(Type.Record(Type.String({ maxLength: 64 }), Type.Object({
@@ -358,8 +599,10 @@ export const McpConfigSchema = Type.Object({
358
599
  enabled: Type.Boolean({ default: true }),
359
600
  exposureLevel: Type.Union([Type.Literal('all'), Type.Literal('read-only'), Type.Literal('custom')], { default: 'all' }),
360
601
  allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
361
- scopedKey: Type.Object({ perAgentKeys: Type.Boolean({ default: false }) }, // V1.5
362
- { default: {} }),
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.
363
606
  rateLimits: Type.Object({ toolCallsPerMin: Type.Integer({ default: 60, minimum: 1, maximum: 6000 }) }, { default: {} }),
364
607
  branding: Type.Object({
365
608
  serverName: Type.String({ default: 'Vxil' }),
@@ -450,9 +693,10 @@ export const ActivityFeedConfigSchema = Type.Object({
450
693
  copyLimit: Type.Integer({ default: 100, minimum: 0, maximum: 1000 }), // backfill budget on follow
451
694
  maxFollowing: Type.Integer({ default: 10_000, minimum: 0 }), // per-feed following cap
452
695
  }, { default: {} }),
453
- aggregation: Type.Object({
454
- maxGroupActivities: Type.Integer({ default: 15, minimum: 1, maximum: 100 }), // newest-N kept per group
455
- }, { default: {} }),
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.
456
700
  realtime: Type.Object({
457
701
  enabled: Type.Boolean({ default: true }), // live new-activity + count push over the realtime ChannelDO
458
702
  }, { default: {} }),
@@ -460,9 +704,11 @@ export const ActivityFeedConfigSchema = Type.Object({
460
704
  enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger (§10)
461
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)
462
706
  }, { default: {} }),
463
- rateLimit: Type.Object({
464
- addPerSec: Type.Integer({ default: 100, minimum: 1, maximum: 100_000 }), // per-tenant activity-write burst
465
- }, { default: {} }),
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.
466
712
  });
467
713
  // vector-search feature (features/vector-search.md §4). Re-declared here to match the
468
714
  // schema the worker EXPORTS from workers/vector-search-v1/src/config.ts — the
@@ -473,10 +719,10 @@ export const ActivityFeedConfigSchema = Type.Object({
473
719
  // synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
474
720
  export const VectorSearchConfigSchema = Type.Object({
475
721
  enabled: Type.Boolean({ default: true }),
476
- // 'auto' resolves to pgvector until Lakebase is GA'd for the tier (#147).
722
+ // 'auto' resolves to the default managed vector backend for the tier (#147).
477
723
  backend: Type.Union([Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')], { default: 'auto' }),
478
724
  // 'byov'/'mock' need NO provider key (zero-config default); openai/cohere read a
479
- // BYO key from public.tenant_secrets via apiKeyRef (envelope-encrypted).
725
+ // BYO key from tenant secrets via apiKeyRef (encrypted at rest).
480
726
  embed: Type.Object({
481
727
  provider: Type.Union([
482
728
  Type.Literal('byov'),
@@ -661,6 +907,13 @@ export const PaymentsConfigSchema = Type.Object({
661
907
  // of the platform PROVIDER_WEBHOOK_SECRET — closes the multi-tenant RC
662
908
  // webhook-forgery vector (mirrors stripe/paddle webhookSecretRef).
663
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 }),
664
917
  })),
665
918
  paypal: Type.Optional(Type.Object({
666
919
  clientIdRef: Type.String(),
@@ -673,8 +926,10 @@ export const PaymentsConfigSchema = Type.Object({
673
926
  // https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
674
927
  // hardcoded host (audit 2026-07-10: the old fallback pointed at a dead apex).
675
928
  returnUrl: Type.Optional(Type.String({ pattern: '^https://', maxLength: 512 })),
676
- prices: Type.Object({ catalogRef: Type.String({ default: 'default' }) }, // tenant-supplied price catalog ref
677
- { default: {} }),
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.
678
933
  defaults: Type.Object({
679
934
  currency: Type.String({ default: 'usd' }),
680
935
  trialDays: Type.Integer({ default: 0, minimum: 0, maximum: 365 }),
@@ -705,11 +960,34 @@ export const PaymentsConfigSchema = Type.Object({
705
960
  // fold (refoldEntitlements WHERE tier IS NOT NULL) reflects the subscription.
706
961
  priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
707
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
+ })),
708
983
  })),
709
- webhooks: Type.Object({ forwardToTenantUrl: Type.Optional(Type.String({ format: 'uri' })) }, { default: {} }),
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).
710
988
  });
711
989
  // functions feature (vxil-functions-design §4.c). Tenant-deployed backend edge
712
- // functions on Workers-for-Platforms. The FUNCTION owns its identity (bundle via
990
+ // functions on the managed serverless runtime. The FUNCTION owns its identity (bundle via
713
991
  // scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
714
992
  // bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
715
993
  // never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
@@ -718,12 +996,16 @@ export const PaymentsConfigSchema = Type.Object({
718
996
  // opt-in (enabled defaults to false), egress-guarded.
719
997
  export const FunctionsConfigSchema = Type.Object({
720
998
  enabled: Type.Boolean({ default: false }),
721
- // isolate = WfP Worker (Lane 2, default); container = CF Containers (Lane 3, design-only).
722
- runtime: Type.Union([Type.Literal('isolate'), Type.Literal('container')], { default: 'isolate' }),
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.
723
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.
724
1008
  cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 300_000 }),
725
- timeoutMs: Type.Integer({ default: 10_000, minimum: 50, maximum: 300_000 }),
726
- memoryMb: Type.Integer({ default: 128, minimum: 64, maximum: 1024 }),
727
1009
  }, { default: {} }),
728
1010
  // Reserve→settle CPU-ms metering into the payments credit ledger (the
729
1011
  // recover-by-price tier, live-prep "functions metering"). When enabled the
@@ -846,16 +1128,16 @@ export const CopilotConfigSchema = Type.Object({
846
1128
  guestToolAllow: Type.Optional(Type.Array(Type.String({ maxLength: 64 }), { maxItems: 16, default: [] })),
847
1129
  })),
848
1130
  }), { default: {} }),
849
- // ── escalation: human hand-off (composes notifications/comments) ──────────
850
- escalation: Type.Optional(Type.Object({
851
- enabled: Type.Boolean({ default: false }),
852
- handler: Type.Union([Type.Literal('comments'), Type.Literal('notifications')], { default: 'comments' }),
853
- notifyTemplate: Type.Optional(Type.String({ maxLength: 128 })),
854
- })),
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.
855
1135
  // ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
856
1136
  limits: Type.Object({
857
1137
  consumeCredits: Type.Boolean({ default: false }),
858
- tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = inherit ai
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.
859
1141
  }, { default: {} }),
860
1142
  // ── widget / embed (the Stage-3 public chat surface) ──────────────────────
861
1143
  widget: Type.Object({
@@ -891,6 +1173,36 @@ export const CONFIG_FLAG_CAP = 15;
891
1173
  * Per features/auth.md §4: an OPTIONAL object (e.g. a provider credential
892
1174
  * block) counts as ONE flag — the tenant's decision is "configure it or
893
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
+ }
894
1206
  export function countLeaves(schema) {
895
1207
  const t = schema;
896
1208
  if (t.type === 'object' && t.properties) {
@@ -1004,6 +1316,9 @@ export function validateFeatureConfig(feature, raw) {
1004
1316
  errors: ["/resendApiKeyRef: required when provider is 'resend' (omit it for provider 'mock')"],
1005
1317
  };
1006
1318
  }
1319
+ const overrideErrs = validateNotificationOverrides(v.templates);
1320
+ if (overrideErrs.length)
1321
+ return { ok: false, errors: overrideErrs.slice(0, 10) };
1007
1322
  }
1008
1323
  // Cross-field rule: a REAL payments provider needs its credential block; the
1009
1324
  // 'mock' provider (the deterministic default) stays zero-config so the whole
@@ -1020,6 +1335,51 @@ export function validateFeatureConfig(feature, raw) {
1020
1335
  errors: [`/${String(key)}: required when provider is '${v.provider}' (omit it for provider 'mock')`],
1021
1336
  };
1022
1337
  }
1338
+ // A reserved (vxil-COGS) credit_type must NEVER appear in a ledger grant map:
1339
+ // the webhook/subscription reducers would otherwise credit `fn_cpu_ms` to a
1340
+ // user, running vxil-billed functions for free (audit F2). Rejected at write
1341
+ // time so the tenant gets a clear `vxil push` error, not a silent runtime skip.
1342
+ const ledgerErrs = [];
1343
+ for (const [productId, rule] of Object.entries(v.ledger?.productMap ?? {})) {
1344
+ if (rule.creditType && isReservedCreditType(rule.creditType)) {
1345
+ ledgerErrs.push(`/ledger/productMap/${productId}/creditType: '${rule.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1346
+ }
1347
+ }
1348
+ for (const [tier, rule] of Object.entries(v.ledger?.tierMap ?? {})) {
1349
+ (rule.grants ?? []).forEach((g, i) => {
1350
+ if (g.creditType && isReservedCreditType(g.creditType)) {
1351
+ ledgerErrs.push(`/ledger/tierMap/${tier}/grants/${i}/creditType: '${g.creditType}' is a reserved vxil-COGS credit type and cannot be granted via config`);
1352
+ }
1353
+ });
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
+ }
1381
+ if (ledgerErrs.length)
1382
+ return { ok: false, errors: ledgerErrs.slice(0, 10) };
1023
1383
  }
1024
1384
  // Cross-field rule: CMS lifecycle hooks. Each hook's expression is parsed and
1025
1385
  // AST-validated against the closed sandbox allow-list HERE, at config-write
@@ -1102,6 +1462,16 @@ export function validateFeatureConfig(feature, raw) {
1102
1462
  return { ok: false, errors: [`/providers/compat/openaiBaseUrl: ${urlErr}`] };
1103
1463
  }
1104
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
+ }
1105
1475
  // Cross-field rule: functions. Reject privilege-escalating scopes and malformed
1106
1476
  // bindings at config-write time (the deploy pipeline additionally asserts the
1107
1477
  // scriptRef sha is uploaded + clamps scopes via DENY_FUNCTION_SCOPES). Bindings
@@ -1126,10 +1496,10 @@ export function validateFeatureConfig(feature, raw) {
1126
1496
  if (b.kind === 'cmsHook' && !b.collection) {
1127
1497
  errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
1128
1498
  }
1129
- // authHook: 'user.created' is the only event today — reject typos at
1130
- // write time so a binding never silently subscribes to nothing.
1131
- if (b.kind === 'authHook' && b.event !== undefined && b.event !== 'user.created') {
1132
- errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be 'user.created' (or omitted)`);
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)`);
1133
1503
  }
1134
1504
  });
1135
1505
  // Manifest-bloat guard: the signature is opaque DATA carried inside the
@@ -1181,11 +1551,138 @@ export function validateFeatureConfig(feature, raw) {
1181
1551
  if (v.widget?.requireAuth === false && !anyGuestAgent) {
1182
1552
  errs.push('/widget/requireAuth: false requires at least one agent with guardrails.allowGuest: true');
1183
1553
  }
1184
- if (v.escalation?.handler === 'notifications' && !v.escalation.notifyTemplate) {
1185
- errs.push("/escalation/notifyTemplate: required when handler is 'notifications'");
1186
- }
1187
1554
  if (errs.length)
1188
1555
  return { ok: false, errors: errs.slice(0, 10) };
1189
1556
  }
1190
1557
  return { ok: true, errors: [], value: withDefaults };
1191
1558
  }
1559
+ // ─────────────────────────────────────────────────────────────────────────────
1560
+ // PLANNER GROUNDING (roadmap §4.6.E; docs/ai-planner-design.md §2).
1561
+ // FEATURE_KEYS is the literal feature list the planner catalog is projected
1562
+ // from; the planner-catalog CI gate asserts set-equality with
1563
+ // Object.keys(FEATURE_SCHEMAS), so adding a feature without extending this
1564
+ // list (and FEATURE_HINTS below — a tsc error via the Record key-closure)
1565
+ // turns the gate red. Coverage is derived-and-gated; intent (whenToUse) is
1566
+ // human-authored but gated-for-presence.
1567
+ // ─────────────────────────────────────────────────────────────────────────────
1568
+ export const FEATURE_KEYS = [
1569
+ 'notifications', 'jobs', 'auth', 'rate-limits', 'files', 'webhooks',
1570
+ 'comments', 'cms', 'mcp', 'realtime', 'presence', 'orgs', 'activity-feed',
1571
+ 'vector-search', 'ai', 'rag', 'payments', 'functions', 'copilot',
1572
+ ];
1573
+ export const FEATURE_HINTS = {
1574
+ notifications: {
1575
+ whenToUse: 'Sending email (transactional or broadcast) or in-app inbox messages to end-users: welcomes, receipts, reminders, alerts, digests.',
1576
+ signals: ['email', 'notify', 'alert', 'reminder', 'welcome', 'receipt', 'digest', 'inbox'],
1577
+ notFor: 'Live in-page updates (realtime) or activity timelines (activity-feed).',
1578
+ dependsOn: [],
1579
+ },
1580
+ jobs: {
1581
+ whenToUse: 'Background work: scheduled/cron tasks, delayed sends, retries, queues, long-running or periodic processing, durable multi-step waits.',
1582
+ signals: ['schedule', 'cron', 'daily', 'background', 'queue', 'later', 'recurring', 'batch'],
1583
+ notFor: 'Simple request-response logic that completes inline.',
1584
+ dependsOn: [],
1585
+ },
1586
+ auth: {
1587
+ whenToUse: 'End-users sign in/up to the app: passwords, magic links, OTP, social login, sessions. Almost every app with per-user data wants it.',
1588
+ signals: ['sign in', 'login', 'account', 'register', 'user', 'password', 'profile', 'member', 'seller', 'buyer'],
1589
+ notFor: 'Pure-public read-only content with no user state.',
1590
+ dependsOn: [],
1591
+ },
1592
+ 'rate-limits': {
1593
+ whenToUse: 'Throttling abuse-prone or costly operations: public forms, expensive endpoints, per-user quotas.',
1594
+ signals: ['rate limit', 'throttle', 'abuse', 'quota', 'spam'],
1595
+ notFor: 'General correctness — the platform already meters requests globally.',
1596
+ dependsOn: [],
1597
+ },
1598
+ files: {
1599
+ whenToUse: 'End-users upload or download files/images/documents: avatars, attachments, photos, PDFs, media libraries.',
1600
+ signals: ['upload', 'image', 'photo', 'file', 'attachment', 'document', 'avatar', 'media'],
1601
+ notFor: 'Structured records (cms) or text content authored in-app.',
1602
+ dependsOn: [],
1603
+ },
1604
+ webhooks: {
1605
+ whenToUse: 'Receiving events FROM external services (Stripe/GitHub/etc. callbacks) or fanning tenant events out TO external URLs.',
1606
+ signals: ['webhook', 'callback', 'external event', 'integration', 'sync with'],
1607
+ notFor: 'In-app eventing between vxil features (the audit bus covers that).',
1608
+ dependsOn: ['jobs'],
1609
+ },
1610
+ comments: {
1611
+ whenToUse: 'Threaded discussion, reviews, replies, or reactions attached to any topic/record; also direct messages between users.',
1612
+ signals: ['comment', 'review', 'discussion', 'reply', 'thread', 'DM', 'message'],
1613
+ notFor: 'A social following timeline (activity-feed) or live chat transport (realtime).',
1614
+ dependsOn: [],
1615
+ },
1616
+ cms: {
1617
+ whenToUse: 'The data spine: typed record collections with fields, relations, owner-scoping, draft/publish, and optional keyless public reads. Most apps are, underneath, cms collections.',
1618
+ signals: ['store', 'list', 'catalog', 'posts', 'products', 'records', 'items', 'content', 'blog', 'tasks', 'listings'],
1619
+ notFor: 'End-user identity (auth) or file bytes (files).',
1620
+ dependsOn: [],
1621
+ },
1622
+ mcp: {
1623
+ whenToUse: 'Exposing the tenant backend as typed tools an AI agent drives (Claude/Cursor etc.).',
1624
+ signals: ['agent', 'MCP', 'AI tools', 'tool-calling'],
1625
+ notFor: 'In-app AI text generation (ai) or chat over documents (rag).',
1626
+ dependsOn: [],
1627
+ },
1628
+ realtime: {
1629
+ whenToUse: 'Live in-page updates over channels: chat rooms, live boards, collaborative views, instant refresh when data changes.',
1630
+ signals: ['live', 'realtime', 'chat', 'instantly', 'websocket', 'multiplayer'],
1631
+ notFor: 'Email/inbox notifications (notifications) or historical timelines (activity-feed).',
1632
+ dependsOn: [],
1633
+ },
1634
+ presence: {
1635
+ whenToUse: 'Showing who is online/typing/active on a channel right now.',
1636
+ signals: ['online', 'who is here', 'typing', 'active users', 'presence'],
1637
+ notFor: 'Message delivery itself (realtime carries the messages).',
1638
+ dependsOn: ['realtime'],
1639
+ },
1640
+ orgs: {
1641
+ whenToUse: 'End-users grouped into teams/workspaces with roles and per-resource permissions (multi-member accounts, RBAC).',
1642
+ signals: ['team', 'workspace', 'organization', 'role', 'invite member', 'permission'],
1643
+ notFor: 'Simple per-user ownership (cms ownerField covers that without orgs).',
1644
+ dependsOn: ['auth'],
1645
+ },
1646
+ 'activity-feed': {
1647
+ whenToUse: 'Social timelines: follow/unfollow, personal feeds, notification feeds of who-did-what.',
1648
+ signals: ['feed', 'timeline', 'follow', 'social', 'activity', 'news feed'],
1649
+ notFor: 'Live transport (realtime) or email (notifications).',
1650
+ dependsOn: [],
1651
+ },
1652
+ 'vector-search': {
1653
+ whenToUse: 'Semantic/similarity search over content: find-similar, meaning-based search boxes.',
1654
+ signals: ['search', 'semantic', 'similar', 'find by meaning'],
1655
+ notFor: 'Exact filters/sorts over records (the cms query DSL covers those).',
1656
+ dependsOn: [],
1657
+ },
1658
+ ai: {
1659
+ whenToUse: 'Calling LLMs from the app: generate/summarize/classify text, prompt templates, streaming completions (BYO provider key).',
1660
+ signals: ['generate', 'summarize', 'AI', 'GPT', 'classify', 'rewrite', 'draft'],
1661
+ notFor: 'Answers grounded in the tenant’s own documents (rag) or an embedded agent (copilot).',
1662
+ dependsOn: [],
1663
+ },
1664
+ rag: {
1665
+ whenToUse: 'Question-answering grounded in the tenant’s own content with citations: “ask your docs/notes/knowledge base”.',
1666
+ signals: ['ask questions', 'chatbot over', 'knowledge base', 'Q&A', 'answers from documents'],
1667
+ notFor: 'Free-form generation with no grounding (ai).',
1668
+ dependsOn: ['vector-search', 'ai', 'files'],
1669
+ },
1670
+ payments: {
1671
+ whenToUse: 'A payments integration: the tenant connects their OWN Stripe/Paddle/PayPal/RevenueCat account for checkout sessions, subscriptions, entitlements, and usage credits. Funds never touch vxil.',
1672
+ signals: ['pay', 'subscription', 'checkout', 'billing', 'sale', 'order', 'price', 'monetize', 'credits'],
1673
+ notFor: 'Anything implying vxil processes money — it is an integration with the tenant’s own provider.',
1674
+ dependsOn: [],
1675
+ },
1676
+ functions: {
1677
+ whenToUse: 'ONLY for truly-unique server logic no feature or cms rule can express: bespoke sagas, custom integrations over the egress guard, computed endpoints. Prefer features/cms first; functions are the escape hatch.',
1678
+ signals: ['custom logic', 'custom backend', 'integration with', 'workflow', 'saga'],
1679
+ notFor: 'Anything a shipped feature or a cms lifecycle hook already covers.',
1680
+ dependsOn: [],
1681
+ },
1682
+ copilot: {
1683
+ whenToUse: 'An embeddable in-app AI assistant that retrieves tenant content and proposes/confirms actions against the tenant’s own API.',
1684
+ signals: ['assistant', 'copilot', 'in-app AI helper', 'agent widget'],
1685
+ notFor: 'Plain text generation (ai) or doc Q&A without actions (rag).',
1686
+ dependsOn: ['ai', 'vector-search', 'rag', 'mcp'],
1687
+ },
1688
+ };