@mettlecast/domain-cdk-packer 0.2.59 → 0.2.61

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.
@@ -1,7 +1,5 @@
1
1
  import * as cdk from 'aws-cdk-lib';
2
2
  import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
3
- import * as apigwv2integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
4
- import * as apigwv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
5
3
  import * as events from 'aws-cdk-lib/aws-events';
6
4
  import * as eventsTargets from 'aws-cdk-lib/aws-events-targets';
7
5
  import * as sqs from 'aws-cdk-lib/aws-sqs';
@@ -20,12 +18,9 @@ import { WafConstruct } from './constructs/waf-construct.js';
20
18
  import { AlarmConstruct } from './constructs/alarm-construct.js';
21
19
  import { CanaryConstruct } from './constructs/canary-construct.js';
22
20
  import { domainResourceName } from './naming.js';
23
- /**
24
- * Top-level CDK Stack that composes all domain constructs from a single DomainRegistry input.
25
- * Uses grouped Lambdas for each primitive type to reduce deployment artifact size.
26
- */
21
+ import { SecurityAssertionAspect } from './aspects/security-assertion-aspect.js';
27
22
  /** L1 helper: adds a CfnRoute + CfnIntegration to an existing HTTP API. */
28
- function addRouteToApi(scope, fn, path, methods, apiId) {
23
+ function addRouteToApi(scope, fn, path, methods, apiId, auth) {
29
24
  const id = path.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_/, 'api') + '_' + methods[0].toLowerCase();
30
25
  const integ = new apigwv2.CfnIntegration(scope, 'Integ_' + id, {
31
26
  apiId,
@@ -37,12 +32,24 @@ function addRouteToApi(scope, fn, path, methods, apiId) {
37
32
  principal: new iam.ServicePrincipal('apigateway.amazonaws.com'),
38
33
  sourceArn: 'arn:aws:execute-api:' + cdk.Aws.REGION + ':' + cdk.Aws.ACCOUNT_ID + ':' + apiId + '/*',
39
34
  });
40
- const routeKey = methods[0] === apigwv2.HttpMethod.GET ? 'GET ' + path : 'POST ' + path;
41
- new apigwv2.CfnRoute(scope, 'Route_' + id, {
42
- apiId,
43
- routeKey,
44
- target: 'integrations/' + integ.ref,
45
- });
35
+ // Use the actual HTTP method (supports GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS/ANY).
36
+ // Previous implementation only handled GET vs POST and silently coerced every
37
+ // other method to POST, breaking PUT/PATCH/DELETE routes.
38
+ const routeKey = `${methods[0]} ${path}`;
39
+ const routeProps = auth
40
+ ? {
41
+ apiId,
42
+ routeKey,
43
+ target: 'integrations/' + integ.ref,
44
+ authorizationType: auth.authorizationType,
45
+ authorizerId: auth.authorizerId,
46
+ }
47
+ : {
48
+ apiId,
49
+ routeKey,
50
+ target: 'integrations/' + integ.ref,
51
+ };
52
+ new apigwv2.CfnRoute(scope, 'Route_' + id, routeProps);
46
53
  }
47
54
  export class DomainStack extends cdk.Stack {
48
55
  /** Shared HTTP API for routing API and webhook requests. */
@@ -88,15 +95,50 @@ export class DomainStack extends cdk.Stack {
88
95
  ...(allowCredentials ? { allowCredentials: true } : {}),
89
96
  },
90
97
  });
91
- // Wire Cognito JWT authorizer when user pool is provided
98
+ // Wire Cognito JWT authorizer when user pool is provided.
99
+ //
100
+ // We use the L2 `HttpAuthorizer` (not `HttpJwtAuthorizer`) because it eagerly
101
+ // creates the underlying `CfnAuthorizer` and exposes `authorizerId` as a
102
+ // concrete string. `HttpJwtAuthorizer` lazily binds through `bind()` and
103
+ // throws if `authorizerId` is read before binding — that prevented the
104
+ // existing L1 `addRouteToApi` from ever attaching an authorizer, which is
105
+ // the bug Wave 4 Task 4.1 fixes.
92
106
  let jwtAuthorizer;
