@mettlecast/domain-cli 0.2.60 → 0.2.62

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.
Files changed (107) hide show
  1. package/dist/builder/build-registry.d.ts +1 -1
  2. package/dist/builder/build-registry.js +1 -36
  3. package/dist/builder/build-types.d.ts +1 -1
  4. package/dist/builder/load-module.d.ts +1 -1
  5. package/dist/cli.js +21 -3
  6. package/dist/commands/add-api.js +2 -2
  7. package/dist/commands/add-domain.js +4 -4
  8. package/dist/commands/add-fixture-factory.js +5 -6
  9. package/dist/commands/build-catalog.d.ts +6 -22
  10. package/dist/commands/build-catalog.js +7 -18
  11. package/dist/commands/build.js +2 -1
  12. package/dist/commands/check-hashes.d.ts +2 -0
  13. package/dist/commands/check-hashes.js +8 -0
  14. package/dist/commands/dev.js +1 -1
  15. package/dist/commands/doctor.js +70 -41
  16. package/dist/commands/explain.js +13 -13
  17. package/dist/commands/generate-openapi.d.ts +10 -1
  18. package/dist/commands/generate-openapi.js +19 -33
  19. package/dist/commands/regenerate-modules-hashes.d.ts +25 -0
  20. package/dist/commands/regenerate-modules-hashes.js +58 -0
  21. package/dist/commands/show.d.ts +2 -3
  22. package/dist/commands/show.js +0 -2
  23. package/dist/commands/test.js +0 -1
  24. package/dist/commands/update-all.d.ts +21 -0
  25. package/dist/commands/update-all.js +62 -0
  26. package/dist/commands/upgrade-backend.js +3 -3
  27. package/dist/commands/validate.js +12 -90
  28. package/dist/server/api-server.d.ts +1 -1
  29. package/dist/server/mount-routes.d.ts +11 -2
  30. package/dist/server/mount-routes.js +20 -8
  31. package/dist/templates/api-skeleton.d.ts +5 -0
  32. package/dist/templates/api-skeleton.js +28 -27
  33. package/dist/templates/claude-md.js +1 -1
  34. package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
  35. package/dist/templates/patterns/api/create-with-event.js +38 -32
  36. package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
  37. package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
  38. package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
  39. package/dist/templates/patterns/api/paginated-list.js +30 -24
  40. package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
  41. package/dist/templates/patterns/api/simple-crud.js +46 -35
  42. package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
  43. package/dist/templates/patterns/api/streaming-list.js +46 -41
  44. package/dist/templates/patterns/api/system-admin.d.ts +4 -0
  45. package/dist/templates/patterns/api/system-admin.js +59 -52
  46. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
  47. package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
  48. package/dist/types.d.ts +100 -0
  49. package/dist/types.js +1 -0
  50. package/dist/utils/file-helpers.d.ts +0 -2
  51. package/dist/utils/file-helpers.js +2 -3
  52. package/dist/utils/manifest.d.ts +1 -0
  53. package/dist/utils/manifest.js +16 -1
  54. package/dist/utils/scaffold-config.d.ts +6 -0
  55. package/dist/utils/scaffold-config.js +2 -0
  56. package/package.json +1 -1
  57. package/src/__tests__/build-registry.test.ts +43 -20
  58. package/src/__tests__/build-types.test.ts +4 -7
  59. package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
  60. package/src/__tests__/commands/add-api.test.ts +12 -10
  61. package/src/__tests__/commands/add-domain.test.ts +8 -5
  62. package/src/__tests__/commands/build-flows.test.ts +55 -0
  63. package/src/__tests__/commands/check-hashes.test.ts +31 -0
  64. package/src/__tests__/commands/create-project.test.ts +5 -5
  65. package/src/__tests__/commands/dev.test.ts +0 -1
  66. package/src/__tests__/commands/regenerate-modules-hashes.test.ts +170 -0
  67. package/src/__tests__/commands/update-all.test.ts +322 -0
  68. package/src/__tests__/doctor.test.ts +73 -0
  69. package/src/__tests__/mount-routes.test.ts +64 -23
  70. package/src/__tests__/package-freshness.test.ts +1 -21
  71. package/src/__tests__/smoke/scaffold.test.ts +13 -15
  72. package/src/__tests__/utils/manifest.test.ts +128 -0
  73. package/src/__tests__/validate.test.ts +21 -103
  74. package/src/builder/build-registry.ts +7 -44
  75. package/src/builder/build-types.ts +1 -1
  76. package/src/cli.ts +23 -3
  77. package/src/commands/add-api.ts +2 -2
  78. package/src/commands/add-domain.ts +4 -4
  79. package/src/commands/add-fixture-factory.ts +5 -6
  80. package/src/commands/build-catalog.ts +13 -35
  81. package/src/commands/build.ts +3 -2
  82. package/src/commands/check-hashes.ts +12 -0
  83. package/src/commands/dev.ts +1 -1
  84. package/src/commands/doctor.ts +72 -41
  85. package/src/commands/explain.ts +13 -13
  86. package/src/commands/generate-openapi.ts +30 -52
  87. package/src/commands/regenerate-modules-hashes.ts +89 -0
  88. package/src/commands/show.ts +2 -5
  89. package/src/commands/test.ts +0 -1
  90. package/src/commands/update-all.ts +79 -0
  91. package/src/commands/upgrade-backend.ts +3 -3
  92. package/src/commands/validate.ts +11 -96
  93. package/src/server/api-server.ts +1 -1
  94. package/src/server/mount-routes.ts +21 -10
  95. package/src/templates/api-skeleton.ts +29 -28
  96. package/src/templates/claude-md.ts +1 -1
  97. package/src/templates/patterns/api/create-with-event.ts +39 -33
  98. package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
  99. package/src/templates/patterns/api/paginated-list.ts +31 -25
  100. package/src/templates/patterns/api/simple-crud.ts +47 -36
  101. package/src/templates/patterns/api/streaming-list.ts +47 -42
  102. package/src/templates/patterns/api/system-admin.ts +60 -53
  103. package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
  104. package/src/types.ts +128 -0
  105. package/src/utils/file-helpers.ts +2 -5
  106. package/src/utils/manifest.ts +16 -1
  107. package/src/utils/scaffold-config.ts +9 -0
