@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.
- package/dist/builder/build-registry.d.ts +1 -1
- package/dist/builder/build-registry.js +1 -36
- package/dist/builder/build-types.d.ts +1 -1
- package/dist/builder/load-module.d.ts +1 -1
- package/dist/cli.js +1 -1
- package/dist/commands/add-api.js +2 -2
- package/dist/commands/add-domain.js +4 -4
- package/dist/commands/add-fixture-factory.js +5 -6
- package/dist/commands/build-catalog.d.ts +6 -22
- package/dist/commands/build-catalog.js +7 -18
- package/dist/commands/build.js +2 -1
- package/dist/commands/dev.js +1 -1
- package/dist/commands/doctor.js +66 -37
- package/dist/commands/explain.js +13 -13
- package/dist/commands/generate-openapi.d.ts +10 -1
- package/dist/commands/generate-openapi.js +19 -33
- package/dist/commands/show.d.ts +2 -3
- package/dist/commands/show.js +0 -2
- package/dist/commands/test.js +0 -1
- package/dist/commands/upgrade-backend.js +3 -3
- package/dist/commands/validate.js +12 -90
- package/dist/server/api-server.d.ts +1 -1
- package/dist/server/mount-routes.d.ts +11 -2
- package/dist/server/mount-routes.js +20 -8
- package/dist/templates/api-skeleton.d.ts +5 -0
- package/dist/templates/api-skeleton.js +28 -27
- package/dist/templates/claude-md.js +1 -1
- package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
- package/dist/templates/patterns/api/create-with-event.js +38 -32
- package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
- package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
- package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
- package/dist/templates/patterns/api/paginated-list.js +30 -24
- package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
- package/dist/templates/patterns/api/simple-crud.js +46 -35
- package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
- package/dist/templates/patterns/api/streaming-list.js +46 -41
- package/dist/templates/patterns/api/system-admin.d.ts +4 -0
- package/dist/templates/patterns/api/system-admin.js +59 -52
- package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
- package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
- package/dist/types.d.ts +100 -0
- package/dist/types.js +1 -0
- package/dist/utils/file-helpers.d.ts +0 -2
- package/dist/utils/file-helpers.js +2 -3
- package/dist/utils/scaffold-config.d.ts +6 -0
- package/dist/utils/scaffold-config.js +2 -0
- package/package.json +1 -1
- package/src/__tests__/build-registry.test.ts +43 -20
- package/src/__tests__/build-types.test.ts +4 -7
- package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
- package/src/__tests__/commands/add-api.test.ts +12 -10
- package/src/__tests__/commands/add-domain.test.ts +8 -5
- package/src/__tests__/commands/create-project.test.ts +5 -5
- package/src/__tests__/commands/dev.test.ts +0 -1
- package/src/__tests__/mount-routes.test.ts +64 -23
- package/src/__tests__/package-freshness.test.ts +1 -21
- package/src/__tests__/smoke/scaffold.test.ts +13 -15
- package/src/__tests__/validate.test.ts +21 -103
- package/src/builder/build-registry.ts +7 -44
- package/src/builder/build-types.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/add-api.ts +2 -2
- package/src/commands/add-domain.ts +4 -4
- package/src/commands/add-fixture-factory.ts +5 -6
- package/src/commands/build-catalog.ts +13 -35
- package/src/commands/build.ts +3 -2
- package/src/commands/dev.ts +1 -1
- package/src/commands/doctor.ts +68 -37
- package/src/commands/explain.ts +13 -13
- package/src/commands/generate-openapi.ts +30 -52
- package/src/commands/show.ts +2 -5
- package/src/commands/test.ts +0 -1
- package/src/commands/upgrade-backend.ts +3 -3
- package/src/commands/validate.ts +11 -96
- package/src/server/api-server.ts +1 -1
- package/src/server/mount-routes.ts +21 -10
- package/src/templates/api-skeleton.ts +29 -28
- package/src/templates/claude-md.ts +1 -1
- package/src/templates/patterns/api/create-with-event.ts +39 -33
- package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
- package/src/templates/patterns/api/paginated-list.ts +31 -25
- package/src/templates/patterns/api/simple-crud.ts +47 -36
- package/src/templates/patterns/api/streaming-list.ts +47 -42
- package/src/templates/patterns/api/system-admin.ts +60 -53
- package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
- package/src/types.ts +128 -0
- package/src/utils/file-helpers.ts +2 -5
- 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, '
|
|
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, '
|
|
45
|
+
const apiContent = await readFile(join(domainDir, 'actions', 'example.ts'), 'utf8');
|
|
46
46
|
expect(apiContent).toContain(`id: 'example'`);
|
|
47
|
-
expect(apiContent).toContain('
|
|
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', '
|
|
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', '
|
|
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
|
|
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/
|
|
289
|
-
content: 'import {
|
|
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/
|
|
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/
|
|
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');
|
|
@@ -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
|
-
|
|
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
|
|
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/
|
|
52
|
-
|
|
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([
|
|
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/
|
|
71
|
-
|
|
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: '
|
|
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/
|
|
90
|
-
noHandler: { id: '
|
|
101
|
+
vi.doMock('/fake/domain/actions/handler-2.ts', () => ({
|
|
102
|
+
noHandler: { id: 'action-2', _kind: 'action' },
|
|
91
103
|
}));
|
|
92
104
|
|
|
93
|
-
const registry = makeRegistry([
|
|
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
|
|
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
|
|
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(/
|
|
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
|
|
503
|
-
// The add-api template now
|
|
504
|
-
//
|
|
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
|
|
509
|
-
const
|
|
510
|
-
if (!
|
|
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
|
|
514
|
-
`
|
|
515
|
-
' expected: import {
|
|
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
|
|
214
|
-
* domain row
|
|
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.
|
|
238
|
-
// 3.
|
|
239
|
-
// 4.
|
|
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('
|
|
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
|
-
|
|
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: '
|
|
662
|
-
id: '
|
|
663
|
-
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
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,
|