93
107
  if ((userPoolId || userPoolArn) && userPoolClientId) {
94
108
  const region = cdk.Stack.of(this).region;
95
109
  const resolvedUserPoolId = userPoolId ?? cdk.Fn.select(1, cdk.Fn.split('/', userPoolArn));
96
- jwtAuthorizer = new apigwv2Authorizers.HttpJwtAuthorizer('CognitoAuthorizer', `https://cognito-idp.${region}.amazonaws.com/${resolvedUserPoolId}`, {
110
+ jwtAuthorizer = new apigwv2.HttpAuthorizer(this, 'CognitoAuthorizer', {
111
+ httpApi: this.httpApi,
112
+ authorizerName: 'CognitoAuthorizer',
113
+ type: apigwv2.HttpAuthorizerType.JWT,
114
+ identitySource: ['$request.header.Authorization'],
97
115
  jwtAudience: [userPoolClientId],
116
+ jwtIssuer: `https://cognito-idp.${region}.amazonaws.com/${resolvedUserPoolId}`,
98
117
  });
99
118
  }
119
+ // Pre-resolve authorizer metadata for `addRouteToApi` — `CfnRoute` consumes
120
+ // a concrete `{ authorizerId, authorizationType }` tuple, not the L2 authorizer.
121
+ const jwtAuth = jwtAuthorizer
122
+ ? { authorizerId: jwtAuthorizer.authorizerId, authorizationType: apigwv2.HttpAuthorizerType.JWT }
123
+ : undefined;
124
+ // Determine whether any route in this domain requires a JWT authorizer.
125
+ // If so and `jwtAuth` is undefined, emit a synth-time error so missing Cognito
126
+ // configuration fails fast instead of producing an unauthenticated route.
127
+ const apiNeedsJwt = registry.apis.some(api => api.authType === 'jwt');
128
+ const actionNeedsJwt = registry.actions.some(a => a.exposure?.type === 'api' && a.exposure.auth === 'required');
129
+ const anyRouteRequiresJwt = apiNeedsJwt || actionNeedsJwt;
130
+ if (anyRouteRequiresJwt && !jwtAuth) {
131
+ cdk.Annotations.of(this).addError(`[DomainStack] Domain "${domainId}" declares one or more JWT-protected routes ` +
132
+ `(api.authType='jwt' or action.exposure.auth='required') but no Cognito user pool is ` +
133
+ `configured. Provide userPoolArn/userPoolId AND userPoolClientId to DomainStackProps.`);
134
+ }
135
+ // Issue #4662 Task D — deployment-time security assertions. The aspect
136
+ // walks the synthesised tree after the stack has been assembled and
137
+ // re-validates the same invariants the CLI checks at build time. It
138
+ // catches regressions where a route was silently synthesised without
139
+ // an authorizer (e.g. a feature flag skipped the wiring), or where an
140
+ // action Lambda was attached to a Function URL outside the registry.
141
+ cdk.Aspects.of(this).add(new SecurityAssertionAspect({ registry }));
100
142
  // Optional WAF
101
143
  if (enableWaf) {
102
144
  new WafConstruct(this, 'Waf', { domainId, namePrefix: resourceBaseName, httpApi: this.httpApi });
@@ -138,12 +180,31 @@ export class DomainStack extends cdk.Stack {
138
180
  const lambdas = [];
139
181
  const byId = new Map();
140
182
  for (const group of splitByOutboundAccess(entries)) {
141
- const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated') || primitiveType === 'api';
183
+ // Force dedicated mode for action groups that contain any API-exposed
184
+ // action — the per-entry adapter override only takes effect in
185
+ // dedicated mode (grouped mode uses a single TIB_HANDLER_MAP dispatcher
186
+ // which assumes a uniform adapter across all entries). We detect
187
+ // api-exposed actions by their `adapter` override (set by the caller
188
+ // in the action block below), since the entry shape is intentionally
189
+ // primitive-agnostic.
190
+ const hasApiExposedAction = primitiveType === 'action'
191
+ && group.entries.some(e => e.adapter === 'createExposedActionApiHandler');
192
+ const dedicated = group.entries.some(entry => entry.deployment?.isolation === 'dedicated')
193
+ || primitiveType === 'api'
194
+ || hasApiExposedAction;
142
195
  const groupLambdas = createGroupedLambdas(this, {
143
196
  domainId,
144
197
  domainRoot: registry.domainRoot,
145
198
  primitiveType,
146
- handlerEntries: group.entries.map(entry => ({ id: entry.id, handlerFile: entry.handlerFile })),
199
+ handlerEntries: group.entries.map(entry => {
200
+ const handlerEntry = {
201
+ id: entry.id,
202
+ handlerFile: entry.handlerFile,
203
+ };
204
+ if (entry.adapter)
205
+ handlerEntry.adapter = entry.adapter;
206
+ return handlerEntry;
207
+ }),
147
208
  environment,
148
209
  eventBusArn,
149
210
  dedicated,
@@ -234,23 +295,11 @@ export class DomainStack extends cdk.Stack {
234
295
  // Add routes for each API entry
235
296
  for (const api of registry.apis) {
236
297
  const fn = apiLambdaById.get(api.id);
237
- const apiRouteBase = {
238
- path: api.path,
239
- methods: [toHttpMethod(api.method)],
240
- integration: new apigwv2integrations.HttpLambdaIntegration(`Apis${toPascalCase(api.id)}Integration`, fn),
241
- };
242
- let apiRoute;
243
- if (api.authType === 'jwt' && jwtAuthorizer) {
244
- apiRoute = { ...apiRouteBase, authorizer: jwtAuthorizer };
245
- }
246
- else if (api.authType === 'api-key') {
298
+ if (api.authType === 'api-key') {
247
299
  cdk.Annotations.of(this).addWarning(`[DomainStack] API "${api.id}" uses authType "api-key" which is not supported on HTTP API v2 — route is UNAUTHENTICATED. Switch to "jwt" or implement a Lambda authorizer.`);
248
- apiRoute = apiRouteBase;
249
- }
250
- else {
251
- apiRoute = apiRouteBase;
252
300
  }
253
- addRouteToApi(this, fn, `/${domainId}${api.path}`, [toHttpMethod(api.method)], this.httpApi.httpApiId);
301
+ const routeAuth = api.authType === 'jwt' ? jwtAuth : undefined;
302
+ addRouteToApi(this, fn, `/${domainId}${api.path}`, [toHttpMethod(api.method)], this.httpApi.httpApiId, routeAuth);
254
303
  }
255
304
  }
256
305
  // Deploy webhooks as grouped Lambdas
@@ -420,7 +469,27 @@ export class DomainStack extends cdk.Stack {
420
469
  }
421
470
  // Deploy callable actions as grouped Lambdas
422
471
  if (registry.actions.length > 0) {
423
- const actionHandlers = createPrimitiveHandlers(registry.actions, 'action');
472
+ // Map action entries to NetworkedRegistryEntry so we can attach a per-entry
473
+ // adapter override. API-exposed actions must use `createExposedActionApiHandler`
474
+ // because they receive raw HTTP API v2 events; internal actions use the
475
+ // default `createActionLambdaHandler` (envelope-based invocation through
476
+ // `ctx.actions`).
477
+ const actionEntries = registry.actions.map((action) => {
478
+ const entry = {
479
+ id: action.id,
480
+ handlerFile: action.handlerFile,
481
+ };
482
+ if (action.outboundAccess !== undefined)
483
+ entry.outboundAccess = action.outboundAccess;
484
+ if (action.deployment !== undefined)
485
+ entry.deployment = action.deployment;
486
+ if (action.exposure?.type === 'api') {
487
+ entry.adapter = 'createExposedActionApiHandler';
488
+ }
489
+ return entry;
490
+ });
491
+ const actionHandlers = createPrimitiveHandlers(actionEntries, 'action');
492
+ const actionLambdaById = actionHandlers.byId;
424
493
  actionLambdas = actionHandlers.lambdas;
425
494
  this.lambdaArns[`${domainId}-action`] = actionLambdas[0].functionArn;
426
495
  const iamPolicies = iamBuilder.forAction([
@@ -436,6 +505,18 @@ export class DomainStack extends cdk.Stack {
436
505
  resources: [dbSecretArn],
437
506
  })));
438
507
  }
508
+ // Generate API Gateway routes for actions whose exposure is `api`.
509
+ //
510
+ // `exposure.type === 'internal'` actions are intentionally NOT routed —
511
+ // they are reachable only through `ctx.actions` and exposing them would
512
+ // silently bypass the `backendAccess` permission gate.
513
+ for (const action of registry.actions) {
514
+ if (action.exposure?.type !== 'api')
515
+ continue;
516
+ const fn = actionLambdaById.get(action.id);
517
+ const routeAuth = action.exposure.auth === 'required' ? jwtAuth : undefined;
518
+ addRouteToApi(this, fn, `/${domainId}${action.exposure.path}`, [toHttpMethod(action.exposure.method)], this.httpApi.httpApiId, routeAuth);
519
+ }
439
520
  }
440
521
  // Grant all domain Lambdas read/write access to the per-domain table and bucket
441
522
  const allDomainLambdas = [
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,159 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as cdk from 'aws-cdk-lib';
3
+ import { Template } from 'aws-cdk-lib/assertions';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { ActionConstruct } from '../constructs/action-construct.js';
7
+ import { LambdaFactory } from '../lambda-factory.js';
8
+ import { IamPolicyBuilder } from '../iam/iam-policy-builder.js';
9
+ const DOMAIN_ROOT = path.join(process.cwd(), '.test-action-construct');
10
+ const baseRegistry = {
11
+ schemaVersion: '1',
12
+ domainRoot: DOMAIN_ROOT,
13
+ domain: { id: 'test-domain', kind: 'domain', name: 'Test Domain', tenancy: 'none' },
14
+ apis: [], webhooks: [], subscribers: [], schedules: [], jobs: [],
15
+ actions: [], integrations: [], events: [],
16
+ };
17
+ beforeAll(() => {
18
+ const handlerDir = path.join(DOMAIN_ROOT, 'src', 'actions');
19
+ fs.mkdirSync(handlerDir, { recursive: true });
20
+ fs.writeFileSync(path.join(handlerDir, 'noop.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
21
+ fs.writeFileSync(path.join(handlerDir, 'workspace.ts'), 'export const handler = async () => ({ statusCode: 200 });\n');
22
+ });
23
+ afterAll(() => {
24
+ fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
25
+ });
26
+ /**
27
+ * Wave 4 Task 4.2 (#4619):
28
+ * Action Lambdas must never be exposed directly through a Lambda Function URL.
29
+ * External reachability is granted ONLY via API Gateway routes synthesized from
30
+ * the action's `exposure` field. Function URLs with `authType: NONE` would
31
+ * create public, unauthenticated endpoints that bypass auth and tenancy.
32
+ */
33
+ describe('ActionConstruct (Wave 4 Task 4.2 — no public Function URLs)', () => {
34
+ const buildStack = (actions) => {
35
+ const app = new cdk.App();
36
+ const stack = new cdk.Stack(app, 'TestActionStack');
37
+ const registry = { ...baseRegistry, actions };
38
+ const factory = new LambdaFactory({ scope: stack, registry, domainRoot: registry.domainRoot });
39
+ const iamBuilder = new IamPolicyBuilder();
40
+ const construct = new ActionConstruct(stack, 'Actions', { registry, lambdaFactory: factory, iamBuilder });
41
+ return { stack, template: Template.fromStack(stack), construct };
42
+ };
43
+ it('synthesises one Lambda per action', () => {
44
+ const { template } = buildStack([
45
+ { id: 'noop', kind: 'action', handlerFile: 'src/actions/noop.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
46
+ ]);
47
+ const lambdas = Object.entries(template.findResources('AWS::Lambda::Function'))
48
+ .filter(([id]) => !id.startsWith('LogRetention'));
49
+ expect(lambdas).toHaveLength(1);
50
+ expect(lambdas[0][0]).toContain('NoopFn');
51
+ });
52
+ it('does NOT create a Lambda::Url resource for any action', () => {
53
+ const { template } = buildStack([
54
+ { id: 'noop', kind: 'action', handlerFile: 'src/actions/noop.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
55
+ ]);
56
+ template.resourceCountIs('AWS::Lambda::Url', 0);
57
+ });
58
+ it('does NOT create a Lambda::Url even when legacy visibility === "workspace"', () => {
59
+ // Legacy entries with the deprecated `visibility: 'workspace'` must NOT
60
+ // resurrect Function URL exposure now that the action-first migration is in place.
61
+ const { template } = buildStack([
62
+ {
63
+ id: 'workspace-action',
64
+ kind: 'action',
65
+ handlerFile: 'src/actions/workspace.ts',
66
+ backendAccess: 'domain',
67
+ exposure: { type: 'internal' },
68
+ idempotent: false,
69
+ visibility: 'workspace',
70
+ },
71
+ ]);
72
+ template.resourceCountIs('AWS::Lambda::Url', 0);
73
+ const lambdas = Object.entries(template.findResources('AWS::Lambda::Function'))
74
+ .filter(([id]) => !id.startsWith('LogRetention'));
75
+ expect(lambdas).toHaveLength(1);
76
+ expect(lambdas[0][0]).toContain('WorkspaceActionFn');
77
+ });
78
+ it('does NOT create a Lambda::Url when an action exposes via API Gateway (exposure.type === "api")', () => {
79
+ // API-exposed actions are routed by DomainStack via API Gateway, not by Function URL.
80
+ const { template } = buildStack([
81
+ {
82
+ id: 'api-action',
83
+ kind: 'action',
84
+ handlerFile: 'src/actions/noop.ts',
85
+ backendAccess: 'domain',
86
+ exposure: { type: 'api', path: '/v1/example', method: 'POST', auth: 'required', tenancy: 'required' },
87
+ idempotent: false,
88
+ },
89
+ ]);
90
+ template.resourceCountIs('AWS::Lambda::Url', 0);
91
+ });
92
+ it('does NOT attach any Lambda::Permission that allows public (apigateway) principal invocation', () => {
93
+ // Defensive: even without a Function URL, the construct must not add
94
+ // resource-based Lambda permissions that would let API Gateway invoke
95
+ // the function without going through the synthesized routes in DomainStack.
96
+ const { template, construct } = buildStack([
97
+ { id: 'noop', kind: 'action', handlerFile: 'src/actions/noop.ts', backendAccess: 'domain', exposure: { type: 'internal' }, idempotent: false },
98
+ ]);
99
+ expect(construct.functions.size).toBe(1);
100
+ template.resourceCountIs('AWS::Lambda::Url', 0);
101
+ const permissions = Object.values(template.findResources('AWS::Lambda::Permission'));
102
+ // No Function URL permission allowed
103
+ const hasUrlPermission = permissions.some(p => p.Properties.FunctionUrlAuthType !== undefined);
104
+ expect(hasUrlPermission).toBe(false);
105
+ });
106
+ });
107
+ /**
108
+ * Tenant-scoped storage IAM (defence-in-depth, Wave 4 Task 4.2).
109
+ *
110
+ * Verifies the IamPolicyBuilder.forTenantScopedStorage output uses
111
+ * PrincipalTag conditions to restrict DynamoDB LeadingKeys and S3 object
112
+ * prefixes to the calling session's tenantId.
113
+ */
114
+ describe('IamPolicyBuilder.forTenantScopedStorage (defence-in-depth)', () => {
115
+ const params = {
116
+ tableArn: 'arn:aws:dynamodb:eu-north-1:123456789012:table/test-domain',
117
+ bucketArn: 'arn:aws:s3:::test-domain',
118
+ };
119
+ it('restricts DynamoDB LeadingKeys to ${aws:PrincipalTag/tenantId}', () => {
120
+ const builder = new IamPolicyBuilder();
121
+ const statements = builder.forTenantScopedStorage(params);
122
+ const ddb = statements.find(s => (s.actions ?? []).some(a => a.startsWith('dynamodb:')));
123
+ expect(ddb).toBeDefined();
124
+ expect(ddb.resources).toContain(params.tableArn);
125
+ const leadingKeys = ddb.conditions?.['ForAllValues:StringEquals']?.['dynamodb:LeadingKeys'];
126
+ expect(leadingKeys).toEqual(['${aws:PrincipalTag/tenantId}']);
127
+ });
128
+ it('restricts S3 object operations to ${aws:PrincipalTag/tenantId}/* prefix', () => {
129
+ const builder = new IamPolicyBuilder();
130
+ const statements = builder.forTenantScopedStorage(params);
131
+ const s3Objects = statements.find(s => (s.actions ?? []).some(a => a === 's3:GetObject' || a === 's3:PutObject' || a === 's3:DeleteObject'));
132
+ expect(s3Objects).toBeDefined();
133
+ expect(s3Objects.resources).toEqual([
134
+ `${params.bucketArn}/\${aws:PrincipalTag/tenantId}/*`,
135
+ ]);
136
+ });
137
+ it('restricts S3:ListBucket to the current tenant prefix via s3:prefix condition', () => {
138
+ const builder = new IamPolicyBuilder();
139
+ const statements = builder.forTenantScopedStorage(params);
140
+ const listBucket = statements.find(s => (s.actions ?? []).includes('s3:ListBucket'));
141
+ expect(listBucket).toBeDefined();
142
+ expect(listBucket.resources).toEqual([params.bucketArn]);
143
+ const prefixCondition = listBucket.conditions
144
+ ?.StringLike?.['s3:prefix'];
145
+ expect(prefixCondition).toEqual(['${aws:PrincipalTag/tenantId}/*']);
146
+ });
147
+ it('does NOT grant bucket-wide S3 access (no unconditional ${bucketArn} resource for object ops)', () => {
148
+ const builder = new IamPolicyBuilder();
149
+ const statements = builder.forTenantScopedStorage(params);
150
+ for (const s of statements) {
151
+ const acts = (s.actions ?? []);
152
+ const isObjectOp = acts.some(a => a === 's3:GetObject' || a === 's3:PutObject' || a === 's3:DeleteObject');
153
+ if (isObjectOp) {
154
+ // The only resource granted must be the tenant-scoped prefix, not the bare bucket.
155
+ expect(s.resources).not.toContain(params.bucketArn);
156
+ }
157
+ }
158
+ });
159
+ });
@@ -5,6 +5,19 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { DomainStack } from '../DomainStack.js';
7
7
  const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer');
8
+ // Wave 7 Task 7.3 (#4619): removed the runtime-export probe that used to
9
+ // gate the action-exposure block via `describe.skipIf(...)`. The probe
10
+ // returned `false` whenever the workspace-resolved `@mettlecast/domain-runtime`
11
+ // package lagged behind the integration branch, which silently masked
12
+ // regressions in the route/wrapper wiring that this block exists to catch.
13
+ //
14
+ // Coverage for the adapter-selection logic itself moved to
15
+ // `grouped-lambda-factory.test.ts` (pure, no CDK / esbuild, no dependency
16
+ // on the published runtime state). This file still exercises the
17
+ // end-to-end CDK wiring — route creation, JWT authorizer attachment, and
18
+ // the "no route for internal actions" guard — so the integration-level
19
+ // regressions the probe used to hide are now caught by the narrow unit
20
+ // tests instead.
8
21
  const minimalRegistry = {
9
22
  schemaVersion: '1',
10
23
  domainRoot: DOMAIN_ROOT,
@@ -29,7 +42,12 @@ const minimalRegistry = {
29
42
  };
30
43
  const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
31
44
  fs.mkdirSync(handlerDir, { recursive: true });
32
- fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const getUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n');
45
+ fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), 'export const getUsers = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
46
+ 'export const createUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
47
+ 'export const replaceUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
48
+ 'export const patchUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
49
+ 'export const deleteUser = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n' +
50
+ 'export const listThings = { versions: { v1: { handler: async () => ({ statusCode: 200 }) } } };\n');
33
51
  afterAll(() => {
34
52
  fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
35
53
  });
@@ -169,4 +187,219 @@ describe('DomainStack', () => {
169
187
  template.hasOutput('DomainTenantScopedRoleArn', {});
170
188
  });
171
189
  });
190
+ /**
191
+ * Wave 4 Task 4.1 — fixes the route key bug where every non-GET method was
192
+ * silently coerced to "POST" and the JWT authorizer was never attached
193
+ * because the HttpJwtAuthorizer was never bound to a route.
194
+ */
195
+ describe('route keys and JWT authorizer wiring (#4619)', () => {
196
+ const userPoolArn = 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz';
197
+ const userPoolClientId = 'test-client-id';
198
+ /**
199
+ * Build a registry whose API entries cover all HTTP method variants the
200
+ * route key bug previously broke. Each entry uses `authType: 'jwt'` so
201
+ * every method exercises the new JWT-wiring path through `addRouteToApi`.
202
+ */
203
+ const multiMethodRegistry = {
204
+ ...minimalRegistry,
205
+ apis: [
206
+ { ...minimalRegistry.apis[0], id: 'get-users', path: '/users', method: 'GET', authType: 'jwt' },
207
+ { ...minimalRegistry.apis[0], id: 'create-user', path: '/users', method: 'POST', authType: 'jwt' },
208
+ { ...minimalRegistry.apis[0], id: 'replace-user', path: '/users/{id}', method: 'PUT', authType: 'jwt' },
209
+ { ...minimalRegistry.apis[0], id: 'patch-user', path: '/users/{id}', method: 'PATCH', authType: 'jwt' },
210
+ { ...minimalRegistry.apis[0], id: 'delete-user', path: '/users/{id}', method: 'DELETE', authType: 'jwt' },
211
+ { ...minimalRegistry.apis[0], id: 'list-things', path: '/things', method: 'GET', authType: 'none' },
212
+ ],
213
+ };
214
+ it('uses the actual HTTP method for each route key (GET/POST/PUT/PATCH/DELETE)', () => {
215
+ const app = new cdk.App();
216
+ const stack = new DomainStack(app, 'TestDomainStackMethods', {
217
+ registry: multiMethodRegistry,
218
+ eventBusArn,
219
+ userPoolArn,
220
+ userPoolClientId,
221
+ });
222
+ const template = Template.fromStack(stack);
223
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
224
+ const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
225
+ // Every method must round-trip — the previous implementation coerced all
226
+ // non-GET methods to POST, so PUT/PATCH/DELETE were broken.
227
+ expect(routeKeys).toContain('GET /test-domain/users');
228
+ expect(routeKeys).toContain('POST /test-domain/users');
229
+ expect(routeKeys).toContain('PUT /test-domain/users/{id}');
230
+ expect(routeKeys).toContain('PATCH /test-domain/users/{id}');
231
+ expect(routeKeys).toContain('DELETE /test-domain/users/{id}');
232
+ });
233
+ it('sets AuthorizationType=JWT and AuthorizerId on jwt-auth routes', () => {
234
+ const app = new cdk.App();
235
+ const stack = new DomainStack(app, 'TestDomainStackRouteAuth', {
236
+ registry: multiMethodRegistry,
237
+ eventBusArn,
238
+ userPoolArn,
239
+ userPoolClientId,
240
+ });
241
+ const template = Template.fromStack(stack);
242
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
243
+ const jwtRoutes = Object.values(routes).filter(r => {
244
+ const key = r.Properties.RouteKey;
245
+ return key.endsWith('/users') || key.endsWith('/users/{id}');
246
+ });
247
+ // Every jwt-auth route must carry AuthorizationType and AuthorizerId.
248
+ expect(jwtRoutes.length).toBeGreaterThan(0);
249
+ for (const r of jwtRoutes) {
250
+ expect(r.Properties.AuthorizationType).toBe('JWT');
251
+ const authorizerId = r.Properties.AuthorizerId;
252
+ expect(authorizerId).toBeDefined();
253
+ }
254
+ });
255
+ it('does not attach authorizer to non-jwt routes (authType=none)', () => {
256
+ const app = new cdk.App();
257
+ const stack = new DomainStack(app, 'TestDomainStackUnauthRoute', {
258
+ registry: multiMethodRegistry,
259
+ eventBusArn,
260
+ userPoolArn,
261
+ userPoolClientId,
262
+ });
263
+ const template = Template.fromStack(stack);
264
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
265
+ const unauthRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'GET /test-domain/things');
266
+ expect(unauthRoute).toBeDefined();
267
+ expect(unauthRoute.Properties.AuthorizationType).toBeUndefined();
268
+ expect(unauthRoute.Properties.AuthorizerId).toBeUndefined();
269
+ });
270
+ it('emits a synth-time error annotation when JWT route is declared without Cognito config', () => {
271
+ const app = new cdk.App();
272
+ const stack = new DomainStack(app, 'TestDomainStackMissingPool', {
273
+ registry: multiMethodRegistry,
274
+ eventBusArn,
275
+ // No userPoolArn / userPoolId / userPoolClientId provided
276
+ });
277
+ // `Annotations.of(stack).errors` is not exposed; read the construct's
278
+ // metadata directly and filter for error-level entries.
279
+ const metadata = stack.node.metadata;
280
+ const errorMessages = metadata
281
+ .filter(m => m.type === 'aws:cdk:error')
282
+ .map(m => (typeof m.data === 'string' ? m.data : JSON.stringify(m.data)));
283
+ expect(errorMessages.some(a => a.includes('JWT-protected routes') && a.includes('Cognito'))).toBe(true);
284
+ });
285
+ it('does NOT emit the missing-pool error when authType=none', () => {
286
+ const app = new cdk.App();
287
+ const noJwtRegistry = {
288
+ ...minimalRegistry,
289
+ apis: [{ ...minimalRegistry.apis[0], authType: 'none' }],
290
+ };
291
+ const stack = new DomainStack(app, 'TestDomainStackNoJwtNeeded', {
292
+ registry: noJwtRegistry,
293
+ eventBusArn,
294
+ });
295
+ const metadata = stack.node.metadata;
296
+ const errorMessages = metadata
297
+ .filter(m => m.type === 'aws:cdk:error')
298
+ .map(m => (typeof m.data === 'string' ? m.data : JSON.stringify(m.data)));
299
+ expect(errorMessages.some(a => a.includes('JWT-protected routes'))).toBe(false);
300
+ });
301
+ });
302
+ /**
303
+ * Wave 4 Task 4.1 — generate API Gateway routes for actions whose
304
+ * `exposure.type === 'api'` and skip `exposure.type === 'internal'`.
305
+ *
306
+ * Wave 7 Task 7.3 (#4619) removed the runtime-export probe and the
307
+ * `describe.skipIf(...)` wrapper. The route-level assertions still need
308
+ * a full CDK synth so the L1 `CfnRoute` resources can be inspected, but
309
+ * adapter-selection coverage (the part most likely to regress when the
310
+ * runtime barrel changes) lives in `grouped-lambda-factory.test.ts`
311
+ * where it runs as a pure unit test in milliseconds. If the runtime
312
+ * barrel ever drops an export we expect to see this block fail with a
313
+ * bundling error rather than be silently skipped.
314
+ */
315
+ describe('action exposure routing (#4619)', () => {
316
+ const actionHandlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
317
+ fs.mkdirSync(actionHandlerDir, { recursive: true });
318
+ fs.writeFileSync(path.join(actionHandlerDir, 'list-invoices.ts'), 'export const listInvoices = { id: "list-invoices", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/invoices", method: "GET", auth: "required", tenancy: "required" }, input: { parse: (x: unknown) => x }, output: { parse: (x: unknown) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n');
319
+ fs.writeFileSync(path.join(actionHandlerDir, 'charge-card.ts'), 'export const chargeCard = { id: "charge-card", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/charge", method: "POST", auth: "required", tenancy: "required" }, input: { parse: (x: unknown) => x }, output: { parse: (x: unknown) => x }, idempotent: true, handler: async () => ({ ok: true }) };\n');
320
+ fs.writeFileSync(path.join(actionHandlerDir, 'internal-helper.ts'), 'export const internalHelper = { id: "internal-helper", backendAccess: "private", exposure: { type: "internal" }, input: { parse: (x: unknown) => x }, output: { parse: (x: unknown) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n');
321
+ const registryWithActions = {
322
+ ...minimalRegistry,
323
+ actions: [
324
+ {
325
+ id: 'list-invoices',
326
+ kind: 'action',
327
+ handlerFile: 'src/handlers/list-invoices.ts',
328
+ backendAccess: 'domain',
329
+ exposure: {
330
+ type: 'api',
331
+ path: '/v1/tenants/{tenantId}/invoices',
332
+ method: 'GET',
333
+ auth: 'required',
334
+ tenancy: 'required',
335
+ },
336
+ idempotent: false,
337
+ },
338
+ {
339
+ id: 'charge-card',
340
+ kind: 'action',
341
+ handlerFile: 'src/handlers/charge-card.ts',
342
+ backendAccess: 'domain',
343
+ exposure: {
344
+ type: 'api',
345
+ path: '/v1/tenants/{tenantId}/charge',
346
+ method: 'POST',
347
+ auth: 'required',
348
+ tenancy: 'required',
349
+ },
350
+ idempotent: true,
351
+ },
352
+ {
353
+ id: 'internal-helper',
354
+ kind: 'action',
355
+ handlerFile: 'src/handlers/internal-helper.ts',
356
+ backendAccess: 'private',
357
+ exposure: { type: 'internal' },
358
+ idempotent: false,
359
+ },
360
+ ],
361
+ };
362
+ it('synthesises routes for API-exposed actions', () => {
363
+ const app = new cdk.App();
364
+ const stack = new DomainStack(app, 'TestDomainStackActionRoutes', {
365
+ registry: registryWithActions,
366
+ eventBusArn,
367
+ userPoolArn: 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz',
368
+ userPoolClientId: 'test-client-id',
369
+ });
370
+ const template = Template.fromStack(stack);
371
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
372
+ const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
373
+ expect(routeKeys).toContain('GET /test-domain/v1/tenants/{tenantId}/invoices');
374
+ expect(routeKeys).toContain('POST /test-domain/v1/tenants/{tenantId}/charge');
375
+ });
376
+ it('does NOT create a route for internal exposure actions', () => {
377
+ const app = new cdk.App();
378
+ const stack = new DomainStack(app, 'TestDomainStackInternalOnly', {
379
+ registry: registryWithActions,
380
+ eventBusArn,
381
+ userPoolArn: 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz',
382
+ userPoolClientId: 'test-client-id',
383
+ });
384
+ const template = Template.fromStack(stack);
385
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
386
+ const routeKeys = Object.values(routes).map(r => r.Properties.RouteKey);
387
+ expect(routeKeys.some(k => k.includes('internal-helper'))).toBe(false);
388
+ });
389
+ it('attaches JWT authorizer to API-exposed action routes when auth=required', () => {
390
+ const app = new cdk.App();
391
+ const stack = new DomainStack(app, 'TestDomainStackActionJwt', {
392
+ registry: registryWithActions,
393
+ eventBusArn,
394
+ userPoolArn: 'arn:aws:cognito-idp:eu-north-1:123456789012:userpool/eu-north-1_abc123xyz',
395
+ userPoolClientId: 'test-client-id',
396
+ });
397
+ const template = Template.fromStack(stack);
398
+ const routes = template.findResources('AWS::ApiGatewayV2::Route');
399
+ const actionRoute = Object.values(routes).find(r => r.Properties.RouteKey === 'POST /test-domain/v1/tenants/{tenantId}/charge');
400
+ expect(actionRoute).toBeDefined();
401
+ expect(actionRoute.Properties.AuthorizationType).toBe('JWT');
402
+ expect(actionRoute.Properties.AuthorizerId).toBeDefined();
403
+ });
404
+ });
172
405
  });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Wave 7 Task 7.1 (#4619) — generated dedicated Lambda wrapper tests.
3
+ *
4
+ * The dedicated-mode bundler writes an entry module that wires a domain action
5
+ * export into a runtime adapter. For action adapters the wiring is non-trivial:
6
+ * `createActionLambdaHandler` takes `(registry, options)` and
7
+ * `createExposedActionApiHandler` takes `(action, options)`. These assertions
8
+ * use the pure entry-content helper so they do not race with other tests that
9
+ * also write `.tib-domain-entries` artifacts.
10
+ */
11
+ export {};