@@ -16,43 +16,55 @@ vi.mock('../runtime-stubs/hydrate-local-ctx.js', () => ({
16
16
 
17
17
  import { mountRoutes } from '../server/mount-routes.js';
18
18
 
19
- function makeRegistry(apis: Array<Partial<DomainRegistry['apis'][0]>>): DomainRegistry {
19
+ /**
20
+ * Build a minimal registry whose `actions[]` contains a single
21
+ * API-exposed entry per call. Issue #4689: the only HTTP endpoint
22
+ * surface is `actions[]` with `exposure.type === 'api'`.
23
+ */
24
+ function makeRegistry(actions: Array<Partial<DomainRegistry['actions'][0]>>): DomainRegistry {
20
25
  return {
21
26
  schemaVersion: '1',
22
27
  domainRoot: '/fake/domain',
23
28
  domain: { id: 'test', kind: 'domain', name: 'Test', tenancy: 'none' },
24
- apis: apis.map((a, i) => ({
25
- id: `api-${i}`,
26
- kind: 'api' as const,
27
- handlerFile: `api/handler-${i}.ts`,
28
- path: `/route-${i}`,
29
- method: 'GET',
30
- authType: 'none' as const,
31
- ...a,
32
- })),
33
29
  webhooks: [],
34
30
  subscribers: [],
35
31
  schedules: [],
36
32
  jobs: [],
37
- actions: [],
33
+ actions: actions.map((a, i) => ({
34
+ id: `action-${i}`,
35
+ kind: 'action' as const,
36
+ handlerFile: `actions/handler-${i}.ts`,
37
+ backendAccess: 'domain' as const,
38
+ exposure: {
39
+ type: 'api' as const,
40
+ path: `/route-${i}`,
41
+ method: 'GET' as const,
42
+ auth: 'required' as const,
43
+ tenancy: 'required' as const,
44
+ },
45
+ idempotent: true,
46
+ ...a,
47
+ })) as DomainRegistry['actions'],
38
48
  integrations: [],
39
49
  events: [],
40
50
  };
41
51
  }
42
52
 
43
53
  describe('mountRoutes', () => {
44
- it('registers one route per api entry', async () => {
54
+ it('registers one route per API-exposed action', async () => {
45
55
  const routes: Array<{ method: unknown; url: string }> = [];
46
56
  const server = {
47
57
  route: vi.fn((opts: { method: unknown; url: string }) => { routes.push(opts); }),
48
58
  log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
49
59
  };
50
60
 
51
- vi.doMock('/fake/domain/api/handler-0.ts', () => ({
52
- myApi: { id: 'api-0', _kind: 'api', handler: vi.fn().mockResolvedValue({ ok: true }) },
61
+ vi.doMock('/fake/domain/actions/handler-0.ts', () => ({
62
+ myAction: { id: 'action-0', _kind: 'action', handler: vi.fn().mockResolvedValue({ ok: true }) },
53
63
  }));
54
64
 
55
- const registry = makeRegistry([{ id: 'api-0', handlerFile: 'api/handler-0.ts', path: '/route-0', method: 'GET' }]);
65
+ const registry = makeRegistry([
66
+ { id: 'action-0', handlerFile: 'actions/handler-0.ts', exposure: { type: 'api', path: '/route-0', method: 'GET', auth: 'required', tenancy: 'required' } },
67
+ ]);
56
68
 
57
69
  await mountRoutes({ server: server as never, registry, domainRoot: '/fake/domain' });
58
70
 
@@ -67,13 +79,13 @@ describe('mountRoutes', () => {
67
79
  log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
68
80
  };
69
81
 
70
- vi.doMock('/fake/domain/api/handler-1.ts', () => ({
71
- myApi: { id: 'api-1', _kind: 'api', handler: vi.fn() },
82
+ vi.doMock('/fake/domain/actions/handler-1.ts', () => ({
83
+ myAction: { id: 'action-1', _kind: 'action', handler: vi.fn() },
72
84
  }));
73
85
 
74
- const registry = makeRegistry([{
75
- id: 'api-1', handlerFile: 'api/handler-1.ts', path: '/users/{id}/orders', method: 'GET',
76
- }]);
86
+ const registry = makeRegistry([
87
+ { id: 'action-1', handlerFile: 'actions/handler-1.ts', exposure: { type: 'api', path: '/users/{id}/orders', method: 'GET', auth: 'required', tenancy: 'required' } },
88
+ ]);
77
89
 
78
90
  await mountRoutes({ server: server as never, registry, domainRoot: '/fake/domain' });
79
91
 
@@ -86,15 +98,44 @@ describe('mountRoutes', () => {
86
98
  log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
87
99
  };
88
100
 
89
- vi.doMock('/fake/domain/api/handler-2.ts', () => ({
90
- noHandler: { id: 'api-2', _kind: 'api' },
101
+ vi.doMock('/fake/domain/actions/handler-2.ts', () => ({
102
+ noHandler: { id: 'action-2', _kind: 'action' },
91
103
  }));
92
104
 
93
- const registry = makeRegistry([{ id: 'api-2', handlerFile: 'api/handler-2.ts', path: '/skip', method: 'GET' }]);
105
+ const registry = makeRegistry([
106
+ { id: 'action-2', handlerFile: 'actions/handler-2.ts', exposure: { type: 'api', path: '/skip', method: 'GET', auth: 'required', tenancy: 'required' } },
107
+ ]);
94
108
 
95
109
  await mountRoutes({ server: server as never, registry, domainRoot: '/fake/domain' });
96
110
 
97
111
  expect(server.route).not.toHaveBeenCalled();
98
112
  expect(server.log.warn).toHaveBeenCalled();
99
113
  });
114
+
115
+ it('skips internal-only actions (exposure.type !== "api")', async () => {
116
+ const server = {
117
+ route: vi.fn(),
118
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
119
+ };
120
+
121
+ vi.doMock('/fake/domain/actions/handler-internal.ts', () => ({
122
+ internalAction: { id: 'internal-1', _kind: 'action', handler: vi.fn() },
123
+ }));
124
+
125
+ const registry: DomainRegistry = {
126
+ ...makeRegistry([]),
127
+ actions: [{
128
+ id: 'internal-1',
129
+ kind: 'action',
130
+ handlerFile: 'actions/handler-internal.ts',
131
+ backendAccess: 'domain',
132
+ exposure: { type: 'internal' },
133
+ idempotent: true,
134
+ } as unknown as DomainRegistry['actions'][0]],
135
+ };
136
+
137
+ await mountRoutes({ server: server as never, registry, domainRoot: '/fake/domain' });
138
+
139
+ expect(server.route).not.toHaveBeenCalled();
140
+ });
100
141
  });
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
2
2
  import {
3
3
  ActionBackendAccess,
4
4
  type ActionApiExposure,
5
- type ApiRegistryEntry,
6
5
  type DomainRegistry,
7
6
  type ActionRegistryEntry,
8
7
  } from '@mettlecast/domain-cdk-packer';
@@ -45,24 +44,6 @@ describe('CLI ↔ CDK packer type freshness (#4662 Task D)', () => {
45
44
  expect(exposure.securityException?.reason).toMatch(/OPS-123/);
46
45
  });
47
46
 
48
- it('exposes the ApiRegistryEntry.securityException field added in #4662', () => {
49
- // The build-registry does not populate this field for legacy
50
- // `defineApi` entries (they don't carry securityException in
51
- // source), but the registry type must declare it so that
52
- // future migrations can attach it. This assertion pins the field
53
- // shape so the CLI doesn't silently fall behind if a future
54
- // packer refactor removes the declaration.
55
- const api: ApiRegistryEntry = {
56
- kind: 'api',
57
- handlerFile: 'src/api.ts',
58
- path: '/v1/health',
59
- method: 'GET',
60
- authType: 'none',
61
- securityException: { reason: 'public liveness probe' },
62
- };
63
- expect(api.securityException?.reason).toMatch(/liveness/);
64
- });
65
-
66
47
  it('exposes the ActionRegistryEntry shape with the latest exposure metadata flags', () => {
67
48
  const action: ActionRegistryEntry = {
68
49
  kind: 'action',
@@ -82,7 +63,6 @@ describe('CLI ↔ CDK packer type freshness (#4662 Task D)', () => {
82
63
  schemaVersion: '1',
83
64
  domainRoot: '/tmp/fake',
84
65
  domain: { kind: 'domain', id: 'test', name: 'Test', tenancy: 'required' },
85
- apis: [],
86
66
  webhooks: [],
87
67
  subscribers: [],
88
68
  schedules: [],
@@ -111,4 +91,4 @@ describe('CLI ↔ CDK packer type freshness (#4662 Task D)', () => {
111
91
  ?? parsed.devDependencies?.['@mettlecast/domain-cdk-packer'];
112
92
  expect(['*', 'workspace:*']).toContain(range);
113
93
  });
114
- });
94
+ });
@@ -448,11 +448,10 @@ describe('scaffold smoke test (issue #3831)', () => {
448
448
  expect(tsconfig.compilerOptions.strict).toBe(true);
449
449
  });
450
450
 
451
- it('seeds api handlers from auth that import defineApi from @mettlecast/domain-runtime', async () => {
451
+ it('seeds api handlers from auth that import defineAction + exposure from @mettlecast/domain-runtime', async () => {
452
452
  // The auth module ships a hand-written seed api/me.ts. We
453
453
  // confirm it parses (no obvious syntax errors) and uses the runtime
454
- // primitive. This is a structural check; the wave-2 "import ky + call
455
- // initOtel()" expectation is documented below as a separate soft gate.
454
+ // primitive. This is a structural check.
456
455
  const apiFile = join(projectDir, 'domains', 'auth', 'api', 'me.ts');
457
456
  const s = await stat(apiFile).catch(() => null);
458
457
  if (!s) {
@@ -462,7 +461,8 @@ describe('scaffold smoke test (issue #3831)', () => {
462
461
  }
463
462
  const content = await readFile(apiFile, 'utf-8');
464
463
  expect(content).toMatch(/from\s+['"]@mettlecast\/domain-runtime['"]/);
465
- expect(content).toMatch(/defineApi\s*\(/);
464
+ expect(content).toMatch(/defineAction\s*\(/);
465
+ expect(content).toMatch(/type:\s*['"]api['"]/);
466
466
  });
467
467
 
468
468
  it('seeds initOtel() references in api handlers (wave-2 acceptance gate)', async () => {
@@ -499,20 +499,18 @@ describe('scaffold smoke test (issue #3831)', () => {
499
499
  }
500
500
  });
501
501
 
502
- it('api-skeleton template (add-api) emits createKyClient + initOtel references', async () => {
503
- // The add-api template now imports `createKyClient` and `initOtel`
504
- // from @mettlecast/domain-runtime (the ky-backed runtime client),
505
- // not a direct `ky` npm import. Seed handlers may also import `ky`
506
- // directly if they need a browser-friendly HTTP client.
502
+ it('api-skeleton template (add-api) emits defineAction + exposure.type=api references', async () => {
503
+ // The add-api template now emits defineAction + exposure.type='api' as
504
+ // the canonical action-first HTTP endpoint primitive (#4689).
507
505
  const out = apiSkeletonTemplateForTest('payments', 'charge-card', 'required');
508
- const hasKy = /createKyClient/.test(out) || /from\s+['"]ky['"]/.test(out);
509
- const hasInitOtel = /\binitOtel\s*\(/.test(out);
510
- if (!hasKy || !hasInitOtel) {
506
+ const hasDefineAction = /defineAction\s*\(/.test(out);
507
+ const hasApiExposure = /type:\s*['"]api['"]/.test(out);
508
+ if (!hasDefineAction || !hasApiExposure) {
511
509
  throw new Error(
512
510
  'api-skeleton template (packages/domain-cli/src/templates/api-skeleton.ts) ' +
513
- 'does not emit createKyClient or initOtel()\n' +
514
- ` hasKy=${hasKy} hasInitOtel=${hasInitOtel}\n` +
515
- ' expected: import { createKyClient, initOtel } from "@mettlecast/domain-runtime" AND initOtel()',
511
+ 'does not emit defineAction + exposure.type=api\n' +
512
+ ` hasDefineAction=${hasDefineAction} hasApiExposure=${hasApiExposure}\n` +
513
+ ' expected: import { defineAction } from "@mettlecast/domain-runtime" AND exposure: { type: "api", ... }',
516
514
  );
517
515
  }
518
516
  });
@@ -19,6 +19,7 @@ import {
19
19
  upsertManifestFile,
20
20
  inferPolicyFromPath,
21
21
  getManifestFile,
22
+ pruneManifest,
22
23
  type ManifestFileEntry,
23
24
  type TibManifest,
24
25
  } from '../../utils/manifest.js';
@@ -271,4 +272,131 @@ describe('manifest utilities', () => {
271
272
  await rm(tmpDir, { recursive: true, force: true });
272
273
  });
273
274
  });
275
+
276
+ describe('pruneManifest', () => {
277
+ it('removes entries for files that do not exist on disk', async () => {
278
+ const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
279
+ // Two entries: one file exists, one does not.
280
+ const existingPath = 'infra/main.tf';
281
+ const missingPath = 'infra/deleted.ts';
282
+ await mkdir(join(tmpDir, 'infra'), { recursive: true });
283
+ await writeFile(join(tmpDir, existingPath), 'content');
284
+
285
+ upsertManifestFile(manifest, {
286
+ path: existingPath,
287
+ module: 'core',
288
+ moduleVersion: '1.0.0',
289
+ sha256: 'abc',
290
+ wasTemplate: false,
291
+ installedAt: '2026-01-01T00:00:00Z',
292
+ policy: 'managed',
293
+ });
294
+ upsertManifestFile(manifest, {
295
+ path: missingPath,
296
+ module: 'core',
297
+ moduleVersion: '1.0.0',
298
+ sha256: 'def',
299
+ wasTemplate: false,
300
+ installedAt: '2026-01-01T00:00:00Z',
301
+ policy: 'managed',
302
+ });
303
+
304
+ const pruned = await pruneManifest(tmpDir, manifest);
305
+
306
+ expect(pruned).toBe(1);
307
+ expect(manifest.files).toHaveLength(1);
308
+ expect(manifest.files[0]?.path).toBe(existingPath);
309
+ });
310
+
311
+ it('keeps entries for files that exist on disk', async () => {
312
+ const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
313
+ const filePath = 'infra/modules/app.ts';
314
+ await mkdir(join(tmpDir, 'infra', 'modules'), { recursive: true });
315
+ await writeFile(join(tmpDir, filePath), '// app');
316
+
317
+ upsertManifestFile(manifest, {
318
+ path: filePath,
319
+ module: 'core',
320
+ moduleVersion: '1.0.0',
321
+ sha256: 'ghi',
322
+ wasTemplate: true,
323
+ installedAt: '2026-01-01T00:00:00Z',
324
+ policy: 'managed',
325
+ });
326
+
327
+ const pruned = await pruneManifest(tmpDir, manifest);
328
+
329
+ expect(pruned).toBe(0);
330
+ expect(manifest.files).toHaveLength(1);
331
+ expect(manifest.files[0]?.path).toBe(filePath);
332
+ });
333
+
334
+ it('returns count of pruned entries', async () => {
335
+ const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
336
+ // Three entries, all missing on disk.
337
+ const paths = ['a/missing1.ts', 'b/missing2.ts', 'c/missing3.ts'];
338
+ for (const path of paths) {
339
+ upsertManifestFile(manifest, {
340
+ path,
341
+ module: 'core',
342
+ moduleVersion: '1.0.0',
343
+ sha256: 'x',
344
+ wasTemplate: false,
345
+ installedAt: '2026-01-01T00:00:00Z',
346
+ policy: 'managed',
347
+ });
348
+ }
349
+
350
+ const pruned = await pruneManifest(tmpDir, manifest);
351
+
352
+ expect(pruned).toBe(3);
353
+ expect(manifest.files).toHaveLength(0);
354
+ });
355
+
356
+ it('handles empty manifest.files array', async () => {
357
+ const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
358
+ // manifest.files is [] by default.
359
+
360
+ const pruned = await pruneManifest(tmpDir, manifest);
361
+
362
+ expect(pruned).toBe(0);
363
+ expect(manifest.files).toEqual([]);
364
+ });
365
+
366
+ it('mutates manifest.files in place', async () => {
367
+ const manifest = createManifest('1.0.0', 'test-project', 'eu-north-1', ['core']);
368
+ const survivor = 'frontend/src/pages/auth/login.tsx';
369
+ await mkdir(join(tmpDir, 'frontend', 'src', 'pages', 'auth'), { recursive: true });
370
+ await writeFile(join(tmpDir, survivor), '// login');
371
+
372
+ upsertManifestFile(manifest, {
373
+ path: survivor,
374
+ module: 'frontend',
375
+ moduleVersion: '1.0.0',
376
+ sha256: 'kept',
377
+ wasTemplate: true,
378
+ installedAt: '2026-01-01T00:00:00Z',
379
+ policy: 'seed',
380
+ });
381
+ upsertManifestFile(manifest, {
382
+ path: 'frontend/src/pages/gone.tsx',
383
+ module: 'frontend',
384
+ moduleVersion: '1.0.0',
385
+ sha256: 'gone',
386
+ wasTemplate: true,
387
+ installedAt: '2026-01-01T00:00:00Z',
388
+ policy: 'seed',
389
+ });
390
+
391
+ const initialLength = manifest.files.length;
392
+ const pruned = await pruneManifest(tmpDir, manifest);
393
+
394
+ // Mutation is observable through the same manifest reference — caller
395
+ // does not need a return value to update its own copy.
396
+ expect(manifest.files).not.toHaveLength(initialLength);
397
+ expect(manifest.files).toHaveLength(1);
398
+ expect(manifest.files[0]?.path).toBe(survivor);
399
+ expect(pruned).toBe(1);
400
+ });
401
+ });
274
402
  });
@@ -49,7 +49,6 @@ describe('buildRegistry action mapping (action-first migration)', () => {
49
49
  it('reads backendAccess directly when raw action exposes it', async () => {
50
50
  mockWalkDomainDir.mockResolvedValue({
51
51
  domain: '/fake/domains/billing/domain.config.ts',
52
- apis: [],
53
52
  webhooks: [],
54
53
  subscribers: [],
55
54
  actions: ['/fake/domains/billing/actions/charge-card.ts'],
@@ -97,7 +96,6 @@ describe('buildRegistry action mapping (action-first migration)', () => {
97
96
  it('falls back from legacy visibility to backendAccess and collapses workspace -> domain', async () => {
98
97
  mockWalkDomainDir.mockResolvedValue({
99
98
  domain: '/fake/domains/billing/domain.config.ts',
100
- apis: [],
101
99
  webhooks: [],
102
100
  subscribers: [],
103
101
  actions: [
@@ -133,7 +131,6 @@ describe('buildRegistry action mapping (action-first migration)', () => {
133
131
  it('defaults missing exposure to { type: internal } for legacy actions', async () => {
134
132
  mockWalkDomainDir.mockResolvedValue({
135
133
  domain: '/fake/domains/billing/domain.config.ts',
136
- apis: [],
137
134
  webhooks: [],
138
135
  subscribers: [],
139
136
  actions: ['/fake/domains/billing/actions/legacy.ts'],
@@ -159,7 +156,6 @@ describe('buildRegistry action mapping (action-first migration)', () => {
159
156
  it('preserves inputSchema and outputSchema extraction', async () => {
160
157
  mockWalkDomainDir.mockResolvedValue({
161
158
  domain: '/fake/domains/billing/domain.config.ts',
162
- apis: [],
163
159
  webhooks: [],
164
160
  subscribers: [],
165
161
  actions: ['/fake/domains/billing/actions/charge-card.ts'],
@@ -210,20 +206,17 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
210
206
 
211
207
  /**
212
208
  * Helper that wires the two file-walker / module-loader mocks so
213
- * `buildRegistry` returns the supplied actions (plus the canonical
214
- * domain row) and the supplied apis list. Returns the ValidationResult
209
+ * `buildRegistry` returns the supplied actions plus the canonical
210
+ * domain row. Returns the ValidationResult
215
211
  * produced by `runValidate`.
216
212
  */
217
213
  async function runValidateWith({
218
214
  actions = [],
219
- apis = [],
220
215
  }: {
221
216
  actions?: Record<string, unknown>[];
222
- apis?: Record<string, unknown>[];
223
217
  }): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
224
218
  mockWalkDomainDir.mockResolvedValue({
225
219
  domain: '/fake/domains/billing/domain.config.ts',
226
- apis: apis.map(() => '/fake/domains/billing/api/x.ts'),
227
220
  webhooks: [],
228
221
  subscribers: [],
229
222
  actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
@@ -234,19 +227,15 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
234
227
  });
235
228
  // Mock consumption order inside buildRegistry:
236
229
  // 1. domain (paths.domain) -> mock #1
237
- // 2. apis files (paths.apis) -> mock #2 (if any)
238
- // 3. webhooks/subscribers/schedules/jobs/integrations -> empty arrays, no calls
239
- // 4. actions files (paths.actions)-> mock #N (if any)
240
- // 5. publishes (paths.publishes) -> undefined, no call
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
241
233
  // Each non-empty slot consumes exactly one mock per file (we
242
234
  // generate one synthetic file path per item so the slot has length
243
235
  // equal to the supplied arrays).
244
236
  mockLoadModuleExports.mockResolvedValueOnce([
245
237
  { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
246
238
  ]);
247
- if (apis.length > 0) {
248
- mockLoadModuleExports.mockResolvedValueOnce(apis as never);
249
- }
250
239
  if (actions.length > 0) {
251
240
  mockLoadModuleExports.mockResolvedValueOnce(actions as never);
252
241
  }
@@ -504,51 +493,6 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
504
493
  expect(errors.find(e => e.code === 'SYSTEM_API_REQUIRES_ROLE')).toBeUndefined();
505
494
  });
506
495
 
507
- it('DEFINE_API_LEGACY_USAGE — fires once per legacy defineApi entry', async () => {
508
- // Stub out the api builder checks (requestSchema, examples, etc.)
509
- // because the test fixture intentionally provides a minimal api
510
- // shape. We only assert on the new DEFINE_API_LEGACY_USAGE rule
511
- // here; other rules are covered by build-registry.test.ts and the
512
- // existing validate.test.ts suite.
513
- const { errors } = await runValidateWith({
514
- actions: [],
515
- apis: [
516
- {
517
- _kind: 'api', _exportName: 'legacyList',
518
- id: 'legacy-list', path: '/v1/list', method: 'GET',
519
- auth: { type: 'jwt' },
520
- versions: {
521
- v1: {
522
- status: 'stable',
523
- input: { type: 'object', properties: {}, default: {} },
524
- output: { type: 'object', properties: {}, default: {} },
525
- handler: () => undefined,
526
- },
527
- },
528
- examples: { request: {}, response: {} },
529
- },
530
- ],
531
- });
532
- const rules = errors.filter(e => e.code === 'DEFINE_API_LEGACY_USAGE');
533
- expect(rules).toHaveLength(1);
534
- expect(rules[0]?.message).toContain("'legacy-list'");
535
- });
536
-
537
- it('DEFINE_API_LEGACY_USAGE — silent when registry has no defineApi rows', async () => {
538
- const { errors } = await runValidateWith({
539
- actions: [
540
- {
541
- _kind: 'action', _exportName: 'internalOnly',
542
- id: 'internal-only', backendAccess: 'domain',
543
- exposure: { type: 'internal' },
544
- idempotent: false,
545
- },
546
- ],
547
- apis: [],
548
- });
549
- expect(errors.find(e => e.code === 'DEFINE_API_LEGACY_USAGE')).toBeUndefined();
550
- });
551
-
552
496
  it('combined: well-formed api action passes every rule', async () => {
553
497
  const { errors } = await runValidateWith({
554
498
  actions: [
@@ -568,7 +512,6 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
568
512
  'ACTION_EXPOSURE_REQUIRED', 'API_EXPOSURE_AUTH_REQUIRED',
569
513
  'TENANT_API_PATH_REQUIRED', 'AUTH_NONE_REQUIRES_EXCEPTION',
570
514
  'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC', 'SYSTEM_API_REQUIRES_ROLE',
571
- 'DEFINE_API_LEGACY_USAGE',
572
515
  ];
573
516
  const firedSecurityRules = errors.filter(e => securityCodes.includes(e.code));
574
517
  expect(firedSecurityRules).toHaveLength(0);
@@ -579,12 +522,18 @@ describe('runValidate action-first security rules (#4619 / Wave 6)', () => {
579
522
  });
580
523
 
581
524
  /**
582
- * Deployment-time security rules (#4662 Task D).
525
+ * Deployment-time security rules (#4662 Task D, #4689).
583
526
  *
584
527
  * These mirror the CDK aspect's synth-time checks so CI can gate the
585
528
  * build BEFORE synth is attempted. Each rule emits a structured
586
529
  * `ValidationError` whose `code` matches the aspect annotation code,
587
530
  * making the two layers correlatable.
531
+ *
532
+ * Issue #4689: defineApi and registry.apis were removed. The action-first
533
+ * `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.
588
537
  */
589
538
  describe('runValidate deployment-time security rules (#4662 Task D)', () => {
590
539
  const DOMAIN_ROOT = '/fake/domains/billing';
@@ -595,14 +544,11 @@ describe('runValidate deployment-time security rules (#4662 Task D)', () => {
595
544
 
596
545
  async function runValidateWith({
597
546
  actions = [],
598
- apis = [],
599
547
  }: {
600
548
  actions?: Record<string, unknown>[];
601
- apis?: Record<string, unknown>[];
602
549
  }): Promise<{ errors: { code: string; message: string }[]; warnings: string[]; valid: boolean; }> {
603
550
  mockWalkDomainDir.mockResolvedValue({
604
551
  domain: '/fake/domains/billing/domain.config.ts',
605
- apis: apis.map(() => '/fake/domains/billing/api/x.ts'),
606
552
  webhooks: [],
607
553
  subscribers: [],
608
554
  actions: actions.map(() => '/fake/domains/billing/actions/x.ts'),
@@ -614,9 +560,6 @@ describe('runValidate deployment-time security rules (#4662 Task D)', () => {
614
560
  mockLoadModuleExports.mockResolvedValueOnce([
615
561
  { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
616
562
  ]);
617
- if (apis.length > 0) {
618
- mockLoadModuleExports.mockResolvedValueOnce(apis as never);
619
- }
620
563
  if (actions.length > 0) {
621
564
  mockLoadModuleExports.mockResolvedValueOnce(actions as never);
622
565
  }
@@ -625,42 +568,17 @@ describe('runValidate deployment-time security rules (#4662 Task D)', () => {
625
568
  return { errors: result.errors, warnings: result.warnings, valid: result.valid };
626
569
  }
627
570
 
628
- it('SECURITY_MISSING_TENANT_PATH fires for an API whose path lacks the tenant placeholder', async () => {
571
+ it('does NOT emit SECURITY_MISSING_TENANT_PATH for an action that already includes the tenant placeholder', async () => {
629
572
  const { errors } = await runValidateWith({
630
- apis: [
631
- {
632
- _kind: 'api', _exportName: 'listUsers',
633
- id: 'list-users', path: '/v1/users', method: 'GET',
634
- authType: 'jwt',
635
- },
636
- ],
637
- });
638
- const rule = errors.find(e => e.code === 'SECURITY_MISSING_TENANT_PATH');
639
- expect(rule).toBeDefined();
640
- expect(rule?.message).toContain("'list-users'");
641
- expect(rule?.message).toContain('/v1/tenants/{tenantId}/');
642
- });
643
-
644
- it('SECURITY_MISSING_TENANT_PATH — silent when the API path includes the placeholder', async () => {
645
- const { errors } = await runValidateWith({
646
- apis: [
647
- {
648
- _kind: 'api', _exportName: 'listUsers',
649
- id: 'list-users', path: '/v1/tenants/{tenantId}/users', method: 'GET',
650
- authType: 'jwt',
651
- },
652
- ],
653
- });
654
- expect(errors.find(e => e.code === 'SECURITY_MISSING_TENANT_PATH')).toBeUndefined();
655
- });
656
-
657
- it('SECURITY_MISSING_TENANT_PATH — silent for anonymous (authType=none) APIs', async () => {
658
- const { errors } = await runValidateWith({
659
- apis: [
573
+ actions: [
660
574
  {
661
- _kind: 'api', _exportName: 'health',
662
- id: 'health', path: '/v1/health', method: 'GET',
663
- auth: { type: 'none' },
575
+ _kind: 'action', _exportName: 'listUsers',
576
+ id: 'list-users', backendAccess: 'domain',
577
+ exposure: {
578
+ type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET',
579
+ auth: 'required', tenancy: 'required',
580
+ },
581
+ idempotent: true,
664
582
  },
665
583
  ],
666
584
  });