@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.
@@ -21,12 +21,6 @@ type ActionApiExposureWithDeclaration = {
21
21
  tenancyDeclared?: boolean;
22
22
  };
23
23
 
24
- type ActionRegistryEntryWithDeclaration = ActionRegistryEntry & {
25
- backendAccess?: 'private' | 'domain' | 'platform';
26
- exposureDeclared?: boolean;
27
- exposure: { type: 'internal' } | ActionApiExposureWithDeclaration;
28
- };
29
-
30
24
  /**
31
25
  * A single validation failure.
32
26
  */
@@ -138,32 +132,7 @@ async function checkRawPathViolations(
138
132
  }
139
133
 
140
134
  /**
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).
135
+ * Check action-first security invariants.
167
136
  *
168
137
  * These checks enforce the action-first security model on the
169
138
  * serialised DomainRegistry produced by `buildRegistry`. The rules
@@ -173,36 +142,42 @@ function checkDeploymentSecurity(_actions: ActionRegistryEntry[]): ValidationErr
173
142
  *
174
143
  * Each rule emits a structured `ValidationError` whose `code` is the
175
144
  * 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.
145
+ * `TENANT_API_PATH_REQUIRED`, `API_INPUT_SCHEMA_REQUIRED`).
178
146
  */
179
147
  function checkActionFirstSecurity(actions: ActionRegistryEntry[]): ValidationError[] {
180
148
  const errors: ValidationError[] = [];
181
149
 
182
- for (const action of actions as ActionRegistryEntryWithDeclaration[]) {
150
+ for (const action of actions) {
183
151
  const id = action.id;
184
152
 
185
153
  // 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.)
154
+ // `exposure` declared and valid. The buildRegistry now rejects
155
+ // missing/invalid exposure at build time, but we also verify here
156
+ // for belt-and-suspenders.
157
+ if (!action.exposure) {
198
158
  errors.push({
199
159
  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.`,
160
+ message: `Action '${id}' has no \`exposure\`. Every action must declare \`exposure\` explicitly.`,
201
161
  });
162
+ continue;
202
163
  }
203
164
 
204
165
  if (action.exposure.type === 'api') {
205
- errors.push(...checkApiExposureSecurity(action.id, action.exposure));
166
+ errors.push(...checkApiExposureSecurity(action.id, action.exposure as unknown as ActionApiExposureWithDeclaration));
167
+
168
+ // API actions must have input/output schema snapshots
169
+ if (!action.inputSchema) {
170
+ errors.push({
171
+ code: 'API_INPUT_SCHEMA_REQUIRED',
172
+ message: `Action '${id}' is API-exposed but has no \`inputSchema\`. API-exposed actions must declare input/output Zod schemas.`,
173
+ });
174
+ }
175
+ if (!action.outputSchema) {
176
+ errors.push({
177
+ code: 'API_OUTPUT_SCHEMA_REQUIRED',
178
+ message: `Action '${id}' is API-exposed but has no \`outputSchema\`. API-exposed actions must declare input/output Zod schemas.`,
179
+ });
180
+ }
206
181
  }
207
182
  }
208
183
 
@@ -217,9 +192,8 @@ function checkApiExposureSecurity(actionId: string, exposure: ActionApiExposureW
217
192
  const errors: ValidationError[] = [];
218
193
 
219
194
  // 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.
195
+ // explicitly. The builder now rejects missing auth, but we validate
196
+ // here too for completeness.
223
197
  if (exposure.authDeclared === false) {
224
198
  errors.push({
225
199
  code: 'API_EXPOSURE_AUTH_REQUIRED',
@@ -251,10 +225,7 @@ function checkApiExposureSecurity(actionId: string, exposure: ActionApiExposureW
251
225
  }
252
226
 
253
227
  // 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.
228
+ // public API route must justify the missing tenant context.
258
229
  if (exposure.tenancy === 'none') {
259
230
  const reason = exposure.securityException?.reason;
260
231
  if (!reason || reason.trim().length === 0) {
@@ -267,8 +238,7 @@ function checkApiExposureSecurity(actionId: string, exposure: ActionApiExposureW
267
238
 
268
239
  // SYSTEM_API_REQUIRES_ROLE — `tenancy: 'system'` combined with
269
240
  // `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.
241
+ // authorizer can scope the call.
272
242
  if (exposure.tenancy === 'system' && exposure.auth === 'required') {
273
243
  if (!Array.isArray(exposure.roles) || exposure.roles.length === 0) {
274
244
  errors.push({
@@ -336,23 +306,12 @@ export async function runValidate(
336
306
 
337
307
  errors.push(...checkSubscriberSemverRanges(registry.subscribers));
338
308
 
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.
342
-
343
309
  const rawPathErrors = await checkRawPathViolations(registry.domainRoot, config);
344
310
  errors.push(...rawPathErrors);
345
311
 
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.
312
+ // Action-first security validation gates (#5090).
349
313
  errors.push(...checkActionFirstSecurity(registry.actions));
350
314
 
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
315
  const totalPrimitives = registry.webhooks.length +
357
316
  registry.subscribers.length + registry.schedules.length +
358
317
  registry.jobs.length + registry.actions.length;
@@ -0,0 +1,236 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ /**
7
+ * Toolchain manifest types and factory for the @mettlecast publish workflow.
8
+ *
9
+ * The manifest is included in the published domain-cli tarball so that
10
+ * `mc-domain-module update-all` (or a future standalone updater) can
11
+ * determine exact package versions to install / pin.
12
+ *
13
+ * Manifest schema:
14
+ * ```json
15
+ * {
16
+ * "schemaVersion": 1,
17
+ * "registrySchemaVersion": "1",
18
+ * "packages": {
19
+ * "domainCli": "0.2.53",
20
+ * "domainCdkPacker": "0.2.53",
21
+ * "domainRuntime": "0.2.53",
22
+ * "eslintPluginDomainModule": "0.2.53"
23
+ * }
24
+ * }
25
+ * ```
26
+ *
27
+ * `registrySchemaVersion` matches the existing `DomainRegistry.schemaVersion`
28
+ * string literal `'1'` so that consumer validation can compare the manifest
29
+ * registry schema version to the generated registry schema version directly.
30
+ */
31
+
32
+ /**
33
+ * Shape of the published toolchain-manifest.json at package root.
34
+ */
35
+ export interface ToolchainManifest {
36
+ /** Manifest schema version (integer). Must be 1 for this generation. */
37
+ schemaVersion: number;
38
+ /**
39
+ * Registry schema version — a string matching the `DomainRegistry.schemaVersion`
40
+ * literal (currently `'1'`). Consumer validation compares this against the
41
+ * generated registry schema version to verify contract compatibility.
42
+ */
43
+ registrySchemaVersion: string;
44
+ packages: ToolchainPackages;
45
+ }
46
+
47
+ export interface ToolchainPackages {
48
+ domainCli: string;
49
+ domainCdkPacker: string;
50
+ domainRuntime: string;
51
+ eslintPluginDomainModule: string;
52
+ }
53
+
54
+ /**
55
+ * The expected registry schema version that matches `DomainRegistry.schemaVersion`.
56
+ * Consumer validation compares the manifest's `registrySchemaVersion` against
57
+ * this constant and the generated registry's `schemaVersion`.
58
+ */
59
+ export const EXPECTED_REGISTRY_SCHEMA_VERSION = '1';
60
+
61
+ /**
62
+ * Package identifiers used in the manifest.
63
+ * Maps to the `packages` key in ToolchainManifest.
64
+ */
65
+ export type ToolchainPackageId = keyof ToolchainPackages;
66
+
67
+ /**
68
+ * Create a ToolchainManifest from exact resolved versions.
69
+ * This is a pure factory function (no side-effects).
70
+ *
71
+ * @param versions - Exact semver strings for each package
72
+ * @returns A complete ToolchainManifest
73
+ */
74
+ export function createToolchainManifest(versions: ToolchainPackages): ToolchainManifest {
75
+ return {
76
+ schemaVersion: 1,
77
+ registrySchemaVersion: EXPECTED_REGISTRY_SCHEMA_VERSION,
78
+ packages: {
79
+ domainCli: versions.domainCli,
80
+ domainCdkPacker: versions.domainCdkPacker,
81
+ domainRuntime: versions.domainRuntime,
82
+ eslintPluginDomainModule: versions.eslintPluginDomainModule,
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Known @mettlecast dependency names that the domain-cli may reference
89
+ * with wildcard ranges and should be replaced with exact versions.
90
+ */
91
+ export const METTLECAST_CLI_DEPS: Record<ToolchainPackageId, string> = {
92
+ domainCli: '@mettlecast/domain-cli',
93
+ domainCdkPacker: '@mettlecast/domain-cdk-packer',
94
+ domainRuntime: '@mettlecast/domain-runtime',
95
+ eslintPluginDomainModule: '@mettlecast/eslint-plugin-domain-module',
96
+ };
97
+
98
+ /**
99
+ * Rewrite the domain-cli package.json's @mettlecast dependencies from
100
+ * wildcard / workspace ranges to exact resolved versions.
101
+ *
102
+ * @param pkgJson - Parsed package.json content (mutated in place)
103
+ * @param versions - Exact semver versions for each toolchain package
104
+ * @returns True if any dependency was rewritten
105
+ */
106
+ export function rewriteCliDependencies(
107
+ pkgJson: { dependencies?: Record<string, string> },
108
+ versions: ToolchainPackages,
109
+ ): boolean {
110
+ let changed = false;
111
+
112
+ if (!pkgJson.dependencies) return changed;
113
+
114
+ for (const [id, depName] of Object.entries(METTLECAST_CLI_DEPS)) {
115
+ const packageId = id as ToolchainPackageId;
116
+ if (depName in pkgJson.dependencies) {
117
+ const exact = versions[packageId];
118
+ pkgJson.dependencies[depName] = exact;
119
+ changed = true;
120
+ }
121
+ }
122
+
123
+ return changed;
124
+ }
125
+
126
+ /**
127
+ * Resolve the toolchain manifest from the installed @mettlecast/domain-cli package.
128
+ * Tries the project's `node_modules/@mettlecast/domain-cli/` first, then falls
129
+ * back to the CLI's own package root (for global/npx installations).
130
+ *
131
+ * @param projectRoot - Project root directory.
132
+ * @returns The parsed toolchain manifest.
133
+ * @throws If the manifest is missing, malformed, or has an incompatible schema version.
134
+ */
135
+ export async function loadToolchainManifest(projectRoot: string): Promise<ToolchainManifest> {
136
+ // Primary path: project's node_modules
137
+ const projectManifestPath = join(
138
+ projectRoot,
139
+ 'node_modules',
140
+ '@mettlecast',
141
+ 'domain-cli',
142
+ 'toolchain-manifest.json',
143
+ );
144
+
145
+ // Fallback path: CLI's own package root (resolved from this module's location)
146
+ const cliPackageRoot = resolveCliPackageRoot();
147
+ const cliManifestPath = join(cliPackageRoot, 'toolchain-manifest.json');
148
+
149
+ let manifestPath: string;
150
+
151
+ if (existsSync(projectManifestPath)) {
152
+ manifestPath = projectManifestPath;
153
+ } else if (existsSync(cliManifestPath)) {
154
+ manifestPath = cliManifestPath;
155
+ } else {
156
+ throw new Error(
157
+ `toolchain-manifest.json not found at ${projectManifestPath} or ${cliManifestPath}. ` +
158
+ "Run 'npm install' or 'npm ci' in the project root first, then retry.",
159
+ );
160
+ }
161
+
162
+ const raw = await readFile(manifestPath, 'utf-8');
163
+ let manifest: ToolchainManifest;
164
+ try {
165
+ manifest = JSON.parse(raw) as ToolchainManifest;
166
+ } catch {
167
+ throw new Error(
168
+ `toolchain-manifest.json at ${manifestPath} is not valid JSON.`,
169
+ );
170
+ }
171
+
172
+ // Validate schema version
173
+ if (typeof manifest.schemaVersion !== 'number' || manifest.schemaVersion !== 1) {
174
+ throw new Error(
175
+ `toolchain manifest schemaVersion is ${String(manifest.schemaVersion)}, expected 1. ` +
176
+ 'The installed @mettlecast/domain-cli version is incompatible with this toolchain resolver.',
177
+ );
178
+ }
179
+
180
+ // Validate registry schema version equals expected value
181
+ if (
182
+ typeof manifest.registrySchemaVersion !== 'string' ||
183
+ manifest.registrySchemaVersion !== EXPECTED_REGISTRY_SCHEMA_VERSION
184
+ ) {
185
+ throw new Error(
186
+ `toolchain manifest registrySchemaVersion is "${String(manifest.registrySchemaVersion)}", ` +
187
+ `expected "${EXPECTED_REGISTRY_SCHEMA_VERSION}". ` +
188
+ 'The registry schema contract is incompatible with this toolchain resolver.',
189
+ );
190
+ }
191
+
192
+ // Validate all package keys are present and non-empty
193
+ if (!manifest.packages || typeof manifest.packages !== 'object') {
194
+ throw new Error(
195
+ 'toolchain manifest is missing required field "packages".',
196
+ );
197
+ }
198
+
199
+ const TOOLCHAIN_PACKAGE_KEYS: Array<keyof ToolchainPackages> = [
200
+ 'domainCli',
201
+ 'domainCdkPacker',
202
+ 'domainRuntime',
203
+ 'eslintPluginDomainModule',
204
+ ];
205
+
206
+ for (const key of TOOLCHAIN_PACKAGE_KEYS) {
207
+ const version = manifest.packages[key];
208
+ if (typeof version !== 'string' || version.length === 0) {
209
+ throw new Error(
210
+ `toolchain manifest is missing or has empty version for package "${key}".`,
211
+ );
212
+ }
213
+ }
214
+
215
+ return manifest;
216
+ }
217
+
218
+ /**
219
+ * Resolve the @mettlecast/domain-cli package root directory.
220
+ * Uses the location of this module file (toolchain-manifest.ts) to find
221
+ * the CLI package root, supporting both source and dist layouts.
222
+ */
223
+ function resolveCliPackageRoot(): string {
224
+ const thisFile = fileURLToPath(import.meta.url);
225
+ // In source: packages/domain-cli/src/utils/toolchain-manifest.ts -> up 3 levels
226
+ // In dist: packages/domain-cli/dist/utils/toolchain-manifest.js -> up 2 levels
227
+ const candidate = dirname(dirname(dirname(thisFile)));
228
+ // Check if candidate has package.json with @mettlecast/domain-cli name
229
+ const pkgJsonPath = join(candidate, 'package.json');
230
+ if (existsSync(pkgJsonPath)) {
231
+ return candidate;
232
+ }
233
+ // Fall back to dirname(dirname(thisFile)) for dist layout
234
+ const candidate2 = dirname(dirname(thisFile));
235
+ return candidate2;
236
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "registrySchemaVersion": "1",
4
+ "packages": {
5
+ "domainCli": "0.2.87",
6
+ "domainCdkPacker": "0.2.88",
7
+ "domainRuntime": "0.2.87",
8
+ "eslintPluginDomainModule": "0.2.87"
9
+ }
10
+ }