@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/LICENSE +21 -0
- package/dist/hooks.d.ts +167 -0
- package/dist/hooks.js +914 -0
- package/dist/index.d.ts +587 -0
- package/dist/index.js +1191 -0
- package/dist/readmodels.d.ts +38 -0
- package/dist/readmodels.js +229 -0
- package/package.json +30 -0
- package/src/hooks.test.ts +451 -0
- package/src/hooks.ts +830 -0
- package/src/index.test.ts +739 -0
- package/src/index.ts +1572 -0
- package/src/readmodels.ts +248 -0
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
|
+
}
|