@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.
@@ -0,0 +1,739 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CONFIG_FLAG_CAP,
4
+ CopilotConfigSchema,
5
+ FEATURE_SCHEMAS,
6
+ NotificationsConfigSchema,
7
+ countLeaves,
8
+ setKnownMcpTools,
9
+ validateFeatureConfig,
10
+ } from './index.js';
11
+ // The LIVE mcp catalog (zero-import pure data) — the same relative-import
12
+ // convention as packages/cli/src/mcp.ts. Test-only: the publish build
13
+ // (tsconfig.build.json) excludes tests, so rootDir stays clean.
14
+ import { TOOLS } from '../../../workers/mcp-v1/src/tools.js';
15
+
16
+ describe('15-flag cap analyzer (architecture §6)', () => {
17
+ it('notifications schema has 14 leaves — under the cap', () => {
18
+ expect(countLeaves(NotificationsConfigSchema)).toBe(14);
19
+ expect(countLeaves(NotificationsConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
20
+ });
21
+
22
+ it('EVERY registered feature schema is ≤ 15 leaves (the CI gate)', () => {
23
+ for (const [feature, schema] of Object.entries(FEATURE_SCHEMAS)) {
24
+ expect(countLeaves(schema), `${feature} exceeds the flag cap`).toBeLessThanOrEqual(
25
+ CONFIG_FLAG_CAP,
26
+ );
27
+ }
28
+ });
29
+
30
+ it('every shipped feature has a registered config schema (typebox-config-required)', () => {
31
+ const SHIPPED_FEATURES = ['notifications', 'jobs', 'auth'];
32
+ for (const f of SHIPPED_FEATURES) {
33
+ expect(FEATURE_SCHEMAS[f], `feature '${f}' has no TypeBox config schema`).toBeDefined();
34
+ }
35
+ });
36
+
37
+ it('auth schema sits AT the cap (optional bags = 1 leaf each)', () => {
38
+ // enabled(1) + methods(6) + providers(1) + session(1) + password(1)
39
+ // + emailVerification(1) + magicLink(1) + otp(1) + anonymous(1)
40
+ // + orgClaims(1) = 15. The OTP/anonymous/orgClaims wave funded itself by
41
+ // collapsing session/password to optional bags and dropping the
42
+ // consumer-less emailVerification.tokenTtlHours (same precedent as the
43
+ // dropped `redirects` block). The cap check is `>`, so 15 passes.
44
+ expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBe(15);
45
+ expect(countLeaves(FEATURE_SCHEMAS['auth']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
46
+ });
47
+
48
+ it('files schema is 14 leaves after the ttl/extractText optional bags', () => {
49
+ expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBe(14);
50
+ expect(countLeaves(FEATURE_SCHEMAS['files']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
51
+ });
52
+
53
+ it('jobs schema is 13 leaves (generation gains reserve caps: +2 = 6 generation leaves)', () => {
54
+ // enabled(1) + retry(1) + retention(2) + dlqDailyQuota(1) + schedules(1)
55
+ // + concurrency(1) + generation(6: maxConcurrent, defaultTimeoutMs,
56
+ // maxTimeoutMs, pollMaxAttempts, maxReserveCredits, maxOutstandingReserveCredits)
57
+ // = 13, with 2 leaves of headroom under the cap.
58
+ expect(countLeaves(FEATURE_SCHEMAS['jobs']!)).toBe(13);
59
+ expect(countLeaves(FEATURE_SCHEMAS['jobs']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
60
+ });
61
+
62
+ it('vector-search schema is 14 leaves (rerank optional object +1, sync array +1)', () => {
63
+ expect(countLeaves(FEATURE_SCHEMAS['vector-search']!)).toBe(14);
64
+ expect(countLeaves(FEATURE_SCHEMAS['vector-search']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
65
+ });
66
+
67
+ it('ai schema is exactly 15 leaves — providers.compat (Optional object) counts as ONE; ZERO headroom', () => {
68
+ // enabled + defaultProvider + 3 keyRefs + compat(1) + defaults(3) + cache(1)
69
+ // + limits(2) + streaming(3) = 15. Any future ai knob must ride inside
70
+ // compat (free) or another optional object.
71
+ expect(countLeaves(FEATURE_SCHEMAS['ai']!)).toBe(15);
72
+ expect(countLeaves(FEATURE_SCHEMAS['ai']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
73
+ });
74
+ });
75
+
76
+ describe('ai config — compat.openaiBaseUrl write-time validation', () => {
77
+ it('accepts a public https base + an openrouter default provider', () => {
78
+ const v = validateFeatureConfig('ai', {
79
+ defaultProvider: 'openrouter',
80
+ providers: { compat: { openrouterKeyRef: 'secret:openrouter', openaiBaseUrl: 'https://api.deepseek.com/v1' } },
81
+ });
82
+ expect(v.ok).toBe(true);
83
+ const manifest = v.value as { providers: { compat?: { openrouterKeyRef?: string } } };
84
+ expect(manifest.providers.compat?.openrouterKeyRef).toBe('secret:openrouter');
85
+ });
86
+
87
+ it('rejects http://, private-IP and internal-host bases before they can publish', () => {
88
+ for (const bad of ['http://api.example.com/v1', 'https://10.1.2.3/v1', 'https://metadata.google.internal/v1', 'https://localhost:8080/v1']) {
89
+ const v = validateFeatureConfig('ai', { providers: { compat: { openaiBaseUrl: bad } } });
90
+ expect(v.ok, bad).toBe(false);
91
+ expect(v.errors?.[0]).toContain('/providers/compat/openaiBaseUrl');
92
+ }
93
+ });
94
+
95
+ it('defaults leave compat absent (the optional-object leaf is free until used)', () => {
96
+ const v = validateFeatureConfig('ai', {});
97
+ expect(v.ok).toBe(true);
98
+ expect((v.value as { providers: { compat?: unknown } }).providers.compat).toBeUndefined();
99
+ });
100
+ });
101
+
102
+ describe('validateFeatureConfig', () => {
103
+ const minimal = {
104
+ fromEmail: 'noreply@acme.com',
105
+ resendApiKeyRef: 'secret:resend_api_key',
106
+ };
107
+
108
+ it('accepts a minimal config and applies defaults', () => {
109
+ const r = validateFeatureConfig('notifications', minimal);
110
+ expect(r.ok).toBe(true);
111
+ const v = r.value as Record<string, unknown>;
112
+ expect(v.enabled).toBe(true);
113
+ expect(v.provider).toBe('resend');
114
+ expect((v.retry as Record<string, unknown>).maxAttempts).toBe(5);
115
+ expect((v.rateLimit as Record<string, unknown>).perTenantSec).toBe(50);
116
+ });
117
+
118
+ it('rejects a malformed fromEmail', () => {
119
+ const r = validateFeatureConfig('notifications', { ...minimal, fromEmail: 'not-an-email' });
120
+ expect(r.ok).toBe(false);
121
+ expect(r.errors.join(' ')).toContain('fromEmail');
122
+ });
123
+
124
+ it('rejects retry.maxAttempts out of range', () => {
125
+ const r = validateFeatureConfig('notifications', {
126
+ ...minimal,
127
+ retry: { maxAttempts: 99, backoff: 'exponential' },
128
+ });
129
+ expect(r.ok).toBe(false);
130
+ });
131
+
132
+ it('rejects an unknown feature', () => {
133
+ const r = validateFeatureConfig('telepathy', { enabled: true });
134
+ expect(r.ok).toBe(false);
135
+ expect(r.errors[0]).toContain('unknown feature');
136
+ });
137
+
138
+ it('rejects an unknown provider', () => {
139
+ const r = validateFeatureConfig('notifications', { ...minimal, provider: 'sendgrid' });
140
+ expect(r.ok).toBe(false);
141
+ });
142
+
143
+ it("provider 'mock' needs no resendApiKeyRef (zero-config); provider 'resend' still requires it", () => {
144
+ const mock = validateFeatureConfig('notifications', {
145
+ fromEmail: 'noreply@acme.com', provider: 'mock', inboxEnabled: true,
146
+ });
147
+ expect(mock.ok).toBe(true);
148
+
149
+ const resendNoRef = validateFeatureConfig('notifications', {
150
+ fromEmail: 'noreply@acme.com', provider: 'resend',
151
+ });
152
+ expect(resendNoRef.ok).toBe(false);
153
+ expect(resendNoRef.errors.join(' ')).toContain('resendApiKeyRef');
154
+ });
155
+
156
+ it('strips unknown top-level AND nested keys so they are never persisted (audit #112/#118)', () => {
157
+ const r = validateFeatureConfig('notifications', {
158
+ ...minimal,
159
+ bogusTopLevel: 'evil',
160
+ retry: { maxAttempts: 3, backoff: 'linear', sneaky: 'nope' },
161
+ });
162
+ expect(r.ok).toBe(true);
163
+ const v = r.value as Record<string, unknown>;
164
+ // declared keys survive (incl. materialized defaults)
165
+ expect(v.fromEmail).toBe('noreply@acme.com');
166
+ expect((v.retry as Record<string, unknown>).maxAttempts).toBe(3);
167
+ // unknown keys at every depth are removed — the stored manifest is closed
168
+ expect(v).not.toHaveProperty('bogusTopLevel');
169
+ expect(v.retry).not.toHaveProperty('sneaky');
170
+ });
171
+
172
+ it('auth: accepts apple + facebook provider config blocks + method toggles (§6.4)', () => {
173
+ const r = validateFeatureConfig('auth', {
174
+ methods: { google: false, github: false, apple: true, facebook: true },
175
+ providers: {
176
+ apple: {
177
+ servicesId: 'com.acme.web',
178
+ teamId: 'TEAM123456',
179
+ keyId: 'KEY7890AB',
180
+ p8KeyRef: 'secret:apple_signin_p8',
181
+ },
182
+ facebook: {
183
+ clientIdRef: 'secret:facebook_app_id',
184
+ clientSecretRef: 'secret:facebook_app_secret',
185
+ },
186
+ },
187
+ });
188
+ expect(r.ok).toBe(true);
189
+ const v = r.value as { providers?: Record<string, Record<string, unknown>> };
190
+ // the apple/facebook BYO refs survive Value.Clean (declared → persisted)
191
+ expect(v.providers?.apple).toEqual({
192
+ servicesId: 'com.acme.web',
193
+ teamId: 'TEAM123456',
194
+ keyId: 'KEY7890AB',
195
+ p8KeyRef: 'secret:apple_signin_p8',
196
+ });
197
+ expect(v.providers?.facebook).toEqual({
198
+ clientIdRef: 'secret:facebook_app_id',
199
+ clientSecretRef: 'secret:facebook_app_secret',
200
+ });
201
+ // The enable toggles survive Value.Clean — the regression that made the
202
+ // shipped oauthCore apple/facebook flow un-enablable via the pipeline.
203
+ const m = (r.value as { methods: Record<string, boolean> }).methods;
204
+ expect(m.apple).toBe(true);
205
+ expect(m.facebook).toBe(true);
206
+ });
207
+
208
+ it('files: extractText + ttl round-trip WITHOUT being stripped (the Value.Clean regression)', () => {
209
+ const r = validateFeatureConfig('files', {
210
+ extractText: { enabled: true, provider: 'gcv', keyRef: 'secret:ocr_api_key' },
211
+ ttl: { enabled: true, defaultExpiresInSeconds: 3600 },
212
+ });
213
+ expect(r.ok).toBe(true);
214
+ const v = r.value as {
215
+ extractText?: Record<string, unknown>;
216
+ ttl?: Record<string, unknown>;
217
+ };
218
+ expect(v.extractText).toMatchObject({
219
+ enabled: true,
220
+ provider: 'gcv',
221
+ keyRef: 'secret:ocr_api_key',
222
+ // inner defaults materialize on a provided bag
223
+ asyncOverJobs: true,
224
+ boundingBoxes: false,
225
+ });
226
+ expect(v.ttl).toMatchObject({
227
+ enabled: true,
228
+ defaultExpiresInSeconds: 3600,
229
+ sweepCron: '0 * * * *',
230
+ });
231
+ // omitted bags stay omitted (optional — no default:{} materialization)
232
+ const bare = validateFeatureConfig('files', { enabled: true });
233
+ expect(bare.ok).toBe(true);
234
+ expect(bare.value).not.toHaveProperty('extractText');
235
+ expect(bare.value).not.toHaveProperty('ttl');
236
+ // unknown providers are rejected, not silently coerced
237
+ const bad = validateFeatureConfig('files', { extractText: { enabled: true, provider: 'tesseract' } });
238
+ expect(bad.ok).toBe(false);
239
+ });
240
+
241
+ it('auth: facebook accepts optional audRefs (extra native-aud allow-list)', () => {
242
+ const r = validateFeatureConfig('auth', {
243
+ providers: {
244
+ facebook: {
245
+ clientIdRef: 'secret:fb_app_id',
246
+ clientSecretRef: 'secret:fb_app_secret',
247
+ audRefs: ['secret:fb_ios_client', 'secret:fb_android_client'],
248
+ },
249
+ },
250
+ });
251
+ expect(r.ok).toBe(true);
252
+ const v = r.value as { providers?: { facebook?: { audRefs?: string[] } } };
253
+ expect(v.providers?.facebook?.audRefs).toEqual(['secret:fb_ios_client', 'secret:fb_android_client']);
254
+ });
255
+
256
+ it('auth: apple bundleIds round-trip through validateFeatureConfig (native iOS aud)', () => {
257
+ // Regression: before the schema carried bundleIds, Value.Clean STRIPPED the
258
+ // field on publish, so genuine iOS ASAuthorization id_tokens (aud = bundle
259
+ // id) could never be allow-listed via the pipeline (the worker already
260
+ // reads providers.apple.bundleIds — oauthCore.ts).
261
+ const r = validateFeatureConfig('auth', {
262
+ providers: {
263
+ apple: {
264
+ servicesId: 'com.acme.web', teamId: 'TEAM123456', keyId: 'KEY7890AB',
265
+ p8KeyRef: 'secret:apple_p8', bundleIds: ['com.acme.app', 'com.acme.app.dev'],
266
+ },
267
+ },
268
+ });
269
+ expect(r.ok).toBe(true);
270
+ const v = r.value as { providers?: { apple?: { bundleIds?: string[] } } };
271
+ expect(v.providers?.apple?.bundleIds).toEqual(['com.acme.app', 'com.acme.app.dev']);
272
+ });
273
+
274
+ it('auth: otp/anonymous/orgClaims optional bags round-trip; session/password stay materialized', () => {
275
+ const r = validateFeatureConfig('auth', {
276
+ otp: { enabled: true, codeTtlMinutes: 5 },
277
+ anonymous: { enabled: true },
278
+ orgClaims: { enabled: true },
279
+ });
280
+ expect(r.ok).toBe(true);
281
+ const v = r.value as Record<string, unknown>;
282
+ // provided bags survive Value.Clean with inner defaults materialized
283
+ expect(v.otp).toEqual({ enabled: true, codeTtlMinutes: 5, maxAttempts: 5, resendCooldownSec: 60 });
284
+ expect(v.anonymous).toEqual({ enabled: true });
285
+ expect(v.orgClaims).toEqual({ enabled: true });
286
+ // session/password are optional bags but keep `{ default: {} }`, so a bare
287
+ // publish still materializes the knobs the worker reads (back-compat).
288
+ expect(v.session).toEqual({ ttlMinutes: 60, refreshTtlDays: 30 });
289
+ expect(v.password).toEqual({ minLength: 8, requireMixed: false });
290
+ // tokenTtlHours is GONE (consumer-less — dropped for cap headroom)
291
+ expect(v.emailVerification).toEqual({ required: false });
292
+
293
+ // omitted new bags stay omitted (absent = disabled at the worker gate)
294
+ const bare = validateFeatureConfig('auth', {});
295
+ expect(bare.ok).toBe(true);
296
+ expect(bare.value).not.toHaveProperty('otp');
297
+ expect(bare.value).not.toHaveProperty('anonymous');
298
+ expect(bare.value).not.toHaveProperty('orgClaims');
299
+ });
300
+
301
+ it('auth: otp knob bounds are enforced (maxAttempts 3..10)', () => {
302
+ expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 2 } }).ok).toBe(false);
303
+ expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 11 } }).ok).toBe(false);
304
+ expect(validateFeatureConfig('auth', { otp: { enabled: true, maxAttempts: 10 } }).ok).toBe(true);
305
+ });
306
+
307
+ it('functions: an authHook binding is accepted; a bad event is rejected', () => {
308
+ const fn = (bindings: unknown[]) => ({
309
+ functions: { provision: { scriptRef: 'fn-t-provision-abc', bindings } },
310
+ });
311
+ const okCfg = validateFeatureConfig('functions', fn([{ kind: 'authHook' }]));
312
+ expect(okCfg.ok).toBe(true);
313
+ const explicit = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.created' }]));
314
+ expect(explicit.ok).toBe(true);
315
+ const bad = validateFeatureConfig('functions', fn([{ kind: 'authHook', event: 'user.deleted' }]));
316
+ expect(bad.ok).toBe(false);
317
+ expect(bad.errors.join(' ')).toContain("must be 'user.created'");
318
+ });
319
+
320
+ it('functions: DENY_FUNCTION_SCOPES rejects cross-tenant/self-integrity scopes but ALLOWS business scopes', () => {
321
+ const fn = (scopes: string[]) => ({ functions: { f: { scriptRef: 'fn-t-f-abc', scopes } } });
322
+ // ALLOWED — legitimate function business actions (checkout saga, email, user
323
+ // mgmt, fn→fn composition).
324
+ for (const s of ['payments:write', 'notifications:send', 'users:write', 'users:read', 'cms:write', 'functions:invoke']) {
325
+ expect(validateFeatureConfig('functions', fn([s])).ok, `expected '${s}' allowed`).toBe(true);
326
+ }
327
+ // DENIED — cross-tenant (admin/*) + self-integrity (features:write/functions:write/secrets:write).
328
+ for (const s of ['admin', '*', 'features:write', 'functions:write', 'secrets:write']) {
329
+ const r = validateFeatureConfig('functions', fn([s]));
330
+ expect(r.ok, `expected '${s}' denied`).toBe(false);
331
+ expect(r.errors.join(' ')).toContain('never grantable');
332
+ }
333
+ });
334
+
335
+ it('auth: rejects an apple block missing a required field (teamId)', () => {
336
+ const r = validateFeatureConfig('auth', {
337
+ providers: {
338
+ apple: { servicesId: 'com.acme.web', keyId: 'KEY7890AB', p8KeyRef: 'secret:apple_p8' },
339
+ },
340
+ });
341
+ expect(r.ok).toBe(false);
342
+ expect(r.errors.join(' ')).toContain('teamId');
343
+ });
344
+
345
+ it('auth: strips an unknown provider key (config is closed — only the 4 known providers)', () => {
346
+ const r = validateFeatureConfig('auth', {
347
+ providers: {
348
+ google: { clientIdRef: 'secret:g_id', clientSecretRef: 'secret:g_secret' },
349
+ twitter: { clientIdRef: 'secret:x_id', clientSecretRef: 'secret:x_secret' },
350
+ },
351
+ });
352
+ expect(r.ok).toBe(true);
353
+ const v = r.value as { providers?: Record<string, unknown> };
354
+ expect(v.providers).toHaveProperty('google');
355
+ expect(v.providers).not.toHaveProperty('twitter');
356
+ });
357
+
358
+ it('a non-schema key named like a prototype pollutant is dropped, not persisted', () => {
359
+ // Closing the schema (Value.Clean) means an attacker-supplied own key —
360
+ // including an own enumerable "__proto__" string key smuggled via JSON —
361
+ // never reaches the stored manifest. (Deep-merge proto-pollution itself is
362
+ // guarded in the control-plane merge layer; this asserts the validator
363
+ // doesn't widen the persisted object.)
364
+ const raw = JSON.parse(`{"fromEmail":"a@b.c","resendApiKeyRef":"r","__proto__":{"polluted":true}}`) as Record<string, unknown>;
365
+ const r = validateFeatureConfig('notifications', raw);
366
+ expect(r.ok).toBe(true);
367
+ // The persisted manifest is its OWN enumerable keys (what csql.json /
368
+ // JSON.stringify serialize) — none of them is the smuggled pollutant.
369
+ const ownKeys = Object.keys(r.value as object);
370
+ expect(ownKeys).not.toContain('__proto__');
371
+ expect(ownKeys).not.toContain('polluted');
372
+ expect(Object.prototype.hasOwnProperty.call(r.value, 'polluted')).toBe(false);
373
+ expect(JSON.parse(JSON.stringify(r.value)) as Record<string, unknown>).not.toHaveProperty('polluted');
374
+ });
375
+ });
376
+
377
+ describe('mcp config: custom tools + prompts (mcp.md §6.5/§11)', () => {
378
+ it('McpConfigSchema stays at 9 leaves (customTools/prompts are ONE Record leaf each)', () => {
379
+ expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBe(9);
380
+ expect(countLeaves(FEATURE_SCHEMAS['mcp']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
381
+ });
382
+
383
+ const tool = (over: Record<string, unknown> = {}) => ({
384
+ customTools: {
385
+ crm_lookup: {
386
+ description: 'Look up a CRM contact by email.',
387
+ url: 'https://api.acme.example/mcp/crm-lookup',
388
+ ...over,
389
+ },
390
+ },
391
+ });
392
+
393
+ it('accepts a well-formed custom tool (config round-trips)', () => {
394
+ const r = validateFeatureConfig('mcp', tool({
395
+ inputSchema: { type: 'object', properties: { email: { type: 'string' } } },
396
+ timeoutMs: 5000,
397
+ }));
398
+ expect(r.ok).toBe(true);
399
+ const v = r.value as { customTools: Record<string, { url: string; timeoutMs: number }> };
400
+ expect(v.customTools.crm_lookup!.url).toBe('https://api.acme.example/mcp/crm-lookup');
401
+ expect(v.customTools.crm_lookup!.timeoutMs).toBe(5000);
402
+ });
403
+
404
+ it('rejects a bad tool name, an http:// URL, and internal/private hosts', () => {
405
+ const badName = validateFeatureConfig('mcp', {
406
+ customTools: { 'Bad-Name': { description: 'x', url: 'https://ok.example/a' } },
407
+ });
408
+ expect(badName.ok).toBe(false);
409
+ expect(badName.errors.join(' ')).toContain('name must match');
410
+
411
+ for (const url of [
412
+ 'http://api.acme.example/hook', // scheme
413
+ 'https://user:pass@api.acme.example/hook', // embedded creds
414
+ 'https://localhost/hook',
415
+ 'https://foo.localhost/hook',
416
+ 'https://db.internal/hook',
417
+ 'https://printer.local/hook',
418
+ 'https://10.1.2.3/hook',
419
+ 'https://169.254.169.254/latest/meta-data',
420
+ 'https://[::1]/hook',
421
+ 'not a url',
422
+ ]) {
423
+ const r = validateFeatureConfig('mcp', tool({ url }));
424
+ expect(r.ok, `should reject ${url}`).toBe(false);
425
+ expect(r.errors.join(' ')).toContain('/customTools/crm_lookup/url');
426
+ }
427
+ });
428
+
429
+ it('rejects >32 custom tools and an oversized inputSchema', () => {
430
+ const many: Record<string, unknown> = {};
431
+ for (let i = 0; i < 33; i++) {
432
+ many[`tool_${i}`] = { description: 'x', url: 'https://ok.example/t' };
433
+ }
434
+ const over = validateFeatureConfig('mcp', { customTools: many });
435
+ expect(over.ok).toBe(false);
436
+ expect(over.errors.join(' ')).toContain('at most 32');
437
+
438
+ const fat = validateFeatureConfig('mcp', tool({
439
+ inputSchema: { type: 'object', description: 'x'.repeat(9000) },
440
+ }));
441
+ expect(fat.ok).toBe(false);
442
+ expect(fat.errors.join(' ')).toContain('8KB');
443
+ });
444
+
445
+ it('prompts: placeholders must be declared arguments; declared-but-unused is fine', () => {
446
+ const good = validateFeatureConfig('mcp', {
447
+ prompts: {
448
+ draft_reply: {
449
+ description: 'Draft a support reply.',
450
+ template: 'Reply to {{customer}} about {{topic}}. Be concise.',
451
+ arguments: [
452
+ { name: 'customer', required: true },
453
+ { name: 'topic' },
454
+ { name: 'unused_extra' }, // allowed
455
+ ],
456
+ },
457
+ },
458
+ });
459
+ expect(good.ok).toBe(true);
460
+
461
+ const bad = validateFeatureConfig('mcp', {
462
+ prompts: {
463
+ draft_reply: { template: 'Reply to {{customer}}.', arguments: [{ name: 'topic' }] },
464
+ },
465
+ });
466
+ expect(bad.ok).toBe(false);
467
+ expect(bad.errors.join(' ')).toContain('{{customer}}');
468
+ });
469
+
470
+ it('prompts: rejects a bad name and >32 prompts', () => {
471
+ const badName = validateFeatureConfig('mcp', {
472
+ prompts: { 'Bad Name': { template: 'x' } },
473
+ });
474
+ expect(badName.ok).toBe(false);
475
+
476
+ const many: Record<string, unknown> = {};
477
+ for (let i = 0; i < 33; i++) many[`p_${i}`] = { template: 'x' };
478
+ const over = validateFeatureConfig('mcp', { prompts: many });
479
+ expect(over.ok).toBe(false);
480
+ expect(over.errors.join(' ')).toContain('at most 32');
481
+ });
482
+
483
+ it('a bare mcp config (no customTools/prompts) still validates with defaults', () => {
484
+ const r = validateFeatureConfig('mcp', { enabled: true });
485
+ expect(r.ok).toBe(true);
486
+ expect(r.value).not.toHaveProperty('customTools');
487
+ expect(r.value).not.toHaveProperty('prompts');
488
+ });
489
+ });
490
+
491
+ describe('copilot config (docs/copilot-agents-sku-design.md §4)', () => {
492
+ it('has exactly 9 leaves — under the cap (agents/allow/collections are DATA)', () => {
493
+ expect(countLeaves(CopilotConfigSchema)).toBe(9);
494
+ expect(countLeaves(CopilotConfigSchema)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
495
+ });
496
+
497
+ it('quickstart minimal body materializes a valid zero-agent manifest', () => {
498
+ const r = validateFeatureConfig('copilot', { enabled: true });
499
+ expect(r.ok).toBe(true);
500
+ const v = r.value as Record<string, unknown>;
501
+ expect(v.agents).toEqual({});
502
+ expect((v.widget as Record<string, unknown>).enabled).toBe(false);
503
+ expect((v.limits as Record<string, unknown>).consumeCredits).toBe(false);
504
+ });
505
+
506
+ // NOTE: runs BEFORE the setKnownMcpTools registration below (vitest executes
507
+ // tests in file order) — asserts the published-package posture where no
508
+ // catalog is available and the name-existence rule is skipped.
509
+ it('without a registered catalog, unknown tool names pass (name rule skipped)', () => {
510
+ const r = validateFeatureConfig('copilot', {
511
+ agents: { a: { name: 'A', actions: { mode: 'actions', allow: { not_a_tool: {} } } } },
512
+ });
513
+ expect(r.ok).toBe(true);
514
+ });
515
+
516
+ it('with the live catalog registered, an unknown allow key is rejected; real names pass', () => {
517
+ setKnownMcpTools(TOOLS.map((t) => t.name));
518
+ const bad = validateFeatureConfig('copilot', {
519
+ agents: { a: { name: 'A', actions: { mode: 'actions', allow: { not_a_tool: {} } } } },
520
+ });
521
+ expect(bad.ok).toBe(false);
522
+ expect(bad.errors.join(' ')).toContain('not_a_tool');
523
+
524
+ const good = validateFeatureConfig('copilot', {
525
+ agents: {
526
+ a: {
527
+ name: 'A',
528
+ actions: { mode: 'actions', allow: { cms_query_items: {}, cms_create_item: { requireConfirm: true } } },
529
+ },
530
+ },
531
+ });
532
+ expect(good.ok).toBe(true);
533
+ });
534
+
535
+ it('guestToolAllow must be a subset of actions.allow', () => {
536
+ const r = validateFeatureConfig('copilot', {
537
+ agents: {
538
+ a: {
539
+ name: 'A',
540
+ actions: { mode: 'actions', allow: { cms_query_items: {} } },
541
+ guardrails: { allowGuest: true, guestToolAllow: ['cms_create_item'] },
542
+ },
543
+ },
544
+ });
545
+ expect(r.ok).toBe(false);
546
+ expect(r.errors.join(' ')).toContain('not in actions.allow');
547
+ });
548
+
549
+ it('a non-empty guestToolAllow requires allowGuest: true', () => {
550
+ const r = validateFeatureConfig('copilot', {
551
+ agents: {
552
+ a: {
553
+ name: 'A',
554
+ actions: { mode: 'actions', allow: { cms_query_items: {} } },
555
+ guardrails: { allowGuest: false, guestToolAllow: ['cms_query_items'] },
556
+ },
557
+ },
558
+ });
559
+ expect(r.ok).toBe(false);
560
+ expect(r.errors.join(' ')).toContain('allowGuest');
561
+ });
562
+
563
+ it('widget.requireAuth=false requires ≥1 agent with allowGuest', () => {
564
+ const bad = validateFeatureConfig('copilot', {
565
+ agents: { a: { name: 'A' } },
566
+ widget: { enabled: true, requireAuth: false, allowedOrigins: ['https://example.com'] },
567
+ });
568
+ expect(bad.ok).toBe(false);
569
+ expect(bad.errors.join(' ')).toContain('allowGuest');
570
+
571
+ const good = validateFeatureConfig('copilot', {
572
+ agents: { a: { name: 'A', guardrails: { allowGuest: true } } },
573
+ widget: { enabled: true, requireAuth: false, allowedOrigins: ['https://example.com'] },
574
+ });
575
+ expect(good.ok).toBe(true);
576
+ });
577
+
578
+ it("escalation.handler 'notifications' requires notifyTemplate", () => {
579
+ const bad = validateFeatureConfig('copilot', {
580
+ agents: {},
581
+ escalation: { enabled: true, handler: 'notifications' },
582
+ });
583
+ expect(bad.ok).toBe(false);
584
+ expect(bad.errors.join(' ')).toContain('notifyTemplate');
585
+
586
+ const good = validateFeatureConfig('copilot', {
587
+ agents: {},
588
+ escalation: { enabled: true, handler: 'notifications', notifyTemplate: 'escalation' },
589
+ });
590
+ expect(good.ok).toBe(true);
591
+ });
592
+ });
593
+
594
+ // fn-online-typed-io: the per-function `signature` (Level-1 typed I/O) is
595
+ // DECLARED schema data inside the functions Type.Record value — Value.Clean
596
+ // must keep it, the size guard must bound it, and the leaf count must not move.
597
+ describe('functions config — signature (Level-1 typed I/O)', () => {
598
+ const entry = (extra: Record<string, unknown> = {}) => ({
599
+ enabled: true,
600
+ functions: {
601
+ hello: { scriptRef: 'fn-t-hello-0123456789ab', ...extra },
602
+ },
603
+ });
604
+
605
+ it('signature survives validateFeatureConfig (Value.Clean keeps declared keys)', () => {
606
+ const sig = { input: { name: 'string' }, output: { ok: 'boolean' } };
607
+ const r = validateFeatureConfig('functions', entry({ signature: sig }));
608
+ expect(r.ok).toBe(true);
609
+ const fns = (r.value as { functions: Record<string, { signature?: unknown }> }).functions;
610
+ expect(fns.hello!.signature).toEqual(sig);
611
+ });
612
+
613
+ it('rejects an oversize signature (manifest bloat guard), naming the path', () => {
614
+ const big = { input: { blob: 'x'.repeat(9_000) } };
615
+ const r = validateFeatureConfig('functions', entry({ signature: big }));
616
+ expect(r.ok).toBe(false);
617
+ expect(r.errors.join(' ')).toContain('/functions/hello/signature');
618
+ });
619
+
620
+ it('functions schema stays at 7 leaves (signature is DATA in the map)', () => {
621
+ expect(countLeaves(FEATURE_SCHEMAS['functions']!)).toBe(7);
622
+ });
623
+ });
624
+
625
+ describe('cms-rel config: readModels + cdc (cms-relational-depth §3/§4)', () => {
626
+ it('cms schema lands at 15 leaves (relations +3, readModels +1, cdc +1, strictEndUserScope +1) — AT the cap', () => {
627
+ expect(countLeaves(FEATURE_SCHEMAS['cms']!)).toBe(15);
628
+ expect(countLeaves(FEATURE_SCHEMAS['cms']!)).toBeLessThanOrEqual(CONFIG_FLAG_CAP);
629
+ });
630
+
631
+ it('relations block defaults + range enforcement', () => {
632
+ const r = validateFeatureConfig('cms', {});
633
+ expect(r.ok).toBe(true);
634
+ const rel = (r.value as { relations: Record<string, number> }).relations;
635
+ expect(rel).toEqual({ maxExpandDepth: 1, maxExpandFields: 5, maxComputedFieldsPerCollection: 10 });
636
+ expect(validateFeatureConfig('cms', { relations: { maxExpandDepth: 3 } }).ok).toBe(false);
637
+ });
638
+
639
+ const Z1 = {
640
+ readModels: {
641
+ discover_featured: {
642
+ collection: 'posts', kind: 'rank',
643
+ spec: {
644
+ groupBy: 'channel', metric: { fn: 'sum', field: 'view_count' },
645
+ rank: 'percent_rank', direction: 'desc',
646
+ filter: { $status: 'published', 'channel.visibility': 'public' },
647
+ window: { field: 'created_at', sinceDays: 30 },
648
+ },
649
+ materialize: { cron: '0 3 * * *', to: 'discover_rollup' },
650
+ },
651
+ },
652
+ };
653
+
654
+ it('accepts the Zaawi Z1 declared read-model (rank + join filter + cron)', () => {
655
+ const r = validateFeatureConfig('cms', Z1);
656
+ expect(r.errors ?? []).toEqual([]);
657
+ expect(r.ok).toBe(true);
658
+ // Value.Clean must NOT hollow out the opaque spec (the functions signature idiom)
659
+ const kept = (r.value as { readModels: Record<string, { spec: Record<string, unknown> }> })
660
+ .readModels.discover_featured!.spec;
661
+ expect(kept.groupBy).toBe('channel');
662
+ expect((kept.filter as Record<string, unknown>)['channel.visibility']).toBe('public');
663
+ });
664
+
665
+ it('accepts a Z3-style aggregate read-model', () => {
666
+ const r = validateFeatureConfig('cms', {
667
+ readModels: {
668
+ daily_bookings: {
669
+ collection: 'bookings', kind: 'aggregate',
670
+ spec: {
671
+ aggregates: [{ fn: 'count' }, { fn: 'sum', field: 'amount', as: 'revenue' }],
672
+ groupBy: ['space'], window: { field: 'created_at', sinceDays: 1 },
673
+ },
674
+ materialize: { cron: '0 2 * * *', to: 'analytics_daily' },
675
+ },
676
+ },
677
+ });
678
+ expect(r.errors ?? []).toEqual([]);
679
+ });
680
+
681
+ it('rejects fn/rank outside the allow-lists at CONFIG-WRITE time', () => {
682
+ const bad = (spec: object, kind = 'aggregate') => validateFeatureConfig('cms', {
683
+ readModels: { m: { collection: 'c', kind, spec, materialize: { cron: '* * * * *', to: 't' } } },
684
+ });
685
+ expect(bad({ aggregates: [{ fn: 'array_agg', field: 'x' }] }).ok).toBe(false);
686
+ expect(bad({ groupBy: 'x', rank: 'ntile' }, 'rank').ok).toBe(false);
687
+ expect(bad({ groupBy: 'x', rank: 'rank() OVER (ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)' }, 'rank').ok).toBe(false);
688
+ });
689
+
690
+ it('rejects structural violations: arity, window, spec size, names, cron, limit smuggling', () => {
691
+ const rm = (over: object) => validateFeatureConfig('cms', {
692
+ readModels: {
693
+ m: {
694
+ collection: 'c', kind: 'aggregate', spec: { aggregates: [{ fn: 'count' }] },
695
+ materialize: { cron: '0 2 * * *', to: 't' }, ...over,
696
+ },
697
+ },
698
+ });
699
+ expect(rm({ spec: { aggregates: [{ fn: 'count' }], groupBy: ['a', 'b', 'c'] } }).ok).toBe(false);
700
+ expect(rm({ spec: { aggregates: [{ fn: 'count' }], window: { field: 'x', sinceDays: 999 } } }).ok).toBe(false);
701
+ expect(rm({ spec: { aggregates: [{ fn: 'count' }], limit: 10 } }).ok).toBe(false);
702
+ expect(rm({ spec: { aggregates: [{ fn: 'count' }], filter: { 'a.b.c': 1 } } }).ok).toBe(false);
703
+ expect(rm({ spec: { aggregates: [{ fn: 'count' }], filter: { 'a.x': 1, 'b.y': 2 } } }).ok).toBe(false);
704
+ expect(rm({ materialize: { cron: 'not-a-cron', to: 't' } }).ok).toBe(false);
705
+ expect(rm({ materialize: { cron: '0 2 * * *', to: 'Bad Slug!' } }).ok).toBe(false);
706
+ // an over-large serialized spec
707
+ expect(rm({ spec: { aggregates: [{ fn: 'count' }], filter: { note: 'x'.repeat(5000) } } }).ok).toBe(false);
708
+ // bad read-model name
709
+ const r = validateFeatureConfig('cms', {
710
+ readModels: { 'Bad Name': { collection: 'c', kind: 'aggregate', spec: { aggregates: [{ fn: 'count' }] } } },
711
+ });
712
+ expect(r.ok).toBe(false);
713
+ });
714
+
715
+ it('caps the bag at 20 read-models', () => {
716
+ const bag: Record<string, unknown> = {};
717
+ for (let i = 0; i < 21; i++) {
718
+ bag[`m${i}`] = { collection: 'c', kind: 'aggregate', spec: { aggregates: [{ fn: 'count' }] } };
719
+ }
720
+ expect(validateFeatureConfig('cms', { readModels: bag }).ok).toBe(false);
721
+ });
722
+
723
+ it('cdc: accepts a rule bag; rejects bad channel/events/payload and >25 rules', () => {
724
+ const ok = validateFeatureConfig('cms', {
725
+ cdc: {
726
+ posts_live: { collection: 'posts', channel: 'posts:live', events: ['created', 'updated'], payload: 'ids' },
727
+ },
728
+ });
729
+ expect(ok.errors ?? []).toEqual([]);
730
+ const bad = (rule: object) => validateFeatureConfig('cms', { cdc: { r: rule } });
731
+ expect(bad({ collection: 'posts', channel: 'has spaces!' }).ok).toBe(false);
732
+ expect(bad({ collection: 'posts', channel: 'c', events: ['archived'] }).ok).toBe(false);
733
+ expect(bad({ collection: 'posts', channel: 'c', payload: 'everything' }).ok).toBe(false);
734
+ expect(bad({ channel: 'c' }).ok).toBe(false);
735
+ const bag: Record<string, unknown> = {};
736
+ for (let i = 0; i < 26; i++) bag[`r${i}`] = { collection: 'c', channel: `ch${i}` };
737
+ expect(validateFeatureConfig('cms', { cdc: bag }).ok).toBe(false);
738
+ });
739
+ });