@mettlecast/domain-cli 0.2.86 → 0.2.87

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.
@@ -12,9 +12,15 @@ export interface BuildResult {
12
12
  * Build a DomainRegistry from a domain source directory.
13
13
  * Walks the conventional directory structure, loads each primitive definition
14
14
  * file via the tsx child-process loader, and assembles the registry snapshot.
15
- * Zod schema fields are omitted in Phase 1e (schema extraction is v1.x scope).
15
+ *
16
+ * Strict contract (#5090): every action must have `kind:'action'`, id,
17
+ * handlerFile, backendAccess, idempotent, and explicit exposure. API requires
18
+ * path/method/auth/tenancy; internal must be exactly `{type:'internal'}`.
19
+ * Missing or invalid fields cause a build failure with actionable diagnostics.
20
+ *
16
21
  * @param domainRoot - Absolute path to the domain root directory.
17
22
  * @returns BuildResult containing the assembled registry and any warnings.
18
- * @throws If domain.config.ts is missing or does not export a 'domain' primitive.
23
+ * @throws If domain.config.ts is missing or does not export a 'domain' primitive,
24
+ * or if any action has missing/invalid required fields.
19
25
  */
20
26
  export declare function buildRegistry(domainRoot: string): Promise<BuildResult>;
@@ -2,69 +2,69 @@ import { relative, resolve } from 'node:path';
2
2
  import { walkDomainDir } from '../utils/file-helpers.js';
3
3
  import { loadModuleExports } from './load-module.js';
4
4
  /**
5
- * Coerce a raw value into a valid backendAccess scope, defaulting to
6
- * 'private' when the value is missing or unrecognized. Used by the
7
- * builder when reading the new-style `backendAccess` field directly.
5
+ * Validate that a raw value is a valid backendAccess scope.
6
+ * Throws with diagnostics on invalid/missing values.
8
7
  */
9
- function toBackendAccess(raw) {
10
- return raw === 'domain' || raw === 'platform' ? raw : 'private';
11
- }
12
- /**
13
- * Map a legacy `visibility` scope to a `backendAccess` scope for the
14
- * action-first migration. The legacy `workspace` scope (which previously
15
- * permitted unauthenticated Function URL exposure) collapses to `domain`
16
- * so that all cross-domain callers must go through `ctx.actions`.
17
- */
18
- function visibilityToBackendAccess(visibility) {
19
- if (visibility === 'workspace')
20
- return 'domain';
21
- if (visibility === 'domain')
22
- return 'domain';
23
- return 'private';
24
- }
25
- /**
26
- * Best-effort reverse mapping from `backendAccess` to the legacy
27
- * `visibility` field. Used to keep the deprecated field populated so
28
- * existing CDK constructs that read it continue to behave the same way.
29
- *
30
- * - private -> private
31
- * - domain -> domain
32
- * - platform -> workspace (closest legacy equivalent for platform-level)
33
- */
34
- function backendAccessToVisibility(backendAccess) {
35
- if (backendAccess === 'platform')
36
- return 'workspace';
37
- return backendAccess;
8
+ function validateBackendAccess(raw, actionId) {
9
+ if (raw === 'domain' || raw === 'platform' || raw === 'private') {
10
+ return raw;
11
+ }
12
+ throw new Error(`Action '${actionId}' has invalid or missing \`backendAccess\`. ` +
13
+ `Must be one of: 'private', 'domain', 'platform'. Got: ${JSON.stringify(raw)}`);
38
14
  }
39
15
  /**
40
- * Type guard + sanitizer for an action's `exposure` field. Returns a
41
- * well-typed `ActionExposure` (extended with `authDeclared` /
42
- * `tenancyDeclared` tracking flags) or the provided fallback when the
43
- * raw value does not match a supported exposure shape.
44
- *
45
- * The `authDeclared` / `tenancyDeclared` flags record whether the source
46
- * code explicitly declared each field, or whether the builder fell back
47
- * to the safe default. They are validation-only and consumed by the
48
- * domain CLI's validate command (Wave 6 Task 6.1) to enforce the
49
- * action-first security model (#4619).
16
+ * Validate and parse an action's `exposure` field.
17
+ * Throws with actionable diagnostics when exposure is missing, null, or malformed.
50
18
  */
51
- function toExposure(raw, fallback) {
52
- if (!raw || typeof raw !== 'object')
53
- return fallback;
19
+ function validateExposure(raw, actionId) {
20
+ if (!raw || typeof raw !== 'object') {
21
+ throw new Error(`Action '${actionId}' has missing or invalid \`exposure\`. ` +
22
+ `Every action must declare \`exposure\` explicitly. ` +
23
+ `Use \`exposure: { type: 'internal' }\` for internal-only actions, or ` +
24
+ `\`exposure: { type: 'api', path: '...', method: '...', auth: '...', tenancy: '...' }\` for API-exposed actions.`);
25
+ }
54
26
  const candidate = raw;
55
- if (candidate.type === 'internal')
56
- return { type: 'internal' };
57
- if (candidate.type !== 'api')
58
- return fallback;
59
- // Best-effort validation of api exposure fields; any missing required
60
- // string field falls back to the supplied default exposure.
27
+ if (candidate.type === 'internal') {
28
+ // Internal must be exactly { type: 'internal' } — no extra keys
29
+ const extraKeys = Object.keys(raw).filter(k => k !== 'type');
30
+ if (extraKeys.length > 0) {
31
+ throw new Error(`Action '${actionId}' has \`exposure.type: 'internal'\` with unexpected keys: ${extraKeys.join(', ')}. ` +
32
+ `Internal exposure must be exactly \`{ type: 'internal' }\`.`);
33
+ }
34
+ return { exposure: { type: 'internal' }, authDeclared: false, tenancyDeclared: false };
35
+ }
36
+ if (candidate.type !== 'api') {
37
+ throw new Error(`Action '${actionId}' has \`exposure.type\` set to ${JSON.stringify(candidate.type)}. ` +
38
+ `Must be 'api' or 'internal'.`);
39
+ }
40
+ // Validate API exposure
61
41
  const api = raw;
62
- if (typeof api.path !== 'string' || typeof api.method !== 'string')
63
- return fallback;
42
+ if (typeof api.path !== 'string' || api.path.trim().length === 0) {
43
+ throw new Error(`Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.path\` is missing or empty. ` +
44
+ `Provide a non-empty path string (e.g. '/v1/tenants/{tenantId}/users').`);
45
+ }
46
+ if (typeof api.method !== 'string' || api.method.trim().length === 0) {
47
+ throw new Error(`Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.method\` is missing or empty. ` +
48
+ `Provide an HTTP method (e.g. 'GET', 'POST', 'PUT', 'PATCH', 'DELETE').`);
49
+ }
64
50
  const authRaw = api.auth;
65
51
  const tenancyRaw = api.tenancy;
66
- const auth = authRaw === 'required' || authRaw === 'none' || authRaw === 'service' ? authRaw : 'required';
67
- const tenancy = tenancyRaw === 'required' || tenancyRaw === 'none' || tenancyRaw === 'system' ? tenancyRaw : 'required';
52
+ let auth;
53
+ if (authRaw === 'required' || authRaw === 'none' || authRaw === 'service') {
54
+ auth = authRaw;
55
+ }
56
+ else {
57
+ throw new Error(`Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.auth\` is missing or invalid. ` +
58
+ `Must be 'required', 'none', or 'service'. Got: ${JSON.stringify(authRaw)}`);
59
+ }
60
+ let tenancy;
61
+ if (tenancyRaw === 'required' || tenancyRaw === 'none' || tenancyRaw === 'system') {
62
+ tenancy = tenancyRaw;
63
+ }
64
+ else {
65
+ throw new Error(`Action '${actionId}' has \`exposure.type: 'api'\` but \`exposure.tenancy\` is missing or invalid. ` +
66
+ `Must be 'required', 'none', or 'system'. Got: ${JSON.stringify(tenancyRaw)}`);
67
+ }
68
68
  const out = {
69
69
  type: 'api',
70
70
  path: api.path,
@@ -83,7 +83,7 @@ function toExposure(raw, fallback) {
83
83
  out.securityException = { reason };
84
84
  }
85
85
  }
86
- return out;
86
+ return { exposure: out, authDeclared: out.authDeclared ?? false, tenancyDeclared: out.tenancyDeclared ?? false };
87
87
  }
88
88
  /** Returns true if a value looks like a JSON Schema object (has a 'type' or '$schema' property). */
89
89
  function isJsonSchema(v) {
@@ -94,10 +94,16 @@ function isJsonSchema(v) {
94
94
  * Build a DomainRegistry from a domain source directory.
95
95
  * Walks the conventional directory structure, loads each primitive definition
96
96
  * file via the tsx child-process loader, and assembles the registry snapshot.
97
- * Zod schema fields are omitted in Phase 1e (schema extraction is v1.x scope).
97
+ *
98
+ * Strict contract (#5090): every action must have `kind:'action'`, id,
99
+ * handlerFile, backendAccess, idempotent, and explicit exposure. API requires
100
+ * path/method/auth/tenancy; internal must be exactly `{type:'internal'}`.
101
+ * Missing or invalid fields cause a build failure with actionable diagnostics.
102
+ *
98
103
  * @param domainRoot - Absolute path to the domain root directory.
99
104
  * @returns BuildResult containing the assembled registry and any warnings.
100
- * @throws If domain.config.ts is missing or does not export a 'domain' primitive.
105
+ * @throws If domain.config.ts is missing or does not export a 'domain' primitive,
106
+ * or if any action has missing/invalid required fields.
101
107
  */
102
108
  export async function buildRegistry(domainRoot) {
103
109
  // Resolve to absolute path so downstream fs operations and tsx eval imports
@@ -200,41 +206,27 @@ export async function buildRegistry(domainRoot) {
200
206
  const actions = paths.actions.flatMap((filePath, i) => (actionExports[i] ?? [])
201
207
  .filter(e => e['_kind'] === 'action')
202
208
  .map(e => {
203
- // Resolve backendAccess. New-style actions carry `backendAccess`
204
- // directly. Legacy actions only carry `visibility`; in that case
205
- // we collapse workspace -> domain per the migration spec, and we
206
- // also remember the legacy flag so we can default exposure to
207
- // `{ type: 'internal' }` for actions that have not opted in yet.
208
- const hasBackendAccess = 'backendAccess' in e;
209
- const rawBackendAccess = e['backendAccess'];
210
- const rawVisibility = e['visibility'];
211
- const backendAccess = hasBackendAccess
212
- ? toBackendAccess(rawBackendAccess)
213
- : visibilityToBackendAccess(rawVisibility);
214
- const legacyVisibility = backendAccessToVisibility(backendAccess);
215
- // Resolve exposure. New-style actions must declare their exposure;
216
- // legacy actions that did not opt in default to `{ type: 'internal' }`
217
- // so existing internal-only behavior is preserved during migration.
218
- const rawExposure = e['exposure'];
219
- const exposure = toExposure(rawExposure, { type: 'internal' });
220
- // Record whether the source explicitly declared the `exposure` field.
221
- // Validation-only; consumed by the domain CLI's validate command
222
- // (Wave 6 Task 6.1) to enforce `ACTION_EXPOSURE_REQUIRED`.
223
- const exposureDeclared = rawExposure !== undefined && rawExposure !== null
224
- && typeof rawExposure === 'object';
209
+ const actionId = String(e['id']);
210
+ // Validate required fields strict contract (#5090)
211
+ if (!actionId) {
212
+ throw new Error(`Action in ${relPath(filePath)} has missing or empty \`id\`.`);
213
+ }
214
+ // Validate backendAccess required, no legacy default
215
+ const backendAccess = validateBackendAccess(e['backendAccess'], actionId);
216
+ // Validate exposure — required, no legacy default
217
+ const { exposure } = validateExposure(e['exposure'], actionId);
218
+ // Validate idempotent — required boolean
219
+ if (typeof e['idempotent'] !== 'boolean') {
220
+ throw new Error(`Action '${actionId}' has missing or invalid \`idempotent\`. ` +
221
+ `Must be a boolean (true or false). Got: ${JSON.stringify(e['idempotent'])}`);
222
+ }
225
223
  return {
226
- id: String(e['id']),
224
+ id: actionId,
227
225
  kind: 'action',
228
226
  handlerFile: relPath(filePath),
229
227
  backendAccess,
230
228
  exposure,
231
- exposureDeclared,
232
- // Keep the legacy field populated so CDK constructs that still
233
- // read `visibility` (e.g. action-construct.ts) keep working
234
- // through the migration window. New constructs should read
235
- // `backendAccess` and `exposure` instead.
236
- visibility: legacyVisibility,
237
- idempotent: Boolean(e['idempotent'] ?? false),
229
+ idempotent: Boolean(e['idempotent']),
238
230
  description: typeof e['description'] === 'string' ? e['description'] : undefined,
239
231
  deployment: deployment(e),
240
232
  outboundAccess: outboundAccess(e),
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Run the full scaffold update pipeline:
3
+ * 0. Resolve toolchain manifest and pin exact versions + regenerate lockfiles
3
4
  * 1. Build each domain registry from `.mc/scaffold-config.json`
4
5
  * 2. Build the combined domain catalog
5
6
  * 3. Build flows registry
@@ -1,14 +1,156 @@
1
1
  import { join } from 'node:path';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { execSync } from 'node:child_process';
2
5
  import { cliLogger } from '../utils/logger.js';
3
6
  import { readScaffoldConfig } from '../utils/scaffold-config.js';
7
+ import { loadToolchainManifest, } from '../utils/toolchain-manifest.js';
4
8
  import { runBuild } from './build.js';
5
9
  import { runBuildCatalog } from './build-catalog.js';
6
10
  import { runBuildFlows } from './build-flows.js';
7
11
  import { runBuildUi } from './build-ui.js';
8
12
  import { runRegenerateModulesHashes } from './regenerate-modules-hashes.js';
9
13
  import { runDoctor } from './doctor.js';
14
+ /**
15
+ * Expected packages in the toolchain manifest.
16
+ */
17
+ const TOOLCHAIN_PACKAGE_KEYS = [
18
+ 'domainCli',
19
+ 'domainCdkPacker',
20
+ 'domainRuntime',
21
+ 'eslintPluginDomainModule',
22
+ ];
23
+ /**
24
+ * Map from toolchain key to npm package name.
25
+ */
26
+ const TOOLCHAIN_NPM_NAMES = {
27
+ domainCli: '@mettlecast/domain-cli',
28
+ domainCdkPacker: '@mettlecast/domain-cdk-packer',
29
+ domainRuntime: '@mettlecast/domain-runtime',
30
+ eslintPluginDomainModule: '@mettlecast/eslint-plugin-domain-module',
31
+ };
32
+ /**
33
+ * Write exact toolchain versions into infra/modules/package.json.
34
+ * Replaces the @mettlecast/* dependency versions with the pinned exact versions
35
+ * from the toolchain manifest. Preserves all other fields unchanged.
36
+ *
37
+ * @param projectRoot - Project root directory.
38
+ * @param manifest - The resolved toolchain manifest.
39
+ */
40
+ async function pinInfraPackageVersions(projectRoot, manifest) {
41
+ const infraPkgPath = join(projectRoot, 'infra', 'modules', 'package.json');
42
+ if (!existsSync(infraPkgPath)) {
43
+ cliLogger.warn({ path: infraPkgPath }, 'infra/modules/package.json not found — skipping version pinning');
44
+ return;
45
+ }
46
+ const raw = await readFile(infraPkgPath, 'utf-8');
47
+ const pkg = JSON.parse(raw);
48
+ const pkgDeps = (pkg.dependencies ?? {});
49
+ const pkgDevDeps = (pkg.devDependencies ?? {});
50
+ // Pin @mettlecast/domain-cdk-packer
51
+ if (TOOLCHAIN_NPM_NAMES.domainCdkPacker in pkgDeps) {
52
+ pkgDeps[TOOLCHAIN_NPM_NAMES.domainCdkPacker] = manifest.packages.domainCdkPacker;
53
+ }
54
+ // Pin @mettlecast/domain-runtime
55
+ if (TOOLCHAIN_NPM_NAMES.domainRuntime in pkgDeps) {
56
+ pkgDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
57
+ }
58
+ // Pin @mettlecast/domain-cli
59
+ if (TOOLCHAIN_NPM_NAMES.domainCli in pkgDevDeps) {
60
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
61
+ }
62
+ // Pin @mettlecast/eslint-plugin-domain-module
63
+ if (TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule in pkgDevDeps) {
64
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
65
+ }
66
+ pkg.dependencies = pkgDeps;
67
+ pkg.devDependencies = pkgDevDeps;
68
+ await writeFile(infraPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
69
+ cliLogger.info({ path: infraPkgPath }, 'Pinned toolchain versions in infra/modules/package.json');
70
+ }
71
+ /**
72
+ * Write exact toolchain versions into root package.json.
73
+ * Replaces the @mettlecast/* devDependency versions with the pinned exact versions
74
+ * from the toolchain manifest. Preserves all other fields unchanged.
75
+ *
76
+ * @param projectRoot - Project root directory.
77
+ * @param manifest - The resolved toolchain manifest.
78
+ */
79
+ async function pinRootPackageVersions(projectRoot, manifest) {
80
+ const rootPkgPath = join(projectRoot, 'package.json');
81
+ if (!existsSync(rootPkgPath)) {
82
+ cliLogger.warn({ path: rootPkgPath }, 'root package.json not found — skipping version pinning');
83
+ return;
84
+ }
85
+ const raw = await readFile(rootPkgPath, 'utf-8');
86
+ const pkg = JSON.parse(raw);
87
+ const pkgDevDeps = (pkg.devDependencies ?? {});
88
+ // Pin @mettlecast/domain-cli (root devDependency)
89
+ if (TOOLCHAIN_NPM_NAMES.domainCli in pkgDevDeps) {
90
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainCli] = manifest.packages.domainCli;
91
+ }
92
+ // Pin @mettlecast/domain-runtime (root devDependency)
93
+ if (TOOLCHAIN_NPM_NAMES.domainRuntime in pkgDevDeps) {
94
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.domainRuntime] = manifest.packages.domainRuntime;
95
+ }
96
+ // Pin @mettlecast/eslint-plugin-domain-module (root devDependency)
97
+ if (TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule in pkgDevDeps) {
98
+ pkgDevDeps[TOOLCHAIN_NPM_NAMES.eslintPluginDomainModule] = manifest.packages.eslintPluginDomainModule;
99
+ }
100
+ pkg.devDependencies = pkgDevDeps;
101
+ await writeFile(rootPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
102
+ cliLogger.info({ path: rootPkgPath }, 'Pinned toolchain versions in root package.json');
103
+ }
104
+ /**
105
+ * Regenerate lockfiles for root and infra/modules after version pinning.
106
+ * Runs `npm install --package-lock-only` (without modifying package.json) to
107
+ * produce deterministic package-lock.json files that match the pinned exact
108
+ * versions. The `--package-lock` flag overrides any `package-lock=false` in
109
+ * the project's .npmrc so lockfiles are always generated.
110
+ *
111
+ * @param projectRoot - Project root directory.
112
+ */
113
+ async function regenerateLockfiles(projectRoot) {
114
+ const rootPkgPath = join(projectRoot, 'package.json');
115
+ const infraPkgPath = join(projectRoot, 'infra', 'modules', 'package.json');
116
+ const npmLockCmd = 'npm install --package-lock-only --package-lock --no-save';
117
+ // Regenerate root lockfile if root package.json exists
118
+ if (existsSync(rootPkgPath)) {
119
+ try {
120
+ cliLogger.info({ projectRoot }, 'Regenerating root package-lock.json');
121
+ execSync(npmLockCmd, {
122
+ cwd: projectRoot,
123
+ stdio: 'pipe',
124
+ timeout: 120_000,
125
+ });
126
+ cliLogger.info({ projectRoot }, 'Root package-lock.json regenerated');
127
+ }
128
+ catch (err) {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ cliLogger.warn({ err: message }, 'Root lockfile regeneration failed (non-fatal — continuing)');
131
+ }
132
+ }
133
+ // Regenerate infra/modules lockfile if infra/modules/package.json exists
134
+ if (existsSync(infraPkgPath)) {
135
+ try {
136
+ const infraDir = join(projectRoot, 'infra', 'modules');
137
+ cliLogger.info({ path: infraDir }, 'Regenerating infra/modules package-lock.json');
138
+ execSync(npmLockCmd, {
139
+ cwd: infraDir,
140
+ stdio: 'pipe',
141
+ timeout: 120_000,
142
+ });
143
+ cliLogger.info({ path: infraDir }, 'infra/modules package-lock.json regenerated');
144
+ }
145
+ catch (err) {
146
+ const message = err instanceof Error ? err.message : String(err);
147
+ cliLogger.warn({ err: message }, 'infra/modules lockfile regeneration failed (non-fatal — continuing)');
148
+ }
149
+ }
150
+ }
10
151
  /**
11
152
  * Run the full scaffold update pipeline:
153
+ * 0. Resolve toolchain manifest and pin exact versions + regenerate lockfiles
12
154
  * 1. Build each domain registry from `.mc/scaffold-config.json`
13
155
  * 2. Build the combined domain catalog
14
156
  * 3. Build flows registry
@@ -25,6 +167,38 @@ import { runDoctor } from './doctor.js';
25
167
  export async function runUpdateAll(opts) {
26
168
  const projectRoot = opts?.projectRoot ?? process.cwd();
27
169
  cliLogger.info({ projectRoot }, 'update-all: starting');
170
+ // 0. Resolve toolchain manifest and pin exact versions
171
+ cliLogger.info({ projectRoot }, 'update-all: resolving toolchain manifest');
172
+ let manifest;
173
+ try {
174
+ manifest = await loadToolchainManifest(projectRoot);
175
+ cliLogger.info({
176
+ domainCli: manifest.packages.domainCli,
177
+ domainRuntime: manifest.packages.domainRuntime,
178
+ domainCdkPacker: manifest.packages.domainCdkPacker,
179
+ eslintPluginDomainModule: manifest.packages.eslintPluginDomainModule,
180
+ registrySchemaVersion: manifest.registrySchemaVersion,
181
+ }, 'update-all: toolchain manifest resolved');
182
+ }
183
+ catch (err) {
184
+ const message = err instanceof Error ? err.message : String(err);
185
+ cliLogger.error({ err: message }, 'update-all: toolchain resolution failed — aborting');
186
+ return { success: false, summary: `Toolchain resolution FAIL: ${message}` };
187
+ }
188
+ // Pin exact versions from manifest into infra/modules/package.json and root package.json
189
+ try {
190
+ await pinInfraPackageVersions(projectRoot, manifest);
191
+ await pinRootPackageVersions(projectRoot, manifest);
192
+ }
193
+ catch (err) {
194
+ const message = err instanceof Error ? err.message : String(err);
195
+ cliLogger.error({ err: message }, 'update-all: version pinning failed — aborting');
196
+ return { success: false, summary: `Version pinning FAIL: ${message}` };
197
+ }
198
+ // Regenerate lockfiles so that root and infra/modules lockfiles exist
199
+ // and match the pinned exact versions before generated CI uses `npm ci`.
200
+ cliLogger.info({ projectRoot }, 'update-all: regenerating lockfiles');
201
+ await regenerateLockfiles(projectRoot);
28
202
  // 1. Read scaffold-config to discover domains
29
203
  const scaffoldConfig = await readScaffoldConfig(projectRoot);
30
204
  const domainIds = scaffoldConfig.domainIds ?? [];
@@ -79,31 +79,7 @@ async function checkRawPathViolations(_repoRoot, config) {
79
79
  return [];
80
80
  }
81
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).
82
+ * Check action-first security invariants.
107
83
  *
108
84
  * These checks enforce the action-first security model on the
109
85
  * serialised DomainRegistry produced by `buildRegistry`. The rules
@@ -113,33 +89,38 @@ function checkDeploymentSecurity(_actions) {
113
89
  *
114
90
  * Each rule emits a structured `ValidationError` whose `code` is the
115
91
  * 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.
92
+ * `TENANT_API_PATH_REQUIRED`, `API_INPUT_SCHEMA_REQUIRED`).
118
93
  */
119
94
  function checkActionFirstSecurity(actions) {
120
95
  const errors = [];
121
96
  for (const action of actions) {
122
97
  const id = action.id;
123
98
  // 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.)
99
+ // `exposure` declared and valid. The buildRegistry now rejects
100
+ // missing/invalid exposure at build time, but we also verify here
101
+ // for belt-and-suspenders.
102
+ if (!action.exposure) {
136
103
  errors.push({
137
104
  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.`,
105
+ message: `Action '${id}' has no \`exposure\`. Every action must declare \`exposure\` explicitly.`,
139
106
  });
107
+ continue;
140
108
  }
141
109
  if (action.exposure.type === 'api') {
142
110
  errors.push(...checkApiExposureSecurity(action.id, action.exposure));
111
+ // API actions must have input/output schema snapshots
112
+ if (!action.inputSchema) {
113
+ errors.push({
114
+ code: 'API_INPUT_SCHEMA_REQUIRED',
115
+ message: `Action '${id}' is API-exposed but has no \`inputSchema\`. API-exposed actions must declare input/output Zod schemas.`,
116
+ });
117
+ }
118
+ if (!action.outputSchema) {
119
+ errors.push({
120
+ code: 'API_OUTPUT_SCHEMA_REQUIRED',
121
+ message: `Action '${id}' is API-exposed but has no \`outputSchema\`. API-exposed actions must declare input/output Zod schemas.`,
122
+ });
123
+ }
143
124
  }
144
125
  }
145
126
  return errors;
@@ -151,9 +132,8 @@ function checkActionFirstSecurity(actions) {
151
132
  function checkApiExposureSecurity(actionId, exposure) {
152
133
  const errors = [];
153
134
  // 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.
135
+ // explicitly. The builder now rejects missing auth, but we validate
136
+ // here too for completeness.
157
137
  if (exposure.authDeclared === false) {
158
138
  errors.push({
159
139
  code: 'API_EXPOSURE_AUTH_REQUIRED',
@@ -182,10 +162,7 @@ function checkApiExposureSecurity(actionId, exposure) {
182
162
  }
183
163
  }
184
164
  // 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.
165
+ // public API route must justify the missing tenant context.
189
166
  if (exposure.tenancy === 'none') {
190
167
  const reason = exposure.securityException?.reason;
191
168
  if (!reason || reason.trim().length === 0) {
@@ -197,8 +174,7 @@ function checkApiExposureSecurity(actionId, exposure) {
197
174
  }
198
175
  // SYSTEM_API_REQUIRES_ROLE — `tenancy: 'system'` combined with
199
176
  // `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.
177
+ // authorizer can scope the call.
202
178
  if (exposure.tenancy === 'system' && exposure.auth === 'required') {
203
179
  if (!Array.isArray(exposure.roles) || exposure.roles.length === 0) {
204
180
  errors.push({
@@ -247,19 +223,10 @@ export async function runValidate(domainRoot, exitOnFailure = true, config = { m
247
223
  }
248
224
  }));
249
225
  errors.push(...checkSubscriberSemverRanges(registry.subscribers));
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.
253
226
  const rawPathErrors = await checkRawPathViolations(registry.domainRoot, config);
254
227
  errors.push(...rawPathErrors);
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.
228
+ // Action-first security validation gates (#5090).
258
229
  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
230
  const totalPrimitives = registry.webhooks.length +
264
231
  registry.subscribers.length + registry.schedules.length +
265
232
  registry.jobs.length + registry.actions.length;