@mettlecast/domain-cdk-packer 0.2.60 → 0.2.62
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/DomainStack.d.ts +5 -0
- package/dist/DomainStack.js +145 -67
- package/dist/__tests__/action-construct.test.d.ts +1 -0
- package/dist/__tests__/action-construct.test.js +159 -0
- package/dist/__tests__/domain-stack.test.js +315 -15
- package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.d.ts +11 -0
- package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.js +47 -0
- package/dist/__tests__/grouped-lambda-factory.test.d.ts +1 -0
- package/dist/__tests__/grouped-lambda-factory.test.js +146 -0
- package/dist/__tests__/lambda-factory.test.js +7 -7
- package/dist/__tests__/registry.test.js +111 -4
- package/dist/__tests__/security-assertion-aspect.test.d.ts +1 -0
- package/dist/__tests__/security-assertion-aspect.test.js +222 -0
- package/dist/aspects/index.d.ts +4 -0
- package/dist/aspects/index.js +2 -0
- package/dist/aspects/security-assertion-aspect.d.ts +161 -0
- package/dist/aspects/security-assertion-aspect.js +226 -0
- package/dist/constructs/action-construct.d.ts +16 -6
- package/dist/constructs/action-construct.js +10 -11
- package/dist/constructs/api-construct.d.ts +10 -2
- package/dist/constructs/api-construct.js +25 -5
- package/dist/grouped-lambda-factory.d.ts +69 -1
- package/dist/grouped-lambda-factory.js +98 -10
- package/dist/iam/iam-policy-builder.js +0 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/pack-domain.d.ts +2 -0
- package/dist/pack-domain.js +9 -3
- package/dist/registry.d.ts +98 -47
- package/package.json +1 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import * as cdk from 'aws-cdk-lib';
|
|
2
|
+
import { CfnRoute as Apigwv2CfnRoute } from 'aws-cdk-lib/aws-apigatewayv2';
|
|
3
|
+
import { CfnUrl as LambdaCfnUrl } from 'aws-cdk-lib/aws-lambda';
|
|
4
|
+
/**
|
|
5
|
+
* Result codes emitted by the aspect. Mirrored in the CLI validator so
|
|
6
|
+
* downstream tooling can correlate synth-time annotations with build-time
|
|
7
|
+
* validation errors. Each code corresponds to a single, narrowly-scoped
|
|
8
|
+
* invariant — see the per-method docstrings for the exact contract.
|
|
9
|
+
*/
|
|
10
|
+
export const SECURITY_ASSERTION_CODES = {
|
|
11
|
+
/**
|
|
12
|
+
* A JWT-required route (api.authType='jwt' or action.exposure.auth='required')
|
|
13
|
+
* was synthesised without an attached `CfnAuthorizer` ID. Synth blocks.
|
|
14
|
+
*/
|
|
15
|
+
MISSING_JWT_AUTHORIZER: 'SECURITY_MISSING_JWT_AUTHORIZER',
|
|
16
|
+
/**
|
|
17
|
+
* A tenancy-required route (api requiring a tenant, or action.exposure.tenancy='required')
|
|
18
|
+
* does not include the canonical tenant placeholder
|
|
19
|
+
* (`/v1/tenants/{tenantId}/`) in its path. The runtime cannot bind
|
|
20
|
+
* `ctx.tenant.id` from a path that does not carry the placeholder, so
|
|
21
|
+
* this would silently produce a tenant-less route. Synth blocks.
|
|
22
|
+
*/
|
|
23
|
+
MISSING_TENANT_PATH: 'SECURITY_MISSING_TENANT_PATH',
|
|
24
|
+
/**
|
|
25
|
+
* An action Lambda is reachable through a `Lambda::Url` resource. This
|
|
26
|
+
* creates a public-or-IAM endpoint that bypasses API Gateway, the JWT
|
|
27
|
+
* authorizer, and the registry's exposure metadata. Synth blocks.
|
|
28
|
+
*/
|
|
29
|
+
ACTION_FUNCTION_URL: 'SECURITY_ACTION_FUNCTION_URL',
|
|
30
|
+
/**
|
|
31
|
+
* An API route is declared `auth: 'none'` or `authType: 'none'` without
|
|
32
|
+
* a `securityException.reason`. Public routes must document the
|
|
33
|
+
* exception so security reviewers can audit the relaxation. Synth
|
|
34
|
+
* blocks — anonymous routes are the exception, not the default.
|
|
35
|
+
*/
|
|
36
|
+
MISSING_SECURITY_EXCEPTION: 'SECURITY_MISSING_SECURITY_EXCEPTION',
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* CDK Aspect that performs deployment-time security assertions against
|
|
40
|
+
* the synthesised {@link DomainStack}.
|
|
41
|
+
*
|
|
42
|
+
* The aspect complements the CLI validator at `packages/domain-cli`. Both
|
|
43
|
+
* layers run from the same source of truth (`DomainRegistry`), but they
|
|
44
|
+
* fail at different points in the pipeline:
|
|
45
|
+
*
|
|
46
|
+
* - The CLI validator fails the build BEFORE synth, so the developer
|
|
47
|
+
* gets a structured error code in their terminal and CI fails on
|
|
48
|
+
* `mc-domain-module validate`.
|
|
49
|
+
* - The aspect fails synth AFTER the stack has been assembled. It can
|
|
50
|
+
* detect runtime regressions (e.g. a route accidentally synthesised
|
|
51
|
+
* without an authorizer because a feature flag silently skipped the
|
|
52
|
+
* wiring) that the CLI cannot see.
|
|
53
|
+
*
|
|
54
|
+
* Adding the aspect to a stack is a one-liner:
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* cdk.Aspects.of(stack).add(new SecurityAssertionAspect({ registry }));
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* The {@link DomainStack} constructor already wires this aspect in for
|
|
61
|
+
* every domain, so most code does not need to interact with the class
|
|
62
|
+
* directly.
|
|
63
|
+
*/
|
|
64
|
+
export class SecurityAssertionAspect {
|
|
65
|
+
props;
|
|
66
|
+
constructor(props) {
|
|
67
|
+
this.props = {
|
|
68
|
+
registry: props.registry,
|
|
69
|
+
failOnMissingAuthorizer: props.failOnMissingAuthorizer ?? true,
|
|
70
|
+
failOnFunctionUrl: props.failOnFunctionUrl ?? true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* CDK calls this for every node in the construct tree. The aspect is
|
|
75
|
+
* intentionally narrow: it inspects the synthesised graph for the
|
|
76
|
+
* specific invariants listed above. It does NOT validate the registry
|
|
77
|
+
* itself — the CLI owns that responsibility and emitting duplicate
|
|
78
|
+
* errors here would be noisy.
|
|
79
|
+
*
|
|
80
|
+
* @param node - The CDK construct being visited.
|
|
81
|
+
*/
|
|
82
|
+
visit(node) {
|
|
83
|
+
this.assertNoFunctionUrls(node);
|
|
84
|
+
this.assertRouteSecurity(node);
|
|
85
|
+
this.assertAnonymousRoutesHaveException(node);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Walk every `CfnRoute` in the construct tree and verify the security
|
|
89
|
+
* invariants in one pass:
|
|
90
|
+
*
|
|
91
|
+
* 1. Routes declared as JWT-protected actually carry an `AuthorizerId`.
|
|
92
|
+
* Covers action API exposures (`exposure.auth === 'required'`).
|
|
93
|
+
* 2. Routes declared as tenant-required include the canonical
|
|
94
|
+
* `/v1/tenants/{tenantId}/` placeholder in their path.
|
|
95
|
+
*
|
|
96
|
+
* Both checks fail synth by default; the authorizer check can be
|
|
97
|
+
* downgraded to a warning via {@link SecurityAssertionAspectProps.failOnMissingAuthorizer}
|
|
98
|
+
* for incremental rollouts.
|
|
99
|
+
*/
|
|
100
|
+
assertRouteSecurity(node) {
|
|
101
|
+
if (!(node instanceof Apigwv2CfnRoute))
|
|
102
|
+
return;
|
|
103
|
+
// CDK's generated L1 `CfnRoute` keeps the constructor-supplied `routeKey`
|
|
104
|
+
// on a private field that is not exposed via the public `.d.ts`. We
|
|
105
|
+
// reach it through a typed bracket cast so we get strong typing on
|
|
106
|
+
// everything else while still being able to inspect the synthesised
|
|
107
|
+
// route key during the aspect's visit.
|
|
108
|
+
const routeKey = node._routeKey ?? '';
|
|
109
|
+
const spaceIndex = routeKey.indexOf(' ');
|
|
110
|
+
if (spaceIndex === -1)
|
|
111
|
+
return;
|
|
112
|
+
const path = routeKey.slice(spaceIndex + 1);
|
|
113
|
+
const method = routeKey.slice(0, spaceIndex).toUpperCase();
|
|
114
|
+
const authorizerId = node.authorizerId;
|
|
115
|
+
const action = this.findActionRegistryEntryForRoute(path, method);
|
|
116
|
+
if (action && action.exposure.type === 'api') {
|
|
117
|
+
const exposure = action.exposure;
|
|
118
|
+
// Invariant 1b: action with required auth has an authorizer
|
|
119
|
+
if (exposure.auth === 'required' && !authorizerId) {
|
|
120
|
+
cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_JWT_AUTHORIZER}: ` +
|
|
121
|
+
`Action '${action.id}' declares exposure.auth='required' but the synthesised route ${routeKey} ` +
|
|
122
|
+
`has no AuthorizerId. Provide a Cognito user pool via DomainStackProps.userPoolArn + userPoolClientId.`);
|
|
123
|
+
}
|
|
124
|
+
// Invariant 2b: action with required tenancy has tenant placeholder
|
|
125
|
+
if (exposure.tenancy === 'required' && !this.hasTenantPlaceholder(path)) {
|
|
126
|
+
cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_TENANT_PATH}: ` +
|
|
127
|
+
`Action '${action.id}' declares exposure.tenancy='required' but its path (${path}) ` +
|
|
128
|
+
`does not include '/v1/tenants/{tenantId}/'. Synth will fail.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Walk every `CfnUrl` in the construct tree and verify no action
|
|
134
|
+
* Lambda is reachable through a `Lambda::Url`. Wave 4 Task 4.2 (#4619)
|
|
135
|
+
* explicitly removed Function URL exposure because the resulting
|
|
136
|
+
* endpoints bypass API Gateway, the JWT authorizer, and the
|
|
137
|
+
* `backendAccess` / `exposure` policy gates.
|
|
138
|
+
*
|
|
139
|
+
* Health and readiness probes are explicitly exempted — they use
|
|
140
|
+
* `Lambda::Url` for cheap internal polling and the runtime never
|
|
141
|
+
* routes tenant traffic through them.
|
|
142
|
+
*/
|
|
143
|
+
assertNoFunctionUrls(node) {
|
|
144
|
+
if (!(node instanceof LambdaCfnUrl))
|
|
145
|
+
return;
|
|
146
|
+
const targetFn = node.targetFunctionArn
|
|
147
|
+
?? node.props?.targetFunction?.functionArn;
|
|
148
|
+
if (!targetFn)
|
|
149
|
+
return;
|
|
150
|
+
// Health & readiness probes have a function name suffix; if the
|
|
151
|
+
// synthesised target is a recognised health/ready function, skip.
|
|
152
|
+
// The aspect's intent is to block *tenant-facing* endpoints.
|
|
153
|
+
const fnName = String(targetFn).split(':').pop() ?? '';
|
|
154
|
+
if (/health|ready/i.test(fnName))
|
|
155
|
+
return;
|
|
156
|
+
cdk.Annotations.of(node).addWarning(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.ACTION_FUNCTION_URL}: ` +
|
|
157
|
+
`Lambda::Url attached to '${fnName}'. Function URLs bypass API Gateway and the registry's ` +
|
|
158
|
+
`exposure policy. Only health/ready probes are permitted to use them; verify the target ` +
|
|
159
|
+
`function name explicitly.`);
|
|
160
|
+
if (this.props.failOnFunctionUrl) {
|
|
161
|
+
cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.ACTION_FUNCTION_URL}: ` +
|
|
162
|
+
`Lambda::Url attached to '${fnName}'. Synth will fail. See registry's action.exposure for the canonical route surface.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Walk every `CfnRoute` whose action registry counterpart declares
|
|
167
|
+
* `auth: 'none'` and verify that the action exposure carries a
|
|
168
|
+
* `securityException.reason`. Public routes must document the exception
|
|
169
|
+
* so security reviewers can audit the relaxation.
|
|
170
|
+
*/
|
|
171
|
+
assertAnonymousRoutesHaveException(node) {
|
|
172
|
+
if (!(node instanceof Apigwv2CfnRoute))
|
|
173
|
+
return;
|
|
174
|
+
const routeKey = node._routeKey ?? '';
|
|
175
|
+
const spaceIndex = routeKey.indexOf(' ');
|
|
176
|
+
if (spaceIndex === -1)
|
|
177
|
+
return;
|
|
178
|
+
const path = routeKey.slice(spaceIndex + 1);
|
|
179
|
+
const method = routeKey.slice(0, spaceIndex).toUpperCase();
|
|
180
|
+
const action = this.findActionRegistryEntryForRoute(path, method);
|
|
181
|
+
if (action
|
|
182
|
+
&& action.exposure.type === 'api'
|
|
183
|
+
&& action.exposure.auth === 'none'
|
|
184
|
+
&& !this.hasActionSecurityException(action)) {
|
|
185
|
+
cdk.Annotations.of(node).addError(`[SecurityAssertionAspect] ${SECURITY_ASSERTION_CODES.MISSING_SECURITY_EXCEPTION}: ` +
|
|
186
|
+
`Action '${action.id}' declares exposure.auth='none' but does not declare ` +
|
|
187
|
+
`exposure.securityException.reason. Public routes must document the exception.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Find the registry action whose `exposure.path` matches a synthesised
|
|
192
|
+
* route for action API exposures.
|
|
193
|
+
*/
|
|
194
|
+
findActionRegistryEntryForRoute(path, method) {
|
|
195
|
+
const domainId = this.props.registry.domain.id;
|
|
196
|
+
if (!path.startsWith(`/${domainId}`))
|
|
197
|
+
return undefined;
|
|
198
|
+
const trimmed = path.slice(`/${domainId}`.length) || '/';
|
|
199
|
+
return this.props.registry.actions.find(action => {
|
|
200
|
+
if (action.exposure.type !== 'api')
|
|
201
|
+
return false;
|
|
202
|
+
const exposure = action.exposure;
|
|
203
|
+
return exposure.path === trimmed && exposure.method.toUpperCase() === method;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Predicate — does the action registry entry carry a securityException
|
|
208
|
+
* on its exposure block?
|
|
209
|
+
*/
|
|
210
|
+
hasActionSecurityException(action) {
|
|
211
|
+
if (action.exposure.type !== 'api')
|
|
212
|
+
return false;
|
|
213
|
+
const exposure = action.exposure;
|
|
214
|
+
const reason = exposure.securityException?.reason;
|
|
215
|
+
return typeof reason === 'string' && reason.trim().length > 0;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Predicate — does the path contain `/v1/tenants/{tenantId}/`?
|
|
219
|
+
*
|
|
220
|
+
* We do not use a regex because the placeholder is a literal string
|
|
221
|
+
* and the runtime extractor performs an identical literal match.
|
|
222
|
+
*/
|
|
223
|
+
hasTenantPlaceholder(path) {
|
|
224
|
+
return path.includes('/v1/tenants/{tenantId}/');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -5,6 +5,12 @@ import { IamPolicyBuilder } from '../iam/iam-policy-builder.js';
|
|
|
5
5
|
import type { DomainRegistry } from '../registry.js';
|
|
6
6
|
/**
|
|
7
7
|
* Configuration properties for ActionConstruct.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: Action Lambdas are NEVER exposed directly via Lambda Function URL.
|
|
10
|
+
* Per Wave 4 Task 4.2 (#4619), `backendAccess` / `exposure` are the canonical
|
|
11
|
+
* permissions model. External reachability for actions is granted only via
|
|
12
|
+
* API Gateway routes synthesized from the `exposure` field — never through
|
|
13
|
+
* an unauthenticated Function URL.
|
|
8
14
|
*/
|
|
9
15
|
export interface ActionConstructProps {
|
|
10
16
|
/** Domain registry containing all action entries. */
|
|
@@ -13,14 +19,18 @@ export interface ActionConstructProps {
|
|
|
13
19
|
lambdaFactory: LambdaFactory;
|
|
14
20
|
/** IAM policy builder for granting permissions to Lambda roles. */
|
|
15
21
|
iamBuilder: IamPolicyBuilder;
|
|
16
|
-
/**
|
|
17
|
-
* Allowed origins for Function URL CORS on workspace-visible actions.
|
|
18
|
-
* Defaults to ['*']. Set to specific dashboard URLs + localhost for production.
|
|
19
|
-
*/
|
|
20
|
-
corsAllowedOrigins?: string[];
|
|
21
22
|
}
|
|
22
23
|
/**
|
|
23
|
-
* CDK Construct that synthesises one Lambda per registered action
|
|
24
|
+
* CDK Construct that synthesises one Lambda per registered action.
|
|
25
|
+
*
|
|
26
|
+
* Action Lambdas are intentionally internal-only. They are invoked by:
|
|
27
|
+
* - `ctx.actions` (same-domain or cross-domain) — backed by `lambda:InvokeFunction`
|
|
28
|
+
* - API Gateway routes — synthesized by DomainStack from the action's
|
|
29
|
+
* `exposure.type === 'api'` metadata.
|
|
30
|
+
*
|
|
31
|
+
* They MUST NOT be exposed through Lambda Function URL with `authType: NONE`
|
|
32
|
+
* because that would create a public, unauthenticated endpoint bypassing
|
|
33
|
+
* API Gateway auth (JWT / IAM / Lambda authorizer) and tenant scoping.
|
|
24
34
|
*/
|
|
25
35
|
export declare class ActionConstruct extends Construct {
|
|
26
36
|
/** Lambda function per action id. */
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
2
1
|
import { Construct } from 'constructs';
|
|
3
2
|
/**
|
|
4
|
-
* CDK Construct that synthesises one Lambda per registered action
|
|
3
|
+
* CDK Construct that synthesises one Lambda per registered action.
|
|
4
|
+
*
|
|
5
|
+
* Action Lambdas are intentionally internal-only. They are invoked by:
|
|
6
|
+
* - `ctx.actions` (same-domain or cross-domain) — backed by `lambda:InvokeFunction`
|
|
7
|
+
* - API Gateway routes — synthesized by DomainStack from the action's
|
|
8
|
+
* `exposure.type === 'api'` metadata.
|
|
9
|
+
*
|
|
10
|
+
* They MUST NOT be exposed through Lambda Function URL with `authType: NONE`
|
|
11
|
+
* because that would create a public, unauthenticated endpoint bypassing
|
|
12
|
+
* API Gateway auth (JWT / IAM / Lambda authorizer) and tenant scoping.
|
|
5
13
|
*/
|
|
6
14
|
export class ActionConstruct extends Construct {
|
|
7
15
|
/** Lambda function per action id. */
|
|
@@ -21,15 +29,6 @@ export class ActionConstruct extends Construct {
|
|
|
21
29
|
props.iamBuilder.forAction().forEach((statement) => {
|
|
22
30
|
fn.addToRolePolicy(statement);
|
|
23
31
|
});
|
|
24
|
-
if (entry.visibility === 'workspace') {
|
|
25
|
-
fn.addFunctionUrl({
|
|
26
|
-
authType: lambda.FunctionUrlAuthType.NONE,
|
|
27
|
-
cors: {
|
|
28
|
-
allowedOrigins: props.corsAllowedOrigins ?? ['*'],
|
|
29
|
-
allowedMethods: [lambda.HttpMethod.ALL],
|
|
30
|
-
},
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
32
|
}
|
|
34
33
|
}
|
|
35
34
|
}
|
|
@@ -7,7 +7,7 @@ import type { DomainRegistry } from '../registry.js';
|
|
|
7
7
|
* Configuration properties for ApiConstruct.
|
|
8
8
|
*/
|
|
9
9
|
export interface ApiConstructProps {
|
|
10
|
-
/** Domain registry
|
|
10
|
+
/** Domain registry. Issue #4689: HTTP endpoints are now action entries with `exposure.type === 'api'`. */
|
|
11
11
|
registry: DomainRegistry;
|
|
12
12
|
/** Shared HttpApi to route requests to Lambdas. */
|
|
13
13
|
httpApi: apigwv2.HttpApi;
|
|
@@ -17,7 +17,15 @@ export interface ApiConstructProps {
|
|
|
17
17
|
iamBuilder: IamPolicyBuilder;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
* CDK Construct that synthesises one Lambda per
|
|
20
|
+
* CDK Construct that synthesises one Lambda per API-exposed action and wires each to the shared HttpApi.
|
|
21
|
+
*
|
|
22
|
+
* Issue #4689: the legacy `defineApi` primitive was removed. The
|
|
23
|
+
* canonical HTTP endpoint surface is `defineAction({ exposure: { type:
|
|
24
|
+
* 'api', ... } })`, so this construct now iterates
|
|
25
|
+
* `registry.actions` and filters for `exposure.type === 'api'`. The
|
|
26
|
+
* legacy `registry.apis` slot is always empty in fresh registries; we
|
|
27
|
+
* intentionally do NOT iterate it because the builder no longer
|
|
28
|
+
* populates it.
|
|
21
29
|
*/
|
|
22
30
|
export declare class ApiConstruct extends Construct {
|
|
23
31
|
/**
|
|
@@ -2,7 +2,15 @@ import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
|
|
|
2
2
|
import * as apigwv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
|
|
3
3
|
import { Construct } from 'constructs';
|
|
4
4
|
/**
|
|
5
|
-
* CDK Construct that synthesises one Lambda per
|
|
5
|
+
* CDK Construct that synthesises one Lambda per API-exposed action and wires each to the shared HttpApi.
|
|
6
|
+
*
|
|
7
|
+
* Issue #4689: the legacy `defineApi` primitive was removed. The
|
|
8
|
+
* canonical HTTP endpoint surface is `defineAction({ exposure: { type:
|
|
9
|
+
* 'api', ... } })`, so this construct now iterates
|
|
10
|
+
* `registry.actions` and filters for `exposure.type === 'api'`. The
|
|
11
|
+
* legacy `registry.apis` slot is always empty in fresh registries; we
|
|
12
|
+
* intentionally do NOT iterate it because the builder no longer
|
|
13
|
+
* populates it.
|
|
6
14
|
*/
|
|
7
15
|
export class ApiConstruct extends Construct {
|
|
8
16
|
/**
|
|
@@ -13,15 +21,27 @@ export class ApiConstruct extends Construct {
|
|
|
13
21
|
*/
|
|
14
22
|
constructor(scope, id, props) {
|
|
15
23
|
super(scope, id);
|
|
16
|
-
for (const
|
|
24
|
+
for (const action of props.registry.actions) {
|
|
25
|
+
if (action.exposure?.type !== 'api')
|
|
26
|
+
continue;
|
|
27
|
+
const exposure = action.exposure;
|
|
28
|
+
const entry = {
|
|
29
|
+
...action,
|
|
30
|
+
// The action entry is structurally compatible with the lambda
|
|
31
|
+
// factory's expected shape; spread the exposure fields onto the
|
|
32
|
+
// top level so existing IAM / deployment logic does not need to
|
|
33
|
+
// change.
|
|
34
|
+
path: exposure.path,
|
|
35
|
+
method: exposure.method,
|
|
36
|
+
};
|
|
17
37
|
const fn = props.lambdaFactory.createFunction(entry, entry.deployment);
|
|
18
38
|
props.iamBuilder.forApi().forEach((statement) => {
|
|
19
39
|
fn.addToRolePolicy(statement);
|
|
20
40
|
});
|
|
21
41
|
props.httpApi.addRoutes({
|
|
22
|
-
path:
|
|
23
|
-
methods: [toHttpMethod(
|
|
24
|
-
integration: new apigwv2integrations.HttpLambdaIntegration(`${id}${toPascalCase(
|
|
42
|
+
path: exposure.path,
|
|
43
|
+
methods: [toHttpMethod(exposure.method)],
|
|
44
|
+
integration: new apigwv2integrations.HttpLambdaIntegration(`${id}${toPascalCase(action.id)}Integration`, fn),
|
|
25
45
|
});
|
|
26
46
|
}
|
|
27
47
|
}
|
|
@@ -2,13 +2,33 @@ import * as lambda from 'aws-cdk-lib/aws-lambda';
|
|
|
2
2
|
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
3
3
|
import { Construct } from 'constructs';
|
|
4
4
|
/** Primitive types that can be grouped into a single Lambda. */
|
|
5
|
-
export type PrimitiveType = '
|
|
5
|
+
export type PrimitiveType = 'subscriber' | 'schedule' | 'job' | 'webhook' | 'action';
|
|
6
6
|
/** A single handler entry within a grouped Lambda. */
|
|
7
7
|
export interface HandlerEntry {
|
|
8
8
|
/** Unique handler ID within the domain + primitive type. */
|
|
9
9
|
id: string;
|
|
10
10
|
/** Path to the handler file relative to the domain's dist directory. */
|
|
11
11
|
handlerFile: string;
|
|
12
|
+
/**
|
|
13
|
+
* Optional override for the runtime adapter exported from
|
|
14
|
+
* `@mettlecast/domain-runtime` that wraps this handler's default export
|
|
15
|
+
* into a Lambda handler.
|
|
16
|
+
*
|
|
17
|
+
* When omitted, the adapter is inferred from `primitiveType`:
|
|
18
|
+
* - `subscriber` → `createSubscriberLambdaHandler`
|
|
19
|
+
* - `job` → `createJobLambdaHandler`
|
|
20
|
+
* - `webhook` → `createWebhookLambdaHandler`
|
|
21
|
+
* - `action` → `createActionLambdaHandler`
|
|
22
|
+
* - `schedule` → uses the inline `hydrateCtx` envelope (no adapter factory).
|
|
23
|
+
*
|
|
24
|
+
* Only honoured in `dedicated: true` mode — grouped mode bundles all
|
|
25
|
+
* entries behind a single dispatcher and assumes a uniform adapter.
|
|
26
|
+
*
|
|
27
|
+
* Wave 4 Task 4.1: API-exposed actions set this to
|
|
28
|
+
* `'createExposedActionApiHandler'` so their dedicated Lambda receives raw
|
|
29
|
+
* HTTP API v2 events instead of the action envelope.
|
|
30
|
+
*/
|
|
31
|
+
adapter?: string;
|
|
12
32
|
}
|
|
13
33
|
/** Props for creating grouped or dedicated Lambdas for a primitive type. */
|
|
14
34
|
export interface GroupedLambdaProps {
|
|
@@ -39,6 +59,54 @@ export interface GroupedLambdaProps {
|
|
|
39
59
|
/** Log retention in days. Default: 30. */
|
|
40
60
|
logRetentionDays?: number;
|
|
41
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Per-primitive-type default adapter exported from `@mettlecast/domain-runtime`.
|
|
64
|
+
*
|
|
65
|
+
* Exported (Wave 7 Task 7.3, #4619) so unit tests can pin the adapter
|
|
66
|
+
* selection logic without instantiating CDK or esbuild. The mapping here
|
|
67
|
+
* is the source of truth for the default wrap used by
|
|
68
|
+
* `buildDedicatedEntryContent`; it does NOT cover the per-entry override
|
|
69
|
+
* used by API-exposed actions (see `EXPOSED_ACTION_ADAPTER`).
|
|
70
|
+
*/
|
|
71
|
+
export declare const ADAPTER_BY_PRIMITIVE: Record<Exclude<PrimitiveType, 'schedule'>, string>;
|
|
72
|
+
/**
|
|
73
|
+
* Adapter used for API-exposed actions (Wave 4 Task 4.1, #4619).
|
|
74
|
+
*
|
|
75
|
+
* API-exposed actions receive raw HTTP API v2 events, so they must wrap
|
|
76
|
+
* with `createExposedActionApiHandler` instead of the envelope-based
|
|
77
|
+
* `createActionLambdaHandler` that internal actions use.
|
|
78
|
+
*
|
|
79
|
+
* Exported (Wave 7 Task 7.3) so tests can assert the override behaviour
|
|
80
|
+
* without depending on the published runtime's barrel.
|
|
81
|
+
*/
|
|
82
|
+
export declare const EXPOSED_ACTION_ADAPTER = "createExposedActionApiHandler";
|
|
83
|
+
/**
|
|
84
|
+
* Pure, side-effect-free entry-content generator for a single dedicated-mode
|
|
85
|
+
* Lambda handler.
|
|
86
|
+
*
|
|
87
|
+
* Returns the TypeScript source string that `NodejsFunction` will bundle.
|
|
88
|
+
* Exported (Wave 7 Task 7.3, #4619) so unit tests can assert adapter
|
|
89
|
+
* selection and import wiring without paying the cost of esbuild bundling
|
|
90
|
+
* on every `DomainStack` synth.
|
|
91
|
+
*
|
|
92
|
+
* Note: Wave 7 Task 7.1 may extend this signature with `domainId` (and
|
|
93
|
+
* rewrite the action-adapter wrappers) so the runtime can plumb
|
|
94
|
+
* `definingDomain` / `domainId` into the dedicated Lambda. The narrow
|
|
95
|
+
* tests in `grouped-lambda-factory.test.ts` deliberately target only the
|
|
96
|
+
* parts that are stable across that change so they keep passing through
|
|
97
|
+
* the merge.
|
|
98
|
+
*
|
|
99
|
+
* @param handlerImportPath Relative path from the temp entry directory
|
|
100
|
+
* to the handler file. Must already be normalised
|
|
101
|
+
* to start with `./` or `../`.
|
|
102
|
+
* @param entry The handler entry whose `id` and `adapter`
|
|
103
|
+
* drive the export name and adapter choice.
|
|
104
|
+
* @param primitiveType The primitive type — selects the default
|
|
105
|
+
* adapter when `entry.adapter` is unset, and
|
|
106
|
+
* switches to the `hydrateCtx` envelope for
|
|
107
|
+
* schedules.
|
|
108
|
+
*/
|
|
109
|
+
export declare function buildDedicatedEntryContent(handlerImportPath: string, entry: HandlerEntry, primitiveType: PrimitiveType, domainId: string): string;
|
|
42
110
|
/**
|
|
43
111
|
* Creates either a single grouped Lambda (default) or per-handler dedicated Lambdas.
|
|
44
112
|
*
|
|
@@ -10,7 +10,59 @@ function camelCase(str) {
|
|
|
10
10
|
function toPascalCase(str) {
|
|
11
11
|
return str.charAt(0).toUpperCase() + camelCase(str).slice(1);
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Per-primitive-type default adapter exported from `@mettlecast/domain-runtime`.
|
|
15
|
+
*
|
|
16
|
+
* Exported (Wave 7 Task 7.3, #4619) so unit tests can pin the adapter
|
|
17
|
+
* selection logic without instantiating CDK or esbuild. The mapping here
|
|
18
|
+
* is the source of truth for the default wrap used by
|
|
19
|
+
* `buildDedicatedEntryContent`; it does NOT cover the per-entry override
|
|
20
|
+
* used by API-exposed actions (see `EXPOSED_ACTION_ADAPTER`).
|
|
21
|
+
*/
|
|
22
|
+
export const ADAPTER_BY_PRIMITIVE = {
|
|
23
|
+
subscriber: 'createSubscriberLambdaHandler',
|
|
24
|
+
job: 'createJobLambdaHandler',
|
|
25
|
+
webhook: 'createWebhookLambdaHandler',
|
|
26
|
+
action: 'createActionLambdaHandler',
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Adapter used for API-exposed actions (Wave 4 Task 4.1, #4619).
|
|
30
|
+
*
|
|
31
|
+
* API-exposed actions receive raw HTTP API v2 events, so they must wrap
|
|
32
|
+
* with `createExposedActionApiHandler` instead of the envelope-based
|
|
33
|
+
* `createActionLambdaHandler` that internal actions use.
|
|
34
|
+
*
|
|
35
|
+
* Exported (Wave 7 Task 7.3) so tests can assert the override behaviour
|
|
36
|
+
* without depending on the published runtime's barrel.
|
|
37
|
+
*/
|
|
38
|
+
export const EXPOSED_ACTION_ADAPTER = 'createExposedActionApiHandler';
|
|
39
|
+
/**
|
|
40
|
+
* Pure, side-effect-free entry-content generator for a single dedicated-mode
|
|
41
|
+
* Lambda handler.
|
|
42
|
+
*
|
|
43
|
+
* Returns the TypeScript source string that `NodejsFunction` will bundle.
|
|
44
|
+
* Exported (Wave 7 Task 7.3, #4619) so unit tests can assert adapter
|
|
45
|
+
* selection and import wiring without paying the cost of esbuild bundling
|
|
46
|
+
* on every `DomainStack` synth.
|
|
47
|
+
*
|
|
48
|
+
* Note: Wave 7 Task 7.1 may extend this signature with `domainId` (and
|
|
49
|
+
* rewrite the action-adapter wrappers) so the runtime can plumb
|
|
50
|
+
* `definingDomain` / `domainId` into the dedicated Lambda. The narrow
|
|
51
|
+
* tests in `grouped-lambda-factory.test.ts` deliberately target only the
|
|
52
|
+
* parts that are stable across that change so they keep passing through
|
|
53
|
+
* the merge.
|
|
54
|
+
*
|
|
55
|
+
* @param handlerImportPath Relative path from the temp entry directory
|
|
56
|
+
* to the handler file. Must already be normalised
|
|
57
|
+
* to start with `./` or `../`.
|
|
58
|
+
* @param entry The handler entry whose `id` and `adapter`
|
|
59
|
+
* drive the export name and adapter choice.
|
|
60
|
+
* @param primitiveType The primitive type — selects the default
|
|
61
|
+
* adapter when `entry.adapter` is unset, and
|
|
62
|
+
* switches to the `hydrateCtx` envelope for
|
|
63
|
+
* schedules.
|
|
64
|
+
*/
|
|
65
|
+
export function buildDedicatedEntryContent(handlerImportPath, entry, primitiveType, domainId) {
|
|
14
66
|
const exportName = camelCase(entry.id);
|
|
15
67
|
if (primitiveType === 'schedule') {
|
|
16
68
|
return [
|
|
@@ -26,14 +78,50 @@ function generateDedicatedEntry(handlerImportPath, entry, primitiveType) {
|
|
|
26
78
|
`};`,
|
|
27
79
|
].join('\n');
|
|
28
80
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
81
|
+
// Per-handler adapter override (Wave 4 Task 4.1) takes precedence over the
|
|
82
|
+
// primitive-type default. Used so API-exposed actions wrap with
|
|
83
|
+
// `createExposedActionApiHandler` while internal actions in the same domain
|
|
84
|
+
// continue to use `createActionLambdaHandler`.
|
|
85
|
+
const adapter = entry.adapter ?? ADAPTER_BY_PRIMITIVE[primitiveType];
|
|
86
|
+
// Wave 7 Task 7.1 (#4619): the action adapters take a (registry, options)
|
|
87
|
+
// pair / (action, options) pair, NOT a bare handler export. Generating
|
|
88
|
+
// `${adapter}(${exportName})` would produce invalid JS for actions — the
|
|
89
|
+
// bundles were silently broken (esbuild succeeded but the runtime crashed
|
|
90
|
+
// on first invocation). We now emit the correct wrapper for each adapter:
|
|
91
|
+
// * internal action → `createActionLambdaHandler(registry, options)`
|
|
92
|
+
// * api-exposed action → `createExposedActionApiHandler(action, options)`
|
|
93
|
+
if (primitiveType === 'action' && adapter !== 'createExposedActionApiHandler') {
|
|
94
|
+
return [
|
|
95
|
+
`import { createActionLambdaHandler } from '@mettlecast/domain-runtime';`,
|
|
96
|
+
`import { ${exportName} } from '${handlerImportPath}';`,
|
|
97
|
+
'',
|
|
98
|
+
`const registry = { ${JSON.stringify(domainId)}: { ${JSON.stringify(entry.id)}: ${exportName} } };`,
|
|
99
|
+
'',
|
|
100
|
+
`export const handler = createActionLambdaHandler(registry, {`,
|
|
101
|
+
` domainId: ${JSON.stringify(domainId)},`,
|
|
102
|
+
` databaseUrl: process.env.DATABASE_URL,`,
|
|
103
|
+
` eventBusName: process.env.EVENT_BUS_NAME,`,
|
|
104
|
+
` idempotencyTableName: process.env.IDEMPOTENCY_TABLE,`,
|
|
105
|
+
`});`,
|
|
106
|
+
].join('\n');
|
|
107
|
+
}
|
|
108
|
+
if (primitiveType === 'action' && adapter === 'createExposedActionApiHandler') {
|
|
109
|
+
return [
|
|
110
|
+
`import { createExposedActionApiHandler } from '@mettlecast/domain-runtime';`,
|
|
111
|
+
`import { ${exportName} } from '${handlerImportPath}';`,
|
|
112
|
+
'',
|
|
113
|
+
// Build a single-action registry so the public handler can call its
|
|
114
|
+
// own action via `ctx.actions[domainId][actionId]` (Wave 7 Task 7.1).
|
|
115
|
+
`const actionRegistry = { ${JSON.stringify(domainId)}: { ${JSON.stringify(entry.id)}: ${exportName} } };`,
|
|
116
|
+
'',
|
|
117
|
+
`export const handler = createExposedActionApiHandler(${exportName}, {`,
|
|
118
|
+
` definingDomain: ${JSON.stringify(domainId)},`,
|
|
119
|
+
` domainId: ${JSON.stringify(domainId)},`,
|
|
120
|
+
` actionRegistry,`,
|
|
121
|
+
` callerDomainId: ${JSON.stringify(domainId)},`,
|
|
122
|
+
`});`,
|
|
123
|
+
].join('\n');
|
|
124
|
+
}
|
|
37
125
|
return [
|
|
38
126
|
`import { ${adapter} } from '@mettlecast/domain-runtime';`,
|
|
39
127
|
`import { ${exportName} } from '${handlerImportPath}';`,
|
|
@@ -76,7 +164,7 @@ export function createGroupedLambdas(scope, props) {
|
|
|
76
164
|
const entryDir = createTempEntryDir();
|
|
77
165
|
const entryPath = join(entryDir, 'entry.ts');
|
|
78
166
|
const handlerImportPath = relative(entryDir, join(domainRoot, entry.handlerFile)).replace(/\\/g, '/');
|
|
79
|
-
const entryContent =
|
|
167
|
+
const entryContent = buildDedicatedEntryContent(handlerImportPath.startsWith('.') ? handlerImportPath : `./${handlerImportPath}`, entry, props.primitiveType, props.domainId);
|
|
80
168
|
writeFileSync(entryPath, entryContent, 'utf8');
|
|
81
169
|
return new lambdaNode.NodejsFunction(scope, `${props.domainId}-${props.primitiveType}${groupSuffix}-${entry.id}`, {
|
|
82
170
|
runtime: lambda.Runtime.NODEJS_22_X,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export type { DomainRegistry, RegistryEntry, RegistryEntryKind, BaseRegistryEntry, SerialDeploymentConfig,
|
|
1
|
+
export type { DomainRegistry, RegistryEntry, RegistryEntryKind, BaseRegistryEntry, SerialDeploymentConfig, WebhookRegistryEntry, SubscriberRegistryEntry, ScheduleRegistryEntry, JobRegistryEntry, ActionRegistryEntry, ActionBackendAccess, ActionExposure, ActionApiExposure, IntegrationRegistryEntry, EventRegistryEntry, DomainRegistryEntry, SchemaSnapshot, } from './registry.js';
|
|
2
2
|
export { LambdaFactory } from './lambda-factory.js';
|
|
3
3
|
export type { LambdaFactoryProps } from './lambda-factory.js';
|
|
4
|
-
export { createGroupedLambdas } from './grouped-lambda-factory.js';
|
|
4
|
+
export { createGroupedLambdas, buildDedicatedEntryContent, ADAPTER_BY_PRIMITIVE, EXPOSED_ACTION_ADAPTER } from './grouped-lambda-factory.js';
|
|
5
5
|
export type { PrimitiveType, HandlerEntry, GroupedLambdaProps } from './grouped-lambda-factory.js';
|
|
6
6
|
export { IamPolicyBuilder } from './iam/iam-policy-builder.js';
|
|
7
7
|
export type { WebhookPolicyParams, QueuePolicyParams } from './iam/iam-policy-builder.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { LambdaFactory } from './lambda-factory.js';
|
|
2
|
-
export { createGroupedLambdas } from './grouped-lambda-factory.js';
|
|
2
|
+
export { createGroupedLambdas, buildDedicatedEntryContent, ADAPTER_BY_PRIMITIVE, EXPOSED_ACTION_ADAPTER } from './grouped-lambda-factory.js';
|
|
3
3
|
export { IamPolicyBuilder } from './iam/iam-policy-builder.js';
|
|
4
4
|
export { ApiConstruct } from './constructs/api-construct.js';
|
|
5
5
|
export { WebhookConstruct } from './constructs/webhook-construct.js';
|
package/dist/pack-domain.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface PackDomainOptions {
|
|
|
47
47
|
projectId?: string;
|
|
48
48
|
/** Environment code used to prefix per-domain physical resource names (e.g. `dev`, `prod`). */
|
|
49
49
|
envCode?: string;
|
|
50
|
+
/** Disable auto-generated per-domain CloudWatch dashboard. */
|
|
51
|
+
disableCloudWatchDashboards?: boolean;
|
|
50
52
|
}
|
|
51
53
|
/**
|
|
52
54
|
* Convenience entry-point: constructs a DomainStack from a compiled registry.
|
package/dist/pack-domain.js
CHANGED
|
@@ -13,9 +13,14 @@ import { DomainStack } from './DomainStack.js';
|
|
|
13
13
|
export function packDomain(registry, app, stackId, eventBusArn, options) {
|
|
14
14
|
// Support both old (env as 5th arg) and new (options object) calling conventions
|
|
15
15
|
// Check if options looks like a CDK Environment (has 'account' and/or 'region' props, not the full options interface)
|
|
16
|
-
const opts = options &&
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const opts = options &&
|
|
17
|
+
typeof options === 'object' &&
|
|
18
|
+
('account' in options || 'region' in options) &&
|
|
19
|
+
!('userPoolArn' in options ||
|
|
20
|
+
'userPoolId' in options ||
|
|
21
|
+
'userPoolClientId' in options ||
|
|
22
|
+
'databaseUrl' in options ||
|
|
23
|
+
'dbSecretArn' in options)
|
|
19
24
|
? { env: options }
|
|
20
25
|
: options || {};
|
|
21
26
|
return new DomainStack(app, stackId, {
|
|
@@ -41,5 +46,6 @@ export function packDomain(registry, app, stackId, eventBusArn, options) {
|
|
|
41
46
|
eventBusName: opts.eventBusName,
|
|
42
47
|
projectId: opts.projectId,
|
|
43
48
|
envCode: opts.envCode,
|
|
49
|
+
disableCloudWatchDashboards: opts.disableCloudWatchDashboards,
|
|
44
50
|
});
|
|
45
51
|
}
|