@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.
Files changed (30) hide show
  1. package/dist/DomainStack.d.ts +5 -0
  2. package/dist/DomainStack.js +145 -67
  3. package/dist/__tests__/action-construct.test.d.ts +1 -0
  4. package/dist/__tests__/action-construct.test.js +159 -0
  5. package/dist/__tests__/domain-stack.test.js +315 -15
  6. package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.d.ts +11 -0
  7. package/dist/__tests__/grouped-lambda-factory-action-wrapper.test.js +47 -0
  8. package/dist/__tests__/grouped-lambda-factory.test.d.ts +1 -0
  9. package/dist/__tests__/grouped-lambda-factory.test.js +146 -0
  10. package/dist/__tests__/lambda-factory.test.js +7 -7
  11. package/dist/__tests__/registry.test.js +111 -4
  12. package/dist/__tests__/security-assertion-aspect.test.d.ts +1 -0
  13. package/dist/__tests__/security-assertion-aspect.test.js +222 -0
  14. package/dist/aspects/index.d.ts +4 -0
  15. package/dist/aspects/index.js +2 -0
  16. package/dist/aspects/security-assertion-aspect.d.ts +161 -0
  17. package/dist/aspects/security-assertion-aspect.js +226 -0
  18. package/dist/constructs/action-construct.d.ts +16 -6
  19. package/dist/constructs/action-construct.js +10 -11
  20. package/dist/constructs/api-construct.d.ts +10 -2
  21. package/dist/constructs/api-construct.js +25 -5
  22. package/dist/grouped-lambda-factory.d.ts +69 -1
  23. package/dist/grouped-lambda-factory.js +98 -10
  24. package/dist/iam/iam-policy-builder.js +0 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +1 -1
  27. package/dist/pack-domain.d.ts +2 -0
  28. package/dist/pack-domain.js +9 -3
  29. package/dist/registry.d.ts +98 -47
  30. 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, with optional Function URL for workspace-visible actions.
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, with optional Function URL for workspace-visible actions.
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 containing all API entries. */
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 registered API and wires each to the shared HttpApi.
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 registered API and wires each to the shared HttpApi.
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 entry of props.registry.apis) {
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: entry.path,
23
- methods: [toHttpMethod(entry.method)],
24
- integration: new apigwv2integrations.HttpLambdaIntegration(`${id}${toPascalCase(entry.id)}Integration`, fn),
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 = 'api' | 'subscriber' | 'schedule' | 'job' | 'webhook' | 'action';
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
- function generateDedicatedEntry(handlerImportPath, entry, primitiveType) {
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
- const adapterMap = {
30
- api: 'createApiLambdaHandler',
31
- subscriber: 'createSubscriberLambdaHandler',
32
- job: 'createJobLambdaHandler',
33
- webhook: 'createWebhookLambdaHandler',
34
- action: 'createActionLambdaHandler',
35
- };
36
- const adapter = adapterMap[primitiveType] ?? 'createApiLambdaHandler';
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 = generateDedicatedEntry(handlerImportPath.startsWith('.') ? handlerImportPath : `./${handlerImportPath}`, entry, props.primitiveType);
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,
@@ -221,8 +221,6 @@ export class IamPolicyBuilder {
221
221
  return this.forSubscriber(params);
222
222
  case 'job':
223
223
  return this.forJob(params);
224
- case 'api':
225
- return this.forApi();
226
224
  case 'schedule':
227
225
  return this.forSchedule();
228
226
  case 'action':
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- export type { DomainRegistry, RegistryEntry, RegistryEntryKind, BaseRegistryEntry, SerialDeploymentConfig, ApiRegistryEntry, ApiVersionSnapshot, WebhookRegistryEntry, SubscriberRegistryEntry, ScheduleRegistryEntry, JobRegistryEntry, ActionRegistryEntry, IntegrationRegistryEntry, EventRegistryEntry, DomainRegistryEntry, SchemaSnapshot, } from './registry.js';
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';
@@ -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.
@@ -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 && typeof options === 'object'
17
- && ('account' in options || 'region' in options)
18
- && !('userPoolArn' in options || 'userPoolId' in options || 'userPoolClientId' in options || 'databaseUrl' in options || 'dbSecretArn' in options)
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
  }