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