@vxil/feature-configs 0.1.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/src/index.ts ADDED
@@ -0,0 +1,1572 @@
1
+ // Per-feature TypeBox config schemas + the 15-leaf cap analyzer.
2
+ // Location note (journaled deviation): the docs place each schema inside its
3
+ // feature Worker; the control plane must validate writes against the same
4
+ // schema (architecture §7.1 stage 2), so schemas live in this shared package
5
+ // and feature Workers import from here — one definition, two consumers.
6
+ import { FormatRegistry, OptionalKind, Type, type Static, type TSchema } from '@sinclair/typebox';
7
+ import { Value } from '@sinclair/typebox/value';
8
+ import { validateHooksConfig, type HookDef } from './hooks.js';
9
+ import { validateCdcConfig, validateReadModelsConfig } from './readmodels.js';
10
+
11
+ // Re-export the CMS lifecycle-hook engine so feature workers (cms-v1, runtime
12
+ // eval) and the control plane (config-time validation) share one definition.
13
+ export * from './hooks.js';
14
+ // Re-export the cms-rel read-model/cdc config gates (the pure-mirror split:
15
+ // grammar validated here at config-write; field existence at runtime).
16
+ export * from './readmodels.js';
17
+
18
+ // TypeBox validates `format:` only for registered formats — register the ones
19
+ // our schemas use (pragmatic RFC-lite email check; providers do the real one).
20
+ if (!FormatRegistry.Has('email')) {
21
+ FormatRegistry.Set('email', (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v));
22
+ }
23
+
24
+ export const NotificationsConfigSchema = Type.Object({
25
+ enabled: Type.Boolean({ default: true }),
26
+ fromEmail: Type.String({ format: 'email' }),
27
+ fromName: Type.String({ default: 'Notifications' }),
28
+ replyTo: Type.Optional(Type.String({ format: 'email' })),
29
+ // Optional at the schema level: the `mock` provider (and inbox-only setups)
30
+ // need no email account, so the mock path is zero-config. A cross-field
31
+ // check in validateFeatureConfig requires it only when provider === 'resend'.
32
+ resendApiKeyRef: Type.Optional(Type.String()),
33
+ // 'mock' exists for staging/e2e (deterministic provider); 'resend' is MVP.
34
+ provider: Type.Union([Type.Literal('resend'), Type.Literal('mock')], {
35
+ default: 'resend',
36
+ }),
37
+ defaultLocale: Type.String({ default: 'en-US' }),
38
+ // nested objects carry `default: {}` so Value.Default can materialize them
39
+ // and then recurse into the leaf defaults
40
+ retry: Type.Object(
41
+ {
42
+ maxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }),
43
+ backoff: Type.Union([Type.Literal('exponential'), Type.Literal('linear')], {
44
+ default: 'exponential',
45
+ }),
46
+ },
47
+ { default: {} },
48
+ ),
49
+ suppression: Type.Object(
50
+ { softBounceThreshold: Type.Integer({ default: 3 }) },
51
+ { default: {} },
52
+ ),
53
+ rateLimit: Type.Object(
54
+ {
55
+ perDay: Type.Integer({ default: 100000 }),
56
+ perTenantSec: Type.Integer({ default: 50 }),
57
+ },
58
+ { default: {} },
59
+ ),
60
+ templates: Type.Object(
61
+ { allowOverride: Type.Boolean({ default: false }) },
62
+ { default: {} },
63
+ ),
64
+ /** in-app inbox channel (send with channel: 'inbox' | 'both') */
65
+ inboxEnabled: Type.Boolean({ default: false }),
66
+ });
67
+ // Leaves: enabled, fromEmail, fromName, replyTo, resendApiKeyRef, provider,
68
+ // defaultLocale, retry.maxAttempts, retry.backoff,
69
+ // suppression.softBounceThreshold, rateLimit.perDay, rateLimit.perTenantSec,
70
+ // templates.allowOverride, inboxEnabled → 14. Cap = 15.
71
+
72
+ export type NotificationsConfig = Static<typeof NotificationsConfigSchema>;
73
+
74
+ export const JobsConfigSchema = Type.Object({
75
+ enabled: Type.Boolean({ default: true }),
76
+ retry: Type.Object(
77
+ { defaultMaxAttempts: Type.Integer({ default: 5, minimum: 1, maximum: 20 }) },
78
+ { default: {} },
79
+ ),
80
+ retention: Type.Object(
81
+ {
82
+ successfulRunDays: Type.Integer({ default: 7, minimum: 1, maximum: 90 }),
83
+ failedRunDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
84
+ },
85
+ { default: {} },
86
+ ),
87
+ /** Per-tenant daily dead-letter quota (0 = unlimited). Once the tenant has
88
+ * dead-lettered this many runs today, a further failing run skips its
89
+ * remaining retry budget and dead-letters immediately (audited as
90
+ * `job.dead_letter_quota_exceeded`) — bounds the retry/DLQ churn one
91
+ * pathological target (e.g. an always-throwing function) can generate. */
92
+ dlqDailyQuota: Type.Integer({ default: 0, minimum: 0, maximum: 100_000 }),
93
+ schedules: Type.Object(
94
+ { maxPerTenant: Type.Integer({ default: 50, minimum: 1, maximum: 1000 }) },
95
+ { default: {} },
96
+ ),
97
+ concurrency: Type.Object(
98
+ { maxConcurrent: Type.Integer({ default: 10, minimum: 1, maximum: 100 }) },
99
+ { default: {} },
100
+ ),
101
+ // 2.F6 generation lifecycle knobs (jobs.md §11) — mirrors the worker-local
102
+ // GENERATION_DEFAULTS in workers/jobs-v1/src/generation.ts (its
103
+ // resolveGenerationConfig reads `loaded.generation` and clamps to these same
104
+ // bounds when a field is absent).
105
+ generation: Type.Object(
106
+ {
107
+ /** per-tenant in-flight generation cap (separate budget from queue jobs) */
108
+ maxConcurrent: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
109
+ /** default expiry/timeout when the descriptor omits one */
110
+ defaultTimeoutMs: Type.Integer({ default: 300_000, minimum: 1_000, maximum: 3_600_000 }),
111
+ /** hard ceiling a tenant-supplied timeout is clamped to */
112
+ maxTimeoutMs: Type.Integer({ default: 3_600_000, minimum: 1_000, maximum: 3_600_000 }),
113
+ /** poll-mode: how many poll cycles before giving up (→ terminal-fail) */
114
+ pollMaxAttempts: Type.Integer({ default: 60, minimum: 1, maximum: 1_000 }),
115
+ /** MANDATORY per-hold cap on a generation `reserve_credits.amount` (jobs.md
116
+ * §11.8). Every requested amount is CLAMPED to this (never rejected) — a
117
+ * conservative default so an untrusted deployed function that carries a
118
+ * reserve block can never hold more than a bounded amount per run without
119
+ * any tenant action. */
120
+ maxReserveCredits: Type.Integer({ default: 1_000, minimum: 1, maximum: 1_000_000 }),
121
+ /** MANDATORY per-tenant ceiling on the SUM of un-settled provisional
122
+ * reserve holds across all in-flight generation runs (jobs.md §11.8): a
123
+ * reserve whose amount would push the tenant's outstanding-holds total over
124
+ * this is rejected 429, so a runaway function cannot hold every user at
125
+ * once. Defaulted so no tenant action is required to be safe. */
126
+ maxOutstandingReserveCredits: Type.Integer({ default: 100_000, minimum: 1, maximum: 100_000_000 }),
127
+ },
128
+ { default: {} },
129
+ ),
130
+ });
131
+ // Leaves: 13 (6 + dlqDailyQuota + the 6 generation leaves). Cap = 15.
132
+
133
+ export type JobsConfig = Static<typeof JobsConfigSchema>;
134
+
135
+ // One social-provider's BYO credential block. The *Ref fields are POINTERS into
136
+ // public.tenant_secrets (envelope-encrypted) — never raw secrets (features/auth.md
137
+ // §6.5). google/github/facebook share the clientId/secret shape; apple is distinct
138
+ // (Sign in with Apple has no static secret — it mints an ES256 client_secret from
139
+ // the .p8, so it carries servicesId/teamId/keyId + a p8 keyRef, §6.4).
140
+ const OAuthRefsSchema = Type.Object({
141
+ // clientId/secret refs are OPTIONAL at the schema layer: a tenant may stage a
142
+ // partial block, and the worker enforces presence at use (→ 501 if missing),
143
+ // matching the worker's OAuthProviderRefs shape (core.ts). They stay POINTERS
144
+ // into tenant_secrets — never raw secrets (features/auth.md §6.5).
145
+ clientIdRef: Type.Optional(Type.String()),
146
+ clientSecretRef: Type.Optional(Type.String()),
147
+ // extra native-aud allow-list entries (iOS/web client ids that differ from the
148
+ // primary clientIdRef) — also tenant_secrets refs (features/auth.md §6.4).
149
+ audRefs: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
150
+ });
151
+ const AppleRefsSchema = Type.Object({
152
+ servicesId: Type.String(), // the OAuth client_id / native aud (NOT a secret)
153
+ teamId: Type.String(),
154
+ keyId: Type.String(),
155
+ p8KeyRef: Type.String(), // tenant_secrets ref → envelope-encrypted .p8 PEM
156
+ // extra native-aud allow-list entries: genuine iOS ASAuthorization id_tokens
157
+ // carry the app BUNDLE ID as aud, not the Services ID (auth.md §6.4). Plain
158
+ // config values — bundle ids are not secrets. The worker already honors them
159
+ // (oauthCore.ts nativeAudAllowList); declaring them here is what stops
160
+ // Value.Clean stripping the field out of PUT /v1/config/auth.
161
+ bundleIds: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
162
+ });
163
+
164
+ export const AuthConfigSchema = Type.Object({
165
+ enabled: Type.Boolean({ default: true }),
166
+ methods: Type.Object(
167
+ {
168
+ emailPassword: Type.Boolean({ default: true }),
169
+ magicLink: Type.Boolean({ default: true }),
170
+ google: Type.Boolean({ default: false }),
171
+ github: Type.Boolean({ default: false }),
172
+ apple: Type.Boolean({ default: false }),
173
+ facebook: Type.Boolean({ default: false }),
174
+ },
175
+ { default: {} },
176
+ ),
177
+ // Per-provider BYO credential refs. ONE optional bag (= ONE leaf per the cap
178
+ // rule) keyed by provider, so the four provider blocks (and any future one)
179
+ // never inflate the flag count — the prior shape spent a leaf per top-level
180
+ // google/github block. This bag REPLACES those two top-level blocks (−2, +1
181
+ // for the bag) and EXTENDS the accepted providers to apple + facebook (§6):
182
+ // • google/github/facebook → { clientIdRef, clientSecretRef, audRefs? }
183
+ // • apple → { servicesId, teamId, keyId, p8KeyRef }
184
+ // All *Ref fields are tenant_secrets POINTERS, never raw secrets (§6.5). The
185
+ // worker reads config.providers?.{google,github,apple,facebook} (oauthCore.ts).
186
+ // The §6.4 runtime sign-in flow for apple/facebook is SHIPPED in oauthCore.ts
187
+ // (id_token/access_token verification + ES256 Apple client_secret minting);
188
+ // methods.{apple,facebook} above are the enable toggles it gates on.
189
+ providers: Type.Optional(Type.Object({
190
+ google: Type.Optional(OAuthRefsSchema),
191
+ github: Type.Optional(OAuthRefsSchema),
192
+ apple: Type.Optional(AppleRefsSchema),
193
+ facebook: Type.Optional(OAuthRefsSchema),
194
+ })),
195
+ // session/password are OPTIONAL bags (= ONE leaf each per the cap rule) since
196
+ // the OTP/anonymous/orgClaims wave — the `{ default: {} }` keeps the inner
197
+ // defaults materializing on publish, so the worker still reads fully-populated
198
+ // manifests; its `config.session?.ttlMinutes ?? 60` fallbacks cover sparse
199
+ // hand-built manifests only.
200
+ session: Type.Optional(Type.Object(
201
+ {
202
+ ttlMinutes: Type.Integer({ default: 60, minimum: 5, maximum: 1440 }),
203
+ refreshTtlDays: Type.Integer({ default: 30, minimum: 1, maximum: 365 }),
204
+ },
205
+ { default: {} },
206
+ )),
207
+ password: Type.Optional(Type.Object(
208
+ {
209
+ minLength: Type.Integer({ default: 8, minimum: 6, maximum: 128 }),
210
+ requireMixed: Type.Boolean({ default: false }),
211
+ },
212
+ { default: {} },
213
+ )),
214
+ // Deviation (journaled): emailVerification.tokenTtlHours was DROPPED —
215
+ // consumer-less (grep-verified: only this schema + dist mentioned it; the
216
+ // verify-email token flow it would bound was never built). Same precedent as
217
+ // the removed `redirects` block below. Its leaf funds the OTP wave.
218
+ emailVerification: Type.Object(
219
+ { required: Type.Boolean({ default: false }) },
220
+ { default: {} },
221
+ ),
222
+ magicLink: Type.Object(
223
+ { tokenTtlMinutes: Type.Integer({ default: 15, minimum: 5, maximum: 60 }) },
224
+ { default: {} },
225
+ ),
226
+ // Email OTP sign-in (roadmap Tier-0) + the knobs step-up re-auth shares.
227
+ // OPTIONAL bag = 1 leaf; absent ⇒ disabled (the worker gates on
228
+ // otp?.enabled === true).
229
+ otp: Type.Optional(Type.Object({
230
+ enabled: Type.Boolean({ default: false }),
231
+ codeTtlMinutes: Type.Integer({ default: 10, minimum: 1, maximum: 60 }),
232
+ maxAttempts: Type.Integer({ default: 5, minimum: 3, maximum: 10 }),
233
+ resendCooldownSec: Type.Integer({ default: 60, minimum: 0, maximum: 600 }),
234
+ })),
235
+ // Anonymous (guest) sign-in (roadmap Tier-0). OPTIONAL bag = 1 leaf.
236
+ anonymous: Type.Optional(Type.Object({
237
+ enabled: Type.Boolean({ default: false }),
238
+ })),
239
+ // Org claims embedded in session JWTs at mint/refresh (roadmap Tier-1C):
240
+ // when enabled, auth-v1 fetches the user's active-org membership from orgs
241
+ // over the EDGE and embeds { org_id, role, perms[] } as the `org` claim.
242
+ // Fail-open: an orgs outage mints WITHOUT claims (sign-in never breaks).
243
+ // OPTIONAL bag = 1 leaf. Lives in AUTH config (not orgs) because auth-v1's
244
+ // CONFIG_KV is the auth namespace and cannot see orgs config.
245
+ orgClaims: Type.Optional(Type.Object({
246
+ enabled: Type.Boolean({ default: false }),
247
+ })),
248
+ // Deviation (journaled): the doc's §4 `redirects` block was DROPPED entirely —
249
+ // it had zero consumers (grep-verified: no worker reads config.redirects) and
250
+ // its leaf was spent on the methods.{apple,facebook} toggles the shipped
251
+ // §6.4 sign-in flow actually gates on. Re-adding it requires headroom or a
252
+ // collapse elsewhere.
253
+ });
254
+ // Leaves: enabled(1) + methods(6) + providers(1, optional bag holding the four
255
+ // google/github/apple/facebook credential blocks) + session(1, optional bag)
256
+ // + password(1, optional bag) + emailVerification(1) + magicLink(1) + otp(1,
257
+ // optional bag) + anonymous(1, optional bag) + orgClaims(1, optional bag) = 15.
258
+ // Cap = 15 — at the cap; the next flag must collapse something.
259
+ // providers is an optional bag → adding social providers (apple/facebook, §6) never
260
+ // grows the flag count; the prior 15-flag version spent a leaf per top-level
261
+ // google/github block.
262
+
263
+ export type AuthConfig = Static<typeof AuthConfigSchema>;
264
+
265
+ export const RateLimitsConfigSchema = Type.Object({
266
+ enabled: Type.Boolean({ default: true }),
267
+ defaults: Type.Object(
268
+ {
269
+ perTenantSec: Type.Integer({ default: 100, minimum: 1, maximum: 10000 }),
270
+ burstSize: Type.Integer({ default: 200, minimum: 1, maximum: 20000 }),
271
+ },
272
+ { default: {} },
273
+ ),
274
+ });
275
+ // Leaves: 3. Cap = 15. Deviation (journaled): the doc's `policies` array is
276
+ // managed via the dedicated REST surface (§1 contract) in the feature's own
277
+ // KV, not duplicated into the config artifact — one writer per datum. The
278
+ // per-identifier overrides live the same way (an `ovr:` KV sibling per policy,
279
+ // cap 50 = a code constant, not a config leaf).
280
+
281
+ export type RateLimitsConfig = Static<typeof RateLimitsConfigSchema>;
282
+
283
+ export const FilesConfigSchema = Type.Object({
284
+ enabled: Type.Boolean({ default: true }),
285
+ bucketRef: Type.String({ default: 'vxil-files' }),
286
+ uploadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
287
+ downloadUrlTtl: Type.Integer({ default: 900, minimum: 60, maximum: 86400 }),
288
+ quotas: Type.Object(
289
+ {
290
+ // maximum caps are a defense-in-depth ceiling on tenant-editable storage —
291
+ // R2 is cheap but Neon-resident metadata + abuse aren't (pricing re-audit
292
+ // 2026-07-10). 5 GiB/object, 1 TiB/tenant absolute; true PER-TIER clamps
293
+ // (Free/Dev 10 GB · Team 50 GB · Business 256 GB) are a follow-up needing the
294
+ // tenant tier threaded to files-v1 (docs/pricing-model-analysis.md §6).
295
+ maxObjectBytes: Type.Integer({ default: 100 * 1024 * 1024, minimum: 1, maximum: 5 * 1024 * 1024 * 1024 }),
296
+ maxTotalBytes: Type.Integer({ default: 10 * 1024 * 1024 * 1024, minimum: 1, maximum: 1024 * 1024 * 1024 * 1024 }),
297
+ maxObjectCount: Type.Integer({ default: 100000, minimum: 1, maximum: 100_000_000 }),
298
+ },
299
+ { default: {} },
300
+ ),
301
+ allowedContentTypes: Type.Array(Type.String(), { default: ['*'], maxItems: 100 }),
302
+ contentScan: Type.Object(
303
+ {
304
+ enabled: Type.Boolean({ default: false }), // V1.5
305
+ quarantineOnFail: Type.Boolean({ default: true }),
306
+ },
307
+ { default: {} },
308
+ ),
309
+ sharedLinks: Type.Object(
310
+ {
311
+ enabled: Type.Boolean({ default: true }),
312
+ maxTtl: Type.Integer({ default: 7 * 24 * 3600 }),
313
+ },
314
+ { default: {} },
315
+ ),
316
+ // Wave-2 extensions (features/files.md §1.1 OCR + §1.2 TTL) — the merge of
317
+ // workers/files-v1/src/ext.ts FilesExtensionsConfigSchema promised by its
318
+ // 'wiring phase' comment. Each is an OPTIONAL bag (= ONE leaf per the cap
319
+ // rule); files-v1 already reads both defensively (FilesConfigWithExt), so
320
+ // declaring them here is what stops Value.Clean silently stripping them
321
+ // out of PUT /v1/config/files.
322
+ ttl: Type.Optional(Type.Object({
323
+ enabled: Type.Boolean({ default: false }),
324
+ // per-bucket default; omitted = never expire by default
325
+ defaultExpiresInSeconds: Type.Optional(Type.Integer({ minimum: 60 })),
326
+ sweepCron: Type.String({ default: '0 * * * *' }), // jobs-v1 TTL sweep schedule
327
+ })),
328
+ extractText: Type.Optional(Type.Object({
329
+ enabled: Type.Boolean({ default: false }),
330
+ provider: Type.Union(
331
+ [Type.Literal('gcv'), Type.Literal('textract'), Type.Literal('azure-di'), Type.Literal('mock')],
332
+ { default: 'mock' },
333
+ ),
334
+ // provider key is BYO + envelope-encrypted in public.tenant_secrets — NOT a
335
+ // config flag. keyRef names the tenant_secrets row (like ai's keyRefs).
336
+ keyRef: Type.Optional(Type.String()),
337
+ asyncOverJobs: Type.Boolean({ default: true }), // large/multi-page → jobs
338
+ boundingBoxes: Type.Boolean({ default: false }),
339
+ })),
340
+ });
341
+ // Leaves: 12 + ttl(1, optional bag) + extractText(1, optional bag) = 14. Cap = 15.
342
+
343
+ export type FilesConfig = Static<typeof FilesConfigSchema>;
344
+
345
+ // webhooks-out (wishlist feature): outbound event fan-out over the jobs delivery
346
+ // engine. Subscriptions live in their own table (§1 contract — one writer
347
+ // per datum); config is just the capability gate + a cap.
348
+ export const WebhooksConfigSchema = Type.Object({
349
+ enabled: Type.Boolean({ default: true }),
350
+ maxSubscriptions: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
351
+ maxSources: Type.Integer({ default: 20, minimum: 1, maximum: 200 }),
352
+ });
353
+ // Leaves: 3. Cap = 15.
354
+
355
+ export type WebhooksConfig = Static<typeof WebhooksConfigSchema>;
356
+
357
+ // comments feature (wishlist): threaded discussion on tenant-defined topics.
358
+ export const CommentsConfigSchema = Type.Object({
359
+ enabled: Type.Boolean({ default: true }),
360
+ maxBodyLength: Type.Integer({ default: 10_000, minimum: 1, maximum: 65_536 }),
361
+ reactionsEnabled: Type.Boolean({ default: true }),
362
+ /** Author edits allowed this long after posting; 0 disables editing. */
363
+ editWindowMinutes: Type.Integer({ default: 15, minimum: 0, maximum: 10_080 }),
364
+ });
365
+ // Leaves: 4. Cap = 15.
366
+
367
+ export type CommentsConfig = Static<typeof CommentsConfigSchema>;
368
+
369
+ // content feature (cms-feature-analysis.md §4.2): flags govern LIMITS, never the
370
+ // content model — the model itself is data (cms.collections / cms.fields via
371
+ // the REST surface). versioning/localization/publicRead land later waves.
372
+ export const CmsConfigSchema = Type.Object({
373
+ enabled: Type.Boolean({ default: true }),
374
+ draftPublish: Type.Boolean({ default: true }),
375
+ // cms end-user default-deny fail-safe (path-to-100 §3.2, Feature B). When ON,
376
+ // a VERIFIED end-user key (owner-scope mode) is DENIED access to any
377
+ // collection that declares no owner_field — 404 on read, 403 on write —
378
+ // instead of the default tenant-wide-shared behavior. Server-caller mode is a
379
+ // byte-for-byte no-op. Default OFF preserves today's shared semantics
380
+ // (owner.int.test.ts's shared-collection invariant). A collection that DOES
381
+ // declare an ownerField is unaffected. ONE boolean leaf.
382
+ strictEndUserScope: Type.Boolean({ default: false }),
383
+ limits: Type.Object(
384
+ {
385
+ maxCollections: Type.Integer({ default: 25, minimum: 1, maximum: 200 }),
386
+ maxFieldsPerCollection: Type.Integer({ default: 50, minimum: 1, maximum: 200 }),
387
+ maxItemBytes: Type.Integer({ default: 256 * 1024, minimum: 1024, maximum: 1024 * 1024 }),
388
+ maxItemsPerCollection: Type.Integer({ default: 100_000, minimum: 1 }),
389
+ },
390
+ { default: {} },
391
+ ),
392
+ query: Type.Object(
393
+ {
394
+ maxPageSize: Type.Integer({ default: 100, minimum: 1, maximum: 500 }),
395
+ defaultPageSize: Type.Integer({ default: 25, minimum: 1, maximum: 500 }),
396
+ },
397
+ { default: {} },
398
+ ),
399
+ // cms ENRICHMENT (cms.md §6.4): the read-time relation budget. The worker
400
+ // clamps via resolveRelationsConfig (enrich.ts) with the SAME defaults +
401
+ // hard ceilings, so an out-of-range value can never widen the bound.
402
+ relations: Type.Object(
403
+ {
404
+ maxExpandDepth: Type.Integer({ default: 1, minimum: 1, maximum: 2 }),
405
+ maxExpandFields: Type.Integer({ default: 5, minimum: 1, maximum: 25 }),
406
+ maxComputedFieldsPerCollection: Type.Integer({ default: 10, minimum: 1, maximum: 50 }),
407
+ },
408
+ { default: {} },
409
+ ),
410
+ // Code-based lifecycle hooks (docs/cms-lifecycle-hooks-rung2-design.md, Lane A):
411
+ // a tenant-authored SAFE expression. Write events (beforeCreate/beforeUpdate/
412
+ // beforeWrite) run in-transaction — `validate` rejects the write, `derive`
413
+ // computes a persisted field. Read events shape the RESPONSE only: `validate`
414
+ // on beforeRead drops the row (visibility filter); `derive`/`redact` on
415
+ // afterRead set/delete a field on the output copy (never persisted). The
416
+ // expression language is a closed, sandboxed, deterministic interpreter (see
417
+ // hooks.ts); each hook is AST-validated + kind/event-matrix-checked at config
418
+ // time (validateFeatureConfig below). Type.Record bag → counts as ONE leaf.
419
+ hooks: Type.Optional(
420
+ Type.Record(
421
+ Type.String(),
422
+ Type.Object({
423
+ collection: Type.String(),
424
+ event: Type.Union([
425
+ Type.Literal('beforeCreate'),
426
+ Type.Literal('beforeUpdate'),
427
+ Type.Literal('beforeWrite'),
428
+ Type.Literal('beforeRead'),
429
+ Type.Literal('afterRead'),
430
+ ]),
431
+ kind: Type.Union([Type.Literal('validate'), Type.Literal('derive'), Type.Literal('redact')]),
432
+ expr: Type.String({ maxLength: 2000 }),
433
+ field: Type.Optional(Type.String({ maxLength: 64 })),
434
+ message: Type.Optional(Type.String({ maxLength: 200 })),
435
+ enabled: Type.Optional(Type.Boolean()),
436
+ }),
437
+ ),
438
+ ),
439
+ // Declarative relational read-models (cms-relational-depth §3 B2/B3/B5).
440
+ // Each is a NAMED, closed-grammar aggregate/rank spec, optionally
441
+ // materialized to a rollup collection on the EXISTING jobs cron (the
442
+ // fn-cron:* reconciler idiom → cms-rollup:* schedules). Grammar is validated
443
+ // at config-write (validateReadModelsConfig — the hooks anti-malice-gate
444
+ // pattern); collection/slot existence at runtime. ONE Type.Record leaf.
445
+ readModels: Type.Optional(Type.Record(
446
+ Type.String({ maxLength: 64 }),
447
+ Type.Object({
448
+ collection: Type.String({ maxLength: 64 }),
449
+ kind: Type.Union([Type.Literal('aggregate'), Type.Literal('rank')]),
450
+ // the §3.1/§4.1 body minus limit — Type.Unknown so Value.Clean keeps it
451
+ // (the functions `signature` idiom); shape checked by the cross-field rule.
452
+ spec: Type.Unknown(),
453
+ materialize: Type.Optional(Type.Object({
454
+ cron: Type.String({ maxLength: 100 }),
455
+ to: Type.String({ maxLength: 64 }),
456
+ })),
457
+ enabled: Type.Optional(Type.Boolean()),
458
+ }),
459
+ )),
460
+ // Realtime CDC bridge (cms-rel E): cms writes auto-publish a change event to
461
+ // a realtime channel — config-only rewiring of postgres_changes-style subs.
462
+ // Fire-and-forget via waitUntil; at-most-once (guaranteed delivery stays
463
+ // webhooks-out / functions cms-hook). ONE Type.Record leaf.
464
+ cdc: Type.Optional(Type.Record(
465
+ Type.String({ maxLength: 64 }),
466
+ Type.Object({
467
+ collection: Type.String({ maxLength: 64 }),
468
+ channel: Type.String({ maxLength: 128 }),
469
+ events: Type.Optional(Type.Array(Type.Union([
470
+ Type.Literal('created'), Type.Literal('updated'),
471
+ Type.Literal('deleted'), Type.Literal('published'),
472
+ ]), { maxItems: 4 })),
473
+ payload: Type.Optional(Type.Union([Type.Literal('ids'), Type.Literal('full')])),
474
+ enabled: Type.Optional(Type.Boolean()),
475
+ }),
476
+ )),
477
+ });
478
+ // Leaves: 15 — enabled, draftPublish, strictEndUserScope, limits(4), query(2),
479
+ // relations.{maxExpandDepth,maxExpandFields,maxComputedFieldsPerCollection}(3),
480
+ // hooks + readModels + cdc (Type.Record bags = ONE leaf each). Cap = 15 (AT the
481
+ // cap now — any future cms knob must ride inside an existing bag).
482
+
483
+ export type CmsConfig = Static<typeof CmsConfigSchema>;
484
+
485
+ // mcp feature (mcp.md §5): the aggregation surface's own knobs. Default-enabled —
486
+ // a tenant with no mcp config row still gets the aggregated tool list.
487
+ export const McpConfigSchema = Type.Object({
488
+ enabled: Type.Boolean({ default: true }),
489
+ exposureLevel: Type.Union(
490
+ [Type.Literal('all'), Type.Literal('read-only'), Type.Literal('custom')],
491
+ { default: 'all' },
492
+ ),
493
+ allowToolList: Type.Optional(Type.Array(Type.String(), { maxItems: 200 })),
494
+ scopedKey: Type.Object(
495
+ { perAgentKeys: Type.Boolean({ default: false }) }, // V1.5
496
+ { default: {} },
497
+ ),
498
+ rateLimits: Type.Object(
499
+ { toolCallsPerMin: Type.Integer({ default: 60, minimum: 1, maximum: 6000 }) },
500
+ { default: {} },
501
+ ),
502
+ branding: Type.Object(
503
+ {
504
+ serverName: Type.String({ default: 'Vxil' }),
505
+ serverInstructions: Type.Optional(Type.String({ maxLength: 4000 })),
506
+ },
507
+ { default: {} },
508
+ ),
509
+
510
+ // Tenant-authored CUSTOM tools (mcp.md §6.5): name → tenant-owned https
511
+ // endpoint. mcp-v1 lists each as `custom_<name>` and POSTs the tool
512
+ // arguments to `url`, HMAC-signed with the per-tenant key from
513
+ // GET /v1/mcp/signing-secret (X-Vxil-Mcp-Signature; the caller's vxil bearer
514
+ // is NEVER forwarded). ONE Type.Record leaf (the cms.hooks precedent).
515
+ // Cross-field rules (count/name/url/schema-size) live in
516
+ // validateFeatureConfig below.
517
+ customTools: Type.Optional(Type.Record(
518
+ Type.String({ maxLength: 64 }),
519
+ Type.Object({
520
+ description: Type.String({ maxLength: 500 }),
521
+ url: Type.String({ maxLength: 2000 }),
522
+ // free-form JSON-Schema (agent-facing) — Type.Unknown so Value.Clean
523
+ // does not hollow it out (the functions signature input/output idiom);
524
+ // shape (plain object) + size (≤8KB) are cross-field rules below.
525
+ inputSchema: Type.Optional(Type.Unknown()),
526
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1000, maximum: 20_000 })),
527
+ }),
528
+ )),
529
+
530
+ // Config-declared MCP PROMPTS (mcp.md §11 closure): name → template with
531
+ // {{placeholder}} interpolation. Served verbatim by mcp-v1 prompts/list +
532
+ // prompts/get. ONE Type.Record leaf; placeholder ↔ arguments consistency is
533
+ // a cross-field rule below.
534
+ prompts: Type.Optional(Type.Record(
535
+ Type.String({ maxLength: 64 }),
536
+ Type.Object({
537
+ description: Type.Optional(Type.String({ maxLength: 500 })),
538
+ template: Type.String({ maxLength: 4000 }),
539
+ arguments: Type.Optional(Type.Array(Type.Object({
540
+ name: Type.String({ maxLength: 64 }),
541
+ description: Type.Optional(Type.String({ maxLength: 200 })),
542
+ required: Type.Optional(Type.Boolean()),
543
+ }), { maxItems: 10 })),
544
+ }),
545
+ )),
546
+ });
547
+ // Leaves: 9 by countLeaves (enabled, exposureLevel, allowToolList,
548
+ // scopedKey.perAgentKeys, rateLimits.toolCallsPerMin, branding.serverName,
549
+ // branding.serverInstructions = 7, + customTools + prompts as ONE Type.Record
550
+ // leaf each). Cap = 15.
551
+
552
+ export type McpConfig = Static<typeof McpConfigSchema>;
553
+
554
+ // realtime feature (promoted wishlist): DO-backed pub/sub channels.
555
+ export const RealtimeConfigSchema = Type.Object({
556
+ enabled: Type.Boolean({ default: true }),
557
+ maxConnectionsPerChannel: Type.Integer({ default: 100, minimum: 1, maximum: 10_000 }),
558
+ });
559
+ // Leaves: 2. Cap = 15.
560
+
561
+ export type RealtimeConfig = Static<typeof RealtimeConfigSchema>;
562
+
563
+ // presence feature (promoted wishlist): join/leave/typing on top of realtime.
564
+ export const PresenceConfigSchema = Type.Object({
565
+ enabled: Type.Boolean({ default: true }),
566
+ });
567
+ // Leaves: 1. Cap = 15.
568
+
569
+ export type PresenceConfig = Static<typeof PresenceConfigSchema>;
570
+
571
+ // orgs + RBAC feature (promoted wishlist): workspaces, invitations, role checks.
572
+ export const OrgsConfigSchema = Type.Object({
573
+ enabled: Type.Boolean({ default: true }),
574
+ maxOrgsPerTenant: Type.Integer({ default: 100, minimum: 1, maximum: 10_000 }),
575
+ maxMembersPerOrg: Type.Integer({ default: 1000, minimum: 1, maximum: 100_000 }),
576
+ invitationTtlHours: Type.Integer({ default: 168, minimum: 1, maximum: 720 }),
577
+ });
578
+ // Leaves: 4. Cap = 15.
579
+
580
+ export type OrgsConfig = Static<typeof OrgsConfigSchema>;
581
+
582
+ // activity-feed feature (features/activity-feed.md §3): a GetStream-class activity-
583
+ // streams engine + a Knock/Novu-class in-app notification FEED. Flags govern
584
+ // the fan-out throttle, the follow/aggregation caps, and the cross-channel /
585
+ // realtime gates — NEVER the verb vocabulary or the personalized ranker (the
586
+ // tenant's moat). feedGroups is a Type.Record (name → group def) → ONE leaf
587
+ // (the bag), so adding feed groups / aggregation rules never grows the cap.
588
+ export const ActivityFeedConfigSchema = Type.Object({
589
+ enabled: Type.Boolean({ default: true }),
590
+
591
+ // FEED-GROUP definitions: name → group def. Each group declares its type +
592
+ // (for aggregated/notification) its aggregation rule + ranking. Counts as ONE
593
+ // leaf (the Record bag), per the cap rule.
594
+ feedGroups: Type.Record(
595
+ Type.String(),
596
+ Type.Object({
597
+ type: Type.Union([
598
+ Type.Literal('flat'),
599
+ Type.Literal('aggregated'),
600
+ Type.Literal('notification'),
601
+ ]),
602
+ aggregation: Type.Optional(Type.String()), // group-format rule (§7); required for aggregated/notification
603
+ ranking: Type.Optional(Type.String()), // 'chronological' | 'decay' (§8); flat-only; default chronological
604
+ }),
605
+ {
606
+ default: {
607
+ user: { type: 'flat' },
608
+ timeline: { type: 'flat', ranking: 'chronological' },
609
+ notification: {
610
+ type: 'notification',
611
+ aggregation: '{{ verb }}:{{ object }}:{{ time|date }}',
612
+ },
613
+ },
614
+ },
615
+ ),
616
+
617
+ fanout: Type.Object(
618
+ {
619
+ celebrityThreshold: Type.Integer({ default: 10_000, minimum: 0 }), // ≥ → pull (read-side); < → push (write-side)
620
+ maxFanoutPerJob: Type.Integer({ default: 1000, minimum: 1, maximum: 10_000 }), // follower batch size per jobs task
621
+ maxConcurrentTasks: Type.Integer({ default: 20, minimum: 1, maximum: 1000 }), // per-tenant in-flight cap (LOCAL throttle §5)
622
+ pendingCeiling: Type.Integer({ default: 50_000, minimum: 1 }), // pending-fan-out-depth back-pressure ceiling
623
+ },
624
+ { default: {} },
625
+ ),
626
+
627
+ follow: Type.Object(
628
+ {
629
+ copyLimit: Type.Integer({ default: 100, minimum: 0, maximum: 1000 }), // backfill budget on follow
630
+ maxFollowing: Type.Integer({ default: 10_000, minimum: 0 }), // per-feed following cap
631
+ },
632
+ { default: {} },
633
+ ),
634
+
635
+ aggregation: Type.Object(
636
+ {
637
+ maxGroupActivities: Type.Integer({ default: 15, minimum: 1, maximum: 100 }), // newest-N kept per group
638
+ },
639
+ { default: {} },
640
+ ),
641
+
642
+ realtime: Type.Object(
643
+ {
644
+ enabled: Type.Boolean({ default: true }), // live new-activity + count push over the realtime ChannelDO
645
+ },
646
+ { default: {} },
647
+ ),
648
+
649
+ crossChannel: Type.Object(
650
+ {
651
+ enabled: Type.Boolean({ default: false }), // master gate for the notifications push/email trigger (§10)
652
+ digestCadence: Type.Union(
653
+ [Type.Literal('off'), Type.Literal('hourly'), Type.Literal('daily')],
654
+ { default: 'off' },
655
+ ), // digest roll-up window (feed owns the roll-up, rides notifications' live single-send)
656
+ },
657
+ { default: {} },
658
+ ),
659
+
660
+ rateLimit: Type.Object(
661
+ {
662
+ addPerSec: Type.Integer({ default: 100, minimum: 1, maximum: 100_000 }), // per-tenant activity-write burst
663
+ },
664
+ { default: {} },
665
+ ),
666
+ });
667
+ // Leaves: enabled(1), feedGroups(1, the Record bag → ONE leaf), fanout.{celebrityThreshold,
668
+ // maxFanoutPerJob, maxConcurrentTasks, pendingCeiling}(+4=6), follow.{copyLimit,
669
+ // maxFollowing}(+2=8), aggregation.maxGroupActivities(9), realtime.enabled(10),
670
+ // crossChannel.{enabled, digestCadence}(+2=12), rateLimit.addPerSec(13).
671
+ // countLeaves → 13. Cap = 15. (feedGroups is Type.Record → patternProperties, ONE leaf.)
672
+
673
+ export type ActivityFeedConfig = Static<typeof ActivityFeedConfigSchema>;
674
+
675
+ // vector-search feature (features/vector-search.md §4). Re-declared here to match the
676
+ // schema the worker EXPORTS from workers/vector-search-v1/src/config.ts — the
677
+ // control plane validates writes against this shared copy (the same
678
+ // one-definition / two-consumers note as the other features above; this package owns
679
+ // the registry, the worker owns its in-process schema). Flags govern the BACKEND,
680
+ // the embedder, chunking, the hybrid-fusion knobs, and limits — NEVER prompts /
681
+ // synthesis / relevance tuning (the tenant's moat). An OPTIONAL leaf = ONE flag.
682
+ export const VectorSearchConfigSchema = Type.Object({
683
+ enabled: Type.Boolean({ default: true }),
684
+ // 'auto' resolves to pgvector until Lakebase is GA'd for the tier (#147).
685
+ backend: Type.Union(
686
+ [Type.Literal('auto'), Type.Literal('lakebase'), Type.Literal('pgvector')],
687
+ { default: 'auto' },
688
+ ),
689
+ // 'byov'/'mock' need NO provider key (zero-config default); openai/cohere read a
690
+ // BYO key from public.tenant_secrets via apiKeyRef (envelope-encrypted).
691
+ embed: Type.Object(
692
+ {
693
+ provider: Type.Union(
694
+ [
695
+ Type.Literal('byov'),
696
+ Type.Literal('mock'),
697
+ Type.Literal('openai'),
698
+ Type.Literal('cohere'),
699
+ ],
700
+ { default: 'mock' },
701
+ ),
702
+ model: Type.Optional(Type.String()), // e.g. 'text-embedding-3-small'
703
+ apiKeyRef: Type.Optional(Type.String()), // 'secret:<name>'; omit for byov/mock
704
+ dimensions: Type.Integer({ default: 1536, minimum: 2, maximum: 4096 }),
705
+ },
706
+ { default: {} },
707
+ ),
708
+ chunking: Type.Object(
709
+ {
710
+ maxTokens: Type.Integer({ default: 512, minimum: 16, maximum: 4096 }),
711
+ overlap: Type.Integer({ default: 64, minimum: 0, maximum: 1024 }),
712
+ },
713
+ { default: {} },
714
+ ),
715
+ hybrid: Type.Object(
716
+ {
717
+ defaultMode: Type.Union(
718
+ [Type.Literal('hybrid'), Type.Literal('vector'), Type.Literal('keyword')],
719
+ { default: 'hybrid' },
720
+ ),
721
+ rrfK: Type.Integer({ default: 60, minimum: 1, maximum: 1000 }),
722
+ },
723
+ { default: {} },
724
+ ),
725
+ limits: Type.Object(
726
+ {
727
+ maxDocsPerCollection: Type.Integer({ default: 100_000, minimum: 1 }),
728
+ maxChunksPerDoc: Type.Integer({ default: 1000, minimum: 1, maximum: 10_000 }),
729
+ },
730
+ { default: {} },
731
+ ),
732
+ // BYO rerank pass over the RRF-fused candidates before the top-k slice.
733
+ // Config-declared provider (key never per-request); a request opts OUT with
734
+ // rerank:false. Fail-OPEN on provider errors (reranked:false). ONE leaf.
735
+ rerank: Type.Optional(
736
+ Type.Object({
737
+ provider: Type.Union(
738
+ [Type.Literal('mock'), Type.Literal('cohere'), Type.Literal('voyage')],
739
+ ),
740
+ model: Type.Optional(Type.String({ maxLength: 128 })), // cohere 'rerank-v3.5' / voyage 'rerank-2'
741
+ topN: Type.Optional(Type.Integer({ default: 50, minimum: 1, maximum: 200 })),
742
+ apiKeyRef: Type.Optional(Type.String({ maxLength: 128 })), // 'secret:<name>' under KEK_VECTOR_SEARCH
743
+ }),
744
+ ),
745
+ // Config-driven auto-embedding sync from cms collections: the control plane
746
+ // reconciles one `vs-sync:` jobs schedule per entry on every config commit;
747
+ // the cron ticks the worker's internal receiver (backfill keyset →
748
+ // updated_at watermark over the public cms REST). ARRAY = ONE leaf.
749
+ sync: Type.Optional(
750
+ Type.Array(
751
+ Type.Object({
752
+ source: Type.Literal('cms'),
753
+ cmsCollection: Type.String({ minLength: 1, maxLength: 128 }),
754
+ collection: Type.String({ minLength: 1, maxLength: 128 }),
755
+ fields: Type.Array(Type.String(), { minItems: 1, maxItems: 16 }),
756
+ template: Type.Optional(Type.String({ maxLength: 4000 })),
757
+ metadataFields: Type.Optional(Type.Array(Type.String(), { maxItems: 16 })),
758
+ statusFilter: Type.Optional(
759
+ Type.Union([Type.Literal('published'), Type.Literal('all')]),
760
+ ),
761
+ cron: Type.Optional(Type.String({ pattern: '^\\S+ \\S+ \\S+ \\S+ \\S+$' })),
762
+ }),
763
+ { maxItems: 8 },
764
+ ),
765
+ ),
766
+ });
767
+ // Leaves: enabled(1), backend(2), embed.{provider,model,apiKeyRef,dimensions}(+4=6),
768
+ // chunking.{maxTokens,overlap}(+2=8), hybrid.{defaultMode,rrfK}(+2=10),
769
+ // limits.{maxDocsPerCollection,maxChunksPerDoc}(+2=12), rerank (optional
770
+ // object, +1=13), sync (array, +1=14). countLeaves → 14. Cap = 15 — one leaf
771
+ // of headroom; fold limits.* into one optional object (−1) if more is needed.
772
+
773
+ export type VectorSearchConfig = Static<typeof VectorSearchConfigSchema>;
774
+
775
+ // ai feature (features/ai.md §4). Re-declared to match workers/ai-v1/src/core.ts's
776
+ // exported AiConfigSchema. vxil owns the call SCAFFOLDING (routing, streaming,
777
+ // token accounting, caching, the reserve→settle budget); the tenant owns the
778
+ // intelligence (prompt TEMPLATES are config-as-code, stored/rendered but never
779
+ // authored). 'mock' is the deterministic default until a BYO key is provisioned;
780
+ // the real providers route via tenant_secrets keyRefs.
781
+ export const AiConfigSchema = Type.Object({
782
+ enabled: Type.Boolean({ default: true }),
783
+ // 'azure' uses the OpenAI adapter shape (azure-openai compatible; pair it with
784
+ // providers.compat.openaiBaseUrl). 'openrouter' is the openai-compatible
785
+ // meta-provider (BYO key under providers.compat.openrouterKeyRef).
786
+ defaultProvider: Type.Union(
787
+ [Type.Literal('mock'), Type.Literal('openai'), Type.Literal('anthropic'),
788
+ Type.Literal('gemini'), Type.Literal('azure'), Type.Literal('openrouter')],
789
+ { default: 'mock' },
790
+ ),
791
+ // BYO keyRefs → public.tenant_secrets (envelope-encrypted). The block is NOT
792
+ // optional (the worker declares it plain), so its three optional refs each count
793
+ // as a leaf. The nested blocks carry `default: {}` (this package's convention)
794
+ // so Value.Default materializes them + recurses into the leaf defaults when a
795
+ // tenant writes a bare `{}` — the control plane is the defaulting authority and
796
+ // persists the materialized manifest the worker then reads verbatim.
797
+ providers: Type.Object({
798
+ openaiKeyRef: Type.Optional(Type.String()),
799
+ anthropicKeyRef: Type.Optional(Type.String()),
800
+ geminiKeyRef: Type.Optional(Type.String()),
801
+ // The openai-compatible extension surface — ONE optional object = ONE config
802
+ // leaf (countLeaves collapses optional objects; 15-leaf cap discipline).
803
+ // compat.openrouterKeyRef: BYO OpenRouter key (tenant_secrets ref, KEK_AI).
804
+ // compat.openaiBaseUrl: point the openai adapter at ANY openai-compatible
805
+ // host (DeepSeek, vLLM, an Azure-compatible proxy). Public-https validated
806
+ // at config WRITE (publicHttpsUrlError below) AND at USE (@vxil/runtime
807
+ // assertPublicHttpsUrl in ai-v1) — the jobs target_url double-guard idiom.
808
+ compat: Type.Optional(Type.Object({
809
+ openrouterKeyRef: Type.Optional(Type.String()),
810
+ openaiBaseUrl: Type.Optional(Type.String({ maxLength: 512 })),
811
+ })),
812
+ }, { default: {} }),
813
+ defaults: Type.Object({
814
+ model: Type.String({ default: 'mock-1' }),
815
+ maxTokens: Type.Integer({ default: 1024, minimum: 1, maximum: 200_000 }),
816
+ temperature: Type.Number({ default: 0.7, minimum: 0, maximum: 2 }),
817
+ }, { default: {} }),
818
+ cache: Type.Object({ ttlSeconds: Type.Integer({ default: 0, minimum: 0 }) }, { default: {} }), // 0 = off
819
+ limits: Type.Object({
820
+ tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = unlimited
821
+ consumeCredits: Type.Boolean({ default: false }), // reserve→settle (STUB until payments)
822
+ }, { default: {} }),
823
+ streaming: Type.Object({
824
+ enabled: Type.Boolean({ default: true }),
825
+ replayBufferFrames: Type.Integer({ default: 256, minimum: 1 }), // §2a ring-buffer depth
826
+ flushMs: Type.Integer({ default: 50, minimum: 0 }), // §2a/#148 token→frame coalesce window
827
+ }, { default: {} }),
828
+ });
829
+ // Leaves: enabled(1), defaultProvider(2), providers.{openai,anthropic,gemini}KeyRef(+3=5),
830
+ // providers.compat(+1=6 — Optional object → ONE leaf),
831
+ // defaults.{model,maxTokens,temperature}(+3=9), cache.ttlSeconds(10),
832
+ // limits.{tokensPerUserPerDay,consumeCredits}(+2=12),
833
+ // streaming.{enabled,replayBufferFrames,flushMs}(+3=15). countLeaves → 15. Cap = 15.
834
+ // ZERO headroom: any future ai knob must ride inside compat (free) or another
835
+ // optional object.
836
+
837
+ export type AiConfig = Static<typeof AiConfigSchema>;
838
+
839
+ // rag feature (features/rag.md §2). Re-declared to match workers/rag-v1/src/config.ts's
840
+ // exported RagConfigSchema. rag owns the PIPELINE knobs only — retrieval budget,
841
+ // context budget + strategy + tokenizer, the citation/stream gates — NEVER the
842
+ // prompt, the synthesis, or relevance tuning (the tenant's `ai` template owns those).
843
+ export const RagConfigSchema = Type.Object({
844
+ enabled: Type.Boolean({ default: true }),
845
+ // default vector-search collection when the request omits `collection`.
846
+ defaultCollection: Type.Optional(Type.String()),
847
+ retrieval: Type.Object(
848
+ {
849
+ topK: Type.Integer({ default: 8, minimum: 1, maximum: 100 }),
850
+ mode: Type.Union(
851
+ [Type.Literal('hybrid'), Type.Literal('vector'), Type.Literal('keyword')],
852
+ { default: 'hybrid' },
853
+ ),
854
+ // drop retrieved chunks below this score BEFORE budgeting (0 = keep all).
855
+ minScore: Type.Number({ default: 0 }),
856
+ // forward rerank:true to vector-search's post-RRF BYO rerank pass (it owns
857
+ // the provider + key); inert until that pass exists/is configured there.
858
+ rerank: Type.Boolean({ default: false }),
859
+ },
860
+ { default: {} },
861
+ ),
862
+ // declarative per-metadata-field relevance boosts applied in rag AFTER
863
+ // retrieval, BEFORE minScore/budget/grounding (rag.md §2f). ONE Type.Record
864
+ // leaf (the activity-feed feedGroups precedent).
865
+ boosts: Type.Record(Type.String(), Type.Union([
866
+ Type.Object({
867
+ kind: Type.Literal('recency'),
868
+ halfLifeDays: Type.Number({ default: 30, minimum: 0.01, maximum: 3650 }),
869
+ weight: Type.Number({ default: 0.3, minimum: 0, maximum: 1 }),
870
+ }),
871
+ Type.Object({
872
+ kind: Type.Literal('value'),
873
+ weights: Type.Record(Type.String(), Type.Number({ minimum: 0, maximum: 100 })),
874
+ }),
875
+ ]), { default: {} }),
876
+ context: Type.Object(
877
+ {
878
+ // bounded context budget — enforced via the §2c tokenizer, BEFORE the ai call.
879
+ maxTokens: Type.Integer({ default: 4000, minimum: 1, maximum: 1_000_000 }),
880
+ strategy: Type.Union([Type.Literal('topk'), Type.Literal('mmr')], {
881
+ default: 'topk',
882
+ }),
883
+ // 'provider' = the resolved ai provider's tokenizer; 'heuristic' = portable
884
+ // ~chars/4 with a safety margin (§2c, #149).
885
+ tokenizer: Type.Union([Type.Literal('provider'), Type.Literal('heuristic')], {
886
+ default: 'provider',
887
+ }),
888
+ },
889
+ { default: {} },
890
+ ),
891
+ // an ai.templates ref (tenant-authored) used when the request omits `template`.
892
+ defaultTemplate: Type.Optional(Type.String()),
893
+ citations: Type.Boolean({ default: true }),
894
+ streaming: Type.Boolean({ default: true }),
895
+ });
896
+ // Leaves: enabled(1), defaultCollection(2),
897
+ // retrieval.{topK,mode,minScore,rerank}(+4=6), boosts(7 — Type.Record = ONE leaf),
898
+ // context.{maxTokens,strategy,tokenizer}(+3=10), defaultTemplate(11),
899
+ // citations(12), streaming(13). countLeaves → 13. Cap = 15.
900
+
901
+ export type RagConfig = Static<typeof RagConfigSchema>;
902
+
903
+ // payments feature (features/payments.md §5). Re-declared to match the schema the
904
+ // worker EXPORTS from workers/payments-v1/src/core.ts — the control plane
905
+ // validates writes against this shared copy (one-definition / two-consumers,
906
+ // like the other features). Two co-equal pillars: (A) provider payments (the
907
+ // optional stripe/paddle/revenuecat credential blocks, each ONE leaf) and (B)
908
+ // the credits & entitlements LEDGER. The `ledger` block is OPTIONAL → ONE leaf,
909
+ // and its interior productMap/tierMap are Type.Record MAPS (data, not flags) —
910
+ // so adding products/tiers never grows the cap. The mock provider is the
911
+ // zero-config default (no provider block needed); a tenant picks ONE real
912
+ // provider so only one optional credential sub-object is ever active.
913
+ export const PaymentsConfigSchema = Type.Object({
914
+ enabled: Type.Boolean({ default: true }),
915
+ // 'mock' is the deterministic default (instant sub/credit, synthetic webhook
916
+ // events) so the ENTIRE ledger path is testable WITHOUT real provider keys.
917
+ provider: Type.Union(
918
+ [Type.Literal('mock'), Type.Literal('stripe'), Type.Literal('paddle'),
919
+ Type.Literal('revenuecat'), Type.Literal('paypal')],
920
+ { default: 'mock' },
921
+ ),
922
+ // BYO-key credential blocks → public.tenant_secrets (envelope-encrypted).
923
+ // Each OPTIONAL object counts as ONE leaf (the tenant's decision is
924
+ // "configure it or not", not each inner ref — features/auth.md §4).
925
+ stripe: Type.Optional(Type.Object({
926
+ secretKeyRef: Type.String(),
927
+ webhookSecretRef: Type.String(),
928
+ taxEnabled: Type.Boolean({ default: false }),
929
+ })),
930
+ paddle: Type.Optional(Type.Object({
931
+ apiKeyRef: Type.String(),
932
+ webhookSecretRef: Type.String(),
933
+ sandbox: Type.Boolean({ default: false }),
934
+ })),
935
+ revenuecat: Type.Optional(Type.Object({
936
+ projectId: Type.String(),
937
+ publicSdkKey: Type.String(),
938
+ secretApiKeyRef: Type.String(),
939
+ // Optional per-tenant webhook secret ref (public.tenant_secrets). When set,
940
+ // inbound RevenueCat webhooks are verified with THIS tenant's secret instead
941
+ // of the platform PROVIDER_WEBHOOK_SECRET — closes the multi-tenant RC
942
+ // webhook-forgery vector (mirrors stripe/paddle webhookSecretRef).
943
+ webhookSecretRef: Type.Optional(Type.String()),
944
+ })),
945
+ paypal: Type.Optional(Type.Object({
946
+ clientIdRef: Type.String(),
947
+ secretRef: Type.String(),
948
+ webhookId: Type.String(), // the registered webhook's id (PayPal verifies by id, not a static secret)
949
+ sandbox: Type.Boolean({ default: false }),
950
+ })),
951
+ // Where a provider-hosted flow sends the payer back (Stripe billing-portal
952
+ // return, PayPal approval return/cancel) — the TENANT's own app URL,
953
+ // https-only. Absent ⇒ the worker's WEB_BASE_URL env (vxil's site), never a
954
+ // hardcoded host (audit 2026-07-10: the old fallback pointed at a dead apex).
955
+ returnUrl: Type.Optional(Type.String({ pattern: '^https://', maxLength: 512 })),
956
+ prices: Type.Object(
957
+ { catalogRef: Type.String({ default: 'default' }) }, // tenant-supplied price catalog ref
958
+ { default: {} },
959
+ ),
960
+ defaults: Type.Object(
961
+ {
962
+ currency: Type.String({ default: 'usd' }),
963
+ trialDays: Type.Integer({ default: 0, minimum: 0, maximum: 365 }),
964
+ },
965
+ { default: {} },
966
+ ),
967
+ // The credits & entitlements ledger config. OPTIONAL → ONE leaf at the
968
+ // analyzer's top level; productMap/tierMap are tenant-supplied Type.Record
969
+ // MAPS (one typed leaf each), so catalog size never inflates the flag count.
970
+ ledger: Type.Optional(Type.Object({
971
+ productMap: Type.Record(Type.String(), Type.Object({ // product_id → grant rule
972
+ creditType: Type.String({ minLength: 1 }),
973
+ amount: Type.Integer({ minimum: 1 }), // #128: a grant only ADDS
974
+ period: Type.Union([Type.Literal('once'), Type.Literal('monthly'),
975
+ Type.Literal('annual')]),
976
+ })),
977
+ tierMap: Type.Record(Type.String(), Type.Object({ // tier → entitlement/quota/grant
978
+ entitlements: Type.Array(Type.String()),
979
+ quotas: Type.Record(Type.String(), Type.Integer({ minimum: 0 })), // #128: no negative quota
980
+ rank: Type.Optional(Type.Integer({ minimum: 0 })), // precedence for the multi-sub fold (#125)
981
+ grants: Type.Optional(Type.Array(Type.Object({
982
+ creditType: Type.String({ minLength: 1 }),
983
+ amount: Type.Integer({ minimum: 1 }), // #128
984
+ period: Type.String(),
985
+ }))),
986
+ })),
987
+ // provider price/plan id → tier. A real subscription webhook carries the
988
+ // provider's price/plan id (Stripe price_…, Paddle pri_…, PayPal plan id),
989
+ // NOT a tier; this map lets the webhook reducer resolve it so the entitlement
990
+ // fold (refoldEntitlements WHERE tier IS NOT NULL) reflects the subscription.
991
+ priceMap: Type.Optional(Type.Record(Type.String(), Type.String())),
992
+ autoRefundOnJobFailure: Type.Boolean({ default: true }), // consume(jobId) reverses on DLQ/timeout
993
+ })),
994
+ webhooks: Type.Object(
995
+ { forwardToTenantUrl: Type.Optional(Type.String({ format: 'uri' })) },
996
+ { default: {} },
997
+ ),
998
+ });
999
+ // Leaves: enabled(1), provider(2), stripe?(3), paddle?(4), revenuecat?(5),
1000
+ // paypal?(6), prices.catalogRef(7), defaults.{currency,trialDays}(+2=9),
1001
+ // ledger?(10), webhooks.forwardToTenantUrl(11). countLeaves → 11. Cap = 15.
1002
+ // (productMap / tierMap / priceMap are Type.Record MAPS inside the ONE optional
1003
+ // ledger leaf — DATA, not flags — so catalog size never moves the count.)
1004
+
1005
+ export type PaymentsConfig = Static<typeof PaymentsConfigSchema>;
1006
+
1007
+ // functions feature (vxil-functions-design §4.c). Tenant-deployed backend edge
1008
+ // functions on Workers-for-Platforms. The FUNCTION owns its identity (bundle via
1009
+ // scriptRef, scopes, secrets, egress, limits, runtime) + a SET of trigger
1010
+ // bindings; every other surface (e.g. cms.hooks) REFERENCES a function BY NAME and
1011
+ // never re-embeds deploy config. The per-function bag is ONE Type.Record leaf
1012
+ // (cms.hooks / payments.ledger precedent), so any number of deployed functions
1013
+ // never grows the flag cap. This is the §7.3 crossing — paid, tier-walled,
1014
+ // opt-in (enabled defaults to false), egress-guarded.
1015
+ export const FunctionsConfigSchema = Type.Object({
1016
+ enabled: Type.Boolean({ default: false }),
1017
+ // isolate = WfP Worker (Lane 2, default); container = CF Containers (Lane 3, design-only).
1018
+ runtime: Type.Union([Type.Literal('isolate'), Type.Literal('container')], { default: 'isolate' }),
1019
+ defaultLimits: Type.Object(
1020
+ {
1021
+ cpuMs: Type.Integer({ default: 50, minimum: 5, maximum: 300_000 }),
1022
+ timeoutMs: Type.Integer({ default: 10_000, minimum: 50, maximum: 300_000 }),
1023
+ memoryMb: Type.Integer({ default: 128, minimum: 64, maximum: 1024 }),
1024
+ },
1025
+ { default: {} },
1026
+ ),
1027
+ // Reserve→settle CPU-ms metering into the payments credit ledger (the
1028
+ // recover-by-price tier, live-prep "functions metering"). When enabled the
1029
+ // dispatcher RESERVES fn.limits.cpuMs (?? defaultLimits.cpuMs) `fn_cpu_ms`
1030
+ // credits per invoke (provisional consume, job_id `fn:<request_id>`) and
1031
+ // settles/reverses via the payments job-reversal after dispatch. Written by
1032
+ // the deploy pipeline (`metering` in the deploy body), like everything else
1033
+ // in this deploy-managed manifest.
1034
+ metering: Type.Object(
1035
+ { enabled: Type.Boolean({ default: false }) },
1036
+ { default: {} },
1037
+ ),
1038
+ // ONE Type.Record leaf — the per-function object is DATA, not flags.
1039
+ functions: Type.Optional(
1040
+ Type.Record(
1041
+ Type.String(),
1042
+ Type.Object({
1043
+ // http/cron/queue/webhook bindings live here; a `cmsHook` binding also
1044
+ // lives here (it carries collection+event) so the control-plane can
1045
+ // auto-wire the webhook_subscription that fires it — the cms.hooks
1046
+ // {kind:function, ref} authoring surface still references the function
1047
+ // by name for the Lane-A path. An `authHook` binding is the post-signup
1048
+ // sibling: the control-plane auto-wires ONE webhook_subscription on the
1049
+ // `auth.user.created` audit event (event defaults to 'user.created' —
1050
+ // the only value today). Per-function fields are OPTIONAL because
1051
+ // Value.Default does not recurse into Type.Record VALUES (same pattern
1052
+ // as cms.hooks' HookDef). scriptRef is the only required field; the
1053
+ // worker applies `?? []` / `?? true` at read time. The deploy pipeline
1054
+ // always writes the full materialized entry.
1055
+ bindings: Type.Optional(
1056
+ Type.Array(
1057
+ Type.Object({
1058
+ kind: Type.Union([
1059
+ Type.Literal('http'),
1060
+ Type.Literal('queue'),
1061
+ Type.Literal('cron'),
1062
+ Type.Literal('webhook'),
1063
+ Type.Literal('cmsHook'),
1064
+ Type.Literal('authHook'),
1065
+ ]),
1066
+ path: Type.Optional(Type.String()), // http: custom path; default /v1/fn/<name>
1067
+ schedule: Type.Optional(Type.String()), // cron: 5-field expression
1068
+ source: Type.Optional(Type.String()), // webhook/queue: source/queue id
1069
+ collection: Type.Optional(Type.String()), // cmsHook: the CMS collection slug
1070
+ event: Type.Optional(Type.String()), // cmsHook: beforeCreate|beforeUpdate|beforeWrite · authHook: 'user.created'
1071
+ }),
1072
+ { maxItems: 8 },
1073
+ ),
1074
+ ),
1075
+ scriptRef: Type.String(), // content-hashed WfP script name: fn-<tenant>-<name>-<sha>
1076
+ scopes: Type.Optional(Type.Array(Type.String())), // clamped via DENY_FUNCTION_SCOPES at deploy (admin/*/features:write/functions:write/secrets:write)
1077
+ secrets: Type.Optional(Type.Array(Type.String())), // tenant_secrets refs (KEK_FUNCTIONS)
1078
+ egressAllow: Type.Optional(Type.Array(Type.String())), // Outbound Worker allowlist hosts
1079
+ limits: Type.Optional(
1080
+ Type.Object({
1081
+ cpuMs: Type.Integer(),
1082
+ timeoutMs: Type.Integer(),
1083
+ memoryMb: Type.Integer(),
1084
+ }),
1085
+ ),
1086
+ enabled: Type.Optional(Type.Boolean()),
1087
+ // Level-1 typed I/O (cli-sdk design §4.5): the declared input/output
1088
+ // contract, persisted by the deploy body so ONLINE `vxil gen` emits the
1089
+ // same typed fn client as --offline. Opaque JSON-schema-ish payloads —
1090
+ // the CLI's lowerSig lowers them; the platform never interprets them.
1091
+ signature: Type.Optional(Type.Object({
1092
+ input: Type.Optional(Type.Unknown()),
1093
+ output: Type.Optional(Type.Unknown()),
1094
+ })),
1095
+ }),
1096
+ ),
1097
+ ),
1098
+ });
1099
+ // Leaves: enabled(1), runtime(2), defaultLimits.{cpuMs,timeoutMs,memoryMb}(+3=5),
1100
+ // metering.enabled(6), functions(7, the Type.Record bag → ONE leaf;
1101
+ // bindings[]/scriptRef/scopes/secrets/egressAllow/limits/enabled/signature
1102
+ // are DATA inside the MAP value and never move the count, like cms.hooks).
1103
+ // countLeaves → 7. Cap = 15.
1104
+ export type FunctionsConfig = Static<typeof FunctionsConfigSchema>;
1105
+
1106
+ // copilot feature (docs/copilot-agents-sku-design.md §4). Re-declared to match
1107
+ // workers/copilot-v1/src/config.ts's exported CopilotConfigSchema (one shape,
1108
+ // two consumers — keep the two definitions byte-identical). A thin COMPOSITION
1109
+ // layer over ai + vector-search + rag + mcp: it owns the AGENT layer (persona,
1110
+ // knowledge wiring, the action-catalog gate, guardrails, the embed surface) and
1111
+ // never re-declares embedder/chunking/synthesis knobs (those belong to
1112
+ // vector-search / ai / rag). The `agents` Record is the one open-ended
1113
+ // structure — keyed by agentId, ONE leaf (cms.hooks / payments.ledger
1114
+ // precedent); inside each agent, `actions.allow` is a second nested Record
1115
+ // (toolKey → gate) — also data, not flags. Cross-feature resolutions
1116
+ // (collection existence, directiveRef → ai.templates) are deliberately NOT
1117
+ // validated here: they need control-plane round-trips, and the runtime already
1118
+ // fails soft with the capability-surface envelope.
1119
+ export const CopilotConfigSchema = Type.Object({
1120
+ enabled: Type.Boolean({ default: true }),
1121
+
1122
+ // Per-agent map (data, not flags) → ONE leaf. NOTE: Value.Default does NOT
1123
+ // recurse into Type.Record VALUES (the cms.hooks / functions.functions
1124
+ // precedent), so nested per-agent objects are OPTIONAL and the worker applies
1125
+ // `?? default` at read time. default {} so the quickstart's minimal
1126
+ // `{ enabled: true }` body materializes to a valid zero-agent manifest.
1127
+ agents: Type.Record(
1128
+ Type.String({ minLength: 1, maxLength: 64 }), // agentId (slug)
1129
+ Type.Object({
1130
+ // ── persona ──────────────────────────────────────────────────────────
1131
+ name: Type.String({ minLength: 1, maxLength: 80 }),
1132
+ // CONFIG-AS-CODE: a ref into the tenant's ai.templates (rendered by
1133
+ // ai-v1, never authored as executable code).
1134
+ directiveRef: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
1135
+ tone: Type.Optional(Type.String({ maxLength: 200 })),
1136
+ greeting: Type.Optional(Type.String({ maxLength: 500 })),
1137
+ locale: Type.Optional(Type.String({ maxLength: 16, default: 'en-US' })),
1138
+
1139
+ // ── knowledge: vector-search collection refs + retrieval policy ───────
1140
+ // Pipeline knobs (topK/mmr/budget/tokenizer) stay in RagConfig.
1141
+ // Interior fields are ALL Optional (Value.Default does not recurse into
1142
+ // Type.Record VALUES — the functions.functions precedent); the `default`
1143
+ // annotations document the read-time `?? default` the worker applies.
1144
+ knowledge: Type.Optional(Type.Object({
1145
+ collections: Type.Optional(Type.Array(Type.String({ maxLength: 128 }), { maxItems: 32, default: [] })),
1146
+ useRag: Type.Optional(Type.Boolean({ default: true })),
1147
+ ragTemplateRef: Type.Optional(Type.String({ maxLength: 128 })),
1148
+ topKOverride: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
1149
+ })),
1150
+
1151
+ // ── citations + grounding ─────────────────────────────────────────────
1152
+ citations: Type.Optional(Type.Boolean({ default: true })),
1153
+ grounding: Type.Optional(Type.Union(
1154
+ [Type.Literal('strict'), Type.Literal('blended')],
1155
+ { default: 'strict' },
1156
+ )),
1157
+
1158
+ // ── actions block: mcp toolKey → gate (the catalog IS the action set) ──
1159
+ actions: Type.Optional(Type.Object({
1160
+ mode: Type.Optional(Type.Union(
1161
+ [Type.Literal('off'), Type.Literal('readonly'), Type.Literal('actions')],
1162
+ { default: 'readonly' },
1163
+ )),
1164
+ allow: Type.Optional(Type.Record(
1165
+ Type.String({ maxLength: 64 }), // mcp toolKey
1166
+ Type.Object({
1167
+ enabled: Type.Optional(Type.Boolean({ default: true })),
1168
+ requireConfirm: Type.Optional(Type.Boolean({ default: true })),
1169
+ minScope: Type.Optional(Type.String({ maxLength: 64 })),
1170
+ }),
1171
+ { default: {} },
1172
+ )),
1173
+ })),
1174
+
1175
+ // ── guardrails ────────────────────────────────────────────────────────
1176
+ guardrails: Type.Optional(Type.Object({
1177
+ maxTurnsPerSession: Type.Optional(Type.Integer({ default: 40, minimum: 1, maximum: 500 })),
1178
+ rateLimitPerUserPerDay: Type.Optional(Type.Integer({ default: 50, minimum: 0 })), // 0 = unlimited
1179
+ maxInputChars: Type.Optional(Type.Integer({ default: 4000, minimum: 1, maximum: 32_000 })),
1180
+ refusalMessage: Type.Optional(Type.String({ maxLength: 500 })),
1181
+ allowGuest: Type.Optional(Type.Boolean({ default: false })),
1182
+ guestToolAllow: Type.Optional(Type.Array(Type.String({ maxLength: 64 }), { maxItems: 16, default: [] })),
1183
+ })),
1184
+ }),
1185
+ { default: {} },
1186
+ ),
1187
+
1188
+ // ── escalation: human hand-off (composes notifications/comments) ──────────
1189
+ escalation: Type.Optional(Type.Object({
1190
+ enabled: Type.Boolean({ default: false }),
1191
+ handler: Type.Union(
1192
+ [Type.Literal('comments'), Type.Literal('notifications')],
1193
+ { default: 'comments' },
1194
+ ),
1195
+ notifyTemplate: Type.Optional(Type.String({ maxLength: 128 })),
1196
+ })),
1197
+
1198
+ // ── limits: DELEGATE token/credit accounting to ai-v1 ─────────────────────
1199
+ limits: Type.Object({
1200
+ consumeCredits: Type.Boolean({ default: false }),
1201
+ tokensPerUserPerDay: Type.Integer({ default: 0, minimum: 0 }), // 0 = inherit ai
1202
+ }, { default: {} }),
1203
+
1204
+ // ── widget / embed (the Stage-3 public chat surface) ──────────────────────
1205
+ widget: Type.Object({
1206
+ enabled: Type.Boolean({ default: false }),
1207
+ requireAuth: Type.Boolean({ default: true }),
1208
+ allowedOrigins: Type.Array(Type.String({ maxLength: 256 }), { maxItems: 32, default: [] }),
1209
+ theme: Type.Optional(Type.String({ maxLength: 32 })),
1210
+ }, { default: {} }),
1211
+ });
1212
+ // Leaves: enabled(1), agents(2 — Type.Record MAP, ONE leaf), escalation?(3 —
1213
+ // optional object, ONE leaf), limits.{consumeCredits,tokensPerUserPerDay}
1214
+ // (+2=5), widget.{enabled,requireAuth,allowedOrigins,theme}(+4=9).
1215
+ // countLeaves → 9. Cap = 15.
1216
+
1217
+ export type CopilotConfig = Static<typeof CopilotConfigSchema>;
1218
+
1219
+ export const FEATURE_SCHEMAS: Record<string, TSchema> = {
1220
+ notifications: NotificationsConfigSchema,
1221
+ jobs: JobsConfigSchema,
1222
+ auth: AuthConfigSchema,
1223
+ 'rate-limits': RateLimitsConfigSchema,
1224
+ files: FilesConfigSchema,
1225
+ webhooks: WebhooksConfigSchema,
1226
+ comments: CommentsConfigSchema,
1227
+ cms: CmsConfigSchema,
1228
+ mcp: McpConfigSchema,
1229
+ realtime: RealtimeConfigSchema,
1230
+ presence: PresenceConfigSchema,
1231
+ orgs: OrgsConfigSchema,
1232
+ 'activity-feed': ActivityFeedConfigSchema,
1233
+ 'vector-search': VectorSearchConfigSchema,
1234
+ ai: AiConfigSchema,
1235
+ rag: RagConfigSchema,
1236
+ payments: PaymentsConfigSchema,
1237
+ functions: FunctionsConfigSchema,
1238
+ copilot: CopilotConfigSchema,
1239
+ };
1240
+
1241
+ export const CONFIG_FLAG_CAP = 15;
1242
+
1243
+ /** Counts leaf flags in a TypeBox object schema (architecture §6).
1244
+ * Per features/auth.md §4: an OPTIONAL object (e.g. a provider credential
1245
+ * block) counts as ONE flag — the tenant's decision is "configure it or
1246
+ * not", not each inner ref. */
1247
+ export function countLeaves(schema: TSchema): number {
1248
+ const t = schema as unknown as {
1249
+ type?: string;
1250
+ properties?: Record<string, TSchema>;
1251
+ };
1252
+ if (t.type === 'object' && t.properties) {
1253
+ return Object.values(t.properties).reduce((n, child) => {
1254
+ const c = child as unknown as { type?: string } & Record<symbol, unknown>;
1255
+ const optionalObject = c.type === 'object' && c[OptionalKind] !== undefined;
1256
+ return n + (optionalObject ? 1 : countLeaves(child));
1257
+ }, 0);
1258
+ }
1259
+ return 1; // unions, primitives, arrays = one flag the tenant must learn
1260
+ }
1261
+
1262
+ export interface ConfigValidation {
1263
+ ok: boolean;
1264
+ errors: string[];
1265
+ /** value with TypeBox defaults applied (only when ok) */
1266
+ value?: unknown;
1267
+ }
1268
+
1269
+ // ── copilot ↔ mcp catalog bridge ─────────────────────────────────────────────
1270
+ // Copilot `actions.allow` keys / `guestToolAllow` entries are mcp catalog tool
1271
+ // names. The catalog lives in workers/mcp-v1/src/tools.ts (the zero-import pure
1272
+ // data module gen/trinity-drift read) — which this PUBLISHED package cannot
1273
+ // import: tsconfig.build.json compiles with rootDir=src, so a relative import
1274
+ // across the monorepo would break the npm build. Instead, consumers that HAVE
1275
+ // the catalog (the control-plane config write path, tests) register it once via
1276
+ // setKnownMcpTools(TOOLS.map(t => t.name)) — the setDocsBaseUrl-style seam.
1277
+ // Without a registered catalog, validateFeatureConfig skips ONLY the
1278
+ // name-existence rule (the pure subset/cross-field rules below still run);
1279
+ // at runtime the loop's manifest intersection makes an unknown key inert.
1280
+ let knownMcpTools: ReadonlySet<string> | null = null;
1281
+ export function setKnownMcpTools(names: readonly string[]): void {
1282
+ knownMcpTools = new Set(names);
1283
+ }
1284
+
1285
+ // ── mcp custom-tool / prompt caps + the pure public-https check ──────────────
1286
+ // (mcp.md §6.5) Caps are deliberately tighter than the 200-tool listing cap:
1287
+ // each custom tool is a platform-signed egress target, so the bag stays small.
1288
+ export const MCP_MAX_CUSTOM_TOOLS = 32;
1289
+ export const MCP_MAX_PROMPTS = 32;
1290
+ const MCP_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
1291
+
1292
+ /** Pure mirror of @vxil/runtime assertPublicHttpsUrl (this package cannot
1293
+ * depend on it — typebox-only). Returns an error string, or null when the URL
1294
+ * is a plain https URL to a public-looking host. IPv6 literals are rejected
1295
+ * outright at config time (stricter than runtime — declare a hostname). */
1296
+ function publicHttpsUrlError(url: string): string | null {
1297
+ let u: URL;
1298
+ try {
1299
+ u = new URL(url);
1300
+ } catch {
1301
+ return 'not a valid URL';
1302
+ }
1303
+ if (u.protocol !== 'https:') return `only https:// is allowed (got ${u.protocol})`;
1304
+ if (u.username !== '' || u.password !== '') return 'URLs with embedded credentials are rejected';
1305
+ const host = u.hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, '').replace(/\.$/, '');
1306
+ if (host === '') return 'empty host';
1307
+ if (
1308
+ host === 'localhost' || host.endsWith('.localhost')
1309
+ || host === 'metadata.google.internal'
1310
+ || host.endsWith('.internal') || host.endsWith('.local')
1311
+ ) {
1312
+ return `host ${host} resolves to internal infrastructure`;
1313
+ }
1314
+ if (host.includes(':')) return 'IPv6 literals are not accepted — declare a hostname';
1315
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
1316
+ if (m) {
1317
+ const o = m.slice(1).map(Number);
1318
+ const [a, b] = o as [number, number, number, number];
1319
+ if (
1320
+ o.some((n) => n > 255)
1321
+ || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
1322
+ || a === 127 || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)
1323
+ || a === 0 || a >= 224
1324
+ ) {
1325
+ return `host ${host} is a private/reserved IP`;
1326
+ }
1327
+ }
1328
+ return null;
1329
+ }
1330
+
1331
+ /** Scopes a FUNCTION may NEVER carry (the deploy clamp + the config-write gate).
1332
+ * NARROWER than the appgen public-page deny-list — a function is authenticated,
1333
+ * per-function-scoped, tier-walled, egress-guarded tenant code, so the owner may
1334
+ * grant it business scopes (payments:write / notifications:send / users:*) that an
1335
+ * anonymous appgen page never gets. Only CROSS-TENANT escalation (admin/*) and
1336
+ * SELF-INTEGRITY rewrites stay forbidden: features:write (config rewrite outside
1337
+ * the versioned pipeline), functions:write (redeploy/modify the tenant's functions
1338
+ * at runtime — a persistence vector; function code ships via the CLI/dashboard,
1339
+ * not another function), and secrets:write (secret mutation). functions:invoke
1340
+ * stays ALLOWED (fn→fn composition). Source of truth for both the config-write
1341
+ * validation below and the control-plane deploy clamp. */
1342
+ export const DENY_FUNCTION_SCOPES = new Set(['admin', '*', 'features:write', 'functions:write', 'secrets:write']);
1343
+
1344
+ export function validateFeatureConfig(feature: string, raw: unknown): ConfigValidation {
1345
+ const schema = FEATURE_SCHEMAS[feature];
1346
+ if (!schema) {
1347
+ return { ok: false, errors: [`unknown feature '${feature}'`] };
1348
+ }
1349
+ if (countLeaves(schema) > CONFIG_FLAG_CAP) {
1350
+ return { ok: false, errors: [`schema for '${feature}' exceeds the ${CONFIG_FLAG_CAP}-flag cap`] };
1351
+ }
1352
+ // Apply defaults to a clone, then STRIP any property the schema does not
1353
+ // declare, then check. Value.Clean makes the validator TOTAL (audit #112):
1354
+ // the schemas are open Type.Object()s, so without it Value.Check passes on —
1355
+ // and putConfig would persist — arbitrary unknown keys. Clean runs AFTER
1356
+ // Default so materialized nested defaults survive but stray top-level/nested
1357
+ // keys are dropped. This also removes the CLI dry-run idempotency drift
1358
+ // (audit #118): plan/push diff the same cleaned manifest the server stores.
1359
+ const withDefaults = Value.Clean(
1360
+ schema,
1361
+ Value.Default(schema, Value.Clone(raw)),
1362
+ ) as unknown;
1363
+ if (!Value.Check(schema, withDefaults)) {
1364
+ const errors = [...Value.Errors(schema, withDefaults)].map(
1365
+ (e) => `${e.path || '/'}: ${e.message}`,
1366
+ );
1367
+ return { ok: false, errors: errors.slice(0, 10) };
1368
+ }
1369
+ // Cross-field rule: notifications only needs a Resend key for the real
1370
+ // provider. The `mock` provider (and inbox-only setups) stay zero-config.
1371
+ if (feature === 'notifications') {
1372
+ const v = withDefaults as { provider?: string; resendApiKeyRef?: string };
1373
+ if (v.provider === 'resend' && !v.resendApiKeyRef) {
1374
+ return {
1375
+ ok: false,
1376
+ errors: ["/resendApiKeyRef: required when provider is 'resend' (omit it for provider 'mock')"],
1377
+ };
1378
+ }
1379
+ }
1380
+ // Cross-field rule: a REAL payments provider needs its credential block; the
1381
+ // 'mock' provider (the deterministic default) stays zero-config so the whole
1382
+ // ledger path is testable without real keys (features/payments.md §0/§6).
1383
+ if (feature === 'payments') {
1384
+ const v = withDefaults as { provider?: string; stripe?: unknown; paddle?: unknown; revenuecat?: unknown };
1385
+ const needsBlock: Record<string, keyof typeof v> = {
1386
+ stripe: 'stripe', paddle: 'paddle', revenuecat: 'revenuecat',
1387
+ };
1388
+ const key = v.provider ? needsBlock[v.provider] : undefined;
1389
+ if (key && !v[key]) {
1390
+ return {
1391
+ ok: false,
1392
+ errors: [`/${String(key)}: required when provider is '${v.provider}' (omit it for provider 'mock')`],
1393
+ };
1394
+ }
1395
+ }
1396
+ // Cross-field rule: CMS lifecycle hooks. Each hook's expression is parsed and
1397
+ // AST-validated against the closed sandbox allow-list HERE, at config-write
1398
+ // time — a malicious/oversized/ill-typed hook is rejected BEFORE it can be
1399
+ // published to KV/runtime (the anti-malice gate). Field-existence is checked
1400
+ // at runtime (the field list is per-collection DB data, not known here).
1401
+ if (feature === 'cms') {
1402
+ const v = withDefaults as {
1403
+ hooks?: Record<string, HookDef>;
1404
+ readModels?: Record<string, never>;
1405
+ cdc?: Record<string, never>;
1406
+ };
1407
+ const hookErrors = validateHooksConfig(v.hooks);
1408
+ if (hookErrors.length) return { ok: false, errors: hookErrors.slice(0, 10) };
1409
+ // cms-rel B5/E: the read-model/cdc grammars are closed at config-write
1410
+ // (readmodels.ts — the same anti-malice posture as the hooks gate).
1411
+ const rmErrors = [
1412
+ ...validateReadModelsConfig(v.readModels),
1413
+ ...validateCdcConfig(v.cdc),
1414
+ ];
1415
+ if (rmErrors.length) return { ok: false, errors: rmErrors.slice(0, 10) };
1416
+ }
1417
+ // Cross-field rules: mcp custom tools + prompts (mcp.md §6.5/§11). The URL
1418
+ // check here is a PURE mirror of @vxil/runtime assertPublicHttpsUrl (this
1419
+ // package is typebox-only) — the authoritative runtime guard re-runs in
1420
+ // mcp-v1 at call time; this gate rejects obviously-internal targets BEFORE
1421
+ // they can be published to config.
1422
+ if (feature === 'mcp') {
1423
+ const v = withDefaults as {
1424
+ customTools?: Record<string, { url?: string; inputSchema?: unknown }>;
1425
+ prompts?: Record<string, { template?: string; arguments?: Array<{ name?: string }> }>;
1426
+ };
1427
+ const errs: string[] = [];
1428
+ const tools = Object.entries(v.customTools ?? {});
1429
+ if (tools.length > MCP_MAX_CUSTOM_TOOLS) {
1430
+ errs.push(`/customTools: at most ${MCP_MAX_CUSTOM_TOOLS} custom tools (got ${tools.length})`);
1431
+ }
1432
+ for (const [name, tool] of tools) {
1433
+ if (!MCP_NAME_RE.test(name)) {
1434
+ errs.push(`/customTools/${name}: name must match ${MCP_NAME_RE.source}`);
1435
+ }
1436
+ const urlErr = publicHttpsUrlError(tool.url ?? '');
1437
+ if (urlErr) errs.push(`/customTools/${name}/url: ${urlErr}`);
1438
+ if (tool.inputSchema !== undefined) {
1439
+ if (typeof tool.inputSchema !== 'object' || tool.inputSchema === null
1440
+ || Array.isArray(tool.inputSchema)) {
1441
+ errs.push(`/customTools/${name}/inputSchema: must be a JSON-Schema object`);
1442
+ } else if (JSON.stringify(tool.inputSchema).length > 8 * 1024) {
1443
+ errs.push(`/customTools/${name}/inputSchema: serialized schema exceeds 8KB`);
1444
+ }
1445
+ }
1446
+ }
1447
+ const prompts = Object.entries(v.prompts ?? {});
1448
+ if (prompts.length > MCP_MAX_PROMPTS) {
1449
+ errs.push(`/prompts: at most ${MCP_MAX_PROMPTS} prompts (got ${prompts.length})`);
1450
+ }
1451
+ for (const [name, prompt] of prompts) {
1452
+ if (!MCP_NAME_RE.test(name)) {
1453
+ errs.push(`/prompts/${name}: name must match ${MCP_NAME_RE.source}`);
1454
+ }
1455
+ // Every {{placeholder}} must be a declared argument (declared-but-unused
1456
+ // arguments are allowed — a template may evolve independently).
1457
+ const declared = new Set((prompt.arguments ?? []).map((a) => a.name ?? ''));
1458
+ for (const m of String(prompt.template ?? '').matchAll(/\{\{([a-z0-9_]+)\}\}/g)) {
1459
+ if (!declared.has(m[1]!)) {
1460
+ errs.push(`/prompts/${name}/template: placeholder {{${m[1]}}} is not a declared argument`);
1461
+ }
1462
+ }
1463
+ }
1464
+ if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
1465
+ }
1466
+ // Cross-field rule: ai. A tenant-supplied openai-compatible base URL is a
1467
+ // platform egress target — reject non-https/private/internal hosts at config-
1468
+ // write time (pure mirror of @vxil/runtime assertPublicHttpsUrl; ai-v1 re-runs
1469
+ // the authoritative runtime guard at use time — the mcp customTools idiom).
1470
+ if (feature === 'ai') {
1471
+ const v = withDefaults as { providers?: { compat?: { openaiBaseUrl?: string } } };
1472
+ const baseUrl = v.providers?.compat?.openaiBaseUrl;
1473
+ if (baseUrl) {
1474
+ const urlErr = publicHttpsUrlError(baseUrl);
1475
+ if (urlErr) return { ok: false, errors: [`/providers/compat/openaiBaseUrl: ${urlErr}`] };
1476
+ }
1477
+ }
1478
+ // Cross-field rule: functions. Reject privilege-escalating scopes and malformed
1479
+ // bindings at config-write time (the deploy pipeline additionally asserts the
1480
+ // scriptRef sha is uploaded + clamps scopes via DENY_FUNCTION_SCOPES). Bindings
1481
+ // are http/cron/queue/webhook/cmsHook/authHook; a cmsHook binding carries
1482
+ // collection+event, an authHook binding fires on auth.user.created (the
1483
+ // control-plane auto-wires the webhook_subscription for both).
1484
+ if (feature === 'functions') {
1485
+ const v = withDefaults as {
1486
+ functions?: Record<
1487
+ string,
1488
+ { scriptRef?: string; scopes?: string[]; bindings?: Array<{ kind: string; schedule?: string; collection?: string; event?: string }>; signature?: unknown }
1489
+ >;
1490
+ };
1491
+ const errs: string[] = [];
1492
+ for (const [name, fn] of Object.entries(v.functions ?? {})) {
1493
+ if (!fn.scriptRef) errs.push(`/functions/${name}/scriptRef: required`);
1494
+ for (const s of fn.scopes ?? []) {
1495
+ if (DENY_FUNCTION_SCOPES.has(s)) {
1496
+ errs.push(`/functions/${name}/scopes: '${s}' is never grantable to a function (deny-by-default) — cross-tenant/self-integrity scopes are forbidden; payments:write / notifications:send / users:* are allowed`);
1497
+ }
1498
+ }
1499
+ (fn.bindings ?? []).forEach((b, i) => {
1500
+ if (b.kind === 'cron' && !b.schedule) {
1501
+ errs.push(`/functions/${name}/bindings/${i}: a 'cron' binding needs a schedule`);
1502
+ }
1503
+ if (b.kind === 'cmsHook' && !b.collection) {
1504
+ errs.push(`/functions/${name}/bindings/${i}: a 'cmsHook' binding needs a collection`);
1505
+ }
1506
+ // authHook: 'user.created' is the only event today — reject typos at
1507
+ // write time so a binding never silently subscribes to nothing.
1508
+ if (b.kind === 'authHook' && b.event !== undefined && b.event !== 'user.created') {
1509
+ errs.push(`/functions/${name}/bindings/${i}: an 'authHook' binding's event must be 'user.created' (or omitted)`);
1510
+ }
1511
+ });
1512
+ // Manifest-bloat guard: the signature is opaque DATA carried inside the
1513
+ // published manifest (and every config_versions row) — bound it.
1514
+ if (fn.signature !== undefined && JSON.stringify(fn.signature).length > 8_192) {
1515
+ errs.push(`/functions/${name}/signature: too large (max 8192 JSON chars)`);
1516
+ }
1517
+ }
1518
+ if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
1519
+ }
1520
+ // Cross-field rules: copilot (docs/copilot-agents-sku-design.md §4). Pure
1521
+ // rules only — cross-feature resolutions (collection existence, directiveRef
1522
+ // → ai.templates) are deferred to runtime, which fails soft with the
1523
+ // capability-surface envelope. Tool-NAME existence runs only when the mcp
1524
+ // catalog has been registered (setKnownMcpTools above).
1525
+ if (feature === 'copilot') {
1526
+ const v = withDefaults as {
1527
+ agents?: Record<string, {
1528
+ actions?: { mode?: string; allow?: Record<string, unknown> };
1529
+ guardrails?: { allowGuest?: boolean; guestToolAllow?: string[] };
1530
+ }>;
1531
+ escalation?: { handler?: string; notifyTemplate?: string };
1532
+ widget?: { requireAuth?: boolean };
1533
+ };
1534
+ const errs: string[] = [];
1535
+ let anyGuestAgent = false;
1536
+ for (const [id, agent] of Object.entries(v.agents ?? {})) {
1537
+ const allow = agent.actions?.allow ?? {};
1538
+ const guestAllow = agent.guardrails?.guestToolAllow ?? [];
1539
+ if (agent.guardrails?.allowGuest === true) anyGuestAgent = true;
1540
+ if (knownMcpTools) {
1541
+ for (const tool of Object.keys(allow)) {
1542
+ if (!knownMcpTools.has(tool)) {
1543
+ errs.push(`/agents/${id}/actions/allow/${tool}: not an mcp catalog tool name`);
1544
+ }
1545
+ }
1546
+ for (const tool of guestAllow) {
1547
+ if (!knownMcpTools.has(tool)) {
1548
+ errs.push(`/agents/${id}/guardrails/guestToolAllow: '${tool}' is not an mcp catalog tool name`);
1549
+ }
1550
+ }
1551
+ }
1552
+ if (guestAllow.length > 0) {
1553
+ if (agent.guardrails?.allowGuest !== true) {
1554
+ errs.push(`/agents/${id}/guardrails/guestToolAllow: requires allowGuest: true`);
1555
+ }
1556
+ for (const tool of guestAllow) {
1557
+ if (!(tool in allow)) {
1558
+ errs.push(`/agents/${id}/guardrails/guestToolAllow: '${tool}' is not in actions.allow`);
1559
+ }
1560
+ }
1561
+ }
1562
+ }
1563
+ if (v.widget?.requireAuth === false && !anyGuestAgent) {
1564
+ errs.push('/widget/requireAuth: false requires at least one agent with guardrails.allowGuest: true');
1565
+ }
1566
+ if (v.escalation?.handler === 'notifications' && !v.escalation.notifyTemplate) {
1567
+ errs.push("/escalation/notifyTemplate: required when handler is 'notifications'");
1568
+ }
1569
+ if (errs.length) return { ok: false, errors: errs.slice(0, 10) };
1570
+ }
1571
+ return { ok: true, errors: [], value: withDefaults };
1572
+ }