@mettlecast/domain-cli 0.2.60 → 0.2.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/builder/build-registry.d.ts +1 -1
  2. package/dist/builder/build-registry.js +1 -36
  3. package/dist/builder/build-types.d.ts +1 -1
  4. package/dist/builder/load-module.d.ts +1 -1
  5. package/dist/cli.js +1 -1
  6. package/dist/commands/add-api.js +2 -2
  7. package/dist/commands/add-domain.js +4 -4
  8. package/dist/commands/add-fixture-factory.js +5 -6
  9. package/dist/commands/build-catalog.d.ts +6 -22
  10. package/dist/commands/build-catalog.js +7 -18
  11. package/dist/commands/build.js +2 -1
  12. package/dist/commands/dev.js +1 -1
  13. package/dist/commands/doctor.js +66 -37
  14. package/dist/commands/explain.js +13 -13
  15. package/dist/commands/generate-openapi.d.ts +10 -1
  16. package/dist/commands/generate-openapi.js +19 -33
  17. package/dist/commands/show.d.ts +2 -3
  18. package/dist/commands/show.js +0 -2
  19. package/dist/commands/test.js +0 -1
  20. package/dist/commands/upgrade-backend.js +3 -3
  21. package/dist/commands/validate.js +12 -90
  22. package/dist/server/api-server.d.ts +1 -1
  23. package/dist/server/mount-routes.d.ts +11 -2
  24. package/dist/server/mount-routes.js +20 -8
  25. package/dist/templates/api-skeleton.d.ts +5 -0
  26. package/dist/templates/api-skeleton.js +28 -27
  27. package/dist/templates/claude-md.js +1 -1
  28. package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
  29. package/dist/templates/patterns/api/create-with-event.js +38 -32
  30. package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
  31. package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
  32. package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
  33. package/dist/templates/patterns/api/paginated-list.js +30 -24
  34. package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
  35. package/dist/templates/patterns/api/simple-crud.js +46 -35
  36. package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
  37. package/dist/templates/patterns/api/streaming-list.js +46 -41
  38. package/dist/templates/patterns/api/system-admin.d.ts +4 -0
  39. package/dist/templates/patterns/api/system-admin.js +59 -52
  40. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
  41. package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
  42. package/dist/types.d.ts +100 -0
  43. package/dist/types.js +1 -0
  44. package/dist/utils/file-helpers.d.ts +0 -2
  45. package/dist/utils/file-helpers.js +2 -3
  46. package/dist/utils/scaffold-config.d.ts +6 -0
  47. package/dist/utils/scaffold-config.js +2 -0
  48. package/package.json +1 -1
  49. package/src/__tests__/build-registry.test.ts +43 -20
  50. package/src/__tests__/build-types.test.ts +4 -7
  51. package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
  52. package/src/__tests__/commands/add-api.test.ts +12 -10
  53. package/src/__tests__/commands/add-domain.test.ts +8 -5
  54. package/src/__tests__/commands/create-project.test.ts +5 -5
  55. package/src/__tests__/commands/dev.test.ts +0 -1
  56. package/src/__tests__/mount-routes.test.ts +64 -23
  57. package/src/__tests__/package-freshness.test.ts +1 -21
  58. package/src/__tests__/smoke/scaffold.test.ts +13 -15
  59. package/src/__tests__/validate.test.ts +21 -103
  60. package/src/builder/build-registry.ts +7 -44
  61. package/src/builder/build-types.ts +1 -1
  62. package/src/cli.ts +1 -1
  63. package/src/commands/add-api.ts +2 -2
  64. package/src/commands/add-domain.ts +4 -4
  65. package/src/commands/add-fixture-factory.ts +5 -6
  66. package/src/commands/build-catalog.ts +13 -35
  67. package/src/commands/build.ts +3 -2
  68. package/src/commands/dev.ts +1 -1
  69. package/src/commands/doctor.ts +68 -37
  70. package/src/commands/explain.ts +13 -13
  71. package/src/commands/generate-openapi.ts +30 -52
  72. package/src/commands/show.ts +2 -5
  73. package/src/commands/test.ts +0 -1
  74. package/src/commands/upgrade-backend.ts +3 -3
  75. package/src/commands/validate.ts +11 -96
  76. package/src/server/api-server.ts +1 -1
  77. package/src/server/mount-routes.ts +21 -10
  78. package/src/templates/api-skeleton.ts +29 -28
  79. package/src/templates/claude-md.ts +1 -1
  80. package/src/templates/patterns/api/create-with-event.ts +39 -33
  81. package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
  82. package/src/templates/patterns/api/paginated-list.ts +31 -25
  83. package/src/templates/patterns/api/simple-crud.ts +47 -36
  84. package/src/templates/patterns/api/streaming-list.ts +47 -42
  85. package/src/templates/patterns/api/system-admin.ts +60 -53
  86. package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
  87. package/src/types.ts +128 -0
  88. package/src/utils/file-helpers.ts +2 -5
  89. package/src/utils/scaffold-config.ts +9 -0
