@mettlecast/domain-cli 0.2.59 → 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 (119) hide show
  1. package/dist/builder/build-registry.d.ts +1 -1
  2. package/dist/builder/build-registry.js +129 -49
  3. package/dist/builder/build-types.d.ts +1 -1
  4. package/dist/builder/load-module.d.ts +1 -1
  5. package/dist/builder/load-module.js +3 -3
  6. package/dist/cli.js +3 -3
  7. package/dist/commands/add-api.js +2 -2
  8. package/dist/commands/add-domain.js +4 -4
  9. package/dist/commands/add-fixture-factory.js +5 -6
  10. package/dist/commands/build-catalog.d.ts +8 -24
  11. package/dist/commands/build-catalog.js +12 -23
  12. package/dist/commands/build-flows.js +1 -1
  13. package/dist/commands/build.js +7 -6
  14. package/dist/commands/check-hashes.js +2 -2
  15. package/dist/commands/create-project.js +1 -1
  16. package/dist/commands/dev.js +1 -1
  17. package/dist/commands/doctor.d.ts +5 -7
  18. package/dist/commands/doctor.js +110 -202
  19. package/dist/commands/explain.js +13 -13
  20. package/dist/commands/generate-openapi.d.ts +10 -1
  21. package/dist/commands/generate-openapi.js +19 -33
  22. package/dist/commands/power-tune.js +2 -2
  23. package/dist/commands/show-dns.d.ts +1 -1
  24. package/dist/commands/show-dns.js +5 -5
  25. package/dist/commands/show.d.ts +2 -3
  26. package/dist/commands/show.js +0 -2
  27. package/dist/commands/test.js +0 -1
  28. package/dist/commands/upgrade-backend.js +3 -3
  29. package/dist/commands/upgrade.js +17 -11
  30. package/dist/commands/validate.js +144 -39
  31. package/dist/server/api-server.d.ts +1 -1
  32. package/dist/server/mount-routes.d.ts +11 -2
  33. package/dist/server/mount-routes.js +20 -8
  34. package/dist/templates/api-skeleton.d.ts +5 -0
  35. package/dist/templates/api-skeleton.js +28 -27
  36. package/dist/templates/claude-md.js +1 -1
  37. package/dist/templates/patterns/api/create-with-event.d.ts +4 -0
  38. package/dist/templates/patterns/api/create-with-event.js +38 -32
  39. package/dist/templates/patterns/api/idempotent-mutation.d.ts +4 -0
  40. package/dist/templates/patterns/api/idempotent-mutation.js +47 -41
  41. package/dist/templates/patterns/api/paginated-list.d.ts +4 -0
  42. package/dist/templates/patterns/api/paginated-list.js +30 -24
  43. package/dist/templates/patterns/api/simple-crud.d.ts +4 -0
  44. package/dist/templates/patterns/api/simple-crud.js +46 -35
  45. package/dist/templates/patterns/api/streaming-list.d.ts +4 -0
  46. package/dist/templates/patterns/api/streaming-list.js +46 -41
  47. package/dist/templates/patterns/api/system-admin.d.ts +4 -0
  48. package/dist/templates/patterns/api/system-admin.js +59 -52
  49. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +4 -0
  50. package/dist/templates/patterns/api/webhook-receiver-style.js +43 -35
  51. package/dist/types.d.ts +100 -0
  52. package/dist/types.js +1 -0
  53. package/dist/utils/file-helpers.d.ts +0 -2
  54. package/dist/utils/file-helpers.js +2 -3
  55. package/dist/utils/header-inject.js +2 -2
  56. package/dist/utils/install-file.d.ts +1 -1
  57. package/dist/utils/install-file.js +1 -1
  58. package/dist/utils/manifest.js +1 -2
  59. package/dist/utils/scaffold-config.d.ts +8 -2
  60. package/dist/utils/scaffold-config.js +3 -1
  61. package/package.json +1 -1
  62. package/src/__tests__/build-registry.test.ts +43 -20
  63. package/src/__tests__/build-types.test.ts +4 -7
  64. package/src/__tests__/builder/walkDomainDir.test.ts +19 -21
  65. package/src/__tests__/commands/add-api.test.ts +12 -10
  66. package/src/__tests__/commands/add-domain.test.ts +8 -5
  67. package/src/__tests__/commands/check-hashes.test.ts +9 -9
  68. package/src/__tests__/commands/create-project.test.ts +5 -5
  69. package/src/__tests__/commands/dev.test.ts +0 -1
  70. package/src/__tests__/commands/upgrade.test.ts +7 -7
  71. package/src/__tests__/doctor.test.ts +60 -67
  72. package/src/__tests__/mount-routes.test.ts +64 -23
  73. package/src/__tests__/package-freshness.test.ts +94 -0
  74. package/src/__tests__/scaffold-src/part-a-layout.test.ts +10 -10
  75. package/src/__tests__/scripts/package-scaffold.test.ts +5 -5
  76. package/src/__tests__/smoke/scaffold.test.ts +13 -15
  77. package/src/__tests__/utils/install-file.test.ts +2 -2
  78. package/src/__tests__/utils/manifest.test.ts +2 -2
  79. package/src/__tests__/validate.test.ts +570 -1
  80. package/src/builder/build-registry.ts +154 -59
  81. package/src/builder/build-types.ts +1 -1
  82. package/src/builder/load-module.ts +3 -3
  83. package/src/cli.ts +4 -4
  84. package/src/commands/add-api.ts +2 -2
  85. package/src/commands/add-domain.ts +4 -4
  86. package/src/commands/add-fixture-factory.ts +5 -6
  87. package/src/commands/build-catalog.ts +18 -40
  88. package/src/commands/build-flows.ts +1 -1
  89. package/src/commands/build.ts +8 -7
  90. package/src/commands/check-hashes.ts +2 -2
  91. package/src/commands/create-project.ts +1 -1
  92. package/src/commands/dev.ts +1 -1
  93. package/src/commands/doctor.ts +120 -218
  94. package/src/commands/explain.ts +13 -13
  95. package/src/commands/generate-openapi.ts +30 -52
  96. package/src/commands/power-tune.ts +2 -2
  97. package/src/commands/show-dns.ts +5 -5
  98. package/src/commands/show.ts +2 -5
  99. package/src/commands/test.ts +0 -1
  100. package/src/commands/upgrade-backend.ts +3 -3
  101. package/src/commands/upgrade.ts +16 -10
  102. package/src/commands/validate.ts +180 -40
  103. package/src/server/api-server.ts +1 -1
  104. package/src/server/mount-routes.ts +21 -10
  105. package/src/templates/api-skeleton.ts +29 -28
  106. package/src/templates/claude-md.ts +1 -1
  107. package/src/templates/patterns/api/create-with-event.ts +39 -33
  108. package/src/templates/patterns/api/idempotent-mutation.ts +48 -42
  109. package/src/templates/patterns/api/paginated-list.ts +31 -25
  110. package/src/templates/patterns/api/simple-crud.ts +47 -36
  111. package/src/templates/patterns/api/streaming-list.ts +47 -42
  112. package/src/templates/patterns/api/system-admin.ts +60 -53
  113. package/src/templates/patterns/api/webhook-receiver-style.ts +48 -40
  114. package/src/types.ts +128 -0
  115. package/src/utils/file-helpers.ts +2 -5
  116. package/src/utils/header-inject.ts +2 -2
  117. package/src/utils/install-file.ts +1 -1
  118. package/src/utils/manifest.ts +1 -2
  119. package/src/utils/scaffold-config.ts +12 -3
@@ -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
- * `defineApi` entry contributes one path under the domain's route prefix.
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
  */
@@ -17,51 +22,24 @@ export interface GenerateOpenapiOptions {
17
22
  output?: string;
18
23
  }
19
24
 
20
- interface ApiRegistryEntry {
21
- id: string;
22
- path: string;
23
- method: string;
24
- tenancy: string;
25
- versions: Record<string, {
26
- status: string;
27
- inputSchema?: unknown;
28
- outputSchema?: unknown;
29
- input?: unknown;
30
- output?: unknown;
31
- }>;
32
- examples?: { request?: unknown; response?: unknown };
33
- }
34
-
35
25
  interface DomainRegistry {
36
26
  domain: { id: string };
37
- apis: ApiRegistryEntry[];
27
+ actions: Array<{
28
+ id: string;
29
+ exposure?: {
30
+ type: 'api' | 'internal';
31
+ path?: string;
32
+ method?: string;
33
+ };
34
+ inputSchema?: Record<string, unknown>;
35
+ outputSchema?: Record<string, unknown>;
36
+ }>;
38
37
  }
39
38
 
40
39
  /**
41
- * Extract a Zod schema shape from a registry entry version.
42
- * The registry may store schemas as `input`/`output` (raw Zod
43
- * objects) or `inputSchema`/`outputSchema` (JSON Schema shapes).
44
- * Returns a best-effort JSON Schema object for the OpenAPI spec.
40
+ * Run the generate-openapi command: read the domain's registry and emit
41
+ * an OpenAPI 3.1 spec covering every API-exposed action.
45
42
  */
46
- function extractSchema(version: ApiRegistryEntry['versions'][string], field: 'input' | 'output'): unknown {
47
- // Prefer JSON Schema if present
48
- const schemaField = field === 'input' ? version.inputSchema : version.outputSchema;
49
- if (schemaField && typeof schemaField === 'object' && schemaField !== null) {
50
- return schemaField;
51
- }
52
- // Fall back to the raw Zod shape — try to produce a minimal JSON
53
- // Schema from the Zod _def. For full support, add zod-to-json-schema
54
- // to the CLI dependencies and call `zodToJsonSchema(zodSchema)`.
55
- const raw = field === 'input' ? version.input : version.output;
56
- if (raw && typeof raw === 'object' && raw !== null) {
57
- // Attempt minimal mapping: if the Zod shape has a `type` field
58
- // from its _def, describe it as JSON Schema.
59
- const def = raw as { type?: string; items?: unknown; properties?: unknown; required?: string[] };
60
- return { type: def.type ?? 'object', properties: def.properties, required: def.required };
61
- }
62
- return { type: 'object' };
63
- }
64
-
65
43
  export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promise<string> {
66
44
  const projectRoot = options.projectRoot ?? process.cwd();
67
45
  const domain = options.domain;
@@ -74,22 +52,22 @@ export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promi
74
52
 
75
53
  const paths: Record<string, unknown> = {};
76
54
 
77
- for (const api of registry.apis) {
78
- const method = (api.method ?? 'get').toLowerCase();
79
- const fullPath = `/v1/${domain}${api.path}`;
80
- const v1 = api.versions['v1'] ?? api.versions[Object.keys(api.versions)[0]];
81
- if (!v1) continue;
55
+ for (const action of registry.actions ?? []) {
56
+ if (action.exposure?.type !== 'api') continue;
57
+ const method = (action.exposure.method ?? 'get').toLowerCase();
58
+ const fullPath = `/v1/${domain}${action.exposure.path}`;
59
+ const inputSchema = action.inputSchema ?? { type: 'object' };
60
+ const outputSchema = action.outputSchema ?? { type: 'object' };
82
61
 
83
62
  if (!paths[fullPath]) paths[fullPath] = {};
84
-
85
63
  (paths[fullPath] as Record<string, unknown>)[method] = {
86
- operationId: `${domain}.${api.id}`,
87
- summary: `${domain}.${api.id}`,
88
- description: `Version: ${v1.status}`,
64
+ operationId: `${domain}.${action.id}`,
65
+ summary: `${domain}.${action.id}`,
66
+ description: 'Action-first contract (defineAction + exposure.type=api).',
89
67
  requestBody: {
90
68
  content: {
91
69
  'application/json': {
92
- schema: extractSchema(v1, 'input'),
70
+ schema: inputSchema,
93
71
  },
94
72
  },
95
73
  },
@@ -98,7 +76,7 @@ export async function runGenerateOpenapi(options: GenerateOpenapiOptions): Promi
98
76
  description: 'OK',
99
77
  content: {
100
78
  'application/json': {
101
- schema: extractSchema(v1, 'output'),
79
+ schema: outputSchema,
102
80
  },
103
81
  },
104
82
  },
@@ -151,4 +129,4 @@ export async function runGenerateOpenapiCli(domain: string, opts: { projectRoot?
151
129
  const outputPath = await runGenerateOpenapi({ domain, ...opts });
152
130
  // eslint-disable-next-line no-console
153
131
  console.log(`OpenAPI spec written to: ${outputPath}`);
154
- }
132
+ }
@@ -5,13 +5,13 @@ import { cliLogger } from '../utils/logger.js';
5
5
 
6
6
  export async function runPowerTune(domain: string, primitive: string): Promise<void> {
7
7
  // Load registry to find Lambda ARN
8
- const registryPath = path.join(process.cwd(), '.tib', `${domain}-registry.json`);
8
+ const registryPath = path.join(process.cwd(), '.mc', `${domain}-registry.json`);
9
9
 
10
10
  let registry: { domain: { id: string }; functionArns?: Record<string, string> };
11
11
  try {
12
12
  registry = JSON.parse(readFileSync(registryPath, 'utf8'));
13
13
  } catch {
14
- cliLogger.error({}, `Registry not found at ${registryPath}. Run: tib build domains/${domain}`);
14
+ cliLogger.error({}, `Registry not found at ${registryPath}. Run: npx mc-domain-module build domains/${domain}`);
15
15
  process.exit(1);
16
16
  }
17
17
 
@@ -26,7 +26,7 @@ function toZoneLine(r: DnsRecord): string {
26
26
  }
27
27
 
28
28
  /**
29
- * tib show-dns [--env dev|staging|prod] [--project-dir <path>]
29
+ * mc-domain-module show-dns [--env dev|staging|prod] [--project-dir <path>]
30
30
  *
31
31
  * Reads the scaffold-config.json and prints DNS records that must be added for
32
32
  * custom domain wiring: certificate validation CNAMEs (step 1) and traffic
@@ -37,27 +37,27 @@ function toZoneLine(r: DnsRecord): string {
37
37
  */
38
38
  export async function runShowDns(opts: ShowDnsOptions): Promise<void> {
39
39
  const projectDir = opts.projectDir ? path.resolve(opts.projectDir) : process.cwd();
40
- const configPath = path.join(projectDir, '.tib', 'scaffold-config.json');
40
+ const configPath = path.join(projectDir, '.mc', 'scaffold-config.json');
41
41
 
42
42
  let config: { environments?: Record<string, { dashboardUrl?: string }> };
43
43
  try {
44
44
  const raw = await readFile(configPath, 'utf-8');
45
45
  config = JSON.parse(raw) as typeof config;
46
46
  } catch {
47
- console.error('[tib] Could not read .mc/scaffold-config.json — run from a TIB project root.');
47
+ console.error('[mc-domain-module] Could not read .mc/scaffold-config.json — run from a Mettlecast project root.');
48
48
  process.exit(1);
49
49
  }
50
50
 
51
51
  const environments = config.environments ?? {};
52
52
  if (Object.keys(environments).length === 0) {
53
- console.log('[tib] No environments configured. Set a custom domain via `tib show-dns` or the Setup tab.');
53
+ console.log('[mc-domain-module] No environments configured. Set a custom domain via `npx mc-domain-module show-dns` or the Setup tab.');
54
54
  return;
55
55
  }
56
56
 
57
57
  // Try to load CDK outputs for routing records
58
58
  let cdkOutputs: Record<string, Record<string, string>> = {};
59
59
  try {
60
- const outputsPath = path.join(projectDir, '.tib', 'cdk-outputs.json');
60
+ const outputsPath = path.join(projectDir, '.mc', 'cdk-outputs.json');
61
61
  const raw = await readFile(outputsPath, 'utf-8');
62
62
  cdkOutputs = JSON.parse(raw) as typeof cdkOutputs;
63
63
  } catch {
@@ -21,10 +21,10 @@ export interface ShowDomainOptions {
21
21
  const KEBAB_REGEX = /^[a-z][a-z0-9-]*$/;
22
22
 
23
23
  /**
24
- * Represents a primitive entry (API, subscriber, action, or job).
24
+ * Represents a primitive entry (action, subscriber, or job).
25
25
  */
26
26
  interface PrimitiveEntry {
27
- type: 'api' | 'subscriber' | 'action' | 'job';
27
+ type: 'subscriber' | 'action' | 'job';
28
28
  id: string;
29
29
  file: string;
30
30
  }
@@ -35,7 +35,6 @@ interface PrimitiveEntry {
35
35
  interface DomainSummary {
36
36
  domain: string;
37
37
  primitives: {
38
- apis: PrimitiveEntry[];
39
38
  subscribers: PrimitiveEntry[];
40
39
  actions: PrimitiveEntry[];
41
40
  jobs: PrimitiveEntry[];
@@ -114,7 +113,6 @@ export async function runShowDomain(opts: ShowDomainOptions): Promise<DomainSumm
114
113
  }
115
114
 
116
115
  // Scan primitives
117
- const apis = await scanPrimitives(join(domainDir, 'api'), 'api');
118
116
  const subscribers = await scanPrimitives(join(domainDir, 'subscribers'), 'subscriber');
119
117
  const actions = await scanPrimitives(join(domainDir, 'actions'), 'action');
120
118
  const jobs = await scanPrimitives(join(domainDir, 'jobs'), 'job');
@@ -155,7 +153,6 @@ export async function runShowDomain(opts: ShowDomainOptions): Promise<DomainSumm
155
153
  const summary: DomainSummary = {
156
154
  domain: opts.domain,
157
155
  primitives: {
158
- apis,
159
156
  subscribers,
160
157
  actions,
161
158
  jobs,
@@ -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
  }
@@ -251,9 +251,9 @@ function buildPrBody(
251
251
  lines.push('### Drift warnings — manual review required');
252
252
  lines.push(
253
253
  'The following files were locally modified after scaffold installation. ' +
254
- 'New versions have been written to `{path}.tib-upgrade` — merge manually then remove the `.tib-upgrade` file.'
254
+ 'New versions have been written to `{path}.mc-upgrade` — merge manually then remove the `.mc-upgrade` file.'
255
255
  );
256
- for (const r of conflicts) lines.push(`- \`${r.path}\` → \`${r.path}.tib-upgrade\``);
256
+ for (const r of conflicts) lines.push(`- \`${r.path}\` → \`${r.path}.mc-upgrade\``);
257
257
  lines.push('');
258
258
  }
259
259
 
@@ -322,9 +322,9 @@ function buildFrontendComponentsPrBody(
322
322
  lines.push('### Drift warnings — manual review required');
323
323
  lines.push(
324
324
  'The following files were locally modified after scaffold installation. ' +
325
- 'New versions have been written to `{path}.tib-upgrade` — merge manually then remove the `.tib-upgrade` file.'
325
+ 'New versions have been written to `{path}.mc-upgrade` — merge manually then remove the `.mc-upgrade` file.'
326
326
  );
327
- for (const r of conflicts) lines.push(`- \`${r.path}\` → \`${r.path}.tib-upgrade\``);
327
+ for (const r of conflicts) lines.push(`- \`${r.path}\` → \`${r.path}.mc-upgrade\``);
328
328
  lines.push('');
329
329
  }
330
330
 
@@ -512,14 +512,20 @@ export async function runUpgrade(
512
512
  continue;
513
513
  }
514
514
 
515
- // Add to results
516
- allResults.push({ path: installPath, status: installResult.status, module: mod.id });
517
-
518
- // update-available: do NOT update manifest (file was not written to disk)
515
+ // update-available: write the proposed new content beside the drifted file
516
+ // for manual merge, but do NOT update the manifest or overwrite the file.
519
517
  if (installResult.status === 'update-available') {
518
+ if (!opts.dryRun) {
519
+ await mkdir(dirname(diskPath), { recursive: true });
520
+ await writeFile(`${diskPath}.mc-upgrade`, newContent, 'utf-8');
521
+ }
522
+ allResults.push({ path: installPath, status: 'conflict', module: mod.id });
520
523
  continue;
521
524
  }
522
525
 
526
+ // Add to results
527
+ allResults.push({ path: installPath, status: installResult.status, module: mod.id });
528
+
523
529
  // Update manifest if file was not skipped
524
530
  const newChecksum = computeChecksumString(newContent);
525
531
  upsertManifestFile(updatedManifest, {
@@ -566,7 +572,7 @@ export async function runUpgrade(
566
572
  if (conflicts.length > 0) {
567
573
  console.log('\nDrift warnings:');
568
574
  for (const r of conflicts) {
569
- console.log(` ${r.path} — new version written to ${r.path}.tib-upgrade`);
575
+ console.log(` ${r.path} — new version written to ${r.path}.mc-upgrade`);
570
576
  }
571
577
  }
572
578
 
@@ -601,7 +607,7 @@ export async function runUpgrade(
601
607
  await writeScaffoldConfig(projectDir, updatedConfig);
602
608
 
603
609
  // 10. Commit on upgrade branch
604
- const branchName = `tib-upgrade/${targetVersion}`;
610
+ const branchName = `mc-upgrade/${targetVersion}`;
605
611
  try {
606
612
  execSync(`git -C "${projectDir}" checkout -b "${branchName}"`, {
607
613
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1,11 +1,32 @@
1
1
  import { stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import type { RegistryEntry } from '@mettlecast/domain-cdk-packer';
3
+ import type {
4
+ RegistryEntry,
5
+ ActionRegistryEntry,
6
+ } from '@mettlecast/domain-cdk-packer';
4
7
  import { validRange } from 'semver';
5
8
  import type { DomainModuleConfig } from '@mettlecast/domain-runtime/types';
6
9
  import { buildRegistry } from '../builder/build-registry.js';
7
10
  import { cliLogger } from '../utils/logger.js';
8
11
 
12
+ type ActionApiExposureWithDeclaration = {
13
+ type: 'api';
14
+ path: string;
15
+ method: string;
16
+ auth: 'required' | 'none' | 'service';
17
+ tenancy: 'required' | 'none' | 'system';
18
+ roles?: string[];
19
+ securityException?: { reason: string };
20
+ authDeclared?: boolean;
21
+ tenancyDeclared?: boolean;
22
+ };
23
+
24
+ type ActionRegistryEntryWithDeclaration = ActionRegistryEntry & {
25
+ backendAccess?: 'private' | 'domain' | 'platform';
26
+ exposureDeclared?: boolean;
27
+ exposure: { type: 'internal' } | ActionApiExposureWithDeclaration;
28
+ };
29
+
9
30
  /**
10
31
  * A single validation failure.
11
32
  */
@@ -116,6 +137,150 @@ async function checkRawPathViolations(
116
137
  return [];
117
138
  }
118
139
 
140
+ /**
141
+ * Deployment-time security checks (#4662 Task D).
142
+ *
143
+ * Mirrors the invariants enforced at CDK synth time by
144
+ * `SecurityAssertionAspect` in `@mettlecast/domain-cdk-packer`. Running
145
+ * them at the CLI stage means developers get a structured error code in
146
+ * their terminal and CI fails on `mc-domain-module validate` BEFORE a
147
+ * (potentially expensive) `cdk synth` is attempted.
148
+ *
149
+ * Each rule maps 1:1 to an aspect annotation code so downstream tooling
150
+ * can correlate build-time and synth-time failures.
151
+ *
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`.
155
+ */
156
+ function checkDeploymentSecurity(_actions: ActionRegistryEntry[]): ValidationError[] {
157
+ // Action API exposures are already covered by `checkActionFirstSecurity`
158
+ // for `tenancy: 'required'` paths and `auth: 'none'` exceptions. The
159
+ // codes there (TENANT_API_PATH_REQUIRED, AUTH_NONE_REQUIRES_EXCEPTION)
160
+ // are kept stable for back-compat — they map to the same aspect codes.
161
+ //
162
+ return [];
163
+ }
164
+
165
+ /**
166
+ * Action-first security validation rules (#4619, Wave 6 Task 6.1).
167
+ *
168
+ * These checks enforce the action-first security model on the
169
+ * serialised DomainRegistry produced by `buildRegistry`. The rules
170
+ * intentionally mirror the Zod cross-field refinements defined in
171
+ * `@mettlecast/domain-runtime/primitives/action` (`ApiExposureSchema`)
172
+ * so violations are caught at build time, not at runtime.
173
+ *
174
+ * Each rule emits a structured `ValidationError` whose `code` is the
175
+ * rule ID listed in the spec (e.g. `ACTION_EXPOSURE_REQUIRED`,
176
+ * `TENANT_API_PATH_REQUIRED`). Messages include the offending action
177
+ * or API id and the actionable fix.
178
+ */
179
+ function checkActionFirstSecurity(actions: ActionRegistryEntry[]): ValidationError[] {
180
+ const errors: ValidationError[] = [];
181
+
182
+ for (const action of actions as ActionRegistryEntryWithDeclaration[]) {
183
+ const id = action.id;
184
+
185
+ // ACTION_EXPOSURE_REQUIRED — every action registry entry must have
186
+ // `exposure` declared in source. The builder defaults missing
187
+ // exposure to `{ type: 'internal' }` for backwards compatibility,
188
+ // and flags the entry with `exposureDeclared: false` so the validator
189
+ // can surface it. Legacy actions that use `visibility` only (no
190
+ // `backendAccess`) are also allowed to default during migration.
191
+ if (action.exposureDeclared === false && action.backendAccess !== 'private') {
192
+ // Only fire for non-private actions: a private action with no
193
+ // exposure is the natural migration state for legacy
194
+ // visibility:'private' handlers, and forcing exposure would
195
+ // produce noisy errors during the migration window.
196
+ // (When Wave 7+ removes the legacy visibility alias this branch
197
+ // becomes a hard error for every action.)
198
+ errors.push({
199
+ code: 'ACTION_EXPOSURE_REQUIRED',
200
+ message: `Action '${id}' has no explicit \`exposure\`. New-style actions must declare \`exposure\` (e.g. \`{ type: 'api', path: '...', method: 'POST', auth: 'required', tenancy: 'required' }\`) or \`{ type: 'internal' }\` to opt out of API exposure.`,
201
+ });
202
+ }
203
+
204
+ if (action.exposure.type === 'api') {
205
+ errors.push(...checkApiExposureSecurity(action.id, action.exposure));
206
+ }
207
+ }
208
+
209
+ return errors;
210
+ }
211
+
212
+ /**
213
+ * Validate a single API-exposed action. Pulled out as a separate helper
214
+ * so each sub-rule is independently testable and the messages stay short.
215
+ */
216
+ function checkApiExposureSecurity(actionId: string, exposure: ActionApiExposureWithDeclaration): ValidationError[] {
217
+ const errors: ValidationError[] = [];
218
+
219
+ // API_EXPOSURE_AUTH_REQUIRED — API-exposed actions must declare auth
220
+ // explicitly. The builder defaults auth to 'required' when the source
221
+ // omits it; the validator surfaces the omission so developers are not
222
+ // silently relying on the safe-default.
223
+ if (exposure.authDeclared === false) {
224
+ errors.push({
225
+ code: 'API_EXPOSURE_AUTH_REQUIRED',
226
+ message: `Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.auth\` is not declared. Set \`exposure.auth\` to 'required' | 'none' | 'service' explicitly.`,
227
+ });
228
+ }
229
+
230
+ // TENANT_API_PATH_REQUIRED — API-exposed actions with `tenancy: 'required'`
231
+ // must use a path that includes the canonical tenant placeholder so the
232
+ // runtime can bind `ctx.tenant.id` from the URL.
233
+ if (exposure.tenancy === 'required' && !exposure.path.includes('/v1/tenants/{tenantId}/')) {
234
+ errors.push({
235
+ code: 'TENANT_API_PATH_REQUIRED',
236
+ message: `Action '${actionId}' declares \`exposure.tenancy: 'required'\` but \`exposure.path\` ('${exposure.path}') does not include the canonical tenant placeholder '/v1/tenants/{tenantId}/'.`,
237
+ });
238
+ }
239
+
240
+ // AUTH_NONE_REQUIRES_EXCEPTION — `auth: 'none'` (anonymous) routes
241
+ // must carry an explicit `securityException` with a non-empty reason
242
+ // so security reviewers can audit the relaxation.
243
+ if (exposure.auth === 'none') {
244
+ const reason = exposure.securityException?.reason;
245
+ if (!reason || reason.trim().length === 0) {
246
+ errors.push({
247
+ code: 'AUTH_NONE_REQUIRES_EXCEPTION',
248
+ message: `Action '${actionId}' has \`exposure.auth: 'none'\` but no \`exposure.securityException.reason\`. Public/anonymous routes must document the security exception with a non-empty reason (and ideally a tracking reference).`,
249
+ });
250
+ }
251
+ }
252
+
253
+ // TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC — `tenancy: 'none'` on a
254
+ // public API route must justify the missing tenant context. If the
255
+ // route is already justified as anonymous via `auth: 'none'` the
256
+ // same `securityException` may be reused; otherwise an exception is
257
+ // required for tenancy: 'none' on its own.
258
+ if (exposure.tenancy === 'none') {
259
+ const reason = exposure.securityException?.reason;
260
+ if (!reason || reason.trim().length === 0) {
261
+ errors.push({
262
+ code: 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC',
263
+ message: `Action '${actionId}' has \`exposure.tenancy: 'none'\` but no \`exposure.securityException.reason\`. Routes without tenant context must document the security exception with a non-empty reason.`,
264
+ });
265
+ }
266
+ }
267
+
268
+ // SYSTEM_API_REQUIRES_ROLE — `tenancy: 'system'` combined with
269
+ // `auth: 'required'` must declare non-empty `roles` so the JWT
270
+ // authorizer can scope the call. Routes that use `auth: 'service'`
271
+ // are service-only and do not require role narrowing.
272
+ if (exposure.tenancy === 'system' && exposure.auth === 'required') {
273
+ if (!Array.isArray(exposure.roles) || exposure.roles.length === 0) {
274
+ errors.push({
275
+ code: 'SYSTEM_API_REQUIRES_ROLE',
276
+ message: `Action '${actionId}' has \`exposure.tenancy: 'system'\` and \`exposure.auth: 'required'\` but no \`exposure.roles\`. System-tenancy routes using user auth must declare at least one required role.`,
277
+ });
278
+ }
279
+ }
280
+
281
+ return errors;
282
+ }
283
+
119
284
  /**
120
285
  * Run the validate command: build the registry and perform structural validation.
121
286
  * Exits the process with code 1 if validation fails (CI gate usage).
@@ -138,7 +303,6 @@ export async function runValidate(
138
303
  }
139
304
 
140
305
  errors.push(
141
- ...checkDuplicateIds(registry.apis, 'api'),
142
306
  ...checkDuplicateIds(registry.webhooks, 'webhook'),
143
307
  ...checkDuplicateIds(registry.subscribers, 'subscriber'),
144
308
  ...checkDuplicateIds(registry.schedules, 'schedule'),
@@ -149,7 +313,6 @@ export async function runValidate(
149
313
  );
150
314
 
151
315
  const entriesWithFiles = [
152
- ...registry.apis,
153
316
  ...registry.webhooks,
154
317
  ...registry.subscribers,
155
318
  ...registry.schedules,
@@ -173,47 +336,24 @@ export async function runValidate(
173
336
 
174
337
  errors.push(...checkSubscriberSemverRanges(registry.subscribers));
175
338
 
176
- const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
177
- for (const api of registry.apis) {
178
- const entry = api as unknown as { deprecatedAt?: string; sunsetAt?: string };
179
- errors.push(...checkLifecycleConsistency(entry, `api '${api.id}'`));
180
- if (!api.requestSchema) {
181
- errors.push({
182
- code: 'MISSING_API_REQUEST_SCHEMA',
183
- message: `API '${api.id}' (${api.method} ${api.path}) has no request schema. Define input/output in defineApi versions.`,
184
- });
185
- }
186
- if (!api.responseSchema) {
187
- errors.push({
188
- code: 'MISSING_API_RESPONSE_SCHEMA',
189
- message: `API '${api.id}' (${api.method} ${api.path}) has no response schema. Define input/output in defineApi versions.`,
190
- });
191
- }
192
- if (!VALID_METHODS.has(api.method)) {
193
- errors.push({
194
- code: 'INVALID_API_METHOD',
195
- message: `API '${api.id}' (${api.method} ${api.path}) has invalid method "${api.method}". Use one of: ${[...VALID_METHODS].join(', ')}.`,
196
- });
197
- }
198
- const isVoidInput = api.requestSchema?.type === 'null';
199
- if (!isVoidInput && !api.examples?.request) {
200
- errors.push({
201
- code: 'MISSING_API_REQUEST_EXAMPLE',
202
- message: `API '${api.id}' (${api.method} ${api.path}) has no request example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
203
- });
204
- }
205
- if (!api.examples?.response) {
206
- errors.push({
207
- code: 'MISSING_API_RESPONSE_EXAMPLE',
208
- message: `API '${api.id}' (${api.method} ${api.path}) has no response example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
209
- });
210
- }
211
- }
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.
212
342
 
213
343
  const rawPathErrors = await checkRawPathViolations(registry.domainRoot, config);
214
344
  errors.push(...rawPathErrors);
215
345
 
216
- const totalPrimitives = registry.apis.length + registry.webhooks.length +
346
+ // Wave 6 Task 6.1: action-first security validation gates (#4619).
347
+ // Runs against the registry built above so the rules see the same
348
+ // shape the CDK packer will eventually consume.
349
+ errors.push(...checkActionFirstSecurity(registry.actions));
350
+
351
+ // Issue #4662 Task D — deployment-time security gates. These mirror
352
+ // the CDK synth-time `SecurityAssertionAspect` so violations are
353
+ // caught before any AWS deployment is attempted.
354
+ errors.push(...checkDeploymentSecurity(registry.actions));
355
+
356
+ const totalPrimitives = registry.webhooks.length +
217
357
  registry.subscribers.length + registry.schedules.length +
218
358
  registry.jobs.length + registry.actions.length;
219
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
+ }