@emarketeer/ts-microservice-commons 8.2.2 → 9.0.0

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/build-handlers.js +149 -48
  2. package/dist/cdk/cjs/index.js +760 -557
  3. package/dist/cdk/cjs/index.js.map +1 -1
  4. package/dist/cdk/index.js +739 -536
  5. package/dist/cdk/index.js.map +1 -1
  6. package/dist/lib/em-commons.js +44 -43
  7. package/dist/lib/jest.config.js +2 -2
  8. package/dist/types/cdk/__tests__/config.test.d.ts +1 -0
  9. package/dist/types/cdk/__tests__/dlq-alarm.test.d.ts +1 -0
  10. package/dist/types/cdk/__tests__/handler-path.test.d.ts +1 -0
  11. package/dist/types/cdk/__tests__/logs.test.d.ts +1 -0
  12. package/dist/types/cdk/__tests__/rds-vpc.test.d.ts +1 -0
  13. package/dist/types/cdk/constructs/dlq-alarm.d.ts +6 -0
  14. package/dist/types/cdk/constructs/lambda-with-http-api.d.ts +2 -2
  15. package/dist/types/cdk/constructs/lambda-with-queue.d.ts +28 -3
  16. package/dist/types/cdk/constructs/stack.d.ts +142 -33
  17. package/dist/types/cdk/constructs/topic-queue-consumer.d.ts +5 -0
  18. package/dist/types/cdk/types/common.d.ts +18 -26
  19. package/dist/types/cdk/utils/config.d.ts +11 -62
  20. package/dist/types/cdk/utils/handler-path.d.ts +26 -4
  21. package/dist/types/cdk/utils/iam.d.ts +1 -64
  22. package/dist/types/cdk/utils/logs.d.ts +11 -9
  23. package/dist/types/cdk/utils/rds-vpc.d.ts +8 -1
  24. package/dist/types/cdk/utils/serverless-migration.d.ts +187 -3
  25. package/dist/types/find-entry-points.d.ts +20 -0
  26. package/dist/types/find-entry-points.test.d.ts +1 -0
  27. package/dist/types/utils.d.ts +1 -1
  28. package/package.json +9 -3
  29. package/dist/types/cdk/jest.config.d.ts +0 -2
  30. package/dist/types/esbuild-plugins.d.ts +0 -5
@@ -4,10 +4,11 @@ Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
5
  var cdk = require('aws-cdk-lib');
6
6
  var awsLambda = require('aws-cdk-lib/aws-lambda');
7
+ var awsIam = require('aws-cdk-lib/aws-iam');
7
8
  var awsLogs = require('aws-cdk-lib/aws-logs');
8
9
  var constructs = require('constructs');
9
- var awsIam = require('aws-cdk-lib/aws-iam');
10
10
  var awsSsm = require('aws-cdk-lib/aws-ssm');
11
+ var path = require('path');
11
12
  var awsDynamodb = require('aws-cdk-lib/aws-dynamodb');
12
13
  var awsApigateway = require('aws-cdk-lib/aws-apigateway');
13
14
  var awsApigatewayv2 = require('aws-cdk-lib/aws-apigatewayv2');
@@ -21,11 +22,32 @@ var awsEvents = require('aws-cdk-lib/aws-events');
21
22
  var awsEventsTargets = require('aws-cdk-lib/aws-events-targets');
22
23
  var awsLambdaEventSources = require('aws-cdk-lib/aws-lambda-event-sources');
23
24
  var awsCloudwatch = require('aws-cdk-lib/aws-cloudwatch');
24
- var path = require('path');
25
25
  var awsCloudwatchActions = require('aws-cdk-lib/aws-cloudwatch-actions');
26
26
  var ec2 = require('aws-cdk-lib/aws-ec2');
27
27
  var cxApi = require('aws-cdk-lib/cx-api');
28
28
 
29
+ function _interopNamespace(e) {
30
+ if (e && e.__esModule) return e;
31
+ var n = Object.create(null);
32
+ if (e) {
33
+ Object.keys(e).forEach(function (k) {
34
+ if (k !== 'default') {
35
+ var d = Object.getOwnPropertyDescriptor(e, k);
36
+ Object.defineProperty(n, k, d.get ? d : {
37
+ enumerable: true,
38
+ get: function () { return e[k]; }
39
+ });
40
+ }
41
+ });
42
+ }
43
+ n["default"] = e;
44
+ return Object.freeze(n);
45
+ }
46
+
47
+ var cdk__namespace = /*#__PURE__*/_interopNamespace(cdk);
48
+ var path__namespace = /*#__PURE__*/_interopNamespace(path);
49
+ var ec2__namespace = /*#__PURE__*/_interopNamespace(ec2);
50
+
29
51
  /**
30
52
  * Stack naming conventions and utilities
31
53
  */
@@ -273,39 +295,40 @@ const getLogRetentionDays = (stage) => {
273
295
  case 'dev':
274
296
  return awsLogs.RetentionDays.THREE_DAYS;
275
297
  default:
276
- return awsLogs.RetentionDays.ONE_WEEK;
298
+ // Stage is a compile-time union. A runtime value outside it means a
299
+ // caller widened the type — fail loud rather than silently returning a
300
+ // dev-shaped default for what may be a production deployment.
301
+ throw new Error(`getLogRetentionDays: unknown stage "${stage}"`);
277
302
  }
278
303
  };
279
304
  /**
280
- * Convert retention days number to RetentionDays enum
305
+ * Convert retention days number to RetentionDays enum.
306
+ *
307
+ * Validates `days` against `RetentionDays`'s TS enum reverse-mapping (every
308
+ * numeric enum member exposes its name as a string-keyed property — e.g.
309
+ * `RetentionDays[1] === 'ONE_DAY'`). This catches typos without
310
+ * hand-maintaining a switch in parallel with the SDK enum.
311
+ *
312
+ * `0` is rejected even though `RetentionDays.INFINITE === 0`: passing 0 from
313
+ * a config object almost always means "unset" rather than "retain forever".
314
+ * Callers that genuinely want INFINITE must pass `RetentionDays.INFINITE`
315
+ * explicitly via a non-numeric path (or update this guard with a clear test).
281
316
  */