@@ -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: 'Migrate defineApi from v1 to v2: rename `versions.v1.handler` shape',
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 apply: defineApi v1→v2 migration in ${repoRoot}/domains/**`);
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
  }
@@ -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
- * NOTE on `SECURITY_MISSING_SECURITY_EXCEPTION` for legacy `defineApi`
153
- * entries: the registry builder does not propagate `securityException`
154
- * from legacy sourcethat field only exists on action-style API
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 surfacesee `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
- // Suppress the unused-actions lint while keeping the parameter shape
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
- const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
391
- for (const api of registry.apis) {
392
- const entry = api as unknown as { deprecatedAt?: string; sunsetAt?: string };
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.apis, registry.actions));
354
+ errors.push(...checkDeploymentSecurity(registry.actions));
440
355
 
441
- const totalPrimitives = registry.apis.length + registry.webhooks.length +
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) {
@@ -1,5 +1,5 @@
1
1
  import Fastify, { type FastifyInstance } from 'fastify';
2
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
2
+ import type { DomainRegistry } from '../types.js';
3
3
  import { mountRoutes } from './mount-routes.js';
4
4
 
5
5
  /**
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import type { FastifyInstance } from 'fastify';
3
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
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
- * Each route dynamically imports its handler file and hydrates a local dev ctx per request.
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 api of registry.apis) {
31
- const handlerAbsPath = join(domainRoot, api.handlerFile);
32
- const fastifyPath = toFastifyPath(api.path);
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'] === api.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({ apiId: api.id, handlerAbsPath }, 'No handler function found, skipping route');
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: api.method === 'ANY' ? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] : [api.method as never],
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: api.method, path: fastifyPath, apiId: api.id }, 'Route mounted');
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 { defineApi } from '@mettlecast/domain-runtime';
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)} = defineApi({
46
+ export const ${camelCase(apiId)} = defineAction({
43
47
  id: '${apiId}',
44
- path: '${tenantPath}/${domainId}/${apiId}',
45
- method: 'POST',
46
- tenancy: '${tenancy}',
47
- outboundAccess: 'internal',
48
- versions: {
49
- v1: {
50
- status: 'stable',
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
- examples: {
61
- request: {},
62
- response: { ok: true },
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- API handlers must use \`defineApi\` from \`@mettlecast/domain-runtime\`.\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`;
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 { defineApi } from '@mettlecast/domain-runtime';
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
- // ── API definition ───────────────────────────────────────────
41
+ // ── Action definition (API exposure) ─────────────────────────
38
42
 
39
- export const ${varName} = defineApi({
43
+ export const ${varName} = defineAction({
40
44
  id: '${id}',
41
- path: '${tenantPath}/${domain}/${id}',
42
- tenancy: '${tenancy}',
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
- versions: {
45
- v1: {
46
- status: 'stable',
47
- input: ${varName}Input,
48
- output: ${varName}Output,
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
- // ── Persist entity ────────────────────────────────────
53
- await ctx.store.put(entityId, { id: entityId, name: input.name });
60
+ // ── Persist entity ────────────────────────────────────
61
+ await ctx.store.put(entityId, { id: entityId, name: input.name });
54
62
 
55
- // ── Publish domain event ──────────────────────────────
56
- await ctx.publish(
57
- '${domain}.${id}.created',
58
- {
59
- id: entityId,
60
- name: input.name,
61
- tenantId: ctx.tenant.id,
62
- },
63
- 1,
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
- await ctx.audit.log('${id}.create', entityId, { tenantId: ctx.tenant.id });
74
+ await ctx.audit.log('${id}.create', entityId, { tenantId: ctx.tenant.id });
67
75
 
68
- return {
69
- id: entityId,
70
- status: 'created',
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 { defineApi } from '@mettlecast/domain-runtime';
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
- // ── API definition ───────────────────────────────────────────
46
+ // ── Action definition (API exposure) ─────────────────────────
43
47
 
44
- export const ${varName} = defineApi({
48
+ export const ${varName} = defineAction({
45
49
  id: '${id}',
46
- path: '${tenantPath}/${domain}/${id}',
47
- tenancy: '${tenancy}',
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
- versions: {
50
- v1: {
51
- status: 'stable',
52
- input: ${varName}Input,
53
- output: ${varName}Output,
54
- handler: async (input, ctx) => {
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
- if (existing) {
59
- await ctx.audit.log('${id}.idempotent.duplicate', input.id);
60
- return {
61
- id: input.id,
62
- status: 'already-processed',
63
- idempotencyKey: input.id,
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
- // ── Apply mutation ────────────────────────────────────
68
- await ctx.store.put(
69
- '${id.toUpperCase()}#' + input.id,
70
- { id: input.id, payload: input.payload, createdAt: new Date().toISOString() },
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
- // ── Record idempotency token with 24h TTL ─────────────
74
- await ctx.store.put(
75
- 'IDEMPOTENT#' + input.id,
76
- { entityId: input.id },
77
- { ttl: Math.floor(Date.now() / 1000) + 86400 },
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
- await ctx.audit.log('${id}.idempotent.applied', input.id, { tenantId: ctx.tenant.id });
88
+ await ctx.audit.log('${id}.idempotent.applied', input.id, { tenantId: ctx.tenant.id });
81
89
 
82
- return {
83
- id: input.id,
84
- status: 'applied',
85
- idempotencyKey: input.id,
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
+ });