@mettlecast/domain-cli 0.2.86 → 0.2.88
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.
- package/dist/builder/build-registry.d.ts +8 -2
- package/dist/builder/build-registry.js +80 -88
- package/dist/commands/create-project.js +15 -19
- package/dist/commands/update-all.d.ts +1 -0
- package/dist/commands/update-all.js +174 -0
- package/dist/commands/validate.js +26 -59
- package/dist/utils/toolchain-manifest.d.ts +90 -0
- package/dist/utils/toolchain-manifest.js +144 -0
- package/package.json +3 -3
- package/scripts/generate-toolchain-manifest.mjs +163 -0
- package/src/__tests__/build-registry.test.ts +125 -3
- package/src/__tests__/commands/update-all.test.ts +341 -3
- package/src/__tests__/package-freshness.test.ts +8 -4
- package/src/__tests__/smoke/scaffold.test.ts +58 -47
- package/src/__tests__/toolchain-manifest.test.ts +185 -0
- package/src/__tests__/validate.test.ts +183 -108
- package/src/builder/build-registry.ts +116 -92
- package/src/commands/create-project.ts +15 -19
- package/src/commands/update-all.ts +216 -0
- package/src/commands/validate.ts +30 -71
- package/src/utils/toolchain-manifest.ts +236 -0
- package/toolchain-manifest.json +10 -0
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
|
52
|
-
if (!raw || typeof raw !== 'object')
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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' ||
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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:
|
|
224
|
+
id: actionId,
|
|
227
225
|
kind: 'action',
|
|
228
226
|
handlerFile: relPath(filePath),
|
|
229
227
|
backendAccess,
|
|
230
228
|
exposure,
|
|
231
|
-
|
|
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,7 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { createInterface } from 'node:readline';
|
|
4
|
-
import { Readable } from 'node:stream';
|
|
5
4
|
import { execSync } from 'node:child_process';
|
|
6
5
|
import * as tar from 'tar';
|
|
7
6
|
import { cliLogger } from '../utils/logger.js';
|
|
@@ -60,27 +59,24 @@ function topoSort(modules, selected) {
|
|
|
60
59
|
async function parseTarball(buf) {
|
|
61
60
|
const entries = [];
|
|
62
61
|
await new Promise((resolve, reject) => {
|
|
63
|
-
const parser = new tar.Parser({
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
entry.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
entry.on('error', reject);
|
|
78
|
-
},
|
|
62
|
+
const parser = new tar.Parser({ gzip: true });
|
|
63
|
+
parser.on('entry', (entry) => {
|
|
64
|
+
if (entry.type !== 'File') {
|
|
65
|
+
entry.resume();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const chunks = [];
|
|
69
|
+
entry.on('data', (chunk) => {
|
|
70
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
71
|
+
});
|
|
72
|
+
entry.on('end', () => {
|
|
73
|
+
entries.push({ path: entry.path, content: Buffer.concat(chunks) });
|
|
74
|
+
});
|
|
75
|
+
entry.on('error', reject);
|
|
79
76
|
});
|
|
80
77
|
parser.on('finish', resolve);
|
|
81
78
|
parser.on('error', reject);
|
|
82
|
-
|
|
83
|
-
readable.pipe(parser);
|
|
79
|
+
parser.end(buf);
|
|
84
80
|
});
|
|
85
81
|
return entries;
|
|
86
82
|
}
|
|
@@ -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
|
-
*
|
|
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`).
|
|
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
|
|
125
|
-
// exposure
|
|
126
|
-
// and
|
|
127
|
-
|
|
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
|
|
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
|
|
155
|
-
//
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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;
|