@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
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* generate-openapi — read a domain's registry and produce an OpenAPI 3.1
|
|
3
3
|
* specification as JSON. The registry is consumed at build time; each
|
|
4
|
-
* `
|
|
4
|
+
* `defineAction({ exposure: { type: 'api', ... } })` entry contributes
|
|
5
|
+
* one path under the domain's route prefix.
|
|
6
|
+
*
|
|
7
|
+
* Issue #4689: the legacy `defineApi` factory was removed and the
|
|
8
|
+
* `registry.apis` slot is now always empty in new registries. This
|
|
9
|
+
* command therefore reads exclusively from `actions[]`.
|
|
5
10
|
*
|
|
6
11
|
* Usage: npx mc-domain-module generate-openapi <domain>
|
|
7
12
|
*/
|
|
@@ -9,29 +14,9 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
|
9
14
|
import { join } from 'node:path';
|
|
10
15
|
import { cliLogger } from '../utils/logger.js';
|
|
11
16
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* objects) or `inputSchema`/`outputSchema` (JSON Schema shapes).
|
|
15
|
-
* Returns a best-effort JSON Schema object for the OpenAPI spec.
|
|
17
|
+
* Run the generate-openapi command: read the domain's registry and emit
|
|
18
|
+
* an OpenAPI 3.1 spec covering every API-exposed action.
|
|
16
19
|
*/
|
|
17
|
-
function extractSchema(version, field) {
|
|
18
|
-
// Prefer JSON Schema if present
|
|
19
|
-
const schemaField = field === 'input' ? version.inputSchema : version.outputSchema;
|
|
20
|
-
if (schemaField && typeof schemaField === 'object' && schemaField !== null) {
|
|
21
|
-
return schemaField;
|
|
22
|
-
}
|
|
23
|
-
// Fall back to the raw Zod shape — try to produce a minimal JSON
|
|
24
|
-
// Schema from the Zod _def. For full support, add zod-to-json-schema
|
|
25
|
-
// to the CLI dependencies and call `zodToJsonSchema(zodSchema)`.
|
|
26
|
-
const raw = field === 'input' ? version.input : version.output;
|
|
27
|
-
if (raw && typeof raw === 'object' && raw !== null) {
|
|
28
|
-
// Attempt minimal mapping: if the Zod shape has a `type` field
|
|
29
|
-
// from its _def, describe it as JSON Schema.
|
|
30
|
-
const def = raw;
|
|
31
|
-
return { type: def.type ?? 'object', properties: def.properties, required: def.required };
|
|
32
|
-
}
|
|
33
|
-
return { type: 'object' };
|
|
34
|
-
}
|
|
35
20
|
export async function runGenerateOpenapi(options) {
|
|
36
21
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
37
22
|
const domain = options.domain;
|
|
@@ -41,22 +26,23 @@ export async function runGenerateOpenapi(options) {
|
|
|
41
26
|
const registryJson = await readFile(registryPath, 'utf8');
|
|
42
27
|
const registry = JSON.parse(registryJson);
|
|
43
28
|
const paths = {};
|
|
44
|
-
for (const
|
|
45
|
-
|
|
46
|
-
const fullPath = `/v1/${domain}${api.path}`;
|
|
47
|
-
const v1 = api.versions['v1'] ?? api.versions[Object.keys(api.versions)[0]];
|
|
48
|
-
if (!v1)
|
|
29
|
+
for (const action of registry.actions ?? []) {
|
|
30
|
+
if (action.exposure?.type !== 'api')
|
|
49
31
|
continue;
|
|
32
|
+
const method = (action.exposure.method ?? 'get').toLowerCase();
|
|
33
|
+
const fullPath = `/v1/${domain}${action.exposure.path}`;
|
|
34
|
+
const inputSchema = action.inputSchema ?? { type: 'object' };
|
|
35
|
+
const outputSchema = action.outputSchema ?? { type: 'object' };
|
|
50
36
|
if (!paths[fullPath])
|
|
51
37
|
paths[fullPath] = {};
|
|
52
38
|
paths[fullPath][method] = {
|
|
53
|
-
operationId: `${domain}.${
|
|
54
|
-
summary: `${domain}.${
|
|
55
|
-
description:
|
|
39
|
+
operationId: `${domain}.${action.id}`,
|
|
40
|
+
summary: `${domain}.${action.id}`,
|
|
41
|
+
description: 'Action-first contract (defineAction + exposure.type=api).',
|
|
56
42
|
requestBody: {
|
|
57
43
|
content: {
|
|
58
44
|
'application/json': {
|
|
59
|
-
schema:
|
|
45
|
+
schema: inputSchema,
|
|
60
46
|
},
|
|
61
47
|
},
|
|
62
48
|
},
|
|
@@ -65,7 +51,7 @@ export async function runGenerateOpenapi(options) {
|
|
|
65
51
|
description: 'OK',
|
|
66
52
|
content: {
|
|
67
53
|
'application/json': {
|
|
68
|
-
schema:
|
|
54
|
+
schema: outputSchema,
|
|
69
55
|
},
|
|
70
56
|
},
|
|
71
57
|
},
|
package/dist/commands/show.d.ts
CHANGED
|
@@ -13,10 +13,10 @@ export interface ShowDomainOptions {
|
|
|
13
13
|
domainsDir?: string;
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* Represents a primitive entry (
|
|
16
|
+
* Represents a primitive entry (action, subscriber, or job).
|
|
17
17
|
*/
|
|
18
18
|
interface PrimitiveEntry {
|
|
19
|
-
type: '
|
|
19
|
+
type: 'subscriber' | 'action' | 'job';
|
|
20
20
|
id: string;
|
|
21
21
|
file: string;
|
|
22
22
|
}
|
|
@@ -26,7 +26,6 @@ interface PrimitiveEntry {
|
|
|
26
26
|
interface DomainSummary {
|
|
27
27
|
domain: string;
|
|
28
28
|
primitives: {
|
|
29
|
-
apis: PrimitiveEntry[];
|
|
30
29
|
subscribers: PrimitiveEntry[];
|
|
31
30
|
actions: PrimitiveEntry[];
|
|
32
31
|
jobs: PrimitiveEntry[];
|
package/dist/commands/show.js
CHANGED
|
@@ -74,7 +74,6 @@ export async function runShowDomain(opts) {
|
|
|
74
74
|
throw new Error(`Domain "${opts.domain}" not found at ${domainDir}`);
|
|
75
75
|
}
|
|
76
76
|
// Scan primitives
|
|
77
|
-
const apis = await scanPrimitives(join(domainDir, 'api'), 'api');
|
|
78
77
|
const subscribers = await scanPrimitives(join(domainDir, 'subscribers'), 'subscriber');
|
|
79
78
|
const actions = await scanPrimitives(join(domainDir, 'actions'), 'action');
|
|
80
79
|
const jobs = await scanPrimitives(join(domainDir, 'jobs'), 'job');
|
|
@@ -113,7 +112,6 @@ export async function runShowDomain(opts) {
|
|
|
113
112
|
const summary = {
|
|
114
113
|
domain: opts.domain,
|
|
115
114
|
primitives: {
|
|
116
|
-
apis,
|
|
117
115
|
subscribers,
|
|
118
116
|
actions,
|
|
119
117
|
jobs,
|
package/dist/commands/test.js
CHANGED
|
@@ -14,7 +14,6 @@ export async function runTest(options) {
|
|
|
14
14
|
const fixturePath = resolve(options.fixturePath);
|
|
15
15
|
const { registry } = await buildRegistry(domainRoot);
|
|
16
16
|
const allEntries = [
|
|
17
|
-
...registry.apis,
|
|
18
17
|
...registry.webhooks,
|
|
19
18
|
...registry.subscribers,
|
|
20
19
|
...registry.schedules,
|
|
@@ -4,10 +4,10 @@ const MIGRATIONS = [
|
|
|
4
4
|
{
|
|
5
5
|
fromMajor: 1,
|
|
6
6
|
toMajor: 2,
|
|
7
|
-
description: '
|
|
7
|
+
description: 'Audit v1 domain backends for action-first API exposure requirements',
|
|
8
8
|
transform(repoRoot, dryRun) {
|
|
9
|
-
// Placeholder: real migration uses ts-morph to rewrite handler signatures
|
|
10
|
-
console.log(`[G20] Would
|
|
9
|
+
// Placeholder: real migration uses ts-morph to rewrite handler signatures.
|
|
10
|
+
console.log(`[G20] Would audit action-first API exposure in ${repoRoot}/domains/**`);
|
|
11
11
|
if (!dryRun) {
|
|
12
12
|
console.log('[G20] ts-morph transform: (not yet implemented — add jscodeshift transforms here)');
|
|
13
13
|
}
|
|
@@ -90,37 +90,17 @@ async function checkRawPathViolations(_repoRoot, config) {
|
|
|
90
90
|
* Each rule maps 1:1 to an aspect annotation code so downstream tooling
|
|
91
91
|
* can correlate build-time and synth-time failures.
|
|
92
92
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* exposures, which are covered by the existing `AUTH_NONE_REQUIRES_EXCEPTION`
|
|
97
|
-
* rule in `checkActionFirstSecurity`. The aspect's corresponding check
|
|
98
|
-
* (`assertAnonymousRoutesHaveException`) catches the legacy case at
|
|
99
|
-
* synth time so a developer migrating to action-style APIs gets the
|
|
100
|
-
* right guidance at the right layer.
|
|
93
|
+
* Issue #4689: the legacy `defineApi` factory and `registry.apis` field
|
|
94
|
+
* were removed. Deployment-time API invariants are enforced against the
|
|
95
|
+
* `actions[]` API-exposure surface — see `checkActionFirstSecurity`.
|
|
101
96
|
*/
|
|
102
|
-
function checkDeploymentSecurity(
|
|
103
|
-
const errors = [];
|
|
104
|
-
for (const api of apis) {
|
|
105
|
-
// Non-anonymous APIs whose path lacks the tenant placeholder cannot
|
|
106
|
-
// bind `ctx.tenant.id` from the URL. The aspect surfaces this as
|
|
107
|
-
// `SECURITY_MISSING_TENANT_PATH`; the CLI version is a faster gate so
|
|
108
|
-
// CI doesn't pay the synth cost.
|
|
109
|
-
if (api.authType !== 'none' && !api.path.includes('/v1/tenants/{tenantId}/')) {
|
|
110
|
-
errors.push({
|
|
111
|
-
code: 'SECURITY_MISSING_TENANT_PATH',
|
|
112
|
-
message: `API '${api.id}' (${api.method} ${api.path}) is JWT-protected but its path does not include the canonical '/v1/tenants/{tenantId}/' placeholder. The runtime cannot bind ctx.tenant.id from a path that lacks the placeholder.`,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
}
|
|
97
|
+
function checkDeploymentSecurity(_actions) {
|
|
116
98
|
// Action API exposures are already covered by `checkActionFirstSecurity`
|
|
117
99
|
// for `tenancy: 'required'` paths and `auth: 'none'` exceptions. The
|
|
118
100
|
// codes there (TENANT_API_PATH_REQUIRED, AUTH_NONE_REQUIRES_EXCEPTION)
|
|
119
101
|
// are kept stable for back-compat — they map to the same aspect codes.
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
void actions;
|
|
123
|
-
return errors;
|
|
102
|
+
//
|
|
103
|
+
return [];
|
|
124
104
|
}
|
|
125
105
|
/**
|
|
126
106
|
* Action-first security validation rules (#4619, Wave 6 Task 6.1).
|
|
@@ -229,29 +209,6 @@ function checkApiExposureSecurity(actionId, exposure) {
|
|
|
229
209
|
}
|
|
230
210
|
return errors;
|
|
231
211
|
}
|
|
232
|
-
/**
|
|
233
|
-
* DEFINE_API_LEGACY_USAGE — the action-first migration replaces
|
|
234
|
-
* standalone `defineApi` calls with `defineAction` + `exposure.type:
|
|
235
|
-
* 'api'`. Every `defineApi` call still in the source produces a row in
|
|
236
|
-
* `registry.apis`; the validator emits one error per legacy API to
|
|
237
|
-
* enforce the alpha breaking migration. The validate command's fixture
|
|
238
|
-
* suite intentionally does not use `defineApi`, so this rule does not
|
|
239
|
-
* break existing unit tests.
|
|
240
|
-
*/
|
|
241
|
-
function checkDefineApiLegacyUsage(apiCount, apis) {
|
|
242
|
-
if (apiCount === 0)
|
|
243
|
-
return [];
|
|
244
|
-
const errors = [];
|
|
245
|
-
for (const api of apis) {
|
|
246
|
-
if (api.kind !== 'api')
|
|
247
|
-
continue;
|
|
248
|
-
errors.push({
|
|
249
|
-
code: 'DEFINE_API_LEGACY_USAGE',
|
|
250
|
-
message: `API '${api.id}' uses legacy \`defineApi\`. Migrate to \`defineAction\` with \`exposure: { type: 'api', path, method, auth, tenancy }\` (#4619).`,
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
return errors;
|
|
254
|
-
}
|
|
255
212
|
/**
|
|
256
213
|
* Run the validate command: build the registry and perform structural validation.
|
|
257
214
|
* Exits the process with code 1 if validation fails (CI gate usage).
|
|
@@ -267,9 +224,8 @@ export async function runValidate(domainRoot, exitOnFailure = true, config = { m
|
|
|
267
224
|
for (const w of warnings) {
|
|
268
225
|
cliLogger.warn(w);
|
|
269
226
|
}
|
|
270
|
-
errors.push(...checkDuplicateIds(registry.
|
|
227
|
+
errors.push(...checkDuplicateIds(registry.webhooks, 'webhook'), ...checkDuplicateIds(registry.subscribers, 'subscriber'), ...checkDuplicateIds(registry.schedules, 'schedule'), ...checkDuplicateIds(registry.jobs, 'job'), ...checkDuplicateIds(registry.actions, 'action'), ...checkDuplicateIds(registry.integrations, 'integration'), ...checkDuplicateIds(registry.events, 'event'));
|
|
271
228
|
const entriesWithFiles = [
|
|
272
|
-
...registry.apis,
|
|
273
229
|
...registry.webhooks,
|
|
274
230
|
...registry.subscribers,
|
|
275
231
|
...registry.schedules,
|
|
@@ -291,54 +247,20 @@ export async function runValidate(domainRoot, exitOnFailure = true, config = { m
|
|
|
291
247
|
}
|
|
292
248
|
}));
|
|
293
249
|
errors.push(...checkSubscriberSemverRanges(registry.subscribers));
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
errors.push(...checkLifecycleConsistency(entry, `api '${api.id}'`));
|
|
298
|
-
if (!api.requestSchema) {
|
|
299
|
-
errors.push({
|
|
300
|
-
code: 'MISSING_API_REQUEST_SCHEMA',
|
|
301
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has no request schema. Define input/output in defineApi versions.`,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
if (!api.responseSchema) {
|
|
305
|
-
errors.push({
|
|
306
|
-
code: 'MISSING_API_RESPONSE_SCHEMA',
|
|
307
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has no response schema. Define input/output in defineApi versions.`,
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
if (!VALID_METHODS.has(api.method)) {
|
|
311
|
-
errors.push({
|
|
312
|
-
code: 'INVALID_API_METHOD',
|
|
313
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has invalid method "${api.method}". Use one of: ${[...VALID_METHODS].join(', ')}.`,
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
const isVoidInput = api.requestSchema?.type === 'null';
|
|
317
|
-
if (!isVoidInput && !api.examples?.request) {
|
|
318
|
-
errors.push({
|
|
319
|
-
code: 'MISSING_API_REQUEST_EXAMPLE',
|
|
320
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has no request example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
if (!api.examples?.response) {
|
|
324
|
-
errors.push({
|
|
325
|
-
code: 'MISSING_API_RESPONSE_EXAMPLE',
|
|
326
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has no response example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
}
|
|
250
|
+
// Issue #4689: defineApi and registry.apis were removed. The action-first
|
|
251
|
+
// validation rules in `checkActionFirstSecurity` cover the action surface
|
|
252
|
+
// that now owns all HTTP endpoints.
|
|
330
253
|
const rawPathErrors = await checkRawPathViolations(registry.domainRoot, config);
|
|
331
254
|
errors.push(...rawPathErrors);
|
|
332
255
|
// Wave 6 Task 6.1: action-first security validation gates (#4619).
|
|
333
256
|
// Runs against the registry built above so the rules see the same
|
|
334
257
|
// shape the CDK packer will eventually consume.
|
|
335
258
|
errors.push(...checkActionFirstSecurity(registry.actions));
|
|
336
|
-
errors.push(...checkDefineApiLegacyUsage(registry.apis.length, registry.apis));
|
|
337
259
|
// Issue #4662 Task D — deployment-time security gates. These mirror
|
|
338
260
|
// the CDK synth-time `SecurityAssertionAspect` so violations are
|
|
339
261
|
// caught before any AWS deployment is attempted.
|
|
340
|
-
errors.push(...checkDeploymentSecurity(registry.
|
|
341
|
-
const totalPrimitives = registry.
|
|
262
|
+
errors.push(...checkDeploymentSecurity(registry.actions));
|
|
263
|
+
const totalPrimitives = registry.webhooks.length +
|
|
342
264
|
registry.subscribers.length + registry.schedules.length +
|
|
343
265
|
registry.jobs.length + registry.actions.length;
|
|
344
266
|
if (totalPrimitives === 0) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
|
-
import type { DomainRegistry } from '
|
|
2
|
+
import type { DomainRegistry } from '../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Options for mountRoutes.
|
|
5
5
|
*/
|
|
@@ -13,7 +13,16 @@ export interface MountRoutesOptions {
|
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* Mount all API routes from the DomainRegistry onto the Fastify server.
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* Issue #4689: the only HTTP endpoint surface in the new registry is
|
|
18
|
+
* `actions[]` whose `exposure.type === 'api'`. `defineApi` was removed
|
|
19
|
+
* and `registry.apis` is always empty in fresh registries, so the
|
|
20
|
+
* dev server iterates the action array and mounts each API exposure
|
|
21
|
+
* directly. Internal-only actions are intentionally skipped — they
|
|
22
|
+
* are reachable only through `ctx.actions`.
|
|
23
|
+
*
|
|
24
|
+
* Each route dynamically imports its handler file and hydrates a local
|
|
25
|
+
* dev ctx per request.
|
|
17
26
|
* @param options - MountRoutesOptions.
|
|
18
27
|
*/
|
|
19
28
|
export declare function mountRoutes(options: MountRoutesOptions): Promise<void>;
|
|
@@ -5,23 +5,35 @@ function toFastifyPath(path) {
|
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* Mount all API routes from the DomainRegistry onto the Fastify server.
|
|
8
|
-
*
|
|
8
|
+
*
|
|
9
|
+
* Issue #4689: the only HTTP endpoint surface in the new registry is
|
|
10
|
+
* `actions[]` whose `exposure.type === 'api'`. `defineApi` was removed
|
|
11
|
+
* and `registry.apis` is always empty in fresh registries, so the
|
|
12
|
+
* dev server iterates the action array and mounts each API exposure
|
|
13
|
+
* directly. Internal-only actions are intentionally skipped — they
|
|
14
|
+
* are reachable only through `ctx.actions`.
|
|
15
|
+
*
|
|
16
|
+
* Each route dynamically imports its handler file and hydrates a local
|
|
17
|
+
* dev ctx per request.
|
|
9
18
|
* @param options - MountRoutesOptions.
|
|
10
19
|
*/
|
|
11
20
|
export async function mountRoutes(options) {
|
|
12
21
|
const { server, registry, domainRoot } = options;
|
|
13
|
-
for (const
|
|
14
|
-
|
|
15
|
-
|
|
22
|
+
for (const action of registry.actions) {
|
|
23
|
+
if (action.exposure?.type !== 'api')
|
|
24
|
+
continue;
|
|
25
|
+
const exposure = action.exposure;
|
|
26
|
+
const handlerAbsPath = join(domainRoot, action.handlerFile);
|
|
27
|
+
const fastifyPath = toFastifyPath(exposure.path);
|
|
16
28
|
const mod = await import(handlerAbsPath);
|
|
17
|
-
const def = Object.values(mod).find(v => v && typeof v === 'object' && v['id'] ===
|
|
29
|
+
const def = Object.values(mod).find(v => v && typeof v === 'object' && v['id'] === action.id);
|
|
18
30
|
if (!def || typeof def['handler'] !== 'function') {
|
|
19
|
-
server.log.warn({
|
|
31
|
+
server.log.warn({ actionId: action.id, handlerAbsPath }, 'No handler function found, skipping route');
|
|
20
32
|
continue;
|
|
21
33
|
}
|
|
22
34
|
const handler = def['handler'];
|
|
23
35
|
server.route({
|
|
24
|
-
method:
|
|
36
|
+
method: [exposure.method],
|
|
25
37
|
url: fastifyPath,
|
|
26
38
|
handler: async (request, reply) => {
|
|
27
39
|
const { ctx } = await hydrateLocalCtx({ domainRoot });
|
|
@@ -37,6 +49,6 @@ export async function mountRoutes(options) {
|
|
|
37
49
|
await reply.send(result);
|
|
38
50
|
},
|
|
39
51
|
});
|
|
40
|
-
server.log.info({ method:
|
|
52
|
+
server.log.info({ method: exposure.method, path: fastifyPath, actionId: action.id }, 'Route mounted');
|
|
41
53
|
}
|
|
42
54
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Template for generating API handler skeleton.
|
|
3
|
+
*
|
|
4
|
+
* Action-first contract: HTTP endpoints are declared with `defineAction`
|
|
5
|
+
* + `exposure: { type: 'api', ... }`. The legacy `defineApi` factory was
|
|
6
|
+
* removed in #4689 — the only public HTTP endpoint primitive is now an
|
|
7
|
+
* action whose exposure type is `'api'`.
|
|
3
8
|
*/
|
|
4
9
|
/**
|
|
5
10
|
* Generates an API handler skeleton with the given domain, API, and tenancy.
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Template for generating API handler skeleton.
|
|
3
|
+
*
|
|
4
|
+
* Action-first contract: HTTP endpoints are declared with `defineAction`
|
|
5
|
+
* + `exposure: { type: 'api', ... }`. The legacy `defineApi` factory was
|
|
6
|
+
* removed in #4689 — the only public HTTP endpoint primitive is now an
|
|
7
|
+
* action whose exposure type is `'api'`.
|
|
3
8
|
*/
|
|
4
9
|
/**
|
|
5
10
|
* Convert kebab-case to camelCase.
|
|
@@ -19,45 +24,41 @@ function camelCase(s) {
|
|
|
19
24
|
export function apiSkeletonTemplate(domainId, apiId, tenancy) {
|
|
20
25
|
const tenantPath = tenancy === 'required' ? '/v1/tenants/{tenantId}' : '/v1';
|
|
21
26
|
const outputType = `${camelCase(domainId)}${camelCase(apiId)}Output`;
|
|
27
|
+
// The `auth` posture must be set explicitly per the action-first contract:
|
|
28
|
+
// - 'required' for tenant-scoped or any authenticated endpoint.
|
|
29
|
+
// - 'none' for anonymous endpoints (then add `securityException`).
|
|
30
|
+
// - 'service' for service-to-service only.
|
|
31
|
+
const authPosture = tenancy === 'system' ? 'service' : 'required';
|
|
22
32
|
return `import { z } from 'zod';
|
|
23
|
-
import {
|
|
24
|
-
import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
|
|
33
|
+
import { defineAction, initOtel } from '@mettlecast/domain-runtime';
|
|
25
34
|
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
26
35
|
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
27
36
|
|
|
28
37
|
// Initialise OpenTelemetry tracing for the process. Idempotent —
|
|
29
38
|
// safe to call at module scope. Sets up OTLP export (when
|
|
30
|
-
// OTEL_EXPORTER_OTLP_ENDPOINT is set) or console-export for
|
|
31
|
-
// local dev. Auto-instruments outbound HTTP calls via ky.
|
|
39
|
+
// OTEL_EXPORTER_OTLP_ENDPOINT is set) or console-export for local dev.
|
|
32
40
|
initOtel();
|
|
33
41
|
|
|
34
|
-
// Shared HTTP client with retry on 503/429. Replace this with
|
|
35
|
-
// your own client if you need custom headers or auth.
|
|
36
|
-
const http = createKyClient();
|
|
37
|
-
|
|
38
42
|
const outputSchema = z.object({ ok: z.boolean() }).default({ ok: true });
|
|
39
43
|
|
|
40
|
-
export const ${camelCase(apiId)} =
|
|
44
|
+
export const ${camelCase(apiId)} = defineAction({
|
|
41
45
|
id: '${apiId}',
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
input: z.object({}).default({}),
|
|
50
|
-
output: outputSchema,
|
|
51
|
-
handler: async (_input, _ctx): Promise<Result<z.infer<typeof outputSchema>, AppError>> => {
|
|
52
|
-
// Replace this with your handler logic.
|
|
53
|
-
// Return ok(value) on success, err({ ... }) on failure.
|
|
54
|
-
return ok({ ok: true });
|
|
55
|
-
},
|
|
56
|
-
},
|
|
46
|
+
backendAccess: 'domain',
|
|
47
|
+
exposure: {
|
|
48
|
+
type: 'api',
|
|
49
|
+
path: '${tenantPath}/${domainId}/${apiId}',
|
|
50
|
+
method: 'POST',
|
|
51
|
+
auth: '${authPosture}',
|
|
52
|
+
tenancy: '${tenancy}',
|
|
57
53
|
},
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
outboundAccess: 'internal',
|
|
55
|
+
idempotent: false,
|
|
56
|
+
input: z.object({}).default({}),
|
|
57
|
+
output: outputSchema,
|
|
58
|
+
handler: async (_input, _ctx): Promise<Result<z.infer<typeof outputSchema>, AppError>> => {
|
|
59
|
+
// Replace this with your handler logic.
|
|
60
|
+
// Return ok(value) on success, err({ ... }) on failure.
|
|
61
|
+
return ok({ ok: true });
|
|
61
62
|
},
|
|
62
63
|
});
|
|
63
64
|
`;
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export function claudeMdTemplate(id) {
|
|
5
5
|
const titleName = id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
6
|
-
return `# ${titleName} Domain — Ownership\n\nYou are the domain owner for \`${id}\`.\n\n## Responsibilities\n\n- All source files under \`domains/${id}/\` are owned by you.\n-
|
|
6
|
+
return `# ${titleName} Domain — Ownership\n\nYou are the domain owner for \`${id}\`.\n\n## Responsibilities\n\n- All source files under \`domains/${id}/\` are owned by you.\n- Public HTTP endpoints must use \`defineAction({ exposure: { type: 'api', ... } })\` from \`@mettlecast/domain-runtime\`. The legacy \`defineApi\` factory is no longer scaffolded for public handlers.\n- Internal-only callable handlers must use \`defineAction({ exposure: { type: 'internal' } })\`.\n- Cross-domain calls must use \`ctx.actions.call()\`, never direct imports.\n- AWS SDK must never be imported directly — use \`ctx\` helpers.\n\n## Scaffold Rules\n\nSee the root \`CLAUDE.md\` for full scaffold rules, anti-patterns, and workflow commands.\n\n## Domain Config\n\nSee \`domains/${id}/domain.config.ts\` for the domain definition.\n`;
|
|
7
7
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Create-with-event API pattern template.
|
|
3
3
|
* Produces: POST handler + publishes a domain event after mutation.
|
|
4
|
+
*
|
|
5
|
+
* Action-first: HTTP endpoints are declared with `defineAction` +
|
|
6
|
+
* `exposure: { type: 'api', ... }`. The legacy `defineApi` factory was
|
|
7
|
+
* removed in #4689.
|
|
4
8
|
*/
|
|
5
9
|
export declare function createWithEventTemplate(domain: string, id: string, tenancy: string): string;
|
|
6
10
|
/**
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Create-with-event API pattern template.
|
|
3
3
|
* Produces: POST handler + publishes a domain event after mutation.
|
|
4
|
+
*
|
|
5
|
+
* Action-first: HTTP endpoints are declared with `defineAction` +
|
|
6
|
+
* `exposure: { type: 'api', ... }`. The legacy `defineApi` factory was
|
|
7
|
+
* removed in #4689.
|
|
4
8
|
*/
|
|
5
9
|
function camelCase(s) {
|
|
6
10
|
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
@@ -8,9 +12,9 @@ function camelCase(s) {
|
|
|
8
12
|
export function createWithEventTemplate(domain, id, tenancy) {
|
|
9
13
|
const varName = camelCase(id);
|
|
10
14
|
const tenantPath = tenancy === 'required' ? '/v1/tenants/{tenantId}' : '/v1';
|
|
15
|
+
const authPosture = tenancy === 'system' ? 'service' : 'required';
|
|
11
16
|
return `import { z } from 'zod';
|
|
12
|
-
import {
|
|
13
|
-
import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
|
|
17
|
+
import { defineAction, initOtel } from '@mettlecast/domain-runtime';
|
|
14
18
|
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
15
19
|
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
16
20
|
|
|
@@ -31,43 +35,45 @@ const ${varName}Output = z.object({
|
|
|
31
35
|
status: 'created',
|
|
32
36
|
});
|
|
33
37
|
|
|
34
|
-
// ──
|
|
38
|
+
// ── Action definition (API exposure) ─────────────────────────
|
|
35
39
|
|
|
36
|
-
export const ${varName} =
|
|
40
|
+
export const ${varName} = defineAction({
|
|
37
41
|
id: '${id}',
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
backendAccess: 'domain',
|
|
43
|
+
exposure: {
|
|
44
|
+
type: 'api',
|
|
45
|
+
path: '${tenantPath}/${domain}/${id}',
|
|
46
|
+
method: 'POST',
|
|
47
|
+
auth: '${authPosture}',
|
|
48
|
+
tenancy: '${tenancy}',
|
|
49
|
+
},
|
|
40
50
|
outboundAccess: 'internal',
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
handler: async (input, ctx) => {
|
|
47
|
-
const entityId = crypto.randomUUID();
|
|
51
|
+
idempotent: false,
|
|
52
|
+
input: ${varName}Input,
|
|
53
|
+
output: ${varName}Output,
|
|
54
|
+
handler: async (input, ctx) => {
|
|
55
|
+
const entityId = crypto.randomUUID();
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
// ── Persist entity ────────────────────────────────────
|
|
58
|
+
await ctx.store.put(entityId, { id: entityId, name: input.name });
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// ── Publish domain event ──────────────────────────────
|
|
61
|
+
await ctx.publish(
|
|
62
|
+
'${domain}.${id}.created',
|
|
63
|
+
{
|
|
64
|
+
id: entityId,
|
|
65
|
+
name: input.name,
|
|
66
|
+
tenantId: ctx.tenant.id,
|
|
67
|
+
},
|
|
68
|
+
1,
|
|
69
|
+
);
|
|
62
70
|
|
|
63
|
-
|
|
71
|
+
await ctx.audit.log('${id}.create', entityId, { tenantId: ctx.tenant.id });
|
|
64
72
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
},
|
|
70
|
-
},
|
|
73
|
+
return {
|
|
74
|
+
id: entityId,
|
|
75
|
+
status: 'created',
|
|
76
|
+
};
|
|
71
77
|
},
|
|
72
78
|
});
|
|
73
79
|
`;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Idempotent-mutation API pattern template.
|
|
3
3
|
* Produces: PUT with idempotency key middleware.
|
|
4
|
+
*
|
|
5
|
+
* Action-first: HTTP endpoints are declared with `defineAction` +
|
|
6
|
+
* `exposure: { type: 'api', ... }`. The legacy `defineApi` factory was
|
|
7
|
+
* removed in #4689.
|
|
4
8
|
*/
|
|
5
9
|
export declare function idempotentMutationTemplate(domain: string, id: string, tenancy: string): string;
|
|
6
10
|
/**
|