@mettlecast/domain-cli 0.2.85 → 0.2.87

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.
@@ -31,22 +31,24 @@ describe('runValidate error shape', () => {
31
31
  });
32
32
 
33
33
  /**
34
- * Action-first migration (#4619, Wave 1 Task 1.2):
34
+ * Action-first migration (#4619, #5090):
35
35
  *
36
36
  * `runValidate` ultimately delegates to `buildRegistry` for the
37
37
  * registry assembly. These tests exercise the action mapping directly
38
38
  * through `buildRegistry` (mocked file walker + module loader) so we
39
- * catch regressions in how legacy `visibility` maps to `backendAccess`
40
- * and how `exposure` is serialized.
39
+ * catch regressions in how exposure is validated.
40
+ *
41
+ * Issue #5090 — strict registry contract: every action must declare
42
+ * exposure, backendAccess, and idempotent explicitly. No legacy defaults.
41
43
  */
42
- describe('buildRegistry action mapping (action-first migration)', () => {
44
+ describe('buildRegistry action mapping (strict contract #5090)', () => {
43
45
  const DOMAIN_ROOT = '/fake/domains/billing';
44
46
 
45
47
  beforeEach(() => {
46
48
  vi.clearAllMocks();
47
49
  });
48
50
 
49
- it('reads backendAccess directly when raw action exposes it', async () => {
51
+ it('reads backendAccess and exposure directly when action declares both', async () => {
50
52
  mockWalkDomainDir.mockResolvedValue({
51
53
  domain: '/fake/domains/billing/domain.config.ts',
52
54
  webhooks: [],
@@ -93,15 +95,12 @@ describe('buildRegistry action mapping (action-first migration)', () => {
93
95
  }
94
96
  });
95
97
 
96
- it('falls back from legacy visibility to backendAccess and collapses workspace -> domain', async () => {
98
+ it('preserves inputSchema and outputSchema extraction', async () => {
97
99
  mockWalkDomainDir.mockResolvedValue({
98
100
  domain: '/fake/domains/billing/domain.config.ts',
99
101
  webhooks: [],
100
102
  subscribers: [],
101
- actions: [
102
- '/fake/domains/billing/actions/verify-token.ts',
103
- '/fake/domains/billing/actions/provision-tenant.ts',
104
- ],
103
+ actions: ['/fake/domains/billing/actions/charge-card.ts'],
105
104
  schedules: [],
106
105
  jobs: [],
107
106
  integrations: [],
@@ -111,24 +110,26 @@ describe('buildRegistry action mapping (action-first migration)', () => {
111
110
  .mockResolvedValueOnce([
112
111
  { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
113
112
  ])
114
- // First call resolves the actions array (Promise.all maps in order).
115
113
  .mockResolvedValueOnce([
116
- { _kind: 'action', _exportName: 'verifyToken', id: 'verify-token', visibility: 'workspace', idempotent: false },
117
- { _kind: 'action', _exportName: 'provisionTenant', id: 'provision-tenant', visibility: 'domain', idempotent: true },
114
+ {
115
+ _kind: 'action',
116
+ _exportName: 'chargeCard',
117
+ id: 'charge-card',
118
+ backendAccess: 'domain',
119
+ exposure: { type: 'internal' },
120
+ idempotent: true,
121
+ input: { type: 'object', properties: { amount: { type: 'number' } } },
122
+ output: { type: 'object', properties: { id: { type: 'string' } } },
123
+ },
118
124
  ]);
119
125
 
120
126
  const { registry } = await buildRegistry(DOMAIN_ROOT);
121
127
 
122
- expect(registry.actions).toHaveLength(2);
123
- const verify = registry.actions.find(a => a.id === 'verify-token');
124
- const provision = registry.actions.find(a => a.id === 'provision-tenant');
125
- expect(verify?.backendAccess).toBe('domain');
126
- expect(verify?.exposure).toEqual({ type: 'internal' });
127
- expect(provision?.backendAccess).toBe('domain');
128
- expect(provision?.exposure).toEqual({ type: 'internal' });
128
+ expect(registry.actions[0]?.inputSchema).toEqual({ type: 'object', properties: { amount: { type: 'number' } } });
129
+ expect(registry.actions[0]?.outputSchema).toEqual({ type: 'object', properties: { id: { type: 'string' } } });
129
130
  });
130
131
 
131
- it('defaults missing exposure to { type: internal } for legacy actions', async () => {
132
+ it('throws on missing exposure (no legacy default) (#5090)', async () => {
132
133
  mockWalkDomainDir.mockResolvedValue({
133
134
  domain: '/fake/domains/billing/domain.config.ts',
134
135
  webhooks: [],
@@ -144,21 +145,45 @@ describe('buildRegistry action mapping (action-first migration)', () => {
144
145
  { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
145
146
  ])
146
147
  .mockResolvedValueOnce([
147
- { _kind: 'action', _exportName: 'legacy', id: 'legacy', visibility: 'private', idempotent: false },
148
+ { _kind: 'action', _exportName: 'legacy', id: 'legacy', backendAccess: 'private', idempotent: false },
148
149
  ]);
149
150
 
150
- const { registry } = await buildRegistry(DOMAIN_ROOT);
151
+ await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow(/exposure/i);
152
+ });
151
153
 
152
- expect(registry.actions[0]?.exposure).toEqual({ type: 'internal' });
153
- expect(registry.actions[0]?.backendAccess).toBe('private');
154
+ it('throws on missing backendAccess (no legacy visibility fallback) (#5090)', async () => {
155
+ mockWalkDomainDir.mockResolvedValue({
156
+ domain: '/fake/domains/billing/domain.config.ts',
157
+ webhooks: [],
158
+ subscribers: [],
159
+ actions: ['/fake/domains/billing/actions/no-backend.ts'],
160
+ schedules: [],
161
+ jobs: [],
162
+ integrations: [],
163
+ publishes: undefined,
164
+ });
165
+ mockLoadModuleExports
166
+ .mockResolvedValueOnce([
167
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
168
+ ])
169
+ .mockResolvedValueOnce([
170
+ {
171
+ _kind: 'action', _exportName: 'noBackend',
172
+ id: 'no-backend',
173
+ exposure: { type: 'internal' },
174
+ idempotent: false,
175
+ },
176
+ ]);
177
+
178
+ await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow(/backendAccess/i);
154
179
  });
155
180
 
156
- it('preserves inputSchema and outputSchema extraction', async () => {
181
+ it('rejects internal exposure with extra keys (#5090)', async () => {
157
182
  mockWalkDomainDir.mockResolvedValue({
158
183
  domain: '/fake/domains/billing/domain.config.ts',
159
184
  webhooks: [],
160
185
  subscribers: [],
161
- actions: ['/fake/domains/billing/actions/charge-card.ts'],
186
+ actions: ['/fake/domains/billing/actions/internal-extra.ts'],
162
187
  schedules: [],
163
188
  jobs: [],
164
189
  integrations: [],
@@ -170,34 +195,59 @@ describe('buildRegistry action mapping (action-first migration)', () => {
170
195
  ])
171
196
  .mockResolvedValueOnce([
172
197
  {
173
- _kind: 'action',
174
- _exportName: 'chargeCard',
175
- id: 'charge-card',
176
- backendAccess: 'domain',
198
+ _kind: 'action', _exportName: 'internalExtra',
199
+ id: 'internal-extra', backendAccess: 'private',
200
+ exposure: { type: 'internal', path: '/v1/secret' },
201
+ idempotent: false,
202
+ },
203
+ ]);
204
+
205
+ await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow(/unexpected keys/i);
206
+ });
207
+
208
+ it('accepts a clean internal action with all required fields (#5090)', async () => {
209
+ mockWalkDomainDir.mockResolvedValue({
210
+ domain: '/fake/domains/billing/domain.config.ts',
211
+ webhooks: [],
212
+ subscribers: [],
213
+ actions: ['/fake/domains/billing/actions/clean.ts'],
214
+ schedules: [],
215
+ jobs: [],
216
+ integrations: [],
217
+ publishes: undefined,
218
+ });
219
+ mockLoadModuleExports
220
+ .mockResolvedValueOnce([
221
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
222
+ ])
223
+ .mockResolvedValueOnce([
224
+ {
225
+ _kind: 'action', _exportName: 'clean',
226
+ id: 'clean', backendAccess: 'private',
177
227
  exposure: { type: 'internal' },
178
- idempotent: true,
179
- input: { type: 'object', properties: { amount: { type: 'number' } } },
180
- output: { type: 'object', properties: { id: { type: 'string' } } },
228
+ idempotent: false,
181
229
  },
182
230
  ]);
183
231
 
184
232
  const { registry } = await buildRegistry(DOMAIN_ROOT);
185
-
186
- expect(registry.actions[0]?.inputSchema).toEqual({ type: 'object', properties: { amount: { type: 'number' } } });
187
- expect(registry.actions[0]?.outputSchema).toEqual({ type: 'object', properties: { id: { type: 'string' } } });
233
+ expect(registry.actions).toHaveLength(1);
234
+ expect(registry.actions[0]!.exposure).toEqual({ type: 'internal' });
188
235
  });
189
236
  });
190
237
 
191
238
  /**
192
- * Action-first security validation rules (#4619, Wave 6 Task 6.1).
239
+ * Action-first security validation rules (#4619, Wave 6 Task 6.1, #5090).
193
240
  *
194
- * These tests exercise the new validation gates added to `runValidate`.
241
+ * These tests exercise the validation gates added to `runValidate`.
195
242
  * Each test mocks `buildRegistry` consumers so the assertions target the
196
243
  * rule codes (`ACTION_EXPOSURE_REQUIRED`, `TENANT_API_PATH_REQUIRED`,
197
244
  * `AUTH_NONE_REQUIRES_EXCEPTION`, etc.) without needing a real
198
245
  * domain on disk.
246
+ *
247
+ * Issue #5090 — strict contract: missing exposure now fails build,
248
+ * and API-exposed actions require input/output schemas.
199
249
  */
200
- describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
250
+ describe('runValidate action-first security rules (#4619 / #5090)', () => {
201
251
  const DOMAIN_ROOT = '/fake/domains/billing';
202
252
 
203
253
  beforeEach(() => {
@@ -225,14 +275,6 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
225
275
  integrations: [],
226
276
  publishes: undefined,
227
277
  });
228
- // Mock consumption order inside buildRegistry:
229
- // 1. domain (paths.domain) -> mock #1
230
- // 2. webhooks/subscribers/schedules/jobs/integrations -> empty arrays, no calls
231
- // 3. actions files (paths.actions)-> mock #N (if any)
232
- // 4. publishes (paths.publishes) -> undefined, no call
233
- // Each non-empty slot consumes exactly one mock per file (we
234
- // generate one synthetic file path per item so the slot has length
235
- // equal to the supplied arrays).
236
278
  mockLoadModuleExports.mockResolvedValueOnce([
237
279
  { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
238
280
  ]);
@@ -244,20 +286,31 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
244
286
  return { errors: result.errors, warnings: result.warnings, valid: result.valid };
245
287
  }
246
288
 
247
- it('ACTION_EXPOSURE_REQUIRED — fires when a non-private action omits exposure', async () => {
248
- const { errors } = await runValidateWith({
249
- actions: [
250
- // `backendAccess: 'platform'` is the strongest "new-style" signal;
251
- // omitting `exposure` should now fail the build.
252
- {
253
- _kind: 'action', _exportName: 'sysReset',
254
- id: 'sys-reset', backendAccess: 'platform', idempotent: false,
255
- },
256
- ],
289
+ it('ACTION_EXPOSURE_REQUIRED — buildRegistry throws when an action has no exposure (#5090)', async () => {
290
+ // buildRegistry now throws on missing exposure — no more legacy default.
291
+ // This test verifies that the build fails fast instead of silently
292
+ // defaulting to internal exposure.
293
+ mockWalkDomainDir.mockResolvedValue({
294
+ domain: '/fake/domains/billing/domain.config.ts',
295
+ webhooks: [],
296
+ subscribers: [],
297
+ actions: ['/fake/domains/billing/actions/x.ts'],
298
+ schedules: [],
299
+ jobs: [],
300
+ integrations: [],
301
+ publishes: undefined,
257
302
  });
258
- const rule = errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED');
259
- expect(rule).toBeDefined();
260
- expect(rule?.message).toContain("'sys-reset'");
303
+ mockLoadModuleExports.mockResolvedValueOnce([
304
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
305
+ ]);
306
+ mockLoadModuleExports.mockResolvedValueOnce([
307
+ {
308
+ _kind: 'action', _exportName: 'sysReset',
309
+ id: 'sys-reset', backendAccess: 'platform', idempotent: false,
310
+ // exposure omitted — the strict contract rejects this
311
+ },
312
+ ]);
313
+ await expect(runValidate(DOMAIN_ROOT, false)).rejects.toThrow(/exposure/i);
261
314
  });
262
315
 
263
316
  it('ACTION_EXPOSURE_REQUIRED — does NOT fire for explicit internal exposure', async () => {
@@ -274,21 +327,6 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
274
327
  expect(errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED')).toBeUndefined();
275
328
  });
276
329
 
277
- it('ACTION_EXPOSURE_REQUIRED — silent on legacy private action (migration compatibility)', async () => {
278
- // Legacy `visibility: 'private'` actions map to `backendAccess: 'private'`
279
- // and default `exposure` to `{ type: 'internal' }`. The rule must not
280
- // flag these so the migration window stays unblocked.
281
- const { errors } = await runValidateWith({
282
- actions: [
283
- {
284
- _kind: 'action', _exportName: 'legacyPrivate',
285
- id: 'legacy-private', visibility: 'private', idempotent: false,
286
- },
287
- ],
288
- });
289
- expect(errors.find(e => e.code === 'ACTION_EXPOSURE_REQUIRED')).toBeUndefined();
290
- });
291
-
292
330
  it('TENANT_API_PATH_REQUIRED — fires when tenancy is required but path lacks tenant placeholder', async () => {
293
331
  const { errors } = await runValidateWith({
294
332
  actions: [
@@ -326,26 +364,33 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
326
364
  expect(errors.find(e => e.code === 'TENANT_API_PATH_REQUIRED')).toBeUndefined();
327
365
  });
328
366
 
329
- it('API_EXPOSURE_AUTH_REQUIRED — fires when auth was defaulted (not declared in source)', async () => {
330
- // Source did NOT include `auth` — the builder will default to
331
- // 'required' and the validator must surface the omission.
332
- const { errors } = await runValidateWith({
333
- actions: [
334
- {
335
- _kind: 'action', _exportName: 'listUsers',
336
- id: 'list-users', backendAccess: 'domain',
337
- exposure: {
338
- type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
339
- tenancy: 'required',
340
- // auth omitted on purpose
341
- },
342
- idempotent: false,
343
- },
344
- ],
367
+ it('API_EXPOSURE_AUTH_REQUIRED — buildRegistry throws when API action has no auth (#5090)', async () => {
368
+ mockWalkDomainDir.mockResolvedValue({
369
+ domain: '/fake/domains/billing/domain.config.ts',
370
+ webhooks: [],
371
+ subscribers: [],
372
+ actions: ['/fake/domains/billing/actions/x.ts'],
373
+ schedules: [],
374
+ jobs: [],
375
+ integrations: [],
376
+ publishes: undefined,
345
377
  });
346
- const rule = errors.find(e => e.code === 'API_EXPOSURE_AUTH_REQUIRED');
347
- expect(rule).toBeDefined();
348
- expect(rule?.message).toContain("'list-users'");
378
+ mockLoadModuleExports.mockResolvedValueOnce([
379
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
380
+ ]);
381
+ mockLoadModuleExports.mockResolvedValueOnce([
382
+ {
383
+ _kind: 'action', _exportName: 'listUsers',
384
+ id: 'list-users', backendAccess: 'domain',
385
+ exposure: {
386
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
387
+ tenancy: 'required',
388
+ // auth omitted — buildRegistry now throws
389
+ },
390
+ idempotent: false,
391
+ },
392
+ ]);
393
+ await expect(runValidate(DOMAIN_ROOT, false)).rejects.toThrow(/auth/i);
349
394
  });
350
395
 
351
396
  it('API_EXPOSURE_AUTH_REQUIRED — silent when auth is explicitly declared', async () => {
@@ -393,7 +438,7 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
393
438
  exposure: {
394
439
  type: 'api', path: '/v1/health', method: 'GET',
395
440
  auth: 'none', tenancy: 'none',
396
- securityException: { reason: 'public liveness probe; no PII; ticket OPS-123' },
441
+ securityException: { reason: 'public liveness probe; ticket OPS-123' },
397
442
  },
398
443
  idempotent: false,
399
444
  },
@@ -493,7 +538,44 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
493
538
  expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
494
539
  });
495
540
 
496
- it('combined: well-formed api action passes every rule', async () => {
541
+ it('API_INPUT_SCHEMA_REQUIRED fires when API action has no inputSchema', async () => {
542
+ const { errors } = await runValidateWith({
543
+ actions: [
544
+ {
545
+ _kind: 'action', _exportName: 'listUsers',
546
+ id: 'list-users', backendAccess: 'domain',
547
+ exposure: {
548
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
549
+ auth: 'required', tenancy: 'required',
550
+ },
551
+ idempotent: false,
552
+ // no input/output schema
553
+ },
554
+ ],
555
+ });
556
+ expect(errors.some(e => e.code === 'API_INPUT_SCHEMA_REQUIRED')).toBe(true);
557
+ });
558
+
559
+ it('API_OUTPUT_SCHEMA_REQUIRED — fires when API action has no outputSchema', async () => {
560
+ const { errors } = await runValidateWith({
561
+ actions: [
562
+ {
563
+ _kind: 'action', _exportName: 'listUsers',
564
+ id: 'list-users', backendAccess: 'domain',
565
+ exposure: {
566
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
567
+ auth: 'required', tenancy: 'required',
568
+ },
569
+ idempotent: false,
570
+ input: { type: 'object', properties: {} },
571
+ // no output schema
572
+ },
573
+ ],
574
+ });
575
+ expect(errors.some(e => e.code === 'API_OUTPUT_SCHEMA_REQUIRED')).toBe(true);
576
+ });
577
+
578
+ it('combined: well-formed api action with input/output schemas passes every rule', async () => {
497
579
  const { errors } = await runValidateWith({
498
580
  actions: [
499
581
  {
@@ -505,6 +587,8 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
505
587
  roles: ['billing-admin'],
506
588
  },
507
589
  idempotent: true,
590
+ input: { type: 'object', properties: { amount: { type: 'number' } } },
591
+ output: { type: 'object', properties: { id: { type: 'string' } } },
508
592
  },
509
593
  ],
510
594
  });
@@ -512,28 +596,19 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
512
596
  'ACTION_EXPOSURE_REQUIRED', 'API_EXPOSURE_AUTH_REQUIRED',
513
597
  'TENANT_API_PATH_REQUIRED', 'AUTH_NONE_REQUIRES_EXCEPTION',
514
598
  'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC', 'SYSTEM_API_REQUIRES_ROLE',
599
+ 'API_INPUT_SCHEMA_REQUIRED', 'API_OUTPUT_SCHEMA_REQUIRED',
515
600
  ];
516
601
  const firedSecurityRules = errors.filter(e => securityCodes.includes(e.code));
517
602
  expect(firedSecurityRules).toHaveLength(0);
518
- // Non-security rules (e.g. MISSING_HANDLER_FILE on the synthetic
519
- // mock file path) may legitimately fire; we only assert that the
520
- // Wave 6 security gates are clean.
521
603
  });
522
604
  });
523
605
 
524
606
  /**
525
607
  * Deployment-time security rules (#4662 Task D, #4689).
526
608
  *
527
- * These mirror the CDK aspect's synth-time checks so CI can gate the
528
- * build BEFORE synth is attempted. Each rule emits a structured
529
- * `ValidationError` whose `code` matches the aspect annotation code,
530
- * making the two layers correlatable.
531
- *
532
609
  * Issue #4689: defineApi and registry.apis were removed. The action-first
533
610
  * `TENANT_API_PATH_REQUIRED` rule in the `#4619 / Wave 6` suite now owns
534
- * the placeholder invariant. The deployment-time rules below assert that
535
- * no duplicate `SECURITY_MISSING_TENANT_PATH` codes are emitted for the
536
- * action surface.
611
+ * the placeholder invariant.
537
612
  */
538
613
  describe('runValidate deployment-time security rules (#4662 Task D)', () => {
539
614
  const DOMAIN_ROOT = '/fake/domains/billing';