@mettlecast/domain-cli 0.2.60 → 0.2.61

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 (89) 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 +1 -1
  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/dev.js +1 -1
  13. package/dist/commands/doctor.js +66 -37
  14. package/dist/commands/explain.js +13 -13
  15. package/dist/commands/generate-openapi.d.ts +10 -1
  16. package/dist/commands/generate-openapi.js +19 -33
  17. package/dist/commands/show.d.ts +2 -3
  18. package/dist/commands/show.js +0 -2
  19. package/dist/commands/test.js +0 -1
  20. package/dist/commands/upgrade-backend.js +3 -3
  21. package/dist/commands/validate.js +12 -90
  22. package/dist/server/api-server.d.ts +1 -1
  23. package/dist/server/mount-routes.d.ts +11 -2
  24. package/dist/server/mount-routes.js +20 -8
  25. package/dist/templates/api-skeleton.d.ts +5 -0
  26. package/dist/templates/api-skeleton.js +28 -27
  27. package/dist/templates/claude-md.js +1 -1
  28. package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
  29. package/dist/templates/patterns/api/create-with-event.js +38 -32
  30. package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
  31. package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
  32. package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
  33. package/dist/templates/patterns/api/paginated-list.js +30 -24
  34. package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
  35. package/dist/templates/patterns/api/simple-crud.js +46 -35
  36. package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
  37. package/dist/templates/patterns/api/streaming-list.js +46 -41
  38. package/dist/templates/patterns/api/system-admin.d.ts +4 -0
  39. package/dist/templates/patterns/api/system-admin.js +59 -52
  40. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
  41. package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
  42. package/dist/types.d.ts +100 -0
  43. package/dist/types.js +1 -0
  44. package/dist/utils/file-helpers.d.ts +0 -2
  45. package/dist/utils/file-helpers.js +2 -3
  46. package/dist/utils/scaffold-config.d.ts +6 -0
  47. package/dist/utils/scaffold-config.js +2 -0
  48. package/package.json +1 -1
  49. package/src/__tests__/build-registry.test.ts +43 -20
  50. package/src/__tests__/build-types.test.ts +4 -7
  51. package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
  52. package/src/__tests__/commands/add-api.test.ts +12 -10
  53. package/src/__tests__/commands/add-domain.test.ts +8 -5
  54. package/src/__tests__/commands/create-project.test.ts +5 -5
  55. package/src/__tests__/commands/dev.test.ts +0 -1
  56. package/src/__tests__/mount-routes.test.ts +64 -23
  57. package/src/__tests__/package-freshness.test.ts +1 -21
  58. package/src/__tests__/smoke/scaffold.test.ts +13 -15
  59. package/src/__tests__/validate.test.ts +21 -103
  60. package/src/builder/build-registry.ts +7 -44
  61. package/src/builder/build-types.ts +1 -1
  62. package/src/cli.ts +1 -1
  63. package/src/commands/add-api.ts +2 -2
  64. package/src/commands/add-domain.ts +4 -4
  65. package/src/commands/add-fixture-factory.ts +5 -6
  66. package/src/commands/build-catalog.ts +13 -35
  67. package/src/commands/build.ts +3 -2
  68. package/src/commands/dev.ts +1 -1
  69. package/src/commands/doctor.ts +68 -37
  70. package/src/commands/explain.ts +13 -13
  71. package/src/commands/generate-openapi.ts +30 -52
  72. package/src/commands/show.ts +2 -5
  73. package/src/commands/test.ts +0 -1
  74. package/src/commands/upgrade-backend.ts +3 -3
  75. package/src/commands/validate.ts +11 -96
  76. package/src/server/api-server.ts +1 -1
  77. package/src/server/mount-routes.ts +21 -10
  78. package/src/templates/api-skeleton.ts +29 -28
  79. package/src/templates/claude-md.ts +1 -1
  80. package/src/templates/patterns/api/create-with-event.ts +39 -33
  81. package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
  82. package/src/templates/patterns/api/paginated-list.ts +31 -25
  83. package/src/templates/patterns/api/simple-crud.ts +47 -36
  84. package/src/templates/patterns/api/streaming-list.ts +47 -42
  85. package/src/templates/patterns/api/system-admin.ts +60 -53
  86. package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
  87. package/src/types.ts +128 -0
  88. package/src/utils/file-helpers.ts +2 -5
  89. package/src/utils/scaffold-config.ts +9 -0