282
317
  const convertRetentionDays = (days) => {
283
- if (!days)
318
+ if (days === undefined || days === null)
284
319
  return undefined;
285
- const retentionMap = {
286
- 1: awsLogs.RetentionDays.ONE_DAY,
287
- 3: awsLogs.RetentionDays.THREE_DAYS,
288
- 5: awsLogs.RetentionDays.FIVE_DAYS,
289
- 7: awsLogs.RetentionDays.ONE_WEEK,
290
- 14: awsLogs.RetentionDays.TWO_WEEKS,
291
- 30: awsLogs.RetentionDays.ONE_MONTH,
292
- 60: awsLogs.RetentionDays.TWO_MONTHS,
293
- 90: awsLogs.RetentionDays.THREE_MONTHS,
294
- 120: awsLogs.RetentionDays.FOUR_MONTHS,
295
- 150: awsLogs.RetentionDays.FIVE_MONTHS,
296
- 180: awsLogs.RetentionDays.SIX_MONTHS,
297
- 365: awsLogs.RetentionDays.ONE_YEAR,
298
- 400: awsLogs.RetentionDays.THIRTEEN_MONTHS,
299
- 545: awsLogs.RetentionDays.EIGHTEEN_MONTHS,
300
- 731: awsLogs.RetentionDays.TWO_YEARS,
301
- 1827: awsLogs.RetentionDays.FIVE_YEARS,
302
- 3653: awsLogs.RetentionDays.TEN_YEARS
303
- };
304
- const result = retentionMap[days];
305
- if (!result) {
306
- throw new Error(`Unsupported logRetentionDays value: ${days}. Supported values: ${Object.keys(retentionMap).join(', ')}`);
320
+ if (days === 0) {
321
+ throw new Error('logRetentionDays: 0 is not accepted (would map to RetentionDays.INFINITE). '
322
+ + 'Pass RetentionDays.INFINITE explicitly if infinite retention is intended.');
323
+ }
324
+ if (typeof awsLogs.RetentionDays[days] !== 'string') {
325
+ const supported = Object.values(awsLogs.RetentionDays)
326
+ .filter((v) => typeof v === 'number')
327
+ .sort((a, b) => a - b)
328
+ .join(', ');
329
+ throw new Error(`Unsupported logRetentionDays value: ${days}. Supported values: ${supported}`);
307
330
  }
308
- return result;
331
+ return days;
309
332
  };
310
333
  /**
311
334
  * Create a CloudWatch log group with standard configuration
@@ -320,17 +343,6 @@ const createLogGroup = (scope, id, config) => {
320
343
  removalPolicy: config.stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY
321
344
  });
322
345
  };
323
- /**
324
- * Create a Lambda function log group
325
- */
326
- const createLambdaLogGroup = (scope, id, stage, serviceName, functionName, retentionDays) => {
327
- const logGroupName = `/aws/lambda/${generateLogGroupName(stage, serviceName, functionName)}`;
328
- return createLogGroup(scope, id, {
329
- logGroupName,
330
- stage,
331
- retentionDays
332
- });
333
- };
334
346
  /**
335
347
  * Create an API Gateway log group
336
348
  */
@@ -346,18 +358,20 @@ const createApiGatewayLogGroup = (scope, id, stage, serviceName, apiName, retent
346
358
  * Get removal policy based on stage
347
359
  */
348
360
  const getRemovalPolicy = (stage) => {
349
- return stage === 'prod' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY;
350
- };
351
- /**
352
- * Should enable log insights based on stage
353
- */
354
- const shouldEnableLogInsights = (stage) => {
355
- return stage === 'prod' || stage === 'staging';
361
+ switch (stage) {
362
+ case 'prod':
363
+ return cdk.RemovalPolicy.RETAIN;
364
+ case 'staging':
365
+ case 'test':
366
+ case 'dev':
367
+ return cdk.RemovalPolicy.DESTROY;
368
+ default:
369
+ // Same rationale as getLogRetentionDays: a typo like 'production' must
370
+ // not silently produce DESTROY for what should be a retained resource.
371
+ throw new Error(`getRemovalPolicy: unknown stage "${stage}"`);
372
+ }
356
373
  };
357
374
 
358
- /**
359
- * IAM role and policy generation helpers
360
- */
361
375
  /**
362
376
  * Create a Lambda execution role with standard permissions
363
377
  */
@@ -366,115 +380,14 @@ const createLambdaExecutionRole = (scope, id, config) => {
366
380
  const role = new awsIam.Role(scope, id, {
367
381
  roleName,
368
382
  assumedBy: new awsIam.ServicePrincipal('lambda.amazonaws.com'),
369
- description: `Lambda execution role for ${config.serviceName}`
383
+ description: `Lambda execution role for ${config.serviceName}`,
384
+ ...(config.inlinePolicies && { inlinePolicies: config.inlinePolicies })
370
385
  });
371
- // Add basic Lambda execution permissions
372
386
  role.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'));
373
- // Add VPC execution permissions if needed
374
- if (config.managedPolicies?.includes('AWSLambdaVPCAccessExecutionRole')) {
375
- role.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
376
- }
377
- return role;
378
- };
379
- /**
380
- * Create a DynamoDB access policy statement
381
- */
382
- const createDynamoDBAccessPolicy = (tableArn, actions = [
383
- 'dynamodb:GetItem',
384
- 'dynamodb:PutItem',
385
- 'dynamodb:UpdateItem',
386
- 'dynamodb:DeleteItem',
387
- 'dynamodb:Query',
388
- 'dynamodb:Scan'
389
- ]) => {
390
- return new awsIam.PolicyStatement({
391
- effect: awsIam.Effect.ALLOW,
392
- actions,
393
- resources: [tableArn, `${tableArn}/index/*`]
394
- });
395
- };
396
- /**
397
- * Create a read-only DynamoDB access policy statement
398
- */
399
- const createDynamoDBReadPolicy = (tableArn) => {
400
- return createDynamoDBAccessPolicy(tableArn, [
401
- 'dynamodb:GetItem',
402
- 'dynamodb:Query',
403
- 'dynamodb:Scan',
404
- 'dynamodb:BatchGetItem'
405
- ]);
406
- };
407
- /**
408
- * Create a write-only DynamoDB access policy statement
409
- */
410
- const createDynamoDBWritePolicy = (tableArn) => {
411
- return createDynamoDBAccessPolicy(tableArn, [
412
- 'dynamodb:PutItem',
413
- 'dynamodb:UpdateItem',
414
- 'dynamodb:DeleteItem',
415
- 'dynamodb:BatchWriteItem'
416
- ]);
417
- };
418
- /**
419
- * Create an SQS access policy statement
420
- */
421
- const createSQSAccessPolicy = (queueArn, actions = [
422
- 'sqs:SendMessage',
423
- 'sqs:ReceiveMessage',
424
- 'sqs:DeleteMessage',
425
- 'sqs:GetQueueAttributes'
426
- ]) => {
427
- return new awsIam.PolicyStatement({
428
- effect: awsIam.Effect.ALLOW,
429
- actions,
430
- resources: [queueArn]
431
- });
432
- };
433
- /**
434
- * Create an SNS publish policy statement
435
- */
436
- const createSNSPublishPolicy = (topicArn) => {
437
- return new awsIam.PolicyStatement({
438
- effect: awsIam.Effect.ALLOW,
439
- actions: ['sns:Publish'],
440
- resources: [topicArn]
441
- });
442
- };
443
- /**
444
- * Create an S3 access policy statement
445
- */
446
- const createS3AccessPolicy = (bucketArn, actions = ['s3:GetObject', 's3:PutObject', 's3:DeleteObject']) => {
447
- return new awsIam.PolicyStatement({
448
- effect: awsIam.Effect.ALLOW,
449
- actions,
450
- resources: [bucketArn, `${bucketArn}/*`]
451
- });
452
- };
453
- /**
454
- * Create an S3 read-only policy statement
455
- */
456
- const createS3ReadPolicy = (bucketArn) => {
457
- return createS3AccessPolicy(bucketArn, ['s3:GetObject', 's3:ListBucket']);
458
- };
459
- /**
460
- * Create a Secrets Manager access policy statement
461
- */
462
- const createSecretsManagerPolicy = (secretArn) => {
463
- return new awsIam.PolicyStatement({
464
- effect: awsIam.Effect.ALLOW,
465
- actions: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'],
466
- resources: [secretArn]
467
- });
468
- };
469
- /**
470
- * Create a Systems Manager Parameter Store access policy statement
471
- */
472
- const createSSMParameterPolicy = (parameterArn) => {
473
- return new awsIam.PolicyStatement({
474
- effect: awsIam.Effect.ALLOW,
475
- actions: ['ssm:GetParameter', 'ssm:GetParameters', 'ssm:GetParametersByPath'],
476
- resources: [parameterArn]
387
+ config.managedPolicies?.forEach(policy => {
388
+ role.addManagedPolicy(policy);
477
389
  });
390
+ return role;
478
391
  };
479
392
  /**
480
393
  * Create an X-Ray tracing policy statement
@@ -486,230 +399,40 @@ const createXRayTracingPolicy = () => {
486
399
  resources: ['*']
487
400
  });
488
401
  };
489
- /**
490
- * Create an EventBridge put events policy statement
491
- */
492
- const createEventBridgePutEventsPolicy = (eventBusArn) => {
493
- return new awsIam.PolicyStatement({
494
- effect: awsIam.Effect.ALLOW,
495
- actions: ['events:PutEvents'],
496
- resources: [eventBusArn]
497
- });
498
- };
499
- /**
500
- * Create a CloudWatch Logs policy statement
501
- */
502
- const createCloudWatchLogsPolicy = (logGroupArn) => {
503
- return new awsIam.PolicyStatement({
504
- effect: awsIam.Effect.ALLOW,
505
- actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
506
- resources: [logGroupArn]
507
- });
508
- };
509
- /**
510
- * Create a KMS decrypt policy statement
511
- */
512
- const createKMSDecryptPolicy = (keyArn) => {
513
- return new awsIam.PolicyStatement({
514
- effect: awsIam.Effect.ALLOW,
515
- actions: ['kms:Decrypt', 'kms:DescribeKey'],
516
- resources: [keyArn]
517
- });
518
- };
519
- /**
520
- * Create a Lambda invoke policy statement
521
- */
522
- const createLambdaInvokePolicy = (functionArn) => {
523
- return new awsIam.PolicyStatement({
524
- effect: awsIam.Effect.ALLOW,
525
- actions: ['lambda:InvokeFunction'],
526
- resources: [functionArn]
527
- });
528
- };
529
- /**
530
- * Create a Step Functions start execution policy statement
531
- */
532
- const createStepFunctionsExecutionPolicy = (stateMachineArn) => {
533
- return new awsIam.PolicyStatement({
534
- effect: awsIam.Effect.ALLOW,
535
- actions: ['states:StartExecution', 'states:DescribeExecution', 'states:StopExecution'],
536
- resources: [stateMachineArn]
537
- });
538
- };
539
- /**
540
- * Combine multiple policy statements into a policy document
541
- */
542
- const createPolicyDocument = (statements) => {
543
- return new awsIam.PolicyDocument({
544
- statements
545
- });
546
- };
547
402
 
548
- /**
549
- * Environment-specific configuration management utilities
550
- */
551
403
  const RECAP_DEV_SSM_KEY = 'recap-dev-sync-endpoint';
552
404
  const recapDevEndpointCache = new WeakMap();
405
+ const RECAP_DEV_TIMEOUT_WINDOW_SECONDS = 300;
553
406
  /**
554
- * Default configuration values by stage
555
- */
556
- const STAGE_DEFAULTS = {
557
- dev: {
558
- region: 'eu-west-1',
559
- tags: {
560
- Environment: 'development'
561
- }
562
- },
563
- test: {
564
- region: 'eu-west-1',
565
- tags: {
566
- Environment: 'test'
567
- }
568
- },
569
- staging: {
570
- region: 'eu-west-1',
571
- tags: {
572
- Environment: 'staging'
573
- }
574
- },
575
- prod: {
576
- region: 'eu-west-1',
577
- tags: {
578
- Environment: 'production'
579
- }
580
- }
581
- };
582
- /**
583
- * Get environment configuration with defaults
584
- */
585
- const getEnvironmentConfig = (stage, overrides) => {
586
- const defaults = STAGE_DEFAULTS[stage];
587
- return {
588
- stage,
589
- region: overrides?.region || defaults.region || 'eu-west-1',
590
- account: overrides?.account || process.env.CDK_DEFAULT_ACCOUNT,
591
- tags: {
592
- ...defaults.tags,
593
- ...overrides?.tags
594
- }
595
- };
596
- };
597
- /**
598
- * Get environment variable with stage prefix
599
- */
600
- const getStageEnvVar = (stage, key, defaultValue) => {
601
- const stageKey = `${stage.toUpperCase()}_${key}`;
602
- return process.env[stageKey] || process.env[key] || defaultValue;
603
- };
604
- /**
605
- * Get required environment variable
606
- */
607
- const getRequiredEnvVar = (key) => {
608
- const value = process.env[key];
609
- if (!value) {
610
- throw new Error(`Required environment variable ${key} is not set`);
611
- }
612
- return value;
613
- };
614
- /**
615
- * Get environment variables for a Lambda function
616
- */
617
- const getLambdaEnvironmentVariables = (stage, additionalVars) => {
618
- return {
619
- STAGE: stage,
620
- NODE_ENV: stage === 'prod' ? 'production' : 'development',
621
- REGION: process.env.AWS_REGION || 'eu-west-1',
622
- ...additionalVars
623
- };
624
- };
625
- /**
626
- * Check if running in production stage
627
- */
628
- const isProduction = (stage) => {
629
- return stage === 'prod';
630
- };
631
- /**
632
- * Check if running in development stage
633
- */
634
- const isDevelopment = (stage) => {
635
- return stage === 'dev';
636
- };
637
- /**
638
- * Get stage from environment or default
407
+ * Returns the standard base environment variables injected into every Lambda.
408
+ * Centralised here so both EmLambdaFunction and LambdaWithQueue stay in sync.
639
409
  */
640
- const getStageFromEnv = (defaultStage = 'dev') => {
641
- const stage = process.env.STAGE || process.env.CDK_STAGE || defaultStage;
642
- if (!['dev', 'test', 'staging', 'prod'].includes(stage)) {
643
- throw new Error(`Invalid stage: ${stage}. Must be one of: dev, test, staging, prod`);
644
- }
645
- return stage;
646
- };
647
- /**
648
- * Get stage-specific resource limits
649
- */
650
- const getResourceLimits = (stage) => {
651
- switch (stage) {
652
- case 'prod':
653
- return {
654
- lambdaMemory: 1024,
655
- lambdaTimeout: 30,
656
- apiThrottleRate: 10000,
657
- apiThrottleBurst: 5000,
658
- dynamoDbReadCapacity: 5,
659
- dynamoDbWriteCapacity: 5,
660
- logRetentionDays: 30
661
- };
662
- case 'staging':
663
- return {
664
- lambdaMemory: 1024,
665
- lambdaTimeout: 30,
666
- apiThrottleRate: 5000,
667
- apiThrottleBurst: 2500,
668
- dynamoDbReadCapacity: 3,
669
- dynamoDbWriteCapacity: 3,
670
- logRetentionDays: 14
671
- };
672
- case 'test':
673
- return {
674
- lambdaMemory: 512,
675
- lambdaTimeout: 15,
676
- apiThrottleRate: 1000,
677
- apiThrottleBurst: 500,
678
- dynamoDbReadCapacity: 1,
679
- dynamoDbWriteCapacity: 1,
680
- logRetentionDays: 7
681
- };
682
- case 'dev':
683
- return {
684
- lambdaMemory: 512,
685
- lambdaTimeout: 15,
686
- apiThrottleRate: 100,
687
- apiThrottleBurst: 50,
688
- dynamoDbReadCapacity: 1,
689
- dynamoDbWriteCapacity: 1,
690
- logRetentionDays: 3
691
- };
692
- }
693
- };
694
- const RECAP_DEV_TIMEOUT_WINDOW_SECONDS = 300;
410
+ const buildBaseEnvironment = (stage, scope) => ({
411
+ STAGE: stage,
412
+ NODE_ENV: stage === 'prod' ? 'production' : 'development',
413
+ REGION: cdk.Stack.of(scope).region,
414
+ });
695
415
  /**
696
416
  * Returns the env var block to inject for recap.dev, or an empty object.
697
- * Handles CDK dummy values returned by valueFromLookup when the SSM key is absent.
417
+ *
418
+ * The `dummy-value-` early-return guards against CDK's SSM lookup placeholder:
419
+ * `valueFromLookup` returns `dummy-value-…` strings during the first synth
420
+ * before the cache file is populated, and we must not bake those into the
421
+ * Lambda's environment.
698
422
  */
699
423
  const buildRecapDevEnvironment = (endpoint) => {
700
424
  if (!endpoint || endpoint.startsWith('dummy-value-')) {
701
425
  return {};
702
426
  }
427
+ let parsed;
703
428
  try {
704
- const parsed = new URL(endpoint);
705
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
706
- throw new Error(`recap.dev endpoint must use http or https, got: ${parsed.protocol}`);
707
- }
429
+ parsed = new URL(endpoint);
430
+ }
431
+ catch {
432
+ throw new Error(`recap.dev endpoint is not a valid URL: "${endpoint}"`);
708
433
  }
709
- catch (err) {
710
- throw err instanceof Error
711
- ? err
712
- : new Error(`recap.dev endpoint is not a valid URL: ${endpoint}`);
434
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
435
+ throw new Error(`recap.dev endpoint must use http or https, got: ${parsed.protocol}`);
713
436
  }
714
437
  return {
715
438
  RECAP_DEV_SYNC_ENDPOINT: endpoint,
@@ -723,63 +446,90 @@ const buildRecapDevEnvironment = (endpoint) => {
723
446
  */
724
447
  const resolveRecapDevEndpoint = (scope) => {
725
448
  const stack = cdk.Stack.of(scope);
449
+ if (cdk.Token.isUnresolved(stack.account) || cdk.Token.isUnresolved(stack.region)) {
450
+ return undefined;
451
+ }
726
452
  if (!recapDevEndpointCache.has(stack)) {
727
453
  recapDevEndpointCache.set(stack, awsSsm.StringParameter.valueFromLookup(stack, RECAP_DEV_SSM_KEY));
728
454
  }
729
455
  return recapDevEndpointCache.get(stack);
730
456
  };
457
+
458
+ const DEFAULT_LAMBDA_RUNTIME = awsLambda.Runtime.NODEJS_24_X;
459
+
460
+ const DEFAULT_HANDLERS_DIR = 'src/handlers';
461
+ const DEFAULT_OUT_DIR = 'dist/handlers';
731
462
  /**
732
- * Get stage-specific tracing configuration
463
+ * Resolve `handlerPath` into `codePath`, `handler`, and optionally `functionName`.
464
+ *
465
+ * Given `handlerPath: 'src/handlers/capture-screenshot/capture-screenshot-from-url'`:
466
+ * - `codePath` → `'dist/handlers/capture-screenshot/capture-screenshot-from-url'`
467
+ * - `handler` → `'index.handler'`
468
+ * - `functionName` → `'capture-screenshot-from-url'` (only when not explicitly provided)
469
+ *
470
+ * When `handlerPath` is not provided, `functionName` is required.
471
+ * `handler` and `codePath` may remain undefined if the caller has its own defaults.
733
472
  */
734
- const getTracingConfig = (stage) => {
473
+ function resolveHandlerPath(config) {
474
+ const { handlerPath } = config;
475
+ if (handlerPath) {
476
+ const normalised = handlerPath.replace(/\.ts$/, '');
477
+ const startsWithHandlersDir = normalised.startsWith(DEFAULT_HANDLERS_DIR + '/');
478
+ const containsSeparator = normalised.includes('/') || normalised.includes(path__namespace.sep);
479
+ if (!startsWithHandlersDir && (path__namespace.isAbsolute(normalised) || containsSeparator)) {
480
+ // A bare basename like 'get-data' is fine (treated as relative to
481
+ // src/handlers). Anything with directory components must be rooted at
482
+ // DEFAULT_HANDLERS_DIR — otherwise we'd silently produce e.g.
483
+ // 'dist/handlers/src/lambdas/foo' and fail at synth with an opaque
484
+ // Code.fromAsset error far from the call site.
485
+ throw new Error(`resolveHandlerPath: handlerPath "${handlerPath}" must either be a bare basename or start with "${DEFAULT_HANDLERS_DIR}/".`);
486
+ }
487
+ const relative = startsWithHandlersDir
488
+ ? normalised.slice(DEFAULT_HANDLERS_DIR.length + 1)
489
+ : normalised;
490
+ return {
491
+ functionName: config.functionName ?? path__namespace.basename(relative),
492
+ handler: config.handler ?? 'index.handler',
493
+ codePath: config.codePath ?? path__namespace.join(DEFAULT_OUT_DIR, relative)
494
+ };
495
+ }
496
+ if (!config.functionName) {
497
+ throw new Error('Either `handlerPath` or `functionName` must be provided.');
498
+ }
735
499
  return {
736
- enableXRay: stage === 'prod' || stage === 'staging',
737
- enableLambdaInsights: stage === 'prod' || stage === 'staging',
738
- enableActiveTracing: stage === 'prod'
500
+ functionName: config.functionName,
501
+ handler: config.handler,
502
+ codePath: config.codePath
739
503
  };
740
- };
741
- /**
742
- * Get stage-specific alarm thresholds
743
- */
744
- const getAlarmThresholds = (stage) => {
745
- switch (stage) {
746
- case 'prod':
747
- return {
748
- errorRate: 1,
749
- throttleRate: 5,
750
- durationP99: 5000,
751
- concurrentExecutions: 900
752
- };
753
- case 'staging':
754
- return {
755
- errorRate: 5,
756
- throttleRate: 10,
757
- durationP99: 10000,
758
- concurrentExecutions: 500
759
- };
760
- default:
761
- return {
762
- errorRate: 10,
763
- throttleRate: 20,
764
- durationP99: 15000,
765
- concurrentExecutions: 100
766
- };
767
- }
768
- };
769
-
770
- const DEFAULT_LAMBDA_RUNTIME = awsLambda.Runtime.NODEJS_24_X;
504
+ }
771
505
 
772
506
  class EmLambdaFunction extends constructs.Construct {
773
507
  constructor(scope, id, config) {
774
508
  super(scope, id);
775
- const functionName = generateLambdaName(config.stage, config.serviceName, config.functionName);
509
+ const resolved = resolveHandlerPath(config);
510
+ const functionName = config.physicalName ??
511
+ generateLambdaName(config.stage, config.serviceName, resolved.functionName);
512
+ const extraPolicies = [];
513
+ if (config.vpcConfig) {
514
+ extraPolicies.push(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
515
+ }
516
+ if (config.enableTracing) {
517
+ extraPolicies.push(awsIam.ManagedPolicy.fromAwsManagedPolicyName('AWSXRayDaemonWriteAccess'));
518
+ }
776
519
  const role = config.role ??
777
520
  createLambdaExecutionRole(this, 'Role', {
778
- roleName: config.functionName,
521
+ roleName: resolved.functionName,
779
522
  stage: config.stage,
780
523
  serviceName: config.serviceName,
781
- managedPolicies: config.vpcConfig ? ['AWSLambdaVPCAccessExecutionRole'] : undefined
524
+ managedPolicies: extraPolicies.length ? extraPolicies : undefined
782
525
  });
526
+ // When the caller supplies a role (typically EmStack's sharedRole during
527
+ // Serverless migration) createLambdaExecutionRole is bypassed and the
528
+ // VPC/X-Ray policies above are not attached. Attach them here so
529
+ // vpcConfig/enableTracing produce a working role regardless of source.
530
+ if (config.role) {
531
+ extraPolicies.forEach(policy => role.addManagedPolicy(policy));
532
+ }
783
533
  const logGroup = config.importExistingLogGroup
784
534
  ? awsLogs.LogGroup.fromLogGroupName(this, `${id}LogGroup`, `/aws/lambda/${functionName}`)
785
535
  : new awsLogs.LogGroup(this, `${id}LogGroup`, {
@@ -787,15 +537,20 @@ class EmLambdaFunction extends constructs.Construct {
787
537
  retention: convertRetentionDays(config.logRetentionDays) ?? getLogRetentionDays(config.stage),
788
538
  removalPolicy: getRemovalPolicy(config.stage)
789
539
  });
540
+ const handler = resolved.handler ?? config.handler;
541
+ const codePath = resolved.codePath ?? config.codePath;
542
+ if (!handler || !codePath) {
543
+ throw new Error(`EmLambdaFunction requires either \`handlerPath\` or both \`handler\` and \`codePath\` for "${resolved.functionName}".`);
544
+ }
790
545
  this.function = new awsLambda.Function(this, 'Function', {
791
546
  functionName,
792
547
  runtime: config.runtime ?? DEFAULT_LAMBDA_RUNTIME,
793
- handler: config.handler,
794
- code: awsLambda.Code.fromAsset(config.codePath),
548
+ handler,
549
+ code: awsLambda.Code.fromAsset(codePath),
795
550
  memorySize: config.memorySize ?? 1024,
796
551
  timeout: config.timeout ?? cdk.Duration.seconds(15),
797
552
  environment: {
798
- ...getLambdaEnvironmentVariables(config.stage),
553
+ ...buildBaseEnvironment(config.stage, this),
799
554
  ...(config.environment ?? {}),
800
555
  ...buildRecapDevEnvironment(resolveRecapDevEndpoint(this))
801
556
  },
@@ -806,7 +561,7 @@ class EmLambdaFunction extends constructs.Construct {
806
561
  retryAttempts: config.retryAttempts,
807
562
  logGroup,
808
563
  layers: config.layers,
809
- description: `${config.serviceName} - ${config.functionName}`,
564
+ description: `${config.serviceName} - ${resolved.functionName}`,
810
565
  ...(config.vpcConfig && {
811
566
  vpc: config.vpcConfig.vpc,
812
567
  vpcSubnets: config.vpcConfig.vpcSubnets,
@@ -1700,40 +1455,26 @@ const EVENT_PATTERNS = {
1700
1455
  })
1701
1456
  };
1702
1457
 
1703
- const DEFAULT_HANDLERS_DIR = 'src/handlers';
1704
- const DEFAULT_OUT_DIR = 'dist/handlers';
1705
- /**
1706
- * Resolve `handlerPath` into `codePath`, `handler`, and optionally `functionName`.
1707
- *
1708
- * Given `handlerPath: 'src/handlers/capture-screenshot/capture-screenshot-from-url'`:
1709
- * - `codePath` → `'dist/handlers/capture-screenshot/capture-screenshot-from-url'`
1710
- * - `handler` → `'index.handler'`
1711
- * - `functionName` → `'capture-screenshot-from-url'` (only when not explicitly provided)
1712
- *
1713
- * When `handlerPath` is not provided, `functionName` is required.
1714
- * `handler` and `codePath` may remain undefined if the caller has its own defaults.
1715
- */
1716
- function resolveHandlerPath(config) {
1717
- const { handlerPath } = config;
1718
- if (handlerPath) {
1719
- const normalised = handlerPath.replace(/\.ts$/, '');
1720
- const relative = normalised.startsWith(DEFAULT_HANDLERS_DIR + '/')
1721
- ? normalised.slice(DEFAULT_HANDLERS_DIR.length + 1)
1722
- : normalised;
1723
- return {
1724
- functionName: config.functionName ?? path.basename(relative),
1725
- handler: config.handler ?? 'index.handler',
1726
- codePath: config.codePath ?? path.join(DEFAULT_OUT_DIR, relative)
1727
- };
1728
- }
1729
- if (!config.functionName) {
1730
- throw new Error('Either `handlerPath` or `functionName` must be provided.');
1458
+ class DlqAlarm extends constructs.Construct {
1459
+ constructor(scope, id, props) {
1460
+ super(scope, id);
1461
+ this.alarm = new awsCloudwatch.Alarm(this, 'Alarm', {
1462
+ alarmName: props.alarmName,
1463
+ metric: props.dlq.metricApproximateNumberOfMessagesVisible(),
1464
+ threshold: 0,
1465
+ comparisonOperator: awsCloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
1466
+ evaluationPeriods: 1,
1467
+ treatMissingData: awsCloudwatch.TreatMissingData.NOT_BREACHING
1468
+ });
1469
+ this.alarm.addAlarmAction(new awsCloudwatchActions.SnsAction(props.alarmTopic));
1470
+ if (props.alarmLogicalId) {
1471
+ const cfnAlarm = this.alarm.node.defaultChild;
1472
+ if (!(cfnAlarm instanceof awsCloudwatch.CfnAlarm)) {
1473
+ throw new Error(`Cannot override alarm logical ID "${props.alarmLogicalId}": defaultChild is not a CfnAlarm.`);
1474
+ }
1475
+ cfnAlarm.overrideLogicalId(props.alarmLogicalId);
1476
+ }
1731
1477
  }
1732
- return {
1733
- functionName: config.functionName,
1734
- handler: config.handler,
1735
- codePath: config.codePath
1736
- };
1737
1478
  }
1738
1479
 
1739
1480
  /**
@@ -1799,7 +1540,13 @@ const overrideLogGroupLogicalId = (logGroup, serverlessFunctionName) => {
1799
1540
  */
1800
1541
  const overrideFunctionLogicalIds = (fn, serverlessFunctionName) => {
1801
1542
  overrideLambdaLogicalId(fn, serverlessFunctionName);
1802
- overrideLogGroupLogicalId(fn.logGroup, serverlessFunctionName);
1543
+ const logGroup = fn.logGroup;
1544
+ if (!(logGroup instanceof constructs.Construct)) {
1545
+ throw new Error(`Cannot override log group logical ID for "${serverlessFunctionName}": ` +
1546
+ 'fn.logGroup is not a Construct. ' +
1547
+ 'Imported log groups (importExistingLogGroup: true) cannot have their logical IDs overridden.');
1548
+ }
1549
+ overrideLogGroupLogicalId(logGroup, serverlessFunctionName);
1803
1550
  };
1804
1551
  /**
1805
1552
  * Override a Lambda layer's logical ID.
@@ -1826,39 +1573,250 @@ const overrideLayerLogicalId = (layer, logicalId) => {
1826
1573
  *
1827
1574
  * Only works with roles created in this stack, not imported roles.
1828
1575
  */
1829
- const overrideRoleLogicalId = (role, logicalId = 'IamRoleLambdaExecution') => {
1576
+ const overrideRoleLogicalId = (role, logicalId = 'IamRoleLambdaExecution', options) => {
1830
1577
  const defaultChild = role.node.defaultChild;
1831
1578
  if (!(defaultChild instanceof awsIam.CfnRole)) {
1832
1579
  throw new Error('Cannot override role logical ID: the role does not have a CfnRole default child. ' +
1833
1580
  'Imported roles (e.g. via Role.fromRoleArn) cannot have their logical IDs overridden.');
1834
1581
  }
1835
1582
  defaultChild.overrideLogicalId(logicalId);
1583
+ if (options?.roleName) {
1584
+ defaultChild.addPropertyOverride('RoleName', options.roleName);
1585
+ }
1586
+ if (options?.deletePath) {
1587
+ defaultChild.addPropertyDeletionOverride('Path');
1588
+ }
1836
1589
  };
1837
1590
  /**
1838
1591
  * Create a CfnOutput with a Serverless Framework-compatible export name.
1839
1592
  * Export pattern: sls-{serviceName}-{stage}-{outputKey}
1840
1593
  */
1841
- const createServerlessCompatibleOutput = (scope, id, props) => {
1842
- return new cdk.CfnOutput(scope, id, {
1843
- value: props.value,
1844
- description: props.description,
1845
- exportName: `sls-${props.serviceName}-${props.stage}-${props.outputKey}`
1594
+ const createServerlessCompatibleOutput = (scope, id, props) => new cdk.CfnOutput(scope, id, {
1595
+ value: props.value,
1596
+ description: props.description,
1597
+ exportName: `sls-${props.serviceName}-${props.stage}-${props.outputKey}`
1598
+ });
1599
+ /**
1600
+ * Creates an SQS queue with a dead-letter queue using the names and logical IDs
1601
+ * from the existing Serverless stack.
1602
+ *
1603
+ * Use this during Serverless to CDK migrations when the queues already exist.
1604
+ * The queue and DLQ logical IDs must match the CloudFormation resources created
1605
+ * by Serverless, otherwise CloudFormation can replace the queues during deploy.
1606
+ *
1607
+ * Production queues are retained. Other stages use the shared removal policy
1608
+ * from `getRemovalPolicy()`. The DLQ message retention is set to 14 days.
1609
+ *
1610
+ * @example
1611
+ * ```typescript
1612
+ * const alarmTopic = (scope as EmStack).alarmTopic()
1613
+ * const { queue, dlq } = makeServerlessQueue(
1614
+ * scope, 'MqlEventsQueue', 'MqlEventsQueueDLQ',
1615
+ * `${svc}-mql-event-queue`, `${svc}-mql-event-queue-dlq`,
1616
+ * stage,
1617
+ * { alarm: { name: 'MqlEventsQueueDLQAlarm', topic: alarmTopic } },
1618
+ * )
1619
+ * ```
1620
+ */
1621
+ function makeServerlessQueue(scope, queueLogicalId, dlqLogicalId, queueName, dlqName, stage, opts = {}) {
1622
+ const removalPolicy = getRemovalPolicy(stage);
1623
+ const dlq = new awsSqs.Queue(scope, dlqLogicalId, {
1624
+ queueName: dlqName,
1625
+ retentionPeriod: cdk.Duration.days(14),
1626
+ removalPolicy
1846
1627
  });
1847
- };
1848
-
1849
- class DlqAlarm extends constructs.Construct {
1850
- constructor(scope, id, props) {
1851
- super(scope, id);
1852
- this.alarm = new awsCloudwatch.Alarm(this, 'Alarm', {
1853
- alarmName: props.alarmName,
1854
- metric: props.dlq.metricApproximateNumberOfMessagesVisible(),
1855
- threshold: 0,
1856
- comparisonOperator: awsCloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
1857
- evaluationPeriods: 1,
1858
- treatMissingData: awsCloudwatch.TreatMissingData.NOT_BREACHING
1628
+ const cfnDlq = dlq.node.defaultChild;
1629
+ if (!(cfnDlq instanceof awsSqs.CfnQueue)) {
1630
+ throw new Error(`Cannot override DLQ logical ID "${dlqLogicalId}": defaultChild is not a CfnQueue.`);
1631
+ }
1632
+ cfnDlq.overrideLogicalId(dlqLogicalId);
1633
+ const queue = new awsSqs.Queue(scope, queueLogicalId, {
1634
+ queueName,
1635
+ visibilityTimeout: opts.visibilityTimeout ?? cdk.Duration.seconds(900),
1636
+ deadLetterQueue: { queue: dlq, maxReceiveCount: opts.maxReceiveCount ?? 3 },
1637
+ removalPolicy
1638
+ });
1639
+ const cfnQueue = queue.node.defaultChild;
1640
+ if (!(cfnQueue instanceof awsSqs.CfnQueue)) {
1641
+ throw new Error(`Cannot override queue logical ID "${queueLogicalId}": defaultChild is not a CfnQueue.`);
1642
+ }
1643
+ cfnQueue.overrideLogicalId(queueLogicalId);
1644
+ if (opts.alarm) {
1645
+ new DlqAlarm(scope, `${queueLogicalId}DLQAlarm`, {
1646
+ dlq,
1647
+ alarmName: opts.alarm.name,
1648
+ alarmTopic: opts.alarm.topic,
1649
+ alarmLogicalId: opts.alarm.logicalId
1859
1650
  });
1860
- this.alarm.addAlarmAction(new awsCloudwatchActions.SnsAction(props.alarmTopic));
1861
1651
  }
1652
+ return { queue, dlq };
1653
+ }
1654
+ /**
1655
+ * Creates an SNS to SQS subscription with the same logical ID as the existing
1656
+ * Serverless resource.
1657
+ *
1658
+ * Use this when migrating an existing subscription from Serverless to CDK.
1659
+ * `topic.addSubscription(new SqsSubscription(queue))` lets CDK generate the
1660
+ * logical ID, which usually does not match the resource already in CloudFormation.
1661
+ * That can cause the subscription to be replaced during deployment.
1662
+ *
1663
+ * Both the construct ID and CloudFormation logical ID are set from `logicalId`.
1664
+ *
1665
+ * **Queue policy not included.** Unlike CDK's `SqsSubscription`, this helper only
1666
+ * creates the `AWS::SNS::Subscription`. It does NOT create an `AWS::SQS::QueuePolicy`
1667
+ * allowing SNS to send to the queue. On migrated stacks the policy was already created
1668
+ * by Serverless Framework — use `makeServerlessQueuePolicy` to preserve it with the
1669
+ * correct logical ID. On new queues call `makeServerlessQueuePolicy` (or use the CDK
1670
+ * `SqsSubscription` helper instead) to ensure SNS can deliver messages.
1671
+ *
1672
+ * @example
1673
+ * ```typescript
1674
+ * makeSnsToSqsSubscription(scope, 'TenantPurgeSubscription', {
1675
+ * topicArn: `arn:aws:sns:${region}:${accountId}:${stage}-emarketeer-event-purge-tenant-data`,
1676
+ * endpoint: queues.tenantPurgeQueue.queue.queueArn,
1677
+ * protocol: 'sqs',
1678
+ * rawMessageDelivery: true,
1679
+ * })
1680
+ * // Also preserve (or create) the matching queue policy:
1681
+ * makeServerlessQueuePolicy(scope, 'TenantPurgeSQSPolicy', { ... })
1682
+ * ```
1683
+ */
1684
+ function makeSnsToSqsSubscription(scope, logicalId, props) {
1685
+ const sub = new awsSns.CfnSubscription(scope, logicalId, props);
1686
+ sub.overrideLogicalId(logicalId);
1687
+ return sub;
1688
+ }
1689
+ /**
1690
+ * Creates an SNS to Lambda subscription and matching invoke permission using
1691
+ * logical IDs from the existing Serverless stack.
1692
+ *
1693
+ * Use this when migrating an existing SNS Lambda trigger to CDK. The normal CDK
1694
+ * subscription helper generates its own logical IDs, so it can replace the
1695
+ * Serverless-created subscription and permission during deploy. For SNS Lambda
1696
+ * subscriptions that means events published during the replacement window are lost.
1697
+ *
1698
+ * Pass literal ARN strings for both the topic and Lambda function. Avoid using
1699
+ * `topic.topicArn` or `function.functionArn` here, since changes in the generated
1700
+ * CloudFormation expression can still force replacement even when the final ARN is
1701
+ * the same.
1702
+ *
1703
+ * The Lambda function must have a fixed `physicalName`, otherwise the function ARN
1704
+ * cannot be written safely by hand.
1705
+ *
1706
+ * Check the live CloudFormation stack for the subscription and permission logical
1707
+ * IDs before using this helper:
1708
+ *
1709
+ * ```bash
1710
+ * aws cloudformation list-stack-resources --stack-name <stack-name> \
1711
+ * --query "StackResourceSummaries[?ResourceType=='AWS::SNS::Subscription' ||
1712
+ * ResourceType=='AWS::Lambda::Permission'].[LogicalResourceId,ResourceType]" \
1713
+ * --output table
1714
+ * ```
1715
+ *
1716
+ * @example
1717
+ * ```typescript
1718
+ * const stagePrefix = stage.charAt(0).toUpperCase() + stage.slice(1)
1719
+ * makeSnsToLambdaSubscription(
1720
+ * scope,
1721
+ * `HandleDashorderDashcreatedSnsSubscription${stagePrefix}myserviceeventordercreated`,
1722
+ * `HandleDashorderDashcreatedLambdaPermission${stagePrefix}myserviceeventordercreatedSNS`,
1723
+ * {
1724
+ * topicArn: `arn:aws:sns:eu-west-1:${accountId}:${stage}-my-service-event-order-created`,
1725
+ * functionArn: `arn:aws:lambda:eu-west-1:${accountId}:function:my-service-${stage}-handle-order-created`,
1726
+ * }
1727
+ * )
1728
+ * ```
1729
+ */
1730
+ function makeSnsToLambdaSubscription(scope, subscriptionLogicalId, permissionLogicalId, props) {
1731
+ if (cdk.Token.isUnresolved(props.topicArn) || cdk.Token.isUnresolved(props.functionArn)) {
1732
+ throw new Error('makeSnsToLambdaSubscription requires literal ARN strings, not CDK tokens. ' +
1733
+ 'Using topic.topicArn or function.functionArn produces a CloudFormation expression whose ' +
1734
+ 'rendered form can change independently of the resolved ARN, forcing subscription replacement. ' +
1735
+ 'Pass the physical ARN as a string literal (e.g. `arn:aws:sns:eu-west-1:${accountId}:${stage}-my-topic`).');
1736
+ }
1737
+ const subscription = new awsSns.CfnSubscription(scope, subscriptionLogicalId, {
1738
+ topicArn: props.topicArn,
1739
+ protocol: 'lambda',
1740
+ endpoint: props.functionArn,
1741
+ });
1742
+ subscription.overrideLogicalId(subscriptionLogicalId);
1743
+ const permission = new awsLambda.CfnPermission(scope, permissionLogicalId, {
1744
+ action: 'lambda:InvokeFunction',
1745
+ functionName: props.functionArn,
1746
+ principal: 'sns.amazonaws.com',
1747
+ sourceArn: props.topicArn,
1748
+ });
1749
+ permission.overrideLogicalId(permissionLogicalId);
1750
+ subscription.addDependency(permission);
1751
+ return { subscription, permission };
1752
+ }
1753
+ /**
1754
+ * Creates an SQS queue policy using the logical ID from the existing Serverless
1755
+ * stack.
1756
+ *
1757
+ * Use this for migrated queues instead of `queue.addToResourcePolicy()`. The CDK
1758
+ * helper creates its own logical ID, which usually does not match the
1759
+ * `AWS::SQS::QueuePolicy` resource already managed by CloudFormation.
1760
+ *
1761
+ * Both the construct ID and CloudFormation logical ID are set from `logicalId`.
1762
+ *
1763
+ * @example
1764
+ * ```typescript
1765
+ * makeServerlessQueuePolicy(scope, 'MqlEventSQSPolicy', {
1766
+ * queues: [queues.mqlEventsQueue.queue.queueUrl],
1767
+ * policyDocument: {
1768
+ * Version: '2012-10-17',
1769
+ * Statement: [{
1770
+ * Effect: 'Allow', Principal: '*', Action: 'sqs:SendMessage', Resource: '*',
1771
+ * // Keep the policy limited to the SNS topic that sends to this queue.
1772
+ * Condition: { ArnEquals: { 'aws:SourceArn': `arn:aws:sns:...` } },
1773
+ * }],
1774
+ * },
1775
+ * })
1776
+ * ```
1777
+ */
1778
+ function makeServerlessQueuePolicy(scope, logicalId, props) {
1779
+ const policy = new awsSqs.CfnQueuePolicy(scope, logicalId, props);
1780
+ policy.overrideLogicalId(logicalId);
1781
+ return policy;
1782
+ }
1783
+ /**
1784
+ * Creates a DynamoDB table with the physical name and logical ID used by the
1785
+ * existing Serverless stack.
1786
+ *
1787
+ * Use this when moving an existing Serverless-managed table to CDK. The logical
1788
+ * ID should be copied from the CloudFormation resource, usually the resource key
1789
+ * from `serverless.yml`. If it does not match, CloudFormation may replace the
1790
+ * table during deploy.
1791
+ *
1792
+ * Defaults used by this helper:
1793
+ * - billing mode is PAY_PER_REQUEST
1794
+ * - point-in-time recovery is enabled only in prod
1795
+ * - removal policy is RETAIN in prod and DESTROY in other stages
1796
+ *
1797
+ * @example
1798
+ * ```typescript
1799
+ * const table = makeServerlessDynamoTable(scope, 'LeadCountTable', `${stage}-em-my-service-lead-count`, stage, {
1800
+ * partitionKey: { name: 'streamId', type: AttributeType.STRING },
1801
+ * sortKey: { name: 'day', type: AttributeType.NUMBER },
1802
+ * })
1803
+ * ```
1804
+ */
1805
+ function makeServerlessDynamoTable(scope, logicalId, tableName, stage, options) {
1806
+ const table = new awsDynamodb.Table(scope, logicalId, {
1807
+ tableName,
1808
+ billingMode: awsDynamodb.BillingMode.PAY_PER_REQUEST,
1809
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: stage === 'prod' },
1810
+ removalPolicy: getRemovalPolicy(stage),
1811
+ ...options
1812
+ });
1813
+ const cfnTable = table.node.defaultChild;
1814
+ if (!(cfnTable instanceof awsDynamodb.CfnTable)) {
1815
+ throw new Error(`Cannot override DynamoDB table logical ID "${logicalId}": defaultChild is not a CfnTable. ` +
1816
+ 'Imported tables cannot have their logical IDs overridden.');
1817
+ }
1818
+ cfnTable.overrideLogicalId(logicalId);
1819
+ return table;
1862
1820
  }
1863
1821
 
1864
1822
  class LambdaWithQueue extends constructs.Construct {
@@ -1867,7 +1825,7 @@ class LambdaWithQueue extends constructs.Construct {
1867
1825
  const resolved = resolveHandlerPath(props);
1868
1826
  const shortName = resolved.functionName;
1869
1827
  const resourceName = props.resourceName ?? shortName;
1870
- const functionName = generateLambdaName(props.stage, props.serviceName, shortName);
1828
+ const functionName = props.physicalName ?? generateLambdaName(props.stage, props.serviceName, shortName);
1871
1829
  const handler = resolved.handler ?? props.handler ?? 'index.handler';
1872
1830
  const codePath = resolved.codePath ?? props.codePath ?? `./dist/handlers/${resourceName}`;
1873
1831
  if (props.reservedConcurrency === 0) {
@@ -1892,7 +1850,7 @@ class LambdaWithQueue extends constructs.Construct {
1892
1850
  },
1893
1851
  removalPolicy: getRemovalPolicy(props.stage)
1894
1852
  });
1895
- const role = props.role ?? this.createRole(props);
1853
+ const role = props.role ?? this.createRole(props, enableTracing);
1896
1854
  const logGroup = new awsLogs.LogGroup(this, 'LogGroup', {
1897
1855
  logGroupName: `/aws/lambda/${functionName}`,
1898
1856
  retention: getLogRetentionDays(props.stage),
@@ -1907,6 +1865,7 @@ class LambdaWithQueue extends constructs.Construct {
1907
1865
  memorySize,
1908
1866
  timeout,
1909
1867
  environment: {
1868
+ ...buildBaseEnvironment(props.stage, this),
1910
1869
  ...(props.environment ?? {}),
1911
1870
  ...buildRecapDevEnvironment(resolveRecapDevEndpoint(this))
1912
1871
  },
@@ -1944,7 +1903,8 @@ class LambdaWithQueue extends constructs.Construct {
1944
1903
  this.dlqAlarm = new DlqAlarm(this, 'DLQAlarm', {
1945
1904
  dlq: this.dlq,
1946
1905
  alarmName: props.alarmName ?? `${props.stage}-${props.serviceName}-${resourceName}-dlq-alarm`,
1947
- alarmTopic: props.alarmTopic
1906
+ alarmTopic: props.alarmTopic,
1907
+ alarmLogicalId: props.overrideLogicalIds?.alarm
1948
1908
  });
1949
1909
  additionalQueues.forEach(queue => {
1950
1910
  queue.grantSendMessages(this.function);
@@ -1968,15 +1928,8 @@ class LambdaWithQueue extends constructs.Construct {
1968
1928
  }
1969
1929
  cfnDlq.overrideLogicalId(props.overrideLogicalIds.dlq);
1970
1930
  }
1971
- if (props.overrideLogicalIds?.alarm) {
1972
- const cfnAlarm = this.dlqAlarm.alarm.node.defaultChild;
1973
- if (!(cfnAlarm instanceof awsCloudwatch.CfnAlarm)) {
1974
- throw new Error(`Cannot override alarm logical ID to "${props.overrideLogicalIds.alarm}": alarm does not have a CfnAlarm default child.`);
1975
- }
1976
- cfnAlarm.overrideLogicalId(props.overrideLogicalIds.alarm);
1977
- }
1978
1931
  }
1979
- createRole(props) {
1932
+ createRole(props, enableTracing) {
1980
1933
  if (!props.roleName) {
1981
1934
  throw new Error('LambdaWithQueue requires either `role` or `roleName` to be provided.');
1982
1935
  }
@@ -1989,10 +1942,45 @@ class LambdaWithQueue extends constructs.Construct {
1989
1942
  if (props.vpcConfig) {
1990
1943
  role.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
1991
1944
  }
1945
+ if (enableTracing) {
1946
+ role.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('AWSXRayDaemonWriteAccess'));
1947
+ }
1992
1948
  return role;
1993
1949
  }
1994
- subscribeToTopic(topic, options) {
1995
- topic.addSubscription(new awsSnsSubscriptions.SqsSubscription(this.queue, options));
1950
+ subscribeToTopic(topic, options, serverlessSubscriptionLogicalId) {
1951
+ if (serverlessSubscriptionLogicalId !== undefined) {
1952
+ const trimmed = serverlessSubscriptionLogicalId.trim();
1953
+ if (!trimmed) {
1954
+ throw new Error('subscribeToTopic: serverlessSubscriptionLogicalId must not be an empty string — ' +
1955
+ 'omit the argument to use the standard CDK subscription path.');
1956
+ }
1957
+ if (options?.filterPolicy) {
1958
+ throw new Error('subscribeToTopic: filterPolicy is not supported with serverlessSubscriptionLogicalId. ' +
1959
+ 'Use the standard CDK subscription path instead.');
1960
+ }
1961
+ if (options?.filterPolicyWithMessageBody) {
1962
+ throw new Error('subscribeToTopic: filterPolicyWithMessageBody is not supported with serverlessSubscriptionLogicalId. ' +
1963
+ 'Use the standard CDK subscription path instead.');
1964
+ }
1965
+ if (options?.deadLetterQueue) {
1966
+ throw new Error('subscribeToTopic: deadLetterQueue is not supported with serverlessSubscriptionLogicalId. ' +
1967
+ 'Use the standard CDK subscription path instead.');
1968
+ }
1969
+ cdk.Annotations.of(this).addWarning(`subscribeToTopic with serverlessSubscriptionLogicalId="${trimmed}" ` +
1970
+ 'creates only the SNS subscription — no SQS queue policy is created. ' +
1971
+ 'On migrated stacks the queue policy already exists in CloudFormation; ' +
1972
+ 'preserve it with makeServerlessQueuePolicy(). ' +
1973
+ 'On new queues you must also call makeServerlessQueuePolicy() or SNS delivery will fail silently.');
1974
+ makeSnsToSqsSubscription(this, trimmed, {
1975
+ topicArn: topic.topicArn,
1976
+ endpoint: this.queue.queueArn,
1977
+ protocol: 'sqs',
1978
+ rawMessageDelivery: options?.rawMessageDelivery
1979
+ });
1980
+ }
1981
+ else {
1982
+ topic.addSubscription(new awsSnsSubscriptions.SqsSubscription(this.queue, options));
1983
+ }
1996
1984
  }
1997
1985
  }
1998
1986
 
@@ -2045,12 +2033,12 @@ class EmLambdaWithHttpApi extends constructs.Construct {
2045
2033
  */
2046
2034
  class TopicQueueConsumer extends LambdaWithQueue {
2047
2035
  constructor(scope, id, props) {
2048
- const { topic: topicOrArn, subscriptionOptions, ...queueProps } = props;
2036
+ const { topic: topicOrArn, subscriptionOptions, serverlessSubscriptionLogicalId, ...queueProps } = props;
2049
2037
  super(scope, id, queueProps);
2050
2038
  const topic = typeof topicOrArn === 'string'
2051
2039
  ? awsSns.Topic.fromTopicArn(this, 'SubscribedTopic', topicOrArn)
2052
2040
  : topicOrArn;
2053
- this.subscribeToTopic(topic, subscriptionOptions);
2041
+ this.subscribeToTopic(topic, subscriptionOptions, serverlessSubscriptionLogicalId);
2054
2042
  }
2055
2043
  }
2056
2044
 
@@ -2088,7 +2076,7 @@ class TopicQueueConsumer extends LambdaWithQueue {
2088
2076
  * }
2089
2077
  * ```
2090
2078
  */
2091
- class EmStack extends cdk.Stack {
2079
+ class EmStack extends cdk__namespace.Stack {
2092
2080
  constructor(scope, id, props) {
2093
2081
  super(scope, id, {
2094
2082
  ...props,
@@ -2128,7 +2116,7 @@ class EmStack extends cdk.Stack {
2128
2116
  /**
2129
2117
  * Update default function config after construction.
2130
2118
  * Use this when defaults depend on resources created after `super()`.
2131
- * Environment is deep-merged with any existing defaults.
2119
+ * The environment map is shallow-merged with existing defaults; all other keys are replaced.
2132
2120
  *
2133
2121
  * @example
2134
2122
  * ```typescript
@@ -2149,19 +2137,17 @@ class EmStack extends cdk.Stack {
2149
2137
  }
2150
2138
  };
2151
2139
  }
2152
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2153
2140
  mergeConfig(config) {
2154
- const defaults = this.defaultFunctionConfig;
2155
- const merged = { ...defaults, ...config };
2156
2141
  const defaultEnv = this.defaultFunctionConfig.environment;
2157
2142
  const configEnv = config.environment;
2158
- if (defaultEnv || configEnv) {
2159
- merged.environment = {
2160
- ...(defaultEnv ?? {}),
2161
- ...(configEnv ?? {})
2162
- };
2163
- }
2164
- return merged;
2143
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
2144
+ return {
2145
+ ...this.defaultFunctionConfig,
2146
+ ...config,
2147
+ ...(defaultEnv || configEnv
2148
+ ? { environment: { ...(defaultEnv ?? {}), ...(configEnv ?? {}) } }
2149
+ : {})
2150
+ };
2165
2151
  }
2166
2152
  /**
2167
2153
  * Create a Lambda function. Defaults `stage` and `serviceName` from the stack.
@@ -2169,8 +2155,11 @@ class EmStack extends cdk.Stack {
2169
2155
  * When `useSharedRole: true` (Serverless migration mode), also:
2170
2156
  * - Overrides Lambda logical ID to `{prefix}LambdaFunction`
2171
2157
  * - Overrides log group logical ID to `{prefix}LogGroup`
2172
- * - Sets log group removal policy to RETAIN
2158
+ * - Sets log group removal policy via `getRemovalPolicy(stage)` (RETAIN in
2159
+ * prod, DESTROY otherwise — see utils/logs.ts)
2173
2160
  * - Uses the shared role unless `config.role` is provided
2161
+ * - Throws if `importExistingLogGroup` is set — migration mode requires managed log groups
2162
+ * to override their logical IDs
2174
2163
  */
2175
2164
  createFunction(id, config) {
2176
2165
  const merged = this.mergeConfig(config);
@@ -2197,6 +2186,7 @@ class EmStack extends cdk.Stack {
2197
2186
  }
2198
2187
  overrideFunctionLogicalIds(fn.function, functionName);
2199
2188
  }
2189
+ this.errorIfRoleOverrideInMigrationMode(fn, 'createFunction', functionName, merged.role);
2200
2190
  return fn;
2201
2191
  }
2202
2192
  /**
@@ -2222,7 +2212,7 @@ class EmStack extends cdk.Stack {
2222
2212
  createQueueConsumer(id, config) {
2223
2213
  const merged = this.mergeConfig(config);
2224
2214
  const { functionName } = resolveHandlerPath(merged);
2225
- return new LambdaWithQueue(this, id, {
2215
+ const consumer = new LambdaWithQueue(this, id, {
2226
2216
  ...merged,
2227
2217
  stage: merged.stage ?? this.stage,
2228
2218
  serviceName: merged.serviceName ?? this.serviceName,
@@ -2231,6 +2221,8 @@ class EmStack extends cdk.Stack {
2231
2221
  serverlessFunctionName: merged.serverlessFunctionName ?? functionName
2232
2222
  })
2233
2223
  });
2224
+ this.errorIfRoleOverrideInMigrationMode(consumer, 'createQueueConsumer', functionName ?? id, merged.role);
2225
+ return consumer;
2234
2226
  }
2235
2227
  /**
2236
2228
  * Create a Lambda function consuming messages from an SNS topic via SQS.
@@ -2252,20 +2244,23 @@ class EmStack extends cdk.Stack {
2252
2244
  * ```
2253
2245
  */
2254
2246
  createTopicQueueConsumer(id, config) {
2255
- const { topic, subscriptionOptions, ...queueConfig } = config;
2247
+ const { topic, subscriptionOptions, serverlessSubscriptionLogicalId, ...queueConfig } = config;
2256
2248
  const merged = this.mergeConfig(queueConfig);
2257
2249
  const { functionName } = resolveHandlerPath(merged);
2258
- return new TopicQueueConsumer(this, id, {
2250
+ const consumer = new TopicQueueConsumer(this, id, {
2259
2251
  ...merged,
2260
2252
  stage: merged.stage ?? this.stage,
2261
2253
  serviceName: merged.serviceName ?? this.serviceName,
2262
2254
  role: merged.role ?? this.sharedRole,
2263
2255
  topic,
2264
2256
  subscriptionOptions,
2257
+ serverlessSubscriptionLogicalId,
2265
2258
  ...(this.sharedRole && {
2266
2259
  serverlessFunctionName: merged.serverlessFunctionName ?? functionName
2267
2260
  })
2268
2261
  });
2262
+ this.errorIfRoleOverrideInMigrationMode(consumer, 'createTopicQueueConsumer', functionName ?? id, merged.role);
2263
+ return consumer;
2269
2264
  }
2270
2265
  /**
2271
2266
  * Create a scheduled Lambda function with an EventBridge rule.
@@ -2281,11 +2276,13 @@ class EmStack extends cdk.Stack {
2281
2276
  */
2282
2277
  createScheduledFunction(id, config) {
2283
2278
  const { schedule, ruleName, ruleDescription, ...functionConfig } = config;
2284
- const fn = this.createFunction(id, functionConfig);
2279
+ const merged = this.mergeConfig(functionConfig);
2280
+ const fn = this.createFunction(id, merged);
2281
+ const { functionName } = resolveHandlerPath(merged);
2285
2282
  const rule = new EmEventBridgeRule(this, `${id}Rule`, {
2286
- stage: config.stage ?? this.stage,
2287
- serviceName: config.serviceName ?? this.serviceName,
2288
- ruleName: ruleName ?? resolveHandlerPath(functionConfig).functionName,
2283
+ stage: functionConfig.stage ?? this.stage,
2284
+ serviceName: functionConfig.serviceName ?? this.serviceName,
2285
+ ruleName: ruleName ?? functionName,
2289
2286
  description: ruleDescription,
2290
2287
  schedule
2291
2288
  });
@@ -2316,35 +2313,51 @@ class EmStack extends cdk.Stack {
2316
2313
  /**
2317
2314
  * Import the alarm email topic by convention.
2318
2315
  * ARN: `arn:{partition}:sns:{region}:{account}:{stage}-alarm-email`
2316
+ *
2317
+ * Memoised: `Topic.fromTopicArn` registers a new construct on each call and
2318
+ * CDK errors on duplicate construct IDs in the same scope, so multiple
2319
+ * call sites would otherwise fail synth.
2319
2320
  */
2320
2321
  alarmTopic() {
2321
- const arn = `arn:${cdk.Aws.PARTITION}:sns:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-alarm-email`;
2322
- return awsSns.Topic.fromTopicArn(this, 'AlarmTopic', arn);
2322
+ if (!this._alarmTopic) {
2323
+ const arn = `arn:${cdk.Aws.PARTITION}:sns:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-alarm-email`;
2324
+ this._alarmTopic = awsSns.Topic.fromTopicArn(this, 'AlarmTopic', arn);
2325
+ }
2326
+ return this._alarmTopic;
2323
2327
  }
2324
2328
  /**
2325
2329
  * Add a Lambda invoke policy to the shared role.
2326
2330
  * @param functionPattern - Optional function name pattern. Defaults to `{stage}-{serviceName}-*`.
2327
2331
  * Pass `'*'` for account-wide access.
2332
+ *
2333
+ * **Note:** The default pattern only matches functions named with the CDK convention
2334
+ * (`{stage}-{serviceName}-{fn}`). Functions created with `physicalName` using the legacy
2335
+ * Serverless convention (`{service}-{stage}-{fn}`) will not be matched. Pass `'*'` when
2336
+ * the service has any `physicalName` functions that must be invocable.
2328
2337
  */
2329
2338
  addLambdaInvokePolicy(functionPattern) {
2330
- this.requireSharedRole('addLambdaInvokePolicy');
2339
+ if (functionPattern !== undefined && functionPattern.trim() === '') {
2340
+ throw new Error('addLambdaInvokePolicy: functionPattern must not be empty.');
2341
+ }
2342
+ const role = this.requireSharedRole('addLambdaInvokePolicy');
2331
2343
  const pattern = functionPattern ?? `${this.stage}-${this.serviceName}-*`;
2332
- this.sharedRole.addToPolicy(new awsIam.PolicyStatement({
2344
+ const resources = pattern === '*'
2345
+ ? ['*']
2346
+ : [`arn:${cdk.Aws.PARTITION}:lambda:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:function:${pattern}`];
2347
+ role.addToPolicy(new awsIam.PolicyStatement({
2333
2348
  effect: awsIam.Effect.ALLOW,
2334
2349
  actions: ['lambda:InvokeFunction'],
2335
- resources: [
2336
- `arn:${cdk.Aws.PARTITION}:lambda:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:function:${pattern}`
2337
- ]
2350
+ resources
2338
2351
  }));
2339
2352
  }
2340
2353
  /**
2341
2354
  * Add a Kinesis PutRecord/PutRecords policy to the shared role.
2342
- * @param streamName - Short stream name (prefixed with `{stage}-`).
2355
+ * @param streamOrName - An IStream reference, or a short stream name (prefixed with `{stage}-`).
2343
2356
  */
2344
- addKinesisPolicy(streamName) {
2345
- this.requireSharedRole('addKinesisPolicy');
2346
- const arn = `arn:${cdk.Aws.PARTITION}:kinesis:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:stream/${this.stage}-${streamName}`;
2347
- this.sharedRole.addToPolicy(new awsIam.PolicyStatement({
2357
+ addKinesisPolicy(streamOrName) {
2358
+ const role = this.requireSharedRole('addKinesisPolicy');
2359
+ const arn = this.resolveResourceArn(streamOrName, 'addKinesisPolicy', 'streamName', name => `arn:${cdk.Aws.PARTITION}:kinesis:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:stream/${this.stage}-${name}`, stream => stream.streamArn);
2360
+ role.addToPolicy(new awsIam.PolicyStatement({
2348
2361
  effect: awsIam.Effect.ALLOW,
2349
2362
  actions: ['kinesis:PutRecord', 'kinesis:PutRecords'],
2350
2363
  resources: [arn]
@@ -2355,11 +2368,9 @@ class EmStack extends cdk.Stack {
2355
2368
  * @param topicOrName - An ITopic reference, or a short topic name (prefixed with `{stage}-`).
2356
2369
  */
2357
2370
  addSnsPublishPolicy(topicOrName) {
2358
- this.requireSharedRole('addSnsPublishPolicy');
2359
- const arn = typeof topicOrName === 'string'
2360
- ? `arn:${cdk.Aws.PARTITION}:sns:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-${topicOrName}`
2361
- : topicOrName.topicArn;
2362
- this.sharedRole.addToPolicy(new awsIam.PolicyStatement({
2371
+ const role = this.requireSharedRole('addSnsPublishPolicy');
2372
+ const arn = this.resolveResourceArn(topicOrName, 'addSnsPublishPolicy', 'topicName', name => `arn:${cdk.Aws.PARTITION}:sns:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-${name}`, topic => topic.topicArn);
2373
+ role.addToPolicy(new awsIam.PolicyStatement({
2363
2374
  effect: awsIam.Effect.ALLOW,
2364
2375
  actions: ['sns:Publish'],
2365
2376
  resources: [arn]
@@ -2367,21 +2378,220 @@ class EmStack extends cdk.Stack {
2367
2378
  }
2368
2379
  /**
2369
2380
  * Add an SQS SendMessage policy to the shared role.
2370
- * @param queueName - Short queue name (prefixed with `{stage}-`).
2381
+ * @param queueOrNames - One or more IQueue references and/or short queue names
2382
+ * (string names are prefixed with `{stage}-`).
2371
2383
  */
2372
- addSqsSendPolicy(queueName) {
2373
- this.requireSharedRole('addSqsSendPolicy');
2374
- const arn = `arn:${cdk.Aws.PARTITION}:sqs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-${queueName}`;
2375
- this.sharedRole.addToPolicy(new awsIam.PolicyStatement({
2384
+ addSqsSendPolicy(queueOrNames) {
2385
+ const items = Array.isArray(queueOrNames) ? queueOrNames : [queueOrNames];
2386
+ if (items.length === 0) {
2387
+ throw new Error('addSqsSendPolicy: queueOrNames must not be empty.');
2388
+ }
2389
+ const role = this.requireSharedRole('addSqsSendPolicy');
2390
+ const resources = items.map(item => this.resolveResourceArn(item, 'addSqsSendPolicy', 'queueName', name => `arn:${cdk.Aws.PARTITION}:sqs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-${name}`, queue => queue.queueArn));
2391
+ role.addToPolicy(new awsIam.PolicyStatement({
2376
2392
  effect: awsIam.Effect.ALLOW,
2377
2393
  actions: ['sqs:SendMessage', 'sqs:GetQueueAttributes', 'sqs:GetQueueUrl'],
2378
- resources: [arn]
2394
+ resources
2395
+ }));
2396
+ }
2397
+ /** Add an XRay tracing policy to the shared role. */
2398
+ addXRayPolicy() {
2399
+ const role = this.requireSharedRole('addXRayPolicy');
2400
+ role.addToPolicy(createXRayTracingPolicy());
2401
+ }
2402
+ /**
2403
+ * Add a CloudWatch Logs policy to the shared role.
2404
+ * Grants create, write, describe, read, and query access on all log groups.
2405
+ */
2406
+ addCloudWatchLogsPolicy() {
2407
+ const role = this.requireSharedRole('addCloudWatchLogsPolicy');
2408
+ role.addToPolicy(new awsIam.PolicyStatement({
2409
+ effect: awsIam.Effect.ALLOW,
2410
+ actions: [
2411
+ 'logs:CreateLogGroup',
2412
+ 'logs:CreateLogStream',
2413
+ 'logs:PutLogEvents',
2414
+ 'logs:DescribeLogGroups',
2415
+ 'logs:DescribeLogStreams',
2416
+ 'logs:FilterLogEvents',
2417
+ 'logs:GetLogEvents',
2418
+ 'logs:StartQuery',
2419
+ 'logs:StopQuery',
2420
+ 'logs:GetQueryResults'
2421
+ ],
2422
+ resources: ['*']
2423
+ }));
2424
+ }
2425
+ /**
2426
+ * Add an SNS policy to the shared role.
2427
+ * @param options.actions - SNS actions (e.g. ['sns:Publish', 'sns:Subscribe']).
2428
+ * @param options.resources - Resource ARNs. When omitted, defaults to `['*']`
2429
+ * (account-wide access). Prefer `addSnsPublishPolicy(topicArn)` for scoped
2430
+ * publish-only access to a specific topic.
2431
+ */
2432
+ addSnsPolicy(options) {
2433
+ if (options.actions.length === 0) {
2434
+ throw new Error('addSnsPolicy: actions must not be empty.');
2435
+ }
2436
+ if (options.resources && options.resources.length === 0) {
2437
+ throw new Error('addSnsPolicy: resources must not be empty. Omit the field to grant account-wide access explicitly.');
2438
+ }
2439
+ const role = this.requireSharedRole('addSnsPolicy');
2440
+ if (!options.resources) {
2441
+ cdk.Annotations.of(this).addWarning('addSnsPolicy: no resources specified — policy grants account-wide SNS access on all topics. ' +
2442
+ 'Pass resources to scope the policy, or use addSnsPublishPolicy(topicArn) for publish access.');
2443
+ }
2444
+ role.addToPolicy(new awsIam.PolicyStatement({
2445
+ effect: awsIam.Effect.ALLOW,
2446
+ actions: options.actions,
2447
+ resources: options.resources ?? ['*']
2448
+ }));
2449
+ }
2450
+ /**
2451
+ * Add an SQS consumer policy to the shared role.
2452
+ * Grants ChangeMessageVisibility, DeleteMessage, ReceiveMessage, and GetQueueAttributes on each queue.
2453
+ * Use `addSqsSendPolicy` separately for queues the service also produces to.
2454
+ * @param queues - One or more IQueue references and/or short queue names
2455
+ * (string names are prefixed with `{stage}-`).
2456
+ */
2457
+ addSqsConsumerPolicy(queues) {
2458
+ if (queues.length === 0) {
2459
+ throw new Error('addSqsConsumerPolicy: queues must not be empty.');
2460
+ }
2461
+ const role = this.requireSharedRole('addSqsConsumerPolicy');
2462
+ const resources = queues.map(item => this.resolveResourceArn(item, 'addSqsConsumerPolicy', 'queueName', name => `arn:${cdk.Aws.PARTITION}:sqs:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${this.stage}-${name}`, queue => queue.queueArn));
2463
+ role.addToPolicy(new awsIam.PolicyStatement({
2464
+ effect: awsIam.Effect.ALLOW,
2465
+ actions: [
2466
+ 'sqs:ChangeMessageVisibility',
2467
+ 'sqs:DeleteMessage',
2468
+ 'sqs:ReceiveMessage',
2469
+ 'sqs:GetQueueAttributes'
2470
+ ],
2471
+ resources
2472
+ }));
2473
+ }
2474
+ /**
2475
+ * Add an execute-api:Invoke policy to the shared role.
2476
+ * Resources are scoped to `*` — API Gateway requires the full execution ARN
2477
+ * (`arn:{partition}:execute-api:{region}:{account}:{api-id}/{stage}/{method}/{path}`)
2478
+ * which is not available as a stable value in migration stacks.
2479
+ */
2480
+ addExecuteApiPolicy() {
2481
+ const role = this.requireSharedRole('addExecuteApiPolicy');
2482
+ role.addToPolicy(new awsIam.PolicyStatement({
2483
+ effect: awsIam.Effect.ALLOW,
2484
+ actions: ['execute-api:Invoke'],
2485
+ resources: ['*']
2379
2486
  }));
2380
2487
  }
2488
+ /**
2489
+ * Add an S3 policy to the shared role.
2490
+ * @param bucketOrName - An IBucket reference, or a full bucket name (no stage prefix added).
2491
+ * @param actions - S3 actions to grant. Defaults to `['s3:GetObject', 's3:PutObject',
2492
+ * 's3:DeleteObject', 's3:ListBucket']`. Pass an explicit non-empty list for narrower or broader access.
2493
+ */
2494
+ addS3Policy(bucketOrName, actions) {
2495
+ if (actions && actions.length === 0) {
2496
+ throw new Error('addS3Policy: actions must not be empty.');
2497
+ }
2498
+ const role = this.requireSharedRole('addS3Policy');
2499
+ const bucketArn = this.resolveResourceArn(bucketOrName, 'addS3Policy', 'bucketName', name => `arn:${cdk.Aws.PARTITION}:s3:::${name}`, bucket => bucket.bucketArn);
2500
+ role.addToPolicy(new awsIam.PolicyStatement({
2501
+ effect: awsIam.Effect.ALLOW,
2502
+ actions: actions ?? ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'],
2503
+ resources: [bucketArn, `${bucketArn}/*`]
2504
+ }));
2505
+ }
2506
+ /**
2507
+ * Add a DynamoDB policy to the shared role.
2508
+ * Grants read, write, transact, and describe actions on each table.
2509
+ *
2510
+ * String-name ARNs use literal `*` for region and account. This matches the
2511
+ * Serverless Framework's source policy on migrated stacks; replacing the `*`
2512
+ * with `${Aws.REGION}/${Aws.ACCOUNT_ID}` would generate a noisy CloudFormation
2513
+ * diff on first deploy without changing effective access.
2514
+ *
2515
+ * @param tables - One or more ITable references and/or short table names
2516
+ * (string names are prefixed with `{stage}-`).
2517
+ * @param options.streamTables - Tables to also grant stream read access.
2518
+ * Accepts ITable refs (uses tableStreamArn) or string short names.
2519
+ * @param options.indexTables - Tables to also grant Query/Scan on all indexes.
2520
+ * Uses a separate policy statement scoped to only dynamodb:Query and dynamodb:Scan.
2521
+ */
2522
+ addDynamoDbPolicy(tables, options) {
2523
+ if (tables.length === 0) {
2524
+ throw new Error('addDynamoDbPolicy: tables must not be empty.');
2525
+ }
2526
+ const role = this.requireSharedRole('addDynamoDbPolicy');
2527
+ const tableArns = tables.map(item => this.resolveResourceArn(item, 'addDynamoDbPolicy', 'tableName', name => `arn:${cdk.Aws.PARTITION}:dynamodb:*:*:table/${this.stage}-${name}`, table => table.tableArn));
2528
+ const streamArns = (options?.streamTables ?? []).map(item => this.resolveResourceArn(item, 'addDynamoDbPolicy(streamTables)', 'tableName', name => `arn:${cdk.Aws.PARTITION}:dynamodb:*:*:table/${this.stage}-${name}/stream/*`, table => table.tableStreamArn ?? `${table.tableArn}/stream/*`));
2529
+ role.addToPolicy(new awsIam.PolicyStatement({
2530
+ effect: awsIam.Effect.ALLOW,
2531
+ actions: [
2532
+ 'dynamodb:GetItem',
2533
+ 'dynamodb:PutItem',
2534
+ 'dynamodb:UpdateItem',
2535
+ 'dynamodb:DeleteItem',
2536
+ 'dynamodb:Query',
2537
+ 'dynamodb:Scan',
2538
+ 'dynamodb:BatchGetItem',
2539
+ 'dynamodb:BatchWriteItem',
2540
+ 'dynamodb:TransactGetItems',
2541
+ 'dynamodb:TransactWriteItems',
2542
+ 'dynamodb:DescribeTable',
2543
+ 'dynamodb:DescribeStream',
2544
+ 'dynamodb:GetRecords',
2545
+ 'dynamodb:GetShardIterator',
2546
+ 'dynamodb:ListStreams'
2547
+ ],
2548
+ resources: [...tableArns, ...streamArns]
2549
+ }));
2550
+ const indexArns = (options?.indexTables ?? []).map(item => this.resolveResourceArn(item, 'addDynamoDbPolicy(indexTables)', 'tableName', name => `arn:${cdk.Aws.PARTITION}:dynamodb:*:*:table/${this.stage}-${name}/index/*`, table => `${table.tableArn}/index/*`));
2551
+ if (indexArns.length > 0) {
2552
+ role.addToPolicy(new awsIam.PolicyStatement({
2553
+ effect: awsIam.Effect.ALLOW,
2554
+ actions: ['dynamodb:Query', 'dynamodb:Scan'],
2555
+ resources: indexArns,
2556
+ }));
2557
+ }
2558
+ }
2381
2559
  requireSharedRole(methodName) {
2382
2560
  if (!this.sharedRole) {
2383
2561
  throw new Error(`${methodName}() requires useSharedRole: true on the stack.`);
2384
2562
  }
2563
+ return this.sharedRole;
2564
+ }
2565
+ /**
2566
+ * Resolve a CDK resource reference or short name into an ARN string.
2567
+ * Centralises empty-input validation so callers (add*Policy methods)
2568
+ * fail loud at synth instead of producing a malformed ARN that
2569
+ * CloudFormation later rejects with a generic error.
2570
+ */
2571
+ resolveResourceArn(refOrName, methodName, nameLabel, fromName, fromRef) {
2572
+ if (typeof refOrName === 'string') {
2573
+ if (refOrName.trim() === '') {
2574
+ throw new Error(`${methodName}: ${nameLabel} must not be empty.`);
2575
+ }
2576
+ return fromName(refOrName);
2577
+ }
2578
+ return fromRef(refOrName);
2579
+ }
2580
+ /**
2581
+ * Blocks synth with an error when a custom role is passed in migration mode.
2582
+ * The Lambda logical ID is overridden to match Serverless naming, but the
2583
+ * custom role's logical ID will NOT be pinned — CloudFormation may replace it.
2584
+ * Uses addError (not addWarning) so the misconfiguration is caught before a
2585
+ * changeset is created.
2586
+ * Called from createFunction, createQueueConsumer, and createTopicQueueConsumer.
2587
+ */
2588
+ errorIfRoleOverrideInMigrationMode(scope, callerMethod, functionName, role) {
2589
+ if (this.sharedRole && role && role !== this.sharedRole) {
2590
+ cdk.Annotations.of(scope).addError(`${callerMethod}("${functionName}"): a custom role was provided in migration mode (useSharedRole: true). ` +
2591
+ 'The Lambda logical ID will be overridden to match Serverless naming, but the custom role\'s logical ID ' +
2592
+ 'will NOT be pinned to IamRoleLambdaExecution — CloudFormation may replace the role on deploy. ' +
2593
+ 'Pass the shared role via the stack\'s sharedRole property instead, or omit config.role.');
2594
+ }
2385
2595
  }
2386
2596
  /**
2387
2597
  * Create a CfnOutput with a stable export name.
@@ -2403,31 +2613,36 @@ class EmStack extends cdk.Stack {
2403
2613
  }
2404
2614
 
2405
2615
  function createRdsVpcConfig(scope, stage, config) {
2406
- const vpc = ec2.Vpc.fromLookup(scope, `RdsVpc-${stage}`, {
2616
+ const vpc = ec2__namespace.Vpc.fromLookup(scope, `RdsVpc-${stage}`, {
2407
2617
  vpcId: config.vpcId
2408
2618
  });
2409
2619
  // The imported subnets are only handed to Lambda's VpcConfig. We never read
2410
2620
  // `subnet.routeTable.routeTableId`, so acknowledge the CDK warning that
2411
2621
  // `fromSubnetId` emits for imports without route-table metadata.
2412
2622
  const privateSubnets = config.privateSubnetIds.map((subnetId, index) => {
2413
- const subnet = ec2.Subnet.fromSubnetId(scope, `RdsPrivateSubnet${index}-${stage}`, subnetId);
2623
+ const subnet = ec2__namespace.Subnet.fromSubnetId(scope, `RdsPrivateSubnet${index}-${stage}`, subnetId);
2414
2624
  cdk.Annotations.of(subnet).acknowledgeWarning('@aws-cdk/aws-ec2:noSubnetRouteTableId', 'This construct only passes imported subnets to Lambda VpcConfig and does not require routeTableId metadata.');
2415
2625
  return subnet;
2416
2626
  });
2417
- const lambdaSecurityGroup = new ec2.SecurityGroup(scope, `RdsLambdaSecurityGroup-${stage}`, {
2627
+ const lambdaSecurityGroup = new ec2__namespace.SecurityGroup(scope, `RdsLambdaSecurityGroup-${stage}`, {
2418
2628
  vpc,
2419
2629
  description: config.securityGroupDescription ?? 'Lambda security group for RDS access',
2420
2630
  allowAllOutbound: true
2421
2631
  });
2422
- if (config.overrideLogicalIds?.securityGroup) {
2632
+ if (config.overrideLogicalIds?.securityGroup || config.manageSgEgress === false) {
2423
2633
  const cfnSg = lambdaSecurityGroup.node.defaultChild;
2424
- if (!(cfnSg instanceof ec2.CfnSecurityGroup)) {
2425
- throw new Error(`Cannot override security group logical ID to "${config.overrideLogicalIds.securityGroup}": ` +
2426
- 'security group does not have a CfnSecurityGroup default child.');
2634
+ if (!(cfnSg instanceof ec2__namespace.CfnSecurityGroup)) {
2635
+ throw new Error('Security group does not have a CfnSecurityGroup default child — ' +
2636
+ 'cannot apply overrideLogicalIds.securityGroup or manageSgEgress.');
2637
+ }
2638
+ if (config.overrideLogicalIds?.securityGroup) {
2639
+ cfnSg.overrideLogicalId(config.overrideLogicalIds.securityGroup);
2640
+ }
2641
+ if (config.manageSgEgress === false) {
2642
+ cfnSg.addPropertyDeletionOverride('SecurityGroupEgress');
2427
2643
  }
2428
- cfnSg.overrideLogicalId(config.overrideLogicalIds.securityGroup);
2429
2644
  }
2430
- const ingress = new ec2.CfnSecurityGroupIngress(scope, `RdsIngress-${stage}`, {
2645
+ const ingress = new ec2__namespace.CfnSecurityGroupIngress(scope, `RdsIngress-${stage}`, {
2431
2646
  groupId: config.dbSecurityGroupId,
2432
2647
  ipProtocol: 'tcp',
2433
2648
  fromPort: config.dbPort ?? 3306,
@@ -2438,6 +2653,15 @@ function createRdsVpcConfig(scope, stage, config) {
2438
2653
  if (config.overrideLogicalIds?.ingress) {
2439
2654
  ingress.overrideLogicalId(config.overrideLogicalIds.ingress);
2440
2655
  }
2656
+ if (config.overrideLogicalIds?.securityGroup) {
2657
+ // When the SG logical ID is pinned, CDK's auto-generated `Ref` on the
2658
+ // ingress points at the original (CDK-generated) logical ID. CloudFormation
2659
+ // can't resolve that reference at deploy and the changeset fails. Rewrite
2660
+ // the Ref to the overridden ID.
2661
+ ingress.addPropertyOverride('SourceSecurityGroupId', {
2662
+ Ref: config.overrideLogicalIds.securityGroup
2663
+ });
2664
+ }
2441
2665
  if (config.sharedRole) {
2442
2666
  config.sharedRole.addManagedPolicy(awsIam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
2443
2667
  }
@@ -2462,7 +2686,7 @@ function createRdsVpcConfig(scope, stage, config) {
2462
2686
  * ```
2463
2687
  */
2464
2688
  const createEmApp = (options) => {
2465
- const app = new cdk.App({
2689
+ const app = new cdk__namespace.App({
2466
2690
  context: {
2467
2691
  ...cxApi.CURRENTLY_RECOMMENDED_FLAGS,
2468
2692
  ...options?.context
@@ -2508,7 +2732,7 @@ function getAccountRdsVpcConfig(stage) {
2508
2732
  dbSecurityGroupId: 'sg-427bda39'
2509
2733
  };
2510
2734
  default:
2511
- throw new Error(`Unsupported RDS VPC stage: ${stage}. Only 'dev' and 'prod' are supported.`);
2735
+ throw new Error(`getAccountRdsVpcConfig: unsupported stage "${stage}". Only 'dev' and 'prod' are supported.`);
2512
2736
  }
2513
2737
  }
2514
2738
 
@@ -2531,41 +2755,26 @@ exports.ServiceLambdaWithQueue = ServiceLambdaWithQueue;
2531
2755
  exports.TopicQueueConsumer = TopicQueueConsumer;
2532
2756
  exports.applyStandardTags = applyStandardTags;
2533
2757
  exports.applyTags = applyTags;
2758
+ exports.buildBaseEnvironment = buildBaseEnvironment;
2534
2759
  exports.buildRecapDevEnvironment = buildRecapDevEnvironment;
2535
2760
  exports.convertRetentionDays = convertRetentionDays;
2536
2761
  exports.createApiGatewayLogGroup = createApiGatewayLogGroup;
2537
- exports.createCloudWatchLogsPolicy = createCloudWatchLogsPolicy;
2538
- exports.createDynamoDBAccessPolicy = createDynamoDBAccessPolicy;
2539
- exports.createDynamoDBReadPolicy = createDynamoDBReadPolicy;
2540
- exports.createDynamoDBWritePolicy = createDynamoDBWritePolicy;
2541
2762
  exports.createEmApp = createEmApp;
2542
- exports.createEventBridgePutEventsPolicy = createEventBridgePutEventsPolicy;
2543
2763
  exports.createEventBridgeRule = createEventBridgeRule;
2544
2764
  exports.createEventPatternRule = createEventPatternRule;
2545
2765
  exports.createFifoQueue = createFifoQueue;
2546
2766
  exports.createFifoTopic = createFifoTopic;
2547
2767
  exports.createHttpApi = createHttpApi;
2548
- exports.createKMSDecryptPolicy = createKMSDecryptPolicy;
2549
2768
  exports.createKeyValueTable = createKeyValueTable;
2550
2769
  exports.createLambdaExecutionRole = createLambdaExecutionRole;
2551
- exports.createLambdaInvokePolicy = createLambdaInvokePolicy;
2552
- exports.createLambdaLogGroup = createLambdaLogGroup;
2553
2770
  exports.createLogGroup = createLogGroup;
2554
- exports.createPolicyDocument = createPolicyDocument;
2555
2771
  exports.createQueue = createQueue;
2556
2772
  exports.createQueueWithDLQ = createQueueWithDLQ;
2557
2773
  exports.createRdsVpcConfig = createRdsVpcConfig;
2558
2774
  exports.createRestApi = createRestApi;
2559
- exports.createS3AccessPolicy = createS3AccessPolicy;
2560
- exports.createS3ReadPolicy = createS3ReadPolicy;
2561
- exports.createSNSPublishPolicy = createSNSPublishPolicy;
2562
- exports.createSQSAccessPolicy = createSQSAccessPolicy;
2563
- exports.createSSMParameterPolicy = createSSMParameterPolicy;
2564
2775
  exports.createScheduledRule = createScheduledRule;
2565
- exports.createSecretsManagerPolicy = createSecretsManagerPolicy;
2566
2776
  exports.createServerlessCompatibleOutput = createServerlessCompatibleOutput;
2567
2777
  exports.createSingleTable = createSingleTable;
2568
- exports.createStepFunctionsExecutionPolicy = createStepFunctionsExecutionPolicy;
2569
2778
  exports.createTopic = createTopic;
2570
2779
  exports.createXRayTracingPolicy = createXRayTracingPolicy;
2571
2780
  exports.generateApiName = generateApiName;
@@ -2580,29 +2789,23 @@ exports.generateStandardTags = generateStandardTags;
2580
2789
  exports.generateTableName = generateTableName;
2581
2790
  exports.generateTopicName = generateTopicName;
2582
2791
  exports.getAccountRdsVpcConfig = getAccountRdsVpcConfig;
2583
- exports.getAlarmThresholds = getAlarmThresholds;
2584
2792
  exports.getComplianceTags = getComplianceTags;
2585
2793
  exports.getCostAllocationTags = getCostAllocationTags;
2586
- exports.getEnvironmentConfig = getEnvironmentConfig;
2587
2794
  exports.getEnvironmentTags = getEnvironmentTags;
2588
- exports.getLambdaEnvironmentVariables = getLambdaEnvironmentVariables;
2589
2795
  exports.getLogRetentionDays = getLogRetentionDays;
2590
2796
  exports.getRemovalPolicy = getRemovalPolicy;
2591
- exports.getRequiredEnvVar = getRequiredEnvVar;
2592
- exports.getResourceLimits = getResourceLimits;
2593
- exports.getStageEnvVar = getStageEnvVar;
2594
- exports.getStageFromEnv = getStageFromEnv;
2595
- exports.getTracingConfig = getTracingConfig;
2596
- exports.isDevelopment = isDevelopment;
2597
- exports.isProduction = isProduction;
2598
2797
  exports.isValidStage = isValidStage;
2798
+ exports.makeServerlessDynamoTable = makeServerlessDynamoTable;
2799
+ exports.makeServerlessQueue = makeServerlessQueue;
2800
+ exports.makeServerlessQueuePolicy = makeServerlessQueuePolicy;
2801
+ exports.makeSnsToLambdaSubscription = makeSnsToLambdaSubscription;
2802
+ exports.makeSnsToSqsSubscription = makeSnsToSqsSubscription;
2599
2803
  exports.mergeTags = mergeTags;
2600
2804
  exports.overrideFunctionLogicalIds = overrideFunctionLogicalIds;
2601
2805
  exports.overrideLayerLogicalId = overrideLayerLogicalId;
2602
2806
  exports.overrideRoleLogicalId = overrideRoleLogicalId;
2603
2807
  exports.resolveHandlerPath = resolveHandlerPath;
2604
2808
  exports.resolveRecapDevEndpoint = resolveRecapDevEndpoint;
2605
- exports.shouldEnableLogInsights = shouldEnableLogInsights;
2606
2809
  exports.stageToUpperCase = stageToUpperCase;
2607
2810
  exports.toServerlessLogicalIdPrefix = toServerlessLogicalIdPrefix;
2608
2811
  //# sourceMappingURL=index.js.map