@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
package/src/commands/test.ts
CHANGED
|
@@ -38,7 +38,6 @@ export async function runTest(options: TestOptions): Promise<void> {
|
|
|
38
38
|
const { registry } = await buildRegistry(domainRoot);
|
|
39
39
|
|
|
40
40
|
const allEntries: RegistryEntry[] = [
|
|
41
|
-
...registry.apis,
|
|
42
41
|
...registry.webhooks,
|
|
43
42
|
...registry.subscribers,
|
|
44
43
|
...registry.schedules,
|
|
@@ -22,10 +22,10 @@ const MIGRATIONS: Migration[] = [
|
|
|
22
22
|
{
|
|
23
23
|
fromMajor: 1,
|
|
24
24
|
toMajor: 2,
|
|
25
|
-
description: '
|
|
25
|
+
description: 'Audit v1 domain backends for action-first API exposure requirements',
|
|
26
26
|
transform(repoRoot: string, dryRun: boolean): void {
|
|
27
|
-
// Placeholder: real migration uses ts-morph to rewrite handler signatures
|
|
28
|
-
console.log(`[G20] Would
|
|
27
|
+
// Placeholder: real migration uses ts-morph to rewrite handler signatures.
|
|
28
|
+
console.log(`[G20] Would audit action-first API exposure in ${repoRoot}/domains/**`);
|
|
29
29
|
if (!dryRun) {
|
|
30
30
|
console.log('[G20] ts-morph transform: (not yet implemented — add jscodeshift transforms here)');
|
|
31
31
|
}
|
package/src/commands/validate.ts
CHANGED
|
@@ -149,44 +149,17 @@ async function checkRawPathViolations(
|
|
|
149
149
|
* Each rule maps 1:1 to an aspect annotation code so downstream tooling
|
|
150
150
|
* can correlate build-time and synth-time failures.
|
|
151
151
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* exposures, which are covered by the existing `AUTH_NONE_REQUIRES_EXCEPTION`
|
|
156
|
-
* rule in `checkActionFirstSecurity`. The aspect's corresponding check
|
|
157
|
-
* (`assertAnonymousRoutesHaveException`) catches the legacy case at
|
|
158
|
-
* synth time so a developer migrating to action-style APIs gets the
|
|
159
|
-
* right guidance at the right layer.
|
|
152
|
+
* Issue #4689: the legacy `defineApi` factory and `registry.apis` field
|
|
153
|
+
* were removed. Deployment-time API invariants are enforced against the
|
|
154
|
+
* `actions[]` API-exposure surface — see `checkActionFirstSecurity`.
|
|
160
155
|
*/
|
|
161
|
-
function checkDeploymentSecurity(
|
|
162
|
-
apis: import('@mettlecast/domain-cdk-packer').ApiRegistryEntry[],
|
|
163
|
-
actions: ActionRegistryEntry[],
|
|
164
|
-
): ValidationError[] {
|
|
165
|
-
const errors: ValidationError[] = [];
|
|
166
|
-
|
|
167
|
-
for (const api of apis) {
|
|
168
|
-
// Non-anonymous APIs whose path lacks the tenant placeholder cannot
|
|
169
|
-
// bind `ctx.tenant.id` from the URL. The aspect surfaces this as
|
|
170
|
-
// `SECURITY_MISSING_TENANT_PATH`; the CLI version is a faster gate so
|
|
171
|
-
// CI doesn't pay the synth cost.
|
|
172
|
-
if (api.authType !== 'none' && !api.path.includes('/v1/tenants/{tenantId}/')) {
|
|
173
|
-
errors.push({
|
|
174
|
-
code: 'SECURITY_MISSING_TENANT_PATH',
|
|
175
|
-
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.`,
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
156
|
+
function checkDeploymentSecurity(_actions: ActionRegistryEntry[]): ValidationError[] {
|
|
180
157
|
// Action API exposures are already covered by `checkActionFirstSecurity`
|
|
181
158
|
// for `tenancy: 'required'` paths and `auth: 'none'` exceptions. The
|
|
182
159
|
// codes there (TENANT_API_PATH_REQUIRED, AUTH_NONE_REQUIRES_EXCEPTION)
|
|
183
160
|
// are kept stable for back-compat — they map to the same aspect codes.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// for the future where actions gain new deployment-time invariants.
|
|
187
|
-
void actions;
|
|
188
|
-
|
|
189
|
-
return errors;
|
|
161
|
+
//
|
|
162
|
+
return [];
|
|
190
163
|
}
|
|
191
164
|
|
|
192
165
|
/**
|
|
@@ -308,28 +281,6 @@ function checkApiExposureSecurity(actionId: string, exposure: ActionApiExposureW
|
|
|
308
281
|
return errors;
|
|
309
282
|
}
|
|
310
283
|
|
|
311
|
-
/**
|
|
312
|
-
* DEFINE_API_LEGACY_USAGE — the action-first migration replaces
|
|
313
|
-
* standalone `defineApi` calls with `defineAction` + `exposure.type:
|
|
314
|
-
* 'api'`. Every `defineApi` call still in the source produces a row in
|
|
315
|
-
* `registry.apis`; the validator emits one error per legacy API to
|
|
316
|
-
* enforce the alpha breaking migration. The validate command's fixture
|
|
317
|
-
* suite intentionally does not use `defineApi`, so this rule does not
|
|
318
|
-
* break existing unit tests.
|
|
319
|
-
*/
|
|
320
|
-
function checkDefineApiLegacyUsage(apiCount: number, apis: RegistryEntry[]): ValidationError[] {
|
|
321
|
-
if (apiCount === 0) return [];
|
|
322
|
-
const errors: ValidationError[] = [];
|
|
323
|
-
for (const api of apis) {
|
|
324
|
-
if (api.kind !== 'api') continue;
|
|
325
|
-
errors.push({
|
|
326
|
-
code: 'DEFINE_API_LEGACY_USAGE',
|
|
327
|
-
message: `API '${api.id}' uses legacy \`defineApi\`. Migrate to \`defineAction\` with \`exposure: { type: 'api', path, method, auth, tenancy }\` (#4619).`,
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
return errors;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
284
|
/**
|
|
334
285
|
* Run the validate command: build the registry and perform structural validation.
|
|
335
286
|
* Exits the process with code 1 if validation fails (CI gate usage).
|
|
@@ -352,7 +303,6 @@ export async function runValidate(
|
|
|
352
303
|
}
|
|
353
304
|
|
|
354
305
|
errors.push(
|
|
355
|
-
...checkDuplicateIds(registry.apis, 'api'),
|
|
356
306
|
...checkDuplicateIds(registry.webhooks, 'webhook'),
|
|
357
307
|
...checkDuplicateIds(registry.subscribers, 'subscriber'),
|
|
358
308
|
...checkDuplicateIds(registry.schedules, 'schedule'),
|
|
@@ -363,7 +313,6 @@ export async function runValidate(
|
|
|
363
313
|
);
|
|
364
314
|
|
|
365
315
|
const entriesWithFiles = [
|
|
366
|
-
...registry.apis,
|
|
367
316
|
...registry.webhooks,
|
|
368
317
|
...registry.subscribers,
|
|
369
318
|
...registry.schedules,
|
|
@@ -387,42 +336,9 @@ export async function runValidate(
|
|
|
387
336
|
|
|
388
337
|
errors.push(...checkSubscriberSemverRanges(registry.subscribers));
|
|
389
338
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
errors.push(...checkLifecycleConsistency(entry, `api '${api.id}'`));
|
|
394
|
-
if (!api.requestSchema) {
|
|
395
|
-
errors.push({
|
|
396
|
-
code: 'MISSING_API_REQUEST_SCHEMA',
|
|
397
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has no request schema. Define input/output in defineApi versions.`,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
if (!api.responseSchema) {
|
|
401
|
-
errors.push({
|
|
402
|
-
code: 'MISSING_API_RESPONSE_SCHEMA',
|
|
403
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has no response schema. Define input/output in defineApi versions.`,
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
if (!VALID_METHODS.has(api.method)) {
|
|
407
|
-
errors.push({
|
|
408
|
-
code: 'INVALID_API_METHOD',
|
|
409
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has invalid method "${api.method}". Use one of: ${[...VALID_METHODS].join(', ')}.`,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
const isVoidInput = api.requestSchema?.type === 'null';
|
|
413
|
-
if (!isVoidInput && !api.examples?.request) {
|
|
414
|
-
errors.push({
|
|
415
|
-
code: 'MISSING_API_REQUEST_EXAMPLE',
|
|
416
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has no request example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
if (!api.examples?.response) {
|
|
420
|
-
errors.push({
|
|
421
|
-
code: 'MISSING_API_RESPONSE_EXAMPLE',
|
|
422
|
-
message: `API '${api.id}' (${api.method} ${api.path}) has no response example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
}
|
|
339
|
+
// Issue #4689: defineApi and registry.apis were removed. The action-first
|
|
340
|
+
// validation rules in `checkActionFirstSecurity` cover the action surface
|
|
341
|
+
// that now owns all HTTP endpoints.
|
|
426
342
|
|
|
427
343
|
const rawPathErrors = await checkRawPathViolations(registry.domainRoot, config);
|
|
428
344
|
errors.push(...rawPathErrors);
|
|
@@ -431,14 +347,13 @@ export async function runValidate(
|
|
|
431
347
|
// Runs against the registry built above so the rules see the same
|
|
432
348
|
// shape the CDK packer will eventually consume.
|
|
433
349
|
errors.push(...checkActionFirstSecurity(registry.actions));
|
|
434
|
-
errors.push(...checkDefineApiLegacyUsage(registry.apis.length, registry.apis));
|
|
435
350
|
|
|
436
351
|
// Issue #4662 Task D — deployment-time security gates. These mirror
|
|
437
352
|
// the CDK synth-time `SecurityAssertionAspect` so violations are
|
|
438
353
|
// caught before any AWS deployment is attempted.
|
|
439
|
-
errors.push(...checkDeploymentSecurity(registry.
|
|
354
|
+
errors.push(...checkDeploymentSecurity(registry.actions));
|
|
440
355
|
|
|
441
|
-
const totalPrimitives = registry.
|
|
356
|
+
const totalPrimitives = registry.webhooks.length +
|
|
442
357
|
registry.subscribers.length + registry.schedules.length +
|
|
443
358
|
registry.jobs.length + registry.actions.length;
|
|
444
359
|
if (totalPrimitives === 0) {
|
package/src/server/api-server.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import type { FastifyInstance } from 'fastify';
|
|
3
|
-
import type { DomainRegistry } from '
|
|
3
|
+
import type { DomainRegistry } from '../types.js';
|
|
4
4
|
import { hydrateLocalCtx } from '../runtime-stubs/hydrate-local-ctx.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -21,30 +21,41 @@ function toFastifyPath(path: string): string {
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Mount all API routes from the DomainRegistry onto the Fastify server.
|
|
24
|
-
*
|
|
24
|
+
*
|
|
25
|
+
* Issue #4689: the only HTTP endpoint surface in the new registry is
|
|
26
|
+
* `actions[]` whose `exposure.type === 'api'`. `defineApi` was removed
|
|
27
|
+
* and `registry.apis` is always empty in fresh registries, so the
|
|
28
|
+
* dev server iterates the action array and mounts each API exposure
|
|
29
|
+
* directly. Internal-only actions are intentionally skipped — they
|
|
30
|
+
* are reachable only through `ctx.actions`.
|
|
31
|
+
*
|
|
32
|
+
* Each route dynamically imports its handler file and hydrates a local
|
|
33
|
+
* dev ctx per request.
|
|
25
34
|
* @param options - MountRoutesOptions.
|
|
26
35
|
*/
|
|
27
36
|
export async function mountRoutes(options: MountRoutesOptions): Promise<void> {
|
|
28
37
|
const { server, registry, domainRoot } = options;
|
|
29
38
|
|
|
30
|
-
for (const
|
|
31
|
-
|
|
32
|
-
const
|
|
39
|
+
for (const action of registry.actions) {
|
|
40
|
+
if (action.exposure?.type !== 'api') continue;
|
|
41
|
+
const exposure = action.exposure;
|
|
42
|
+
const handlerAbsPath = join(domainRoot, action.handlerFile);
|
|
43
|
+
const fastifyPath = toFastifyPath(exposure.path);
|
|
33
44
|
|
|
34
45
|
const mod = await import(handlerAbsPath) as Record<string, unknown>;
|
|
35
46
|
const def = Object.values(mod).find(
|
|
36
|
-
v => v && typeof v === 'object' && (v as Record<string, unknown>)['id'] ===
|
|
47
|
+
v => v && typeof v === 'object' && (v as Record<string, unknown>)['id'] === action.id
|
|
37
48
|
) as Record<string, unknown> | undefined;
|
|
38
49
|
|
|
39
50
|
if (!def || typeof def['handler'] !== 'function') {
|
|
40
|
-
server.log.warn({
|
|
51
|
+
server.log.warn({ actionId: action.id, handlerAbsPath }, 'No handler function found, skipping route');
|
|
41
52
|
continue;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
const handler = def['handler'] as (event: unknown, ctx: unknown) => Promise<unknown>;
|
|
45
56
|
|
|
46
57
|
server.route({
|
|
47
|
-
method:
|
|
58
|
+
method: [exposure.method as never],
|
|
48
59
|
url: fastifyPath,
|
|
49
60
|
handler: async (request, reply) => {
|
|
50
61
|
const { ctx } = await hydrateLocalCtx({ domainRoot });
|
|
@@ -63,6 +74,6 @@ export async function mountRoutes(options: MountRoutesOptions): Promise<void> {
|
|
|
63
74
|
},
|
|
64
75
|
});
|
|
65
76
|
|
|
66
|
-
server.log.info({ method:
|
|
77
|
+
server.log.info({ method: exposure.method, path: fastifyPath, actionId: action.id }, 'Route mounted');
|
|
67
78
|
}
|
|
68
|
-
}
|
|
79
|
+
}
|
|
@@ -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
|
/**
|
|
@@ -21,45 +26,41 @@ function camelCase(s: string): string {
|
|
|
21
26
|
export function apiSkeletonTemplate(domainId: string, apiId: string, tenancy: string): string {
|
|
22
27
|
const tenantPath = tenancy === 'required' ? '/v1/tenants/{tenantId}' : '/v1';
|
|
23
28
|
const outputType = `${camelCase(domainId)}${camelCase(apiId)}Output`;
|
|
29
|
+
// The `auth` posture must be set explicitly per the action-first contract:
|
|
30
|
+
// - 'required' for tenant-scoped or any authenticated endpoint.
|
|
31
|
+
// - 'none' for anonymous endpoints (then add `securityException`).
|
|
32
|
+
// - 'service' for service-to-service only.
|
|
33
|
+
const authPosture = tenancy === 'system' ? 'service' : 'required';
|
|
24
34
|
return `import { z } from 'zod';
|
|
25
|
-
import {
|
|
26
|
-
import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
|
|
35
|
+
import { defineAction, initOtel } from '@mettlecast/domain-runtime';
|
|
27
36
|
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
28
37
|
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
29
38
|
|
|
30
39
|
// Initialise OpenTelemetry tracing for the process. Idempotent —
|
|
31
40
|
// safe to call at module scope. Sets up OTLP export (when
|
|
32
|
-
// OTEL_EXPORTER_OTLP_ENDPOINT is set) or console-export for
|
|
33
|
-
// local dev. Auto-instruments outbound HTTP calls via ky.
|
|
41
|
+
// OTEL_EXPORTER_OTLP_ENDPOINT is set) or console-export for local dev.
|
|
34
42
|
initOtel();
|
|
35
43
|
|
|
36
|
-
// Shared HTTP client with retry on 503/429. Replace this with
|
|
37
|
-
// your own client if you need custom headers or auth.
|
|
38
|
-
const http = createKyClient();
|
|
39
|
-
|
|
40
44
|
const outputSchema = z.object({ ok: z.boolean() }).default({ ok: true });
|
|
41
45
|
|
|
42
|
-
export const ${camelCase(apiId)} =
|
|
46
|
+
export const ${camelCase(apiId)} = defineAction({
|
|
43
47
|
id: '${apiId}',
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
input: z.object({}).default({}),
|
|
52
|
-
output: outputSchema,
|
|
53
|
-
handler: async (_input, _ctx): Promise<Result<z.infer<typeof outputSchema>, AppError>> => {
|
|
54
|
-
// Replace this with your handler logic.
|
|
55
|
-
// Return ok(value) on success, err({ ... }) on failure.
|
|
56
|
-
return ok({ ok: true });
|
|
57
|
-
},
|
|
58
|
-
},
|
|
48
|
+
backendAccess: 'domain',
|
|
49
|
+
exposure: {
|
|
50
|
+
type: 'api',
|
|
51
|
+
path: '${tenantPath}/${domainId}/${apiId}',
|
|
52
|
+
method: 'POST',
|
|
53
|
+
auth: '${authPosture}',
|
|
54
|
+
tenancy: '${tenancy}',
|
|
59
55
|
},
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
outboundAccess: 'internal',
|
|
57
|
+
idempotent: false,
|
|
58
|
+
input: z.object({}).default({}),
|
|
59
|
+
output: outputSchema,
|
|
60
|
+
handler: async (_input, _ctx): Promise<Result<z.infer<typeof outputSchema>, AppError>> => {
|
|
61
|
+
// Replace this with your handler logic.
|
|
62
|
+
// Return ok(value) on success, err({ ... }) on failure.
|
|
63
|
+
return ok({ ok: true });
|
|
63
64
|
},
|
|
64
65
|
});
|
|
65
66
|
`;
|
|
@@ -101,4 +102,4 @@ export function apiFixtureSkeleton(
|
|
|
101
102
|
body: exampleBody,
|
|
102
103
|
},
|
|
103
104
|
}, null, 2) + '\n';
|
|
104
|
-
}
|
|
105
|
+
}
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export function claudeMdTemplate(id: string): string {
|
|
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
|
|
|
6
10
|
function camelCase(s: string): string {
|
|
@@ -10,10 +14,10 @@ function camelCase(s: string): string {
|
|
|
10
14
|
export function createWithEventTemplate(domain: string, id: string, tenancy: string): string {
|
|
11
15
|
const varName = camelCase(id);
|
|
12
16
|
const tenantPath = tenancy === 'required' ? '/v1/tenants/{tenantId}' : '/v1';
|
|
17
|
+
const authPosture = tenancy === 'system' ? 'service' : 'required';
|
|
13
18
|
|
|
14
19
|
return `import { z } from 'zod';
|
|
15
|
-
import {
|
|
16
|
-
import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
|
|
20
|
+
import { defineAction, initOtel } from '@mettlecast/domain-runtime';
|
|
17
21
|
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
18
22
|
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
19
23
|
|
|
@@ -34,43 +38,45 @@ const ${varName}Output = z.object({
|
|
|
34
38
|
status: 'created',
|
|
35
39
|
});
|
|
36
40
|
|
|
37
|
-
// ──
|
|
41
|
+
// ── Action definition (API exposure) ─────────────────────────
|
|
38
42
|
|
|
39
|
-
export const ${varName} =
|
|
43
|
+
export const ${varName} = defineAction({
|
|
40
44
|
id: '${id}',
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
backendAccess: 'domain',
|
|
46
|
+
exposure: {
|
|
47
|
+
type: 'api',
|
|
48
|
+
path: '${tenantPath}/${domain}/${id}',
|
|
49
|
+
method: 'POST',
|
|
50
|
+
auth: '${authPosture}',
|
|
51
|
+
tenancy: '${tenancy}',
|
|
52
|
+
},
|
|
43
53
|
outboundAccess: 'internal',
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
handler: async (input, ctx) => {
|
|
50
|
-
const entityId = crypto.randomUUID();
|
|
54
|
+
idempotent: false,
|
|
55
|
+
input: ${varName}Input,
|
|
56
|
+
output: ${varName}Output,
|
|
57
|
+
handler: async (input, ctx) => {
|
|
58
|
+
const entityId = crypto.randomUUID();
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
// ── Persist entity ────────────────────────────────────
|
|
61
|
+
await ctx.store.put(entityId, { id: entityId, name: input.name });
|
|
54
62
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// ── Publish domain event ──────────────────────────────
|
|
64
|
+
await ctx.publish(
|
|
65
|
+
'${domain}.${id}.created',
|
|
66
|
+
{
|
|
67
|
+
id: entityId,
|
|
68
|
+
name: input.name,
|
|
69
|
+
tenantId: ctx.tenant.id,
|
|
70
|
+
},
|
|
71
|
+
1,
|
|
72
|
+
);
|
|
65
73
|
|
|
66
|
-
|
|
74
|
+
await ctx.audit.log('${id}.create', entityId, { tenantId: ctx.tenant.id });
|
|
67
75
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
},
|
|
73
|
-
},
|
|
76
|
+
return {
|
|
77
|
+
id: entityId,
|
|
78
|
+
status: 'created',
|
|
79
|
+
};
|
|
74
80
|
},
|
|
75
81
|
});
|
|
76
82
|
`;
|
|
@@ -80,4 +86,4 @@ export const ${varName} = defineApi({
|
|
|
80
86
|
* Example body string used as the event body in the add-api fixture for the
|
|
81
87
|
* create-with-event pattern. Mirrors the default shape of the input schema.
|
|
82
88
|
*/
|
|
83
|
-
export const createWithEventExampleBody = JSON.stringify({ name: 'Example' });
|
|
89
|
+
export const createWithEventExampleBody = JSON.stringify({ name: 'Example' });
|
|
@@ -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
|
|
|
6
10
|
function camelCase(s: string): string {
|
|
@@ -10,10 +14,10 @@ function camelCase(s: string): string {
|
|
|
10
14
|
export function idempotentMutationTemplate(domain: string, id: string, tenancy: string): string {
|
|
11
15
|
const varName = camelCase(id);
|
|
12
16
|
const tenantPath = tenancy === 'required' ? '/v1/tenants/{tenantId}' : '/v1';
|
|
17
|
+
const authPosture = tenancy === 'system' ? 'service' : 'required';
|
|
13
18
|
|
|
14
19
|
return `import { z } from 'zod';
|
|
15
|
-
import {
|
|
16
|
-
import { createKyClient, initOtel } from '@mettlecast/domain-runtime';
|
|
20
|
+
import { defineAction, initOtel } from '@mettlecast/domain-runtime';
|
|
17
21
|
import type { Result, AppError } from '@mettlecast/domain-runtime';
|
|
18
22
|
import { ok, err, notFound } from '@mettlecast/domain-runtime';
|
|
19
23
|
|
|
@@ -39,53 +43,55 @@ const ${varName}Output = z.object({
|
|
|
39
43
|
idempotencyKey: '00000000-0000-0000-0000-000000000000',
|
|
40
44
|
});
|
|
41
45
|
|
|
42
|
-
// ──
|
|
46
|
+
// ── Action definition (API exposure) ─────────────────────────
|
|
43
47
|
|
|
44
|
-
export const ${varName} =
|
|
48
|
+
export const ${varName} = defineAction({
|
|
45
49
|
id: '${id}',
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
backendAccess: 'domain',
|
|
51
|
+
exposure: {
|
|
52
|
+
type: 'api',
|
|
53
|
+
path: '${tenantPath}/${domain}/${id}',
|
|
54
|
+
method: 'PUT',
|
|
55
|
+
auth: '${authPosture}',
|
|
56
|
+
tenancy: '${tenancy}',
|
|
57
|
+
},
|
|
48
58
|
outboundAccess: 'internal',
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// ── Check for duplicate (idempotency key = input.id) ──
|
|
56
|
-
const existing = await ctx.store.get('IDEMPOTENT#' + input.id);
|
|
59
|
+
idempotent: true,
|
|
60
|
+
input: ${varName}Input,
|
|
61
|
+
output: ${varName}Output,
|
|
62
|
+
handler: async (input, ctx) => {
|
|
63
|
+
// ── Check for duplicate (idempotency key = input.id) ──
|
|
64
|
+
const existing = await ctx.store.get('IDEMPOTENT#' + input.id);
|
|
57
65
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
if (existing) {
|
|
67
|
+
await ctx.audit.log('${id}.idempotent.duplicate', input.id);
|
|
68
|
+
return {
|
|
69
|
+
id: input.id,
|
|
70
|
+
status: 'already-processed',
|
|
71
|
+
idempotencyKey: input.id,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
// ── Apply mutation ────────────────────────────────────
|
|
76
|
+
await ctx.store.put(
|
|
77
|
+
'${id.toUpperCase()}#' + input.id,
|
|
78
|
+
{ id: input.id, payload: input.payload, createdAt: new Date().toISOString() },
|
|
79
|
+
);
|
|
72
80
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
// ── Record idempotency token with 24h TTL ─────────────
|
|
82
|
+
await ctx.store.put(
|
|
83
|
+
'IDEMPOTENT#' + input.id,
|
|
84
|
+
{ entityId: input.id },
|
|
85
|
+
{ ttl: Math.floor(Date.now() / 1000) + 86400 },
|
|
86
|
+
);
|
|
79
87
|
|
|
80
|
-
|
|
88
|
+
await ctx.audit.log('${id}.idempotent.applied', input.id, { tenantId: ctx.tenant.id });
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
},
|
|
88
|
-
},
|
|
90
|
+
return {
|
|
91
|
+
id: input.id,
|
|
92
|
+
status: 'applied',
|
|
93
|
+
idempotencyKey: input.id,
|
|
94
|
+
};
|
|
89
95
|
},
|
|
90
96
|
});
|
|
91
97
|
`;
|
|
@@ -98,4 +104,4 @@ export const ${varName} = defineApi({
|
|
|
98
104
|
export const idempotentMutationExampleBody = JSON.stringify({
|
|
99
105
|
id: '00000000-0000-0000-0000-000000000000',
|
|
100
106
|
payload: {},
|
|
101
|
-
});
|
|
107
|
+
});
|