@@ -33,7 +33,7 @@ describe('add-domain command', () => {
33
33
 
34
34
  // Verify directory structure
35
35
  const domainDir = join(tmpDir, 'test-domain');
36
- await stat(join(domainDir, 'api'));
36
+ await stat(join(domainDir, 'actions'));
37
37
  await stat(join(domainDir, 'subscribers'));
38
38
  await stat(join(domainDir, 'publishes'));
39
39
 
@@ -42,9 +42,10 @@ describe('add-domain command', () => {
42
42
  expect(configContent).toContain(`id: 'test-domain'`);
43
43
  expect(configContent).toContain(`tenancy: 'required'`);
44
44
 
45
- const apiContent = await readFile(join(domainDir, 'api', 'example.ts'), 'utf8');
45
+ const apiContent = await readFile(join(domainDir, 'actions', 'example.ts'), 'utf8');
46
46
  expect(apiContent).toContain(`id: 'example'`);
47
- expect(apiContent).toContain('defineApi');
47
+ expect(apiContent).toContain('defineAction');
48
+ expect(apiContent).toContain("type: 'api'");
48
49
 
49
50
  const eventsContent = await readFile(join(domainDir, 'publishes', 'events.ts'), 'utf8');
50
51
  expect(eventsContent).toContain(`id: 'test-domain.example'`);
@@ -154,10 +155,12 @@ describe('add-domain command', () => {
154
155
  const configContent = await readFile(join(tmpDir, 'system-domain', 'domain.config.ts'), 'utf8');
155
156
  expect(configContent).toContain(`tenancy: 'system'`);
156
157
 
157
- const apiContent = await readFile(join(tmpDir, 'system-domain', 'api', 'example.ts'), 'utf8');
158
+ const apiContent = await readFile(join(tmpDir, 'system-domain', 'actions', 'example.ts'), 'utf8');
158
159
  expect(apiContent).toContain(`tenancy: 'system'`);
159
160
  // For 'system' tenancy, path should not include /v1/tenants/{tenantId}
160
161
  expect(apiContent).toContain(`path: '/v1/system-domain/example'`);
162
+ // System tenancy should default to service-to-service auth.
163
+ expect(apiContent).toContain(`auth: 'service'`);
161
164
  });
162
165
 
163
166
  it('should use correct path structure for required tenancy', async () => {
@@ -167,7 +170,7 @@ describe('add-domain command', () => {
167
170
  domainsDir: tmpDir,
168
171
  });
169
172
 
170
- const apiContent = await readFile(join(tmpDir, 'tenant-domain', 'api', 'example.ts'), 'utf8');
173
+ const apiContent = await readFile(join(tmpDir, 'tenant-domain', 'actions', 'example.ts'), 'utf8');
171
174
  // For 'required' tenancy, path should include /v1/tenants/{tenantId}
172
175
  expect(apiContent).toContain(`path: '/v1/tenants/{tenantId}/tenant-domain/example'`);
173
176
  });
@@ -263,7 +263,7 @@ describe('runCreateProject', () => {
263
263
  const coreTarball = await createTestTarball([
264
264
  {
265
265
  path: 'lib/domain-runtime.ts',
266
- content: 'export const defineApi = () => {};',
266
+ content: 'export const defineAction = () => {};',
267
267
  isTemplate: false,
268
268
  },
269
269
  {
@@ -285,14 +285,14 @@ describe('runCreateProject', () => {
285
285
 
286
286
  const authTarball = await createTestTarball([
287
287
  {
288
- path: 'domains/auth/api/login.ts',
289
- content: 'import { defineApi } from "../../lib/domain-runtime";',
288
+ path: 'domains/auth/actions/login.ts',
289
+ content: 'import { defineAction } from "../../lib/domain-runtime";',
290
290
  isTemplate: false,
291
291
  },
292
292
  {
293
293
  path: 'MANIFEST.json',
294
294
  content: createTarballManifest([
295
- { path: 'domains/auth/api/login.ts', isTemplate: false },
295
+ { path: 'domains/auth/actions/login.ts', isTemplate: false },
296
296
  ]),
297
297
  isTemplate: false,
298
298
  },
@@ -319,7 +319,7 @@ describe('runCreateProject', () => {
319
319
  await runCreateProject({ name: 'test-project', outDir: tempDir });
320
320
 
321
321
  // Read the generated auth login file
322
- const authPath = join(outputDir, 'domains/auth/api/login.ts');
322
+ const authPath = join(outputDir, 'domains/auth/actions/login.ts');
323
323
  const authContent = await readFile(authPath, 'utf-8');
324
324
 
325
325
  expect(authContent).toContain('../../lib/domain-runtime');
@@ -34,7 +34,6 @@ const mocks = vi.hoisted(() => {
34
34
  buildRegistry: vi.fn().mockResolvedValue({
35
35
  registry: {
36
36
  domain: { id: 'mock-domain' },
37
- apis: [],
38
37
  webhooks: [],
39
38
  subscribers: [],
40
39
  schedules: [],
@@ -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
  });
@@ -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
  });
@@ -1,22 +1,24 @@
1
1
  import { relative, resolve } from 'node:path';
2
2
  import type {
3
3
  DomainRegistry,
4
- ApiRegistryEntry,
5
- ApiVersionSnapshot,
4
+ ActionRegistryEntry,
6
5
  SchemaSnapshot,
7
6
  WebhookRegistryEntry,
8
7
  SubscriberRegistryEntry,
9
8
  ScheduleRegistryEntry,
10
9
  JobRegistryEntry,
11
- ActionRegistryEntry,
12
10
  IntegrationRegistryEntry,
13
11
  EventRegistryEntry,
14
12
  DomainRegistryEntry,
15
13
  SerialDeploymentConfig,
16
- } from '@mettlecast/domain-cdk-packer';
14
+ } from '../types.js';
17
15
  import { walkDomainDir } from '../utils/file-helpers.js';
18
16
  import { loadModuleExports, type RawPrimitiveExport } from './load-module.js';
19
17
 
18
+ // Issue #4689: the legacy `defineApi` primitive was removed from
19
+ // `@mettlecast/domain-runtime`. The only HTTP endpoint surface in
20
+ // the new registry is `actions[]` whose `exposure.type === 'api'`.
21
+
20
22
  type ActionBackendAccess = 'private' | 'domain' | 'platform';
21
23
 
22
24
  type ActionExposure =
@@ -190,9 +192,8 @@ export async function buildRegistry(domainRoot: string): Promise<BuildResult> {
190
192
  defaultDeployment: deployment(domainRaw),
191
193
  };
192
194
 
193
- const [apiExports, webhookExports, subscriberExports, actionExports,
195
+ const [webhookExports, subscriberExports, actionExports,
194
196
  scheduleExports, jobExports, integrationExports, eventExports] = await Promise.all([
195
- Promise.all(paths.apis.map(load)),
196
197
  Promise.all(paths.webhooks.map(load)),
197
198
  Promise.all(paths.subscribers.map(load)),
198
199
  Promise.all(paths.actions.map(load)),
@@ -202,43 +203,6 @@ export async function buildRegistry(domainRoot: string): Promise<BuildResult> {
202
203
  paths.publishes ? load(paths.publishes) : Promise.resolve([]),
203
204
  ]);
204
205
 
205
- const VALID_API_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
206
-
207
- const apis: ApiRegistryEntry[] = paths.apis.flatMap((filePath, i) =>
208
- (apiExports[i] ?? [])
209
- .filter(e => e['_kind'] === 'api')
210
- .map(e => {
211
- const rawMethod = typeof e['method'] === 'string' ? e['method'].toUpperCase() : '';
212
- if (!rawMethod || !VALID_API_METHODS.has(rawMethod)) {
213
- warnings.push(`${relPath(filePath)}: defineApi "${e['id']}" has invalid or missing method "${rawMethod || '(none)'}". Use one of: ${[...VALID_API_METHODS].join(', ')}.`);
214
- }
215
- const rawVersions = e['versions'] as Record<string, { input?: unknown; output?: unknown }> | undefined;
216
- const versionSnapshots: ApiVersionSnapshot[] = rawVersions
217
- ? Object.entries(rawVersions).map(([ver, v]) => ({
218
- version: ver,
219
- requestSchema: isJsonSchema(v?.input) ? (v.input as SchemaSnapshot) : undefined,
220
- responseSchema: isJsonSchema(v?.output) ? (v.output as SchemaSnapshot) : undefined,
221
- }))
222
- : [];
223
- const latestVersion = versionSnapshots[versionSnapshots.length - 1];
224
- return {
225
- id: String(e['id']),
226
- kind: 'api' as const,
227
- handlerFile: relPath(filePath),
228
- path: String(e['path']),
229
- method: VALID_API_METHODS.has(rawMethod) ? rawMethod : 'GET',
230
- authType: ((e['auth'] as { type?: string } | undefined)?.type as 'jwt' | 'api-key' | 'none' | undefined) ?? 'jwt',
231
- description: typeof e['description'] === 'string' ? e['description'] : undefined,
232
- deployment: deployment(e),
233
- outboundAccess: outboundAccess(e),
234
- requestSchema: latestVersion?.requestSchema,
235
- responseSchema: latestVersion?.responseSchema,
236
- versions: versionSnapshots.length > 0 ? versionSnapshots : undefined,
237
- examples: (e['examples'] as { request?: Record<string, unknown>; response?: Record<string, unknown> } | undefined),
238
- };
239
- })
240
- );
241
-
242
206
  const webhooks: WebhookRegistryEntry[] = paths.webhooks.flatMap((filePath, i) =>
243
207
  (webhookExports[i] ?? [])
244
208
  .filter(e => e['_kind'] === 'webhook')
@@ -374,7 +338,6 @@ export async function buildRegistry(domainRoot: string): Promise<BuildResult> {
374
338
  schemaVersion: '1',
375
339
  domainRoot,
376
340
  domain,
377
- apis,
378
341
  webhooks,
379
342
  subscribers,
380
343
  schedules,
@@ -1,4 +1,4 @@
1
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
1
+ import type { DomainRegistry } from '../types.js';
2
2
 
3
3
  /**
4
4
  * Generate a .d.ts file declaring typed `ctx.actions.call(actionId, input)`