@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
  */
@@ -9,29 +14,9 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
9
14
  import { join } from 'node:path';
10
15
  import { cliLogger } from '../utils/logger.js';
11
16
  /**
12
- * Extract a Zod schema shape from a registry entry version.
13
- * The registry may store schemas as `input`/`output` (raw Zod
14
- * objects) or `inputSchema`/`outputSchema` (JSON Schema shapes).
15
- * Returns a best-effort JSON Schema object for the OpenAPI spec.
17
+ * Run the generate-openapi command: read the domain's registry and emit
18
+ * an OpenAPI 3.1 spec covering every API-exposed action.
16
19
  */
17
- function extractSchema(version, field) {
18
- // Prefer JSON Schema if present
19
- const schemaField = field === 'input' ? version.inputSchema : version.outputSchema;
20
- if (schemaField && typeof schemaField === 'object' && schemaField !== null) {
21
- return schemaField;
22
- }
23
- // Fall back to the raw Zod shape — try to produce a minimal JSON
24
- // Schema from the Zod _def. For full support, add zod-to-json-schema
25
- // to the CLI dependencies and call `zodToJsonSchema(zodSchema)`.
26
- const raw = field === 'input' ? version.input : version.output;
27
- if (raw && typeof raw === 'object' && raw !== null) {
28
- // Attempt minimal mapping: if the Zod shape has a `type` field
29
- // from its _def, describe it as JSON Schema.
30
- const def = raw;
31
- return { type: def.type ?? 'object', properties: def.properties, required: def.required };
32
- }
33
- return { type: 'object' };
34
- }
35
20
  export async function runGenerateOpenapi(options) {
36
21
  const projectRoot = options.projectRoot ?? process.cwd();
37
22
  const domain = options.domain;
@@ -41,22 +26,23 @@ export async function runGenerateOpenapi(options) {
41
26
  const registryJson = await readFile(registryPath, 'utf8');
42
27
  const registry = JSON.parse(registryJson);
43
28
  const paths = {};
44
- for (const api of registry.apis) {
45
- const method = (api.method ?? 'get').toLowerCase();
46
- const fullPath = `/v1/${domain}${api.path}`;
47
- const v1 = api.versions['v1'] ?? api.versions[Object.keys(api.versions)[0]];
48
- if (!v1)
29
+ for (const action of registry.actions ?? []) {
30
+ if (action.exposure?.type !== 'api')
49
31
  continue;
32
+ const method = (action.exposure.method ?? 'get').toLowerCase();
33
+ const fullPath = `/v1/${domain}${action.exposure.path}`;
34
+ const inputSchema = action.inputSchema ?? { type: 'object' };
35
+ const outputSchema = action.outputSchema ?? { type: 'object' };
50
36
  if (!paths[fullPath])
51
37
  paths[fullPath] = {};
52
38
  paths[fullPath][method] = {
53
- operationId: `${domain}.${api.id}`,
54
- summary: `${domain}.${api.id}`,
55
- description: `Version: ${v1.status}`,
39
+ operationId: `${domain}.${action.id}`,
40
+ summary: `${domain}.${action.id}`,
41
+ description: 'Action-first contract (defineAction + exposure.type=api).',
56
42
  requestBody: {
57
43
  content: {
58
44
  'application/json': {
59
- schema: extractSchema(v1, 'input'),
45
+ schema: inputSchema,
60
46
  },
61
47
  },
62
48
  },
@@ -65,7 +51,7 @@ export async function runGenerateOpenapi(options) {
65
51
  description: 'OK',
66
52
  content: {
67
53
  'application/json': {
68
- schema: extractSchema(v1, 'output'),
54
+ schema: outputSchema,
69
55
  },
70
56
  },
71
57
  },
@@ -4,13 +4,13 @@ import path from 'path';
4
4
  import { cliLogger } from '../utils/logger.js';
5
5
  export async function runPowerTune(domain, primitive) {
6
6
  // Load registry to find Lambda ARN
7
- const registryPath = path.join(process.cwd(), '.tib', `${domain}-registry.json`);
7
+ const registryPath = path.join(process.cwd(), '.mc', `${domain}-registry.json`);
8
8
  let registry;
9
9
  try {
10
10
  registry = JSON.parse(readFileSync(registryPath, 'utf8'));
11
11
  }
12
12
  catch {
13
- cliLogger.error({}, `Registry not found at ${registryPath}. Run: tib build domains/${domain}`);
13
+ cliLogger.error({}, `Registry not found at ${registryPath}. Run: npx mc-domain-module build domains/${domain}`);
14
14
  process.exit(1);
15
15
  }
16
16
  // Find Lambda ARN for the primitive
@@ -3,7 +3,7 @@ export interface ShowDnsOptions {
3
3
  projectDir?: string;
4
4
  }
5
5
  /**
6
- * tib show-dns [--env dev|staging|prod] [--project-dir <path>]
6
+ * mc-domain-module show-dns [--env dev|staging|prod] [--project-dir <path>]
7
7
  *
8
8
  * Reads the scaffold-config.json and prints DNS records that must be added for
9
9
  * custom domain wiring: certificate validation CNAMEs (step 1) and traffic
@@ -5,7 +5,7 @@ function toZoneLine(r) {
5
5
  return `${r.name}. 300 IN ${r.type} ${r.value}.`;
6
6
  }
7
7
  /**
8
- * tib show-dns [--env dev|staging|prod] [--project-dir <path>]
8
+ * mc-domain-module show-dns [--env dev|staging|prod] [--project-dir <path>]
9
9
  *
10
10
  * Reads the scaffold-config.json and prints DNS records that must be added for
11
11
  * custom domain wiring: certificate validation CNAMEs (step 1) and traffic
@@ -16,25 +16,25 @@ function toZoneLine(r) {
16
16
  */
17
17
  export async function runShowDns(opts) {
18
18
  const projectDir = opts.projectDir ? path.resolve(opts.projectDir) : process.cwd();
19
- const configPath = path.join(projectDir, '.tib', 'scaffold-config.json');
19
+ const configPath = path.join(projectDir, '.mc', 'scaffold-config.json');
20
20
  let config;
21
21
  try {
22
22
  const raw = await readFile(configPath, 'utf-8');
23
23
  config = JSON.parse(raw);
24
24
  }
25
25
  catch {
26
- console.error('[tib] Could not read .mc/scaffold-config.json — run from a TIB project root.');
26
+ console.error('[mc-domain-module] Could not read .mc/scaffold-config.json — run from a Mettlecast project root.');
27
27
  process.exit(1);
28
28
  }
29
29
  const environments = config.environments ?? {};
30
30
  if (Object.keys(environments).length === 0) {
31
- console.log('[tib] No environments configured. Set a custom domain via `tib show-dns` or the Setup tab.');
31
+ console.log('[mc-domain-module] No environments configured. Set a custom domain via `npx mc-domain-module show-dns` or the Setup tab.');
32
32
  return;
33
33
  }
34
34
  // Try to load CDK outputs for routing records
35
35
  let cdkOutputs = {};
36
36
  try {
37
- const outputsPath = path.join(projectDir, '.tib', 'cdk-outputs.json');
37
+ const outputsPath = path.join(projectDir, '.mc', 'cdk-outputs.json');
38
38
  const raw = await readFile(outputsPath, 'utf-8');
39
39
  cdkOutputs = JSON.parse(raw);
40
40
  }
@@ -13,10 +13,10 @@ export interface ShowDomainOptions {
13
13
  domainsDir?: string;
14
14
  }
15
15
  /**
16
- * Represents a primitive entry (API, subscriber, action, or job).
16
+ * Represents a primitive entry (action, subscriber, or job).
17
17
  */
18
18
  interface PrimitiveEntry {
19
- type: 'api' | 'subscriber' | 'action' | 'job';
19
+ type: 'subscriber' | 'action' | 'job';
20
20
  id: string;
21
21
  file: string;
22
22
  }
@@ -26,7 +26,6 @@ interface PrimitiveEntry {
26
26
  interface DomainSummary {
27
27
  domain: string;
28
28
  primitives: {
29
- apis: PrimitiveEntry[];
30
29
  subscribers: PrimitiveEntry[];
31
30
  actions: PrimitiveEntry[];
32
31
  jobs: PrimitiveEntry[];
@@ -74,7 +74,6 @@ export async function runShowDomain(opts) {
74
74
  throw new Error(`Domain "${opts.domain}" not found at ${domainDir}`);
75
75
  }
76
76
  // Scan primitives
77
- const apis = await scanPrimitives(join(domainDir, 'api'), 'api');
78
77
  const subscribers = await scanPrimitives(join(domainDir, 'subscribers'), 'subscriber');
79
78
  const actions = await scanPrimitives(join(domainDir, 'actions'), 'action');
80
79
  const jobs = await scanPrimitives(join(domainDir, 'jobs'), 'job');
@@ -113,7 +112,6 @@ export async function runShowDomain(opts) {
113
112
  const summary = {
114
113
  domain: opts.domain,
115
114
  primitives: {
116
- apis,
117
115
  subscribers,
118
116
  actions,
119
117
  jobs,
@@ -14,7 +14,6 @@ export async function runTest(options) {
14
14
  const fixturePath = resolve(options.fixturePath);
15
15
  const { registry } = await buildRegistry(domainRoot);
16
16
  const allEntries = [
17
- ...registry.apis,
18
17
  ...registry.webhooks,
19
18
  ...registry.subscribers,
20
19
  ...registry.schedules,
@@ -4,10 +4,10 @@ const MIGRATIONS = [
4
4
  {
5
5
  fromMajor: 1,
6
6
  toMajor: 2,
7
- description: 'Migrate defineApi from v1 to v2: rename `versions.v1.handler` shape',
7
+ description: 'Audit v1 domain backends for action-first API exposure requirements',
8
8
  transform(repoRoot, dryRun) {
9
- // Placeholder: real migration uses ts-morph to rewrite handler signatures
10
- console.log(`[G20] Would apply: defineApi v1→v2 migration in ${repoRoot}/domains/**`);
9
+ // Placeholder: real migration uses ts-morph to rewrite handler signatures.
10
+ console.log(`[G20] Would audit action-first API exposure in ${repoRoot}/domains/**`);
11
11
  if (!dryRun) {
12
12
  console.log('[G20] ts-morph transform: (not yet implemented — add jscodeshift transforms here)');
13
13
  }
@@ -1,7 +1,7 @@
1
1
  import { createGunzip } from 'node:zlib';
2
2
  import { Readable } from 'node:stream';
3
- import { unlink } from 'node:fs/promises';
4
- import { join, resolve } from 'node:path';
3
+ import { mkdir, writeFile, unlink } from 'node:fs/promises';
4
+ import { join, resolve, dirname } from 'node:path';
5
5
  import { execSync } from 'node:child_process';
6
6
  import ky from 'ky';
7
7
  import { cliLogger } from '../utils/logger.js';
@@ -176,9 +176,9 @@ function buildPrBody(currentVersion, targetVersion, results, changelogUrl) {
176
176
  if (conflicts.length > 0) {
177
177
  lines.push('### Drift warnings — manual review required');
178
178
  lines.push('The following files were locally modified after scaffold installation. ' +
179
- 'New versions have been written to `{path}.tib-upgrade` — merge manually then remove the `.tib-upgrade` file.');
179
+ 'New versions have been written to `{path}.mc-upgrade` — merge manually then remove the `.mc-upgrade` file.');
180
180
  for (const r of conflicts)
181
- lines.push(`- \`${r.path}\` → \`${r.path}.tib-upgrade\``);
181
+ lines.push(`- \`${r.path}\` → \`${r.path}.mc-upgrade\``);
182
182
  lines.push('');
183
183
  }
184
184
  if (changelogUrl) {
@@ -235,9 +235,9 @@ function buildFrontendComponentsPrBody(currentVersion, targetVersion, results, c
235
235
  if (conflicts.length > 0) {
236
236
  lines.push('### Drift warnings — manual review required');
237
237
  lines.push('The following files were locally modified after scaffold installation. ' +
238
- 'New versions have been written to `{path}.tib-upgrade` — merge manually then remove the `.tib-upgrade` file.');
238
+ 'New versions have been written to `{path}.mc-upgrade` — merge manually then remove the `.mc-upgrade` file.');
239
239
  for (const r of conflicts)
240
- lines.push(`- \`${r.path}\` → \`${r.path}.tib-upgrade\``);
240
+ lines.push(`- \`${r.path}\` → \`${r.path}.mc-upgrade\``);
241
241
  lines.push('');
242
242
  }
243
243
  if (changelogUrl) {
@@ -367,12 +367,18 @@ export async function runUpgrade(packageSpec, opts) {
367
367
  // Skipped files are not tracked in results (e.g., seed file with --frontend-components)
368
368
  continue;
369
369
  }
370
- // Add to results
371
- allResults.push({ path: installPath, status: installResult.status, module: mod.id });
372
- // update-available: do NOT update manifest (file was not written to disk)
370
+ // update-available: write the proposed new content beside the drifted file
371
+ // for manual merge, but do NOT update the manifest or overwrite the file.
373
372
  if (installResult.status === 'update-available') {
373
+ if (!opts.dryRun) {
374
+ await mkdir(dirname(diskPath), { recursive: true });
375
+ await writeFile(`${diskPath}.mc-upgrade`, newContent, 'utf-8');
376
+ }
377
+ allResults.push({ path: installPath, status: 'conflict', module: mod.id });
374
378
  continue;
375
379
  }
380
+ // Add to results
381
+ allResults.push({ path: installPath, status: installResult.status, module: mod.id });
376
382
  // Update manifest if file was not skipped
377
383
  const newChecksum = computeChecksumString(newContent);
378
384
  upsertManifestFile(updatedManifest, {
@@ -417,7 +423,7 @@ export async function runUpgrade(packageSpec, opts) {
417
423
  if (conflicts.length > 0) {
418
424
  console.log('\nDrift warnings:');
419
425
  for (const r of conflicts) {
420
- console.log(` ${r.path} — new version written to ${r.path}.tib-upgrade`);
426
+ console.log(` ${r.path} — new version written to ${r.path}.mc-upgrade`);
421
427
  }
422
428
  }
423
429
  if (opts.check) {
@@ -452,7 +458,7 @@ export async function runUpgrade(packageSpec, opts) {
452
458
  const updatedConfig = { ...scaffoldConfig, scaffoldVersion: targetVersion };
453
459
  await writeScaffoldConfig(projectDir, updatedConfig);
454
460
  // 10. Commit on upgrade branch
455
- const branchName = `tib-upgrade/${targetVersion}`;
461
+ const branchName = `mc-upgrade/${targetVersion}`;
456
462
  try {
457
463
  execSync(`git -C "${projectDir}" checkout -b "${branchName}"`, {
458
464
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -78,6 +78,137 @@ async function checkRawPathViolations(_repoRoot, config) {
78
78
  return [];
79
79
  return [];
80
80
  }
81
+ /**
82
+ * Deployment-time security checks (#4662 Task D).
83
+ *
84
+ * Mirrors the invariants enforced at CDK synth time by
85
+ * `SecurityAssertionAspect` in `@mettlecast/domain-cdk-packer`. Running
86
+ * them at the CLI stage means developers get a structured error code in
87
+ * their terminal and CI fails on `mc-domain-module validate` BEFORE a
88
+ * (potentially expensive) `cdk synth` is attempted.
89
+ *
90
+ * Each rule maps 1:1 to an aspect annotation code so downstream tooling
91
+ * can correlate build-time and synth-time failures.
92
+ *
93
+ * Issue #4689: the legacy `defineApi` factory and `registry.apis` field
94
+ * were removed. Deployment-time API invariants are enforced against the
95
+ * `actions[]` API-exposure surface — see `checkActionFirstSecurity`.
96
+ */
97
+ function checkDeploymentSecurity(_actions) {
98
+ // Action API exposures are already covered by `checkActionFirstSecurity`
99
+ // for `tenancy: 'required'` paths and `auth: 'none'` exceptions. The
100
+ // codes there (TENANT_API_PATH_REQUIRED, AUTH_NONE_REQUIRES_EXCEPTION)
101
+ // are kept stable for back-compat — they map to the same aspect codes.
102
+ //
103
+ return [];
104
+ }
105
+ /**
106
+ * Action-first security validation rules (#4619, Wave 6 Task 6.1).
107
+ *
108
+ * These checks enforce the action-first security model on the
109
+ * serialised DomainRegistry produced by `buildRegistry`. The rules
110
+ * intentionally mirror the Zod cross-field refinements defined in
111
+ * `@mettlecast/domain-runtime/primitives/action` (`ApiExposureSchema`)
112
+ * so violations are caught at build time, not at runtime.
113
+ *
114
+ * Each rule emits a structured `ValidationError` whose `code` is the
115
+ * rule ID listed in the spec (e.g. `ACTION_EXPOSURE_REQUIRED`,
116
+ * `TENANT_API_PATH_REQUIRED`). Messages include the offending action
117
+ * or API id and the actionable fix.
118
+ */
119
+ function checkActionFirstSecurity(actions) {
120
+ const errors = [];
121
+ for (const action of actions) {
122
+ const id = action.id;
123
+ // ACTION_EXPOSURE_REQUIRED — every action registry entry must have
124
+ // `exposure` declared in source. The builder defaults missing
125
+ // exposure to `{ type: 'internal' }` for backwards compatibility,
126
+ // and flags the entry with `exposureDeclared: false` so the validator
127
+ // can surface it. Legacy actions that use `visibility` only (no
128
+ // `backendAccess`) are also allowed to default during migration.
129
+ if (action.exposureDeclared === false && action.backendAccess !== 'private') {
130
+ // Only fire for non-private actions: a private action with no
131
+ // exposure is the natural migration state for legacy
132
+ // visibility:'private' handlers, and forcing exposure would
133
+ // produce noisy errors during the migration window.
134
+ // (When Wave 7+ removes the legacy visibility alias this branch
135
+ // becomes a hard error for every action.)
136
+ errors.push({
137
+ code: 'ACTION_EXPOSURE_REQUIRED',
138
+ 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.`,
139
+ });
140
+ }
141
+ if (action.exposure.type === 'api') {
142
+ errors.push(...checkApiExposureSecurity(action.id, action.exposure));
143
+ }
144
+ }
145
+ return errors;
146
+ }
147
+ /**
148
+ * Validate a single API-exposed action. Pulled out as a separate helper
149
+ * so each sub-rule is independently testable and the messages stay short.
150
+ */
151
+ function checkApiExposureSecurity(actionId, exposure) {
152
+ const errors = [];
153
+ // API_EXPOSURE_AUTH_REQUIRED — API-exposed actions must declare auth
154
+ // explicitly. The builder defaults auth to 'required' when the source
155
+ // omits it; the validator surfaces the omission so developers are not
156
+ // silently relying on the safe-default.
157
+ if (exposure.authDeclared === false) {
158
+ errors.push({
159
+ code: 'API_EXPOSURE_AUTH_REQUIRED',
160
+ message: `Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.auth\` is not declared. Set \`exposure.auth\` to 'required' | 'none' | 'service' explicitly.`,
161
+ });
162
+ }
163
+ // TENANT_API_PATH_REQUIRED — API-exposed actions with `tenancy: 'required'`
164
+ // must use a path that includes the canonical tenant placeholder so the
165
+ // runtime can bind `ctx.tenant.id` from the URL.
166
+ if (exposure.tenancy === 'required' && !exposure.path.includes('/v1/tenants/{tenantId}/')) {
167
+ errors.push({
168
+ code: 'TENANT_API_PATH_REQUIRED',
169
+ message: `Action '${actionId}' declares \`exposure.tenancy: 'required'\` but \`exposure.path\` ('${exposure.path}') does not include the canonical tenant placeholder '/v1/tenants/{tenantId}/'.`,
170
+ });
171
+ }
172
+ // AUTH_NONE_REQUIRES_EXCEPTION — `auth: 'none'` (anonymous) routes
173
+ // must carry an explicit `securityException` with a non-empty reason
174
+ // so security reviewers can audit the relaxation.
175
+ if (exposure.auth === 'none') {
176
+ const reason = exposure.securityException?.reason;
177
+ if (!reason || reason.trim().length === 0) {
178
+ errors.push({
179
+ code: 'AUTH_NONE_REQUIRES_EXCEPTION',
180
+ 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).`,
181
+ });
182
+ }
183
+ }
184
+ // TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC — `tenancy: 'none'` on a
185
+ // public API route must justify the missing tenant context. If the
186
+ // route is already justified as anonymous via `auth: 'none'` the
187
+ // same `securityException` may be reused; otherwise an exception is
188
+ // required for tenancy: 'none' on its own.
189
+ if (exposure.tenancy === 'none') {
190
+ const reason = exposure.securityException?.reason;
191
+ if (!reason || reason.trim().length === 0) {
192
+ errors.push({
193
+ code: 'TENANCY_NONE_REQUIRES_REASON_WHEN_PUBLIC',
194
+ 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.`,
195
+ });
196
+ }
197
+ }
198
+ // SYSTEM_API_REQUIRES_ROLE — `tenancy: 'system'` combined with
199
+ // `auth: 'required'` must declare non-empty `roles` so the JWT
200
+ // authorizer can scope the call. Routes that use `auth: 'service'`
201
+ // are service-only and do not require role narrowing.
202
+ if (exposure.tenancy === 'system' && exposure.auth === 'required') {
203
+ if (!Array.isArray(exposure.roles) || exposure.roles.length === 0) {
204
+ errors.push({
205
+ code: 'SYSTEM_API_REQUIRES_ROLE',
206
+ 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.`,
207
+ });
208
+ }
209
+ }
210
+ return errors;
211
+ }
81
212
  /**
82
213
  * Run the validate command: build the registry and perform structural validation.
83
214
  * Exits the process with code 1 if validation fails (CI gate usage).
@@ -93,9 +224,8 @@ export async function runValidate(domainRoot, exitOnFailure = true, config = { m
93
224
  for (const w of warnings) {
94
225
  cliLogger.warn(w);
95
226
  }
96
- errors.push(...checkDuplicateIds(registry.apis, 'api'), ...checkDuplicateIds(registry.webhooks, 'webhook'), ...checkDuplicateIds(registry.subscribers, 'subscriber'), ...checkDuplicateIds(registry.schedules, 'schedule'), ...checkDuplicateIds(registry.jobs, 'job'), ...checkDuplicateIds(registry.actions, 'action'), ...checkDuplicateIds(registry.integrations, 'integration'), ...checkDuplicateIds(registry.events, 'event'));
227
+ errors.push(...checkDuplicateIds(registry.webhooks, 'webhook'), ...checkDuplicateIds(registry.subscribers, 'subscriber'), ...checkDuplicateIds(registry.schedules, 'schedule'), ...checkDuplicateIds(registry.jobs, 'job'), ...checkDuplicateIds(registry.actions, 'action'), ...checkDuplicateIds(registry.integrations, 'integration'), ...checkDuplicateIds(registry.events, 'event'));
97
228
  const entriesWithFiles = [
98
- ...registry.apis,
99
229
  ...registry.webhooks,
100
230
  ...registry.subscribers,
101
231
  ...registry.schedules,
@@ -117,45 +247,20 @@ export async function runValidate(domainRoot, exitOnFailure = true, config = { m
117
247
  }
118
248
  }));
119
249
  errors.push(...checkSubscriberSemverRanges(registry.subscribers));
120
- const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
121
- for (const api of registry.apis) {
122
- const entry = api;
123
- errors.push(...checkLifecycleConsistency(entry, `api '${api.id}'`));
124
- if (!api.requestSchema) {
125
- errors.push({
126
- code: 'MISSING_API_REQUEST_SCHEMA',
127
- message: `API '${api.id}' (${api.method} ${api.path}) has no request schema. Define input/output in defineApi versions.`,
128
- });
129
- }
130
- if (!api.responseSchema) {
131
- errors.push({
132
- code: 'MISSING_API_RESPONSE_SCHEMA',
133
- message: `API '${api.id}' (${api.method} ${api.path}) has no response schema. Define input/output in defineApi versions.`,
134
- });
135
- }
136
- if (!VALID_METHODS.has(api.method)) {
137
- errors.push({
138
- code: 'INVALID_API_METHOD',
139
- message: `API '${api.id}' (${api.method} ${api.path}) has invalid method "${api.method}". Use one of: ${[...VALID_METHODS].join(', ')}.`,
140
- });
141
- }
142
- const isVoidInput = api.requestSchema?.type === 'null';
143
- if (!isVoidInput && !api.examples?.request) {
144
- errors.push({
145
- code: 'MISSING_API_REQUEST_EXAMPLE',
146
- message: `API '${api.id}' (${api.method} ${api.path}) has no request example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
147
- });
148
- }
149
- if (!api.examples?.response) {
150
- errors.push({
151
- code: 'MISSING_API_RESPONSE_EXAMPLE',
152
- message: `API '${api.id}' (${api.method} ${api.path}) has no response example. Add examples: { request: {...}, response: {...} } to the defineApi config.`,
153
- });
154
- }
155
- }
250
+ // Issue #4689: defineApi and registry.apis were removed. The action-first
251
+ // validation rules in `checkActionFirstSecurity` cover the action surface
252
+ // that now owns all HTTP endpoints.
156
253
  const rawPathErrors = await checkRawPathViolations(registry.domainRoot, config);
157
254
  errors.push(...rawPathErrors);
158
- const totalPrimitives = registry.apis.length + registry.webhooks.length +
255
+ // Wave 6 Task 6.1: action-first security validation gates (#4619).
256
+ // Runs against the registry built above so the rules see the same
257
+ // shape the CDK packer will eventually consume.
258
+ errors.push(...checkActionFirstSecurity(registry.actions));
259
+ // Issue #4662 Task D — deployment-time security gates. These mirror
260
+ // the CDK synth-time `SecurityAssertionAspect` so violations are
261
+ // caught before any AWS deployment is attempted.
262
+ errors.push(...checkDeploymentSecurity(registry.actions));
263
+ const totalPrimitives = registry.webhooks.length +
159
264
  registry.subscribers.length + registry.schedules.length +
160
265
  registry.jobs.length + registry.actions.length;
161
266
  if (totalPrimitives === 0) {
@@ -1,5 +1,5 @@
1
1
  import { type FastifyInstance } from 'fastify';
2
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
2
+ import type { DomainRegistry } from '../types.js';
3
3
  /**
4
4
  * Options for creating the local API server.
5
5
  */
@@ -1,5 +1,5 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
- import type { DomainRegistry } from '@mettlecast/domain-cdk-packer';
2
+ import type { DomainRegistry } from '../types.js';
3
3
  /**
4
4
  * Options for mountRoutes.
5
5
  */
@@ -13,7 +13,16 @@ export interface MountRoutesOptions {
13
13
  }
14
14
  /**
15
15
  * Mount all API routes from the DomainRegistry onto the Fastify server.
16
- * Each route dynamically imports its handler file and hydrates a local dev ctx per request.
16
+ *
17
+ * Issue #4689: the only HTTP endpoint surface in the new registry is
18
+ * `actions[]` whose `exposure.type === 'api'`. `defineApi` was removed
19
+ * and `registry.apis` is always empty in fresh registries, so the
20
+ * dev server iterates the action array and mounts each API exposure
21
+ * directly. Internal-only actions are intentionally skipped — they
22
+ * are reachable only through `ctx.actions`.
23
+ *
24
+ * Each route dynamically imports its handler file and hydrates a local
25
+ * dev ctx per request.
17
26
  * @param options - MountRoutesOptions.
18
27
  */
19
28
  export declare function mountRoutes(options: MountRoutesOptions): Promise<void>;
@@ -5,23 +5,35 @@ function toFastifyPath(path) {
5
5
  }
6
6
  /**
7
7
  * Mount all API routes from the DomainRegistry onto the Fastify server.
8
- * Each route dynamically imports its handler file and hydrates a local dev ctx per request.
8
+ *
9
+ * Issue #4689: the only HTTP endpoint surface in the new registry is
10
+ * `actions[]` whose `exposure.type === 'api'`. `defineApi` was removed
11
+ * and `registry.apis` is always empty in fresh registries, so the
12
+ * dev server iterates the action array and mounts each API exposure
13
+ * directly. Internal-only actions are intentionally skipped — they
14
+ * are reachable only through `ctx.actions`.
15
+ *
16
+ * Each route dynamically imports its handler file and hydrates a local
17
+ * dev ctx per request.
9
18
  * @param options - MountRoutesOptions.
10
19
  */
11
20
  export async function mountRoutes(options) {
12
21
  const { server, registry, domainRoot } = options;
13
- for (const api of registry.apis) {
14
- const handlerAbsPath = join(domainRoot, api.handlerFile);
15
- const fastifyPath = toFastifyPath(api.path);
22
+ for (const action of registry.actions) {
23
+ if (action.exposure?.type !== 'api')
24
+ continue;
25
+ const exposure = action.exposure;
26
+ const handlerAbsPath = join(domainRoot, action.handlerFile);
27
+ const fastifyPath = toFastifyPath(exposure.path);
16
28
  const mod = await import(handlerAbsPath);
17
- const def = Object.values(mod).find(v => v && typeof v === 'object' && v['id'] === api.id);
29
+ const def = Object.values(mod).find(v => v && typeof v === 'object' && v['id'] === action.id);
18
30
  if (!def || typeof def['handler'] !== 'function') {
19
- server.log.warn({ apiId: api.id, handlerAbsPath }, 'No handler function found, skipping route');
31
+ server.log.warn({ actionId: action.id, handlerAbsPath }, 'No handler function found, skipping route');
20
32
  continue;
21
33
  }
22
34
  const handler = def['handler'];
23
35
  server.route({
24
- method: api.method === 'ANY' ? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] : [api.method],
36
+ method: [exposure.method],
25
37
  url: fastifyPath,
26
38
  handler: async (request, reply) => {
27
39
  const { ctx } = await hydrateLocalCtx({ domainRoot });
@@ -37,6 +49,6 @@ export async function mountRoutes(options) {
37
49
  await reply.send(result);
38
50
  },
39
51
  });
40
- server.log.info({ method: api.method, path: fastifyPath, apiId: api.id }, 'Route mounted');
52
+ server.log.info({ method: exposure.method, path: fastifyPath, actionId: action.id }, 'Route mounted');
41
53
  }
42
54
  }
@@ -1,5 +1,10 @@
1
1
  /**
2
2
  * Template for generating API handler skeleton.
3
+ *
4
+ * Action-first contract: HTTP endpoints are declared with `defineAction`
5
+ * + `exposure: { type: 'api', ... }`. The legacy `defineApi` factory was
6
+ * removed in #4689 — the only public HTTP endpoint primitive is now an
7
+ * action whose exposure type is `'api'`.
3
8
  */
4
9
  /**
5
10
  * Generates an API handler skeleton with the given domain, API, and tenancy.