@mettlecast/domain-cdk-packer 0.2.103 → 0.2.105

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.
@@ -9,6 +9,9 @@ import * as iam from 'aws-cdk-lib/aws-iam';
9
9
  import * as scheduler from 'aws-cdk-lib/aws-scheduler';
10
10
  import * as ec2 from 'aws-cdk-lib/aws-ec2';
11
11
  import * as s3 from 'aws-cdk-lib/aws-s3';
12
+ import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
13
+ import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
14
+ import * as sns from 'aws-cdk-lib/aws-sns';
12
15
  import { createGroupedLambdas, createGroupedApiActionLambda } from './grouped-lambda-factory.js';
13
16
  import { IamPolicyBuilder } from './iam/iam-policy-builder.js';
14
17
  import { HealthConstruct } from './constructs/health-construct.js';
@@ -175,6 +178,11 @@ export class DomainStack extends cdk.Stack {
175
178
  const environment = {
176
179
  TIB_DOMAIN_ID: domainId,
177
180
  TIB_EVENT_BUS_ARN: eventBusArn,
181
+ // #5282: the runtime resolves the `ctx.publish` bus from
182
+ // EVENT_BUS_NAME (falling back to 'default'). Inject the shared bus
183
+ // name so published domain events land on the project-scoped bus.
184
+ // Prefer the supplied name; derive from the ARN when it is omitted.
185
+ EVENT_BUS_NAME: eventBusName ?? cdk.Fn.select(1, cdk.Fn.split('/', eventBusArn)),
178
186
  };
179
187
  if (databaseUrl) {
180
188
  environment.DATABASE_URL = databaseUrl;
@@ -199,6 +207,56 @@ export class DomainStack extends cdk.Stack {
199
207
  if (Object.keys(crossDomainActionArns).length > 0) {
200
208
  environment.TIB_ACTION_LAMBDA_ARNS = JSON.stringify(crossDomainActionArns);
201
209
  }
210
+ // Transactional outbox (issue #5294): when the domain declares an outbox
211
+ // table in defineDomain, the runtime hydrates a real ctx.outbox backed by
212
+ // Postgres + SQS. The table itself is created by the domain's
213
+ // `outbox-table` migration.
214
+ if (registry.domain.outboxTableName) {
215
+ environment.TIB_OUTBOX_TABLE = registry.domain.outboxTableName;
216
+ }
217
+ // ── Job queues (issue #5294) ────────────────────────────────────────────
218
+ // Pre-create every declared job's SQS queue + DLQ BEFORE any producer
219
+ // Lambda so TIB_JOB_QUEUE_URLS / TIB_JOB_DLQ_ARNS can be injected into
220
+ // the shared environment. The runtime's ctx.jobs.enqueue resolves queue
221
+ // URLs from that map at hydrate time, so every handler that receives ctx
222
+ // can enqueue declared jobs. Queues are encrypted (KMS when enableCmk,
223
+ // else SQS-managed), redrive honors defineJob.maxRetries as RETRY
224
+ // ATTEMPTS AFTER THE FIRST DELIVERY (maxReceiveCount = maxRetries + 1,
225
+ // minimum 1 so zero retries is valid), and the configured visibility
226
+ // timeout is preserved.
227
+ const jobQueues = new Map();
228
+ const jobDlqs = new Map();
229
+ const jobQueueUrlMap = {};
230
+ const jobDlqArnMap = {};
231
+ for (const job of registry.jobs) {
232
+ const pascalId = toPascalCase(job.id);
233
+ const dlq = new sqs.Queue(this, `${pascalId}JobDlq`, {
234
+ retentionPeriod: cdk.Duration.days(14),
235
+ encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
236
+ encryptionMasterKey: enableCmk ? cmkKey : undefined,
237
+ });
238
+ jobDlqs.set(job.id, dlq);
239
+ const queue = new sqs.Queue(this, `${pascalId}JobQueue`, {
240
+ visibilityTimeout: cdk.Duration.seconds(job.visibilityTimeoutSeconds ?? 30),
241
+ deadLetterQueue: {
242
+ queue: dlq,
243
+ maxReceiveCount: maxReceiveCountForJob(job.maxRetries),
244
+ },
245
+ encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
246
+ encryptionMasterKey: enableCmk ? cmkKey : undefined,
247
+ });
248
+ jobQueues.set(job.id, queue);
249
+ jobQueueUrlMap[job.id] = queue.queueUrl;
250
+ jobDlqArnMap[job.id] = dlq.queueArn;
251
+ }
252
+ if (Object.keys(jobQueueUrlMap).length > 0) {
253
+ // Fn::ToJsonString resolves the queue URL tokens at deploy time so the
254
+ // Lambda env var carries the real per-job URLs.
255
+ environment.TIB_JOB_QUEUE_URLS = cdk.Fn.toJsonString(jobQueueUrlMap);
256
+ // Per-job DLQ ARNs back ctx.jobs.replayDlq (operator DLQ redrive via
257
+ // SQS StartMessageMoveTask). Scoped IAM is granted below.
258
+ environment.TIB_JOB_DLQ_ARNS = cdk.Fn.toJsonString(jobDlqArnMap);
259
+ }
202
260
  // Deterministic physical name for THIS domain's internal-action grouped
203
261
  // Lambda so other stacks can construct its ARN for cross-domain calls.
204
262
  const actionLambdaName = `${resourceBaseName}-action`;
@@ -248,6 +306,7 @@ export class DomainStack extends cdk.Stack {
248
306
  apiEntries,
249
307
  environment,
250
308
  eventBusArn,
309
+ eventBusName,
251
310
  reservedConcurrency,
252
311
  logRetentionDays: logRetentionDays ?? 30,
253
312
  lambdaGroupId: group.outboundAccess,
@@ -278,6 +337,7 @@ export class DomainStack extends cdk.Stack {
278
337
  }),
279
338
  environment,
280
339
  eventBusArn,
340
+ eventBusName,
281
341
  dedicated: internalDedicated,
282
342
  // Deterministic physical name so OTHER domain stacks can resolve
283
343
  // this Lambda's ARN for cross-domain `ctx.actions` dispatch.
@@ -319,6 +379,7 @@ export class DomainStack extends cdk.Stack {
319
379
  }),
320
380
  environment,
321
381
  eventBusArn,
382
+ eventBusName,
322
383
  dedicated,
323
384
  reservedConcurrency,
324
385
  logRetentionDays: logRetentionDays ?? 30,
@@ -547,30 +608,55 @@ export class DomainStack extends cdk.Stack {
547
608
  const jobLambdaById = jobHandlers.byId;
548
609
  jobLambdas = jobHandlers.lambdas;
549
610
  this.lambdaArns[`${domainId}-job`] = jobLambdas[0].functionArn;
550
- // Wire SQS → Lambda for each job
611
+ // Wire SQS → Lambda for each job. Queues/DLQs were pre-created above so
612
+ // TIB_JOB_QUEUE_URLS could be injected before any Lambda existed.
613
+ const alarmTopic = alarmSnsTopicArn
614
+ ? sns.Topic.fromTopicArn(this, 'JobDlqAlarmTopic', alarmSnsTopicArn)
615
+ : undefined;
616
+ const alarmAction = alarmTopic ? [new cloudwatchActions.SnsAction(alarmTopic)] : [];
551
617
  for (const job of registry.jobs) {
552
618
  const pascalId = toPascalCase(job.id);
553
- // Create dead-letter queue
554
- const dlq = new sqs.Queue(this, `${pascalId}JobDlq`, {
555
- retentionPeriod: cdk.Duration.days(14),
556
- encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
557
- encryptionMasterKey: enableCmk ? cmkKey : undefined,
619
+ const dlq = jobDlqs.get(job.id);
620
+ const queue = jobQueues.get(job.id);
621
+ // Standard per-job DLQ alarms (issue #5294): fire when any message
622
+ // lands in the DLQ, and when the oldest message ages past 15 minutes.
623
+ // Both are wired to the project SNS topic when one is configured.
624
+ const dlqDepthAlarm = new cloudwatch.Alarm(this, `${pascalId}JobDlqDepthAlarm`, {
625
+ alarmName: `${resourceBaseName}-job-${job.id}-dlq-depth`,
626
+ alarmDescription: `Job ${job.id} DLQ has visible messages — investigate failed job processing`,
627
+ metric: dlq.metricApproximateNumberOfMessagesVisible({
628
+ period: cdk.Duration.minutes(1),
629
+ statistic: 'Maximum',
630
+ }),
631
+ threshold: 0,
632
+ evaluationPeriods: 1,
633
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
634
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
558
635
  });
559
- allDlqs.push(dlq);
560
- // Create main queue with dead-letter queue configuration
561
- const queue = new sqs.Queue(this, `${pascalId}JobQueue`, {
562
- visibilityTimeout: cdk.Duration.seconds(job.visibilityTimeoutSeconds),
563
- deadLetterQueue: {
564
- queue: dlq,
565
- maxReceiveCount: 5,
566
- },
567
- encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
568
- encryptionMasterKey: enableCmk ? cmkKey : undefined,
636
+ if (alarmAction.length > 0)
637
+ dlqDepthAlarm.addAlarmAction(...alarmAction);
638
+ const dlqAgeAlarm = new cloudwatch.Alarm(this, `${pascalId}JobDlqAgeAlarm`, {
639
+ alarmName: `${resourceBaseName}-job-${job.id}-dlq-oldest-age`,
640
+ alarmDescription: `Job ${job.id} DLQ oldest message exceeded 15 minutes — investigate failed job processing`,
641
+ metric: dlq.metricApproximateAgeOfOldestMessage({
642
+ period: cdk.Duration.minutes(5),
643
+ statistic: 'Maximum',
644
+ }),
645
+ threshold: 900,
646
+ evaluationPeriods: 1,
647
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
648
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
569
649
  });
570
- // Add SQS as event source for the first grouped Lambda
650
+ if (alarmAction.length > 0)
651
+ dlqAgeAlarm.addAlarmAction(...alarmAction);
652
+ // Add SQS as event source for the first grouped Lambda. Batch size
653
+ // stays 1 (one message per invocation), but partial-batch failure
654
+ // reporting is enabled so a future batch-size increase degrades
655
+ // safely: only failed messages are retried/redriven (issue #5294).
571
656
  const fn = jobLambdaById.get(job.id);
572
657
  fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
573
658
  batchSize: 1,
659
+ reportBatchItemFailures: true,
574
660
  }));
575
661
  // Grant IAM permissions for this queue
576
662
  const queuePolicies = iamBuilder.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
@@ -620,6 +706,12 @@ export class DomainStack extends cdk.Stack {
620
706
  actionLambdas.forEach((fn) => {
621
707
  iamPolicies.forEach(statement => fn.addToRolePolicy(statement));
622
708
  });
709
+ // #5282: actions publish domain events via `ctx.publish`. The runtime
710
+ // targets the bus named by EVENT_BUS_NAME, so the execution role needs
711
+ // events:PutEvents scoped to the shared bus ARN.
712
+ actionLambdas.forEach((fn) => {
713
+ eventBus.grantPutEventsTo(fn);
714
+ });
623
715
  // Add dbSecretArn grant if provided
624
716
  if (dbSecretArn) {
625
717
  actionLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
@@ -640,6 +732,44 @@ export class DomainStack extends cdk.Stack {
640
732
  addRouteToApi(this, fn, `/${domainId}${action.exposure.path}`, [toHttpMethod(action.exposure.method)], this.httpApi.httpApiId, routeAuth);
641
733
  }
642
734
  }
735
+ // Producer SQS grant (issue #5294): every producer handler — actions,
736
+ // subscribers, schedules, jobs, and webhooks — receives ctx.jobs, so each
737
+ // gets sqs:SendMessage scoped to THIS domain's declared job queue ARNs.
738
+ // Never a wildcard; producers cannot touch other domains' queues.
739
+ if (jobQueues.size > 0) {
740
+ const producerQueueArns = [...jobQueues.values()].map(q => q.queueArn);
741
+ const producerStatements = iamBuilder.forJobProducer(producerQueueArns);
742
+ const producerLambdas = [
743
+ ...actionLambdas,
744
+ ...subscriberLambdas,
745
+ ...scheduleLambdas,
746
+ ...jobLambdas,
747
+ ...webhookLambdas,
748
+ ];
749
+ for (const fn of producerLambdas) {
750
+ producerStatements.forEach(statement => fn.addToRolePolicy(statement));
751
+ }
752
+ }
753
+ // DLQ redrive grant (issue #5294): the runtime's ctx.jobs.replayDlq
754
+ // starts an SQS StartMessageMoveTask on a declared job's DLQ. Every
755
+ // domain Lambda that receives ctx.jobs can invoke it, so grant
756
+ // sqs:StartMessageMoveTask scoped to THIS domain's exact DLQ ARNs — the
757
+ // destination is derived by SQS from the DLQ's redrive policy, so no
758
+ // broader resource is needed.
759
+ if (jobDlqs.size > 0) {
760
+ const dlqArns = [...jobDlqs.values()].map(q => q.queueArn);
761
+ const replayStatements = iamBuilder.forDlqReplay(dlqArns);
762
+ const replayLambdas = [
763
+ ...actionLambdas,
764
+ ...subscriberLambdas,
765
+ ...scheduleLambdas,
766
+ ...jobLambdas,
767
+ ...webhookLambdas,
768
+ ];
769
+ for (const fn of replayLambdas) {
770
+ replayStatements.forEach(statement => fn.addToRolePolicy(statement));
771
+ }
772
+ }
643
773
  // Grant all domain Lambdas read/write access to the per-domain table and bucket
644
774
  const allDomainLambdas = [
645
775
  ...webhookLambdas, ...subscriberLambdas,
@@ -649,6 +779,18 @@ export class DomainStack extends cdk.Stack {
649
779
  domainTable.grantReadWriteData(fn);
650
780
  domainBucket.grantReadWrite(fn);
651
781
  }
782
+ // SQS KMS grants (issue #5294): with enableCmk every queue/DLQ — job
783
+ // queues, subscriber queues — is encrypted with the domain CMK. SQS
784
+ // producers need kms:GenerateDataKey (envelope encrypt) and consumers
785
+ // need kms:Decrypt + kms:GenerateDataKey (envelope decrypt). Grant the
786
+ // exact operations on the exact queue key to every Lambda that touches
787
+ // SQS (producers enqueue, subscribers/jobs consume).
788
+ if (enableCmk && cmkKey) {
789
+ const sqsKmsStatements = iamBuilder.forSqsKmsEncryption(cmkKey.keyArn);
790
+ for (const fn of allDomainLambdas) {
791
+ sqsKmsStatements.forEach(statement => fn.addToRolePolicy(statement));
792
+ }
793
+ }
652
794
  // Grant Cognito Admin permissions when a user pool is configured.
653
795
  // The auth domain's provision-cognito-user internal action needs
654
796
  // AdminCreateUser, AdminSetUserPassword, and AdminGetUser to
@@ -809,6 +951,19 @@ function toPascalCase(s) {
809
951
  .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
810
952
  .join('');
811
953
  }
954
+ /**
955
+ * Map `defineJob.maxRetries` to the SQS redrive policy `maxReceiveCount`.
956
+ *
957
+ * `maxRetries` is documented as the number of RETRY ATTEMPTS AFTER THE FIRST
958
+ * DELIVERY, so the receive count is `maxRetries + 1`. Zero retries is valid
959
+ * (exactly one delivery) and the receive count is clamped to a minimum of 1
960
+ * (SQS rejects 0). This mirrors the domain-runtime `defineJob` contract
961
+ * (issue #5294).
962
+ */
963
+ function maxReceiveCountForJob(maxRetries) {
964
+ const retries = Number.isFinite(maxRetries) && maxRetries !== undefined ? maxRetries : 3;
965
+ return Math.max(1, retries + 1);
966
+ }
812
967
  /**
813
968
  * Convert HTTP method string to ApiGatewayV2 HttpMethod enum.
814
969
  * @param method - HTTP method string (e.g., 'GET', 'POST').
@@ -91,6 +91,14 @@ describe('auth registry — bootstrap-invite registration (#5226)', () => {
91
91
  expect(bootstrapInviteSource).toContain("VALUES ($1, 'system', NULL, $2, 'sys_admin')");
92
92
  expect(bootstrapInviteSource).not.toContain("00000000-0000-0000-0000-000000000000");
93
93
  });
94
+ it('runs SQL through the raw pg client (ctx.db.client.query), not the Drizzle builder', () => {
95
+ // Regression for #5277: `DbContext.query` is the Drizzle query builder —
96
+ // an object, not a function. Calling it (`ctx.db.query(sql, params)`)
97
+ // throws `TypeError: e.db.query is not a function` at runtime. Raw SQL
98
+ // must go through the raw pg client exposed as `DbContext.client`.
99
+ expect(bootstrapInviteSource).toContain('ctx.db.client.query(');
100
+ expect(bootstrapInviteSource).not.toContain('ctx.db.query(');
101
+ });
94
102
  });
95
103
  describe('full documented build process — all current domains', () => {
96
104
  it('generates a registry file for EVERY domain under domains/ (auth, data-management, email, orgs)', () => {
@@ -568,4 +568,69 @@ describe('DomainStack', () => {
568
568
  expect(actionRoute.Properties.AuthorizerId).toBeDefined();
569
569
  });
570
570
  });
571
+ /**
572
+ * Issue #5282 — API action event publishing.
573
+ *
574
+ * Production evidence: the auth API-action Lambda inserted a bootstrap
575
+ * invitation but `ctx.publish('auth.member.invited')` failed with
576
+ * `AccessDeniedException: events:PutEvents` on the EventBridge default bus.
577
+ * The runtime resolves the `ctx.publish` bus from the `EVENT_BUS_NAME` env
578
+ * var (falling back to 'default') and the grouped API-action Lambda's
579
+ * execution role previously had no `events:PutEvents`. These synth tests
580
+ * pin both halves of the packer fix.
581
+ */
582
+ describe('action event publishing (#5282)', () => {
583
+ const busName = 'tib-event-bus';
584
+ it('injects EVENT_BUS_NAME into the grouped API-action Lambda', () => {
585
+ const app = new cdk.App();
586
+ const stack = new DomainStack(app, 'TestDomainStackEventBusName', {
587
+ registry: minimalRegistry,
588
+ eventBusArn,
589
+ eventBusName: busName,
590
+ projectId: 'Test',
591
+ envCode: 'Dev',
592
+ });
593
+ const template = Template.fromStack(stack);
594
+ const lambdas = template.findResources('AWS::Lambda::Function');
595
+ const actionApiLambdas = Object.values(lambdas).filter(l => l.Properties.Environment?.Variables?.POWERTOOLS_SERVICE_NAME === 'test-domain-action-api');
596
+ expect(actionApiLambdas.length).toBeGreaterThan(0);
597
+ for (const fn of actionApiLambdas) {
598
+ expect(fn.Properties.Environment?.Variables?.EVENT_BUS_NAME).toBe(busName);
599
+ }
600
+ });
601
+ it('derives EVENT_BUS_NAME from the bus ARN when eventBusName is omitted', () => {
602
+ const app = new cdk.App();
603
+ const stack = new DomainStack(app, 'TestDomainStackEventBusDerived', {
604
+ registry: minimalRegistry,
605
+ eventBusArn,
606
+ projectId: 'Test',
607
+ envCode: 'Dev',
608
+ });
609
+ const template = Template.fromStack(stack);
610
+ const lambdas = template.findResources('AWS::Lambda::Function');
611
+ const actionApiLambdas = Object.values(lambdas).filter(l => l.Properties.Environment?.Variables?.POWERTOOLS_SERVICE_NAME === 'test-domain-action-api');
612
+ expect(actionApiLambdas.length).toBeGreaterThan(0);
613
+ for (const fn of actionApiLambdas) {
614
+ // Without an explicit name the packer derives it from the supplied
615
+ // bus ARN tail (`...:event-bus/tib-event-bus` → `tib-event-bus`) so
616
+ // published events target the shared bus, not the 'default' one.
617
+ expect(fn.Properties.Environment?.Variables?.EVENT_BUS_NAME).toBe('tib-event-bus');
618
+ }
619
+ });
620
+ it('grants action Lambda roles events:PutEvents scoped to the shared bus', () => {
621
+ const app = new cdk.App();
622
+ const stack = new DomainStack(app, 'TestDomainStackPutEvents', {
623
+ registry: minimalRegistry,
624
+ eventBusArn,
625
+ eventBusName: busName,
626
+ projectId: 'Test',
627
+ envCode: 'Dev',
628
+ });
629
+ const template = Template.fromStack(stack);
630
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
631
+ const hasScopedPutEvents = policies.some(resource => resource.Properties.PolicyDocument.Statement.some(statement => statement.Action?.includes('events:PutEvents')
632
+ && JSON.stringify(statement.Resource).includes('event-bus/tib-event-bus')));
633
+ expect(hasScopedPutEvents).toBe(true);
634
+ });
635
+ });
571
636
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, 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 { DomainStack } from '../DomainStack.js';
7
+ const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer-cmk');
8
+ const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
9
+ fs.mkdirSync(handlerDir, { recursive: true });
10
+ fs.writeFileSync(path.join(handlerDir, 'send-email.ts'), `export const sendEmail = { id: "send-email", _kind: "job", maxRetries: 2, visibilityTimeoutSeconds: 60, handler: async () => undefined };\n`);
11
+ fs.writeFileSync(path.join(handlerDir, 'on-user-created.ts'), `export const onUserCreated = { id: "on-user-created", _kind: "subscriber", event: "auth.user.created", semverRange: ">=1", handler: async () => undefined };\n`);
12
+ // Grouped Lambdas need stub asset dirs to synth.
13
+ const assetDirs = ['subscriber', 'job'].map(type => path.join(process.cwd(), 'dist', 'domains', 'email', type));
14
+ for (const dir of assetDirs) {
15
+ fs.mkdirSync(dir, { recursive: true });
16
+ fs.writeFileSync(path.join(dir, 'index.js'), 'exports.handler = async () => ({});\n');
17
+ }
18
+ afterAll(() => {
19
+ fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
20
+ for (const dir of assetDirs) {
21
+ fs.rmSync(dir, { recursive: true, force: true });
22
+ }
23
+ });
24
+ const eventBusArn = 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus';
25
+ const cmkRegistry = {
26
+ schemaVersion: '1',
27
+ domainRoot: DOMAIN_ROOT,
28
+ domain: { id: 'email', kind: 'domain', name: 'Email', tenancy: 'required', outboxTableName: 'email.outbox' },
29
+ webhooks: [],
30
+ subscribers: [
31
+ {
32
+ id: 'on-user-created', kind: 'subscriber', handlerFile: 'src/handlers/on-user-created.ts',
33
+ event: 'auth.user.created', semverRange: '>=1',
34
+ },
35
+ ],
36
+ schedules: [],
37
+ jobs: [
38
+ { id: 'send-email', kind: 'job', handlerFile: 'src/handlers/send-email.ts', maxRetries: 2, visibilityTimeoutSeconds: 60 },
39
+ ],
40
+ actions: [],
41
+ integrations: [],
42
+ events: [],
43
+ };
44
+ describe('DomainStack enableCmk SQS KMS grants (#5294)', () => {
45
+ const app = new cdk.App();
46
+ const stack = new DomainStack(app, 'CmkTestDomainStack', {
47
+ registry: cmkRegistry,
48
+ eventBusArn,
49
+ projectId: 'Test',
50
+ envCode: 'Dev',
51
+ enableCmk: true,
52
+ });
53
+ const template = Template.fromStack(stack);
54
+ it('encrypts job queues and DLQs with the customer-managed key', () => {
55
+ const queues = Object.values(template.findResources('AWS::SQS::Queue'));
56
+ expect(queues.length).toBeGreaterThan(0);
57
+ for (const q of queues) {
58
+ expect(q.Properties.KmsMasterKeyId).toBeDefined();
59
+ }
60
+ });
61
+ it('grants the exact KMS operations (kms:Decrypt + kms:GenerateDataKey) on the queue key to SQS producers and consumers', () => {
62
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
63
+ const kmsStatements = policies.flatMap(p => p.Properties.PolicyDocument.Statement
64
+ .filter(s => {
65
+ const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
66
+ return actions.some(a => a === 'kms:Decrypt' || a === 'kms:GenerateDataKey');
67
+ }));
68
+ expect(kmsStatements.length).toBeGreaterThan(0);
69
+ // The SQS grant is the statement that carries BOTH exact operations on
70
+ // the domain CMK (S3/DDB grants carry narrower single-operation grants on
71
+ // the same key — do not conflate them).
72
+ const sqsKmsStatement = kmsStatements.find(s => {
73
+ const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
74
+ return actions.includes('kms:Decrypt') && actions.includes('kms:GenerateDataKey');
75
+ });
76
+ expect(sqsKmsStatement).toBeDefined();
77
+ // Scoped to the domain CMK ARN (references the DomainKey resource).
78
+ expect(JSON.stringify(sqsKmsStatement.Resource ?? '')).toContain('DomainKey');
79
+ // No broader kms:* wildcard anywhere.
80
+ for (const s of kmsStatements) {
81
+ const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
82
+ for (const a of actions) {
83
+ expect(String(a)).not.toBe('kms:*');
84
+ }
85
+ }
86
+ });
87
+ it('grants the KMS operations to both job consumers and subscriber consumers (SQS receive paths)', () => {
88
+ // The subscriber and job lambdas consume from KMS-encrypted queues; at
89
+ // least one policy must be attached to a role used by each group. The
90
+ // check is intentionally coarse: every domain Lambda policy that carries
91
+ // SQS actions also carries the KMS decrypt grant.
92
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
93
+ const sqsKmsPolicies = policies.filter(p => p.Properties.PolicyDocument.Statement
94
+ .some(s => {
95
+ const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
96
+ return actions.includes('sqs:ReceiveMessage') || actions.includes('sqs:SendMessage');
97
+ }));
98
+ expect(sqsKmsPolicies.length).toBeGreaterThan(0);
99
+ for (const p of sqsKmsPolicies) {
100
+ const statements = p.Properties.PolicyDocument.Statement;
101
+ const hasKms = statements.some(s => {
102
+ const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
103
+ return actions.includes('kms:Decrypt') || actions.includes('kms:GenerateDataKey');
104
+ });
105
+ expect(hasKms).toBe(true);
106
+ }
107
+ });
108
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, 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 { DomainStack } from '../DomainStack.js';
7
+ const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer-jobs');
8
+ const handlerDir = path.join(DOMAIN_ROOT, 'src', 'handlers');
9
+ fs.mkdirSync(handlerDir, { recursive: true });
10
+ fs.writeFileSync(path.join(handlerDir, 'get-users.ts'), `export const getUsers = { id: "get-users", backendAccess: "domain", exposure: { type: "api", path: "/v1/tenants/{tenantId}/users", method: "GET", auth: "required", tenancy: "required" }, input: { parse: (x) => x }, output: { parse: (x) => x }, idempotent: false, handler: async () => ({ ok: true }) };\n`);
11
+ fs.writeFileSync(path.join(handlerDir, 'send-email.ts'), `export const sendEmail = { id: "send-email", _kind: "job", maxRetries: 4, visibilityTimeoutSeconds: 120, handler: async () => undefined };\n`);
12
+ fs.writeFileSync(path.join(handlerDir, 'sync-users.ts'), `export const syncUsers = { id: "sync-users", _kind: "job", handler: async () => undefined };\n`);
13
+ fs.writeFileSync(path.join(handlerDir, 'no-retry.ts'), `export const noRetry = { id: "no-retry", _kind: "job", maxRetries: 0, handler: async () => undefined };\n`);
14
+ fs.writeFileSync(path.join(handlerDir, 'daily-digest.ts'), `export const dailyDigest = { id: "daily-digest", _kind: "schedule", cron: "rate(1 day)", enabled: true, handler: async () => undefined };\n`);
15
+ fs.writeFileSync(path.join(handlerDir, 'on-user-created.ts'), `export const onUserCreated = { id: "on-user-created", _kind: "subscriber", event: "auth.user.created", semverRange: ">=1", handler: async () => undefined };\n`);
16
+ fs.writeFileSync(path.join(handlerDir, 'github-push.ts'), `export const githubPush = { id: "github-push", _kind: "webhook", path: "/github/push", provider: "github", handler: async () => undefined };\n`);
17
+ // Grouped (non-dedicated) Lambdas use Code.fromAsset('dist/domains/{domain}/{type}').
18
+ // Create stub asset dirs so `Template.fromStack` can synth without a real build.
19
+ const assetDirs = ['webhook', 'subscriber', 'schedule', 'job'].map(type => path.join(process.cwd(), 'dist', 'domains', 'email', type));
20
+ for (const dir of assetDirs) {
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ fs.writeFileSync(path.join(dir, 'index.js'), 'exports.handler = async () => ({});\n');
23
+ }
24
+ afterAll(() => {
25
+ fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
26
+ for (const dir of assetDirs) {
27
+ fs.rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ });
30
+ const eventBusArn = 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus';
31
+ const jobsRegistry = {
32
+ schemaVersion: '1',
33
+ domainRoot: DOMAIN_ROOT,
34
+ domain: {
35
+ id: 'email',
36
+ kind: 'domain',
37
+ name: 'Email',
38
+ tenancy: 'required',
39
+ outboxTableName: 'email.outbox',
40
+ },
41
+ webhooks: [
42
+ {
43
+ id: 'github-push', kind: 'webhook', handlerFile: 'src/handlers/github-push.ts',
44
+ path: '/github/push', provider: 'github',
45
+ },
46
+ ],
47
+ subscribers: [
48
+ {
49
+ id: 'on-user-created', kind: 'subscriber', handlerFile: 'src/handlers/on-user-created.ts',
50
+ event: 'auth.user.created', semverRange: '>=1',
51
+ },
52
+ ],
53
+ schedules: [
54
+ {
55
+ id: 'daily-digest', kind: 'schedule', handlerFile: 'src/handlers/daily-digest.ts',
56
+ cron: 'rate(1 day)', enabled: true,
57
+ },
58
+ ],
59
+ jobs: [
60
+ {
61
+ id: 'send-email', kind: 'job', handlerFile: 'src/handlers/send-email.ts',
62
+ maxRetries: 4, visibilityTimeoutSeconds: 120,
63
+ },
64
+ {
65
+ id: 'sync-users', kind: 'job', handlerFile: 'src/handlers/sync-users.ts',
66
+ maxRetries: 3, visibilityTimeoutSeconds: 30,
67
+ },
68
+ {
69
+ id: 'no-retry', kind: 'job', handlerFile: 'src/handlers/no-retry.ts',
70
+ maxRetries: 0, visibilityTimeoutSeconds: 30,
71
+ },
72
+ ],
73
+ actions: [
74
+ {
75
+ id: 'get-users', kind: 'action', handlerFile: 'src/handlers/get-users.ts',
76
+ backendAccess: 'domain',
77
+ exposure: { type: 'api', path: '/v1/tenants/{tenantId}/users', method: 'GET', auth: 'required', tenancy: 'required' },
78
+ idempotent: false,
79
+ inputSchema: { type: 'object', properties: {} },
80
+ outputSchema: { type: 'object', properties: {} },
81
+ },
82
+ ],
83
+ integrations: [],
84
+ events: [],
85
+ };
86
+ describe('DomainStack jobs/outbox wiring (#5294)', () => {
87
+ const app = new cdk.App();
88
+ const stack = new DomainStack(app, 'JobsTestDomainStack', {
89
+ registry: jobsRegistry,
90
+ eventBusArn,
91
+ projectId: 'Test',
92
+ envCode: 'Dev',
93
+ alarmSnsTopicArn: 'arn:aws:sns:eu-north-1:123456789012:alerts',
94
+ });
95
+ const template = Template.fromStack(stack);
96
+ function domainLambdaEnvVars() {
97
+ const lambdas = template.findResources('AWS::Lambda::Function');
98
+ return Object.entries(lambdas)
99
+ .filter(([id]) => !id.includes('Health') && !id.includes('Ready') && !id.includes('LogRetention'))
100
+ .map(([, res]) => res.Properties.Environment?.Variables ?? {});
101
+ }
102
+ it('injects TIB_JOB_QUEUE_URLS into every producer Lambda environment', () => {
103
+ const envs = domainLambdaEnvVars();
104
+ expect(envs.length).toBeGreaterThan(0);
105
+ for (const env of envs) {
106
+ expect(env.TIB_JOB_QUEUE_URLS).toBeDefined();
107
+ expect(JSON.stringify(env.TIB_JOB_QUEUE_URLS)).toContain('send-email');
108
+ expect(JSON.stringify(env.TIB_JOB_QUEUE_URLS)).toContain('sync-users');
109
+ }
110
+ });
111
+ it('injects TIB_OUTBOX_TABLE when the domain declares an outbox', () => {
112
+ const envs = domainLambdaEnvVars();
113
+ for (const env of envs) {
114
+ expect(env.TIB_OUTBOX_TABLE).toBe('email.outbox');
115
+ }
116
+ });
117
+ it('maps defineJob.maxRetries to maxReceiveCount = maxRetries + 1 (retries AFTER first delivery)', () => {
118
+ const queues = template.findResources('AWS::SQS::Queue');
119
+ const redriveCounts = Object.values(queues)
120
+ .map(q => q.Properties.RedrivePolicy?.maxReceiveCount)
121
+ .filter((v) => v !== undefined)
122
+ .map(v => String(v));
123
+ // send-email maxRetries: 4 → 5 deliveries total
124
+ expect(redriveCounts).toContain('5');
125
+ // sync-users default maxRetries: 3 → 4 deliveries total
126
+ expect(redriveCounts).toContain('4');
127
+ // no-retry maxRetries: 0 → exactly 1 delivery (zero retries is valid)
128
+ expect(redriveCounts).toContain('1');
129
+ // No queue can carry a receive count of 0 (SQS rejects it)
130
+ expect(redriveCounts).not.toContain('0');
131
+ });
132
+ it('enables SQS partial-batch failure reporting on JOB event-source mappings (batch stays 1)', () => {
133
+ const mappings = Object.values(template.findResources('AWS::Lambda::EventSourceMapping'));
134
+ // Filter to JOB mappings only (the subscriber mapping legitimately uses
135
+ // batchSize 10 — job mappings are the #5294 contract).
136
+ const jobMappings = mappings.filter(m => JSON.stringify(m.Properties.EventSourceArn ?? '').includes('JobQueue'));
137
+ expect(jobMappings.length).toBeGreaterThan(0);
138
+ for (const m of jobMappings) {
139
+ const props = m.Properties;
140
+ expect(props.BatchSize).toBe(1);
141
+ // FunctionResponseTypes 'ReportBatchItemFailures' — safe future batch growth
142
+ expect(props.FunctionResponseTypes).toContain('ReportBatchItemFailures');
143
+ }
144
+ });
145
+ it('injects TIB_JOB_DLQ_ARNS (jobId → DLQ ARN) for the ctx.jobs.replayDlq operator path', () => {
146
+ const envs = domainLambdaEnvVars();
147
+ for (const env of envs) {
148
+ expect(env.TIB_JOB_DLQ_ARNS).toBeDefined();
149
+ const raw = JSON.stringify(env.TIB_JOB_DLQ_ARNS);
150
+ // Fn::ToJsonString synthesizes to an Fn::Join token — assert the
151
+ // resolved literals (job ids + the DLQ logical-id references) are present.
152
+ expect(raw).toContain('no-retry');
153
+ expect(raw).toContain('send-email');
154
+ expect(raw).toContain('sync-users');
155
+ expect(raw).toContain('JobDlq');
156
+ }
157
+ });
158
+ it('grants sqs:StartMessageMoveTask scoped to the domain DLQ ARNs (operator replay, no wildcard)', () => {
159
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
160
+ const replayStatements = policies.flatMap(p => p.Properties.PolicyDocument.Statement
161
+ .filter(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('sqs:StartMessageMoveTask')));
162
+ expect(replayStatements.length).toBeGreaterThan(0);
163
+ for (const s of replayStatements) {
164
+ const res = JSON.stringify(s.Resource ?? '');
165
+ expect(res).not.toBe('"*"');
166
+ expect(res).toContain('JobDlq'); // scoped to the job DLQ logical ids
167
+ }
168
+ });
169
+ it('includes webhook handlers in the producer sqs:SendMessage grant (ctx.jobs is hydrated for webhooks)', () => {
170
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
171
+ const webhookPolicy = policies.find(p => p.Properties.PolicyDocument.Statement
172
+ .some(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('sqs:SendMessage')));
173
+ expect(webhookPolicy).toBeDefined();
174
+ });
175
+ it('preserves the configured visibility timeout and keeps encryption enabled', () => {
176
+ const queues = template.findResources('AWS::SQS::Queue');
177
+ const mainQueues = Object.values(queues).filter(q => !String(q.Properties.QueueName ?? '').includes('dlq'));
178
+ expect(mainQueues.some(q => q.Properties.VisibilityTimeout === 120)).toBe(true);
179
+ // SQS-managed encryption is the default — every queue must declare it (no plaintext queues).
180
+ for (const q of Object.values(queues)) {
181
+ expect(q.Properties.SqsManagedSseEnabled).toBe(true);
182
+ }
183
+ });
184
+ it('grants producers sqs:SendMessage scoped to the domain queue ARNs (no wildcard SQS)', () => {
185
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
186
+ const sendStatements = policies.flatMap(p => p.Properties.PolicyDocument.Statement
187
+ .filter(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('sqs:SendMessage')));
188
+ expect(sendStatements.length).toBeGreaterThan(0);
189
+ // The producer grant targets THIS domain's job queue ARNs (logical ids
190
+ // contain "JobQueue"), not the per-handler DLQ grants.
191
+ const producerStatement = sendStatements.find(s => JSON.stringify(s.Resource ?? '').includes('JobQueue'));
192
+ expect(producerStatement).toBeDefined();
193
+ // Never a wildcard — every sqs:SendMessage resource is an exact queue ARN.
194
+ for (const s of sendStatements) {
195
+ const res = JSON.stringify(s.Resource ?? '');
196
+ expect(res).not.toBe('"*"');
197
+ expect(res).not.toContain('*');
198
+ }
199
+ });
200
+ it('creates standard DLQ depth + oldest-age alarms for each job', () => {
201
+ const alarms = Object.values(template.findResources('AWS::CloudWatch::Alarm'))
202
+ .map(a => a.Properties.AlarmName);
203
+ expect(alarms.some(name => name.includes('-job-send-email-dlq-depth'))).toBe(true);
204
+ expect(alarms.some(name => name.includes('-job-send-email-dlq-oldest-age'))).toBe(true);
205
+ expect(alarms.some(name => name.includes('-job-sync-users-dlq-depth'))).toBe(true);
206
+ expect(alarms.some(name => name.includes('-job-sync-users-dlq-oldest-age'))).toBe(true);
207
+ });
208
+ it('wires DLQ alarms to the SNS topic when one is provided', () => {
209
+ const alarms = template.findResources('AWS::CloudWatch::Alarm');
210
+ const depthAlarm = Object.entries(alarms).find(([, a]) => a.Properties.AlarmName.includes('-job-send-email-dlq-depth'));
211
+ expect(depthAlarm).toBeDefined();
212
+ expect(JSON.stringify(depthAlarm[1].Properties.AlarmActions)).toContain('alerts');
213
+ });
214
+ it('does not expose any wildcard SQS policy action', () => {
215
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
216
+ const sqsStatements = policies.flatMap(p => p.Properties.PolicyDocument.Statement
217
+ .filter(s => {
218
+ const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
219
+ return actions.some(a => typeof a === 'string' && a.startsWith('sqs:'));
220
+ }));
221
+ for (const s of sqsStatements) {
222
+ const actions = Array.isArray(s.Action) ? s.Action : [s.Action];
223
+ for (const action of actions) {
224
+ expect(action).not.toBe('sqs:*');
225
+ }
226
+ }
227
+ });
228
+ });
@@ -1,3 +1,4 @@
1
+ import * as cdk from 'aws-cdk-lib';
1
2
  import * as sqs from 'aws-cdk-lib/aws-sqs';
2
3
  import { Construct } from 'constructs';
3
4
  import { LambdaFactory } from '../lambda-factory.js';
@@ -13,10 +14,21 @@ export interface JobConstructProps {
13
14
  lambdaFactory: LambdaFactory;
14
15
  /** Builder for generating IAM policies. */
15
16
  iamBuilder: IamPolicyBuilder;
17
+ /** Whether queues/DLQs use a customer-managed KMS key. Defaults to SQS-managed encryption. */
18
+ enableCmk?: boolean;
19
+ /** KMS key used when enableCmk is true. */
20
+ encryptionKey?: cdk.aws_kms.IKey;
16
21
  }
17
22
  /**
18
23
  * CDK Construct that synthesizes Lambda functions with SQS queues, dead-letter queues,
19
24
  * and event source mappings for background jobs defined in the domain registry.
25
+ *
26
+ * Issue #5294: each job's SQS redrive policy honors `defineJob.maxRetries` as
27
+ * RETRY ATTEMPTS AFTER THE FIRST DELIVERY (`maxReceiveCount = maxRetries + 1`,
28
+ * minimum 1 so zero retries is valid), the configured `visibilityTimeoutSeconds`
29
+ * is preserved, queue/DLQ encryption is always enabled (KMS when `enableCmk`,
30
+ * otherwise SQS-managed), and job event-source mappings report partial batch
31
+ * failures so a future batch-size increase stays safe.
20
32
  */
21
33
  export declare class JobConstruct extends Construct {
22
34
  /**
@@ -5,6 +5,13 @@ import { Construct } from 'constructs';
5
5
  /**
6
6
  * CDK Construct that synthesizes Lambda functions with SQS queues, dead-letter queues,
7
7
  * and event source mappings for background jobs defined in the domain registry.
8
+ *
9
+ * Issue #5294: each job's SQS redrive policy honors `defineJob.maxRetries` as
10
+ * RETRY ATTEMPTS AFTER THE FIRST DELIVERY (`maxReceiveCount = maxRetries + 1`,
11
+ * minimum 1 so zero retries is valid), the configured `visibilityTimeoutSeconds`
12
+ * is preserved, queue/DLQ encryption is always enabled (KMS when `enableCmk`,
13
+ * otherwise SQS-managed), and job event-source mappings report partial batch
14
+ * failures so a future batch-size increase stays safe.
8
15
  */
9
16
  export class JobConstruct extends Construct {
10
17
  /**
@@ -24,23 +31,40 @@ export class JobConstruct extends Construct {
24
31
  const pascalId = toPascalCase(entry.id);
25
32
  const dlq = new sqs.Queue(this, `${pascalId}Dlq`, {
26
33
  retentionPeriod: cdk.Duration.days(14),
34
+ encryption: props.enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
35
+ encryptionMasterKey: props.enableCmk ? props.encryptionKey : undefined,
27
36
  });
28
37
  const queue = new sqs.Queue(this, `${pascalId}Queue`, {
29
- visibilityTimeout: cdk.Duration.seconds(entry.visibilityTimeoutSeconds),
38
+ visibilityTimeout: cdk.Duration.seconds(entry.visibilityTimeoutSeconds ?? 30),
30
39
  deadLetterQueue: {
31
40
  queue: dlq,
32
- maxReceiveCount: entry.maxRetries,
41
+ maxReceiveCount: maxReceiveCountForJob(entry.maxRetries),
33
42
  },
43
+ encryption: props.enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
44
+ encryptionMasterKey: props.enableCmk ? props.encryptionKey : undefined,
34
45
  });
35
46
  this.queues.set(entry.id, queue);
36
47
  const fn = props.lambdaFactory.createFunction(entry, entry.deployment);
37
48
  props.iamBuilder
38
49
  .forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn })
39
50
  .forEach((s) => fn.addToRolePolicy(s));
40
- fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, { batchSize: 1 }));
51
+ fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
52
+ batchSize: 1,
53
+ reportBatchItemFailures: true,
54
+ }));
41
55
  }
42
56
  }
43
57
  }
58
+ /**
59
+ * Map `defineJob.maxRetries` to the SQS redrive policy `maxReceiveCount`.
60
+ *
61
+ * `maxRetries` is the number of retry attempts AFTER the first delivery, so
62
+ * the receive count is `maxRetries + 1` (minimum 1 — zero retries is valid).
63
+ */
64
+ function maxReceiveCountForJob(maxRetries) {
65
+ const retries = Number.isFinite(maxRetries) && maxRetries !== undefined ? maxRetries : 3;
66
+ return Math.max(1, retries + 1);
67
+ }
44
68
  /**
45
69
  * Convert a kebab-case or snake_case string to PascalCase.
46
70
  * @param s - Input string to convert
@@ -45,6 +45,13 @@ export interface GroupedLambdaProps {
45
45
  environment: Record<string, string>;
46
46
  /** ARN of the project-scoped EventBridge bus. */
47
47
  eventBusArn: string;
48
+ /**
49
+ * Name of the project-scoped EventBridge bus. Injected as `EVENT_BUS_NAME`
50
+ * so the runtime's `ctx.publish` targets the shared bus instead of falling
51
+ * back to the AWS 'default' bus (#5282). When omitted, the name is derived
52
+ * from `eventBusArn` (e.g. `arn:...:event-bus/my-bus` → `my-bus`).
53
+ */
54
+ eventBusName?: string;
48
55
  /** VPC to place Lambdas in — required for Aurora connectivity. */
49
56
  vpc?: ec2.IVpc;
50
57
  /** Security groups to attach to the Lambda(s). */
@@ -200,6 +200,7 @@ export function createGroupedLambdas(scope, props) {
200
200
  POWERTOOLS_LOG_LEVEL: 'INFO',
201
201
  TIB_HANDLER_ID: entry.id,
202
202
  TIB_EVENT_BUS_ARN: props.eventBusArn,
203
+ EVENT_BUS_NAME: resolveEventBusName(props.eventBusName, props.eventBusArn),
203
204
  },
204
205
  timeout: cdk.Duration.seconds(30),
205
206
  memorySize: 256,
@@ -226,6 +227,7 @@ export function createGroupedLambdas(scope, props) {
226
227
  POWERTOOLS_LOG_LEVEL: 'INFO',
227
228
  TIB_HANDLER_MAP: JSON.stringify(props.handlerEntries.map(e => e.id)),
228
229
  TIB_EVENT_BUS_ARN: props.eventBusArn,
230
+ EVENT_BUS_NAME: resolveEventBusName(props.eventBusName, props.eventBusArn),
229
231
  },
230
232
  timeout: cdk.Duration.seconds(30),
231
233
  memorySize: 256,
@@ -235,6 +237,15 @@ export function createGroupedLambdas(scope, props) {
235
237
  }),
236
238
  ];
237
239
  }
240
+ /**
241
+ * Resolves the EventBridge bus name injected as `EVENT_BUS_NAME`.
242
+ * Prefers the explicitly supplied name (avoids ARN parsing when the ARN is a
243
+ * dynamic reference); falls back to deriving the name from the ARN tail
244
+ * (`arn:...:event-bus/{name}` → `{name}`).
245
+ */
246
+ function resolveEventBusName(eventBusName, eventBusArn) {
247
+ return eventBusName ?? cdk.Fn.select(1, cdk.Fn.split('/', eventBusArn));
248
+ }
238
249
  function toLogRetention(days) {
239
250
  const map = {
240
251
  30: logs.RetentionDays.ONE_MONTH,
@@ -329,6 +340,7 @@ export function createGroupedApiActionLambda(scope, props) {
329
340
  POWERTOOLS_SERVICE_NAME: serviceName,
330
341
  POWERTOOLS_LOG_LEVEL: 'INFO',
331
342
  TIB_EVENT_BUS_ARN: props.eventBusArn,
343
+ EVENT_BUS_NAME: resolveEventBusName(props.eventBusName, props.eventBusArn),
332
344
  },
333
345
  timeout: cdk.Duration.seconds(30),
334
346
  memorySize: 256,
@@ -58,6 +58,35 @@ export declare class IamPolicyBuilder {
58
58
  * @returns Array of policy statements for SQS message receive/delete and DLQ send access.
59
59
  */
60
60
  forJob(params: QueuePolicyParams): iam.PolicyStatement[];
61
+ /**
62
+ * Returns IAM policy statements for PRODUCER handlers that enqueue jobs via
63
+ * `ctx.jobs.enqueue(...)`. Grants `sqs:SendMessage` scoped to the exact
64
+ * queue ARNs of THIS domain's declared `defineJob` queues — never a
65
+ * wildcard. Applies to action, subscriber, schedule, job, and webhook roles
66
+ * because the runtime exposes `ctx.jobs` to all of them.
67
+ * @param queueArns - Exact ARNs of the domain's declared job queues.
68
+ * @returns Array of policy statements (empty when there are no queues).
69
+ */
70
+ forJobProducer(queueArns: string[]): iam.PolicyStatement[];
71
+ /**
72
+ * Returns IAM policy statements for the operator DLQ redrive operation
73
+ * (`ctx.jobs.replayDlq`, SQS `StartMessageMoveTask`). Scoped to the exact
74
+ * DLQ ARNs of THIS domain's declared `defineJob` queues — never a wildcard.
75
+ * The move-task destination is derived by SQS from the DLQ's redrive
76
+ * policy, so no destination grant is needed.
77
+ * @param dlqArns - Exact ARNs of the domain's job DLQs.
78
+ * @returns Array of policy statements (empty when there are no DLQs).
79
+ */
80
+ forDlqReplay(dlqArns: string[]): iam.PolicyStatement[];
81
+ /**
82
+ * Returns IAM policy statements for SQS KMS envelope encryption on the
83
+ * domain CMK (enableCmk mode). Producers need `kms:GenerateDataKey` to
84
+ * encrypt a message; consumers need `kms:Decrypt` + `kms:GenerateDataKey`
85
+ * to decrypt it. Both are granted on the exact queue key ARN.
86
+ * @param keyArn - ARN of the domain CMK used to encrypt the queues.
87
+ * @returns Array of policy statements (exact KMS operations on the key).
88
+ */
89
+ forSqsKmsEncryption(keyArn: string): iam.PolicyStatement[];
61
90
  /**
62
91
  * Returns IAM policy statements for callable action handlers.
63
92
  * @param crossDomainActionArns - Optional ARNs of other domain action Lambdas to invoke.
@@ -91,6 +91,60 @@ export class IamPolicyBuilder {
91
91
  }),
92
92
  ];
93
93
  }
94
+ /**
95
+ * Returns IAM policy statements for PRODUCER handlers that enqueue jobs via
96
+ * `ctx.jobs.enqueue(...)`. Grants `sqs:SendMessage` scoped to the exact
97
+ * queue ARNs of THIS domain's declared `defineJob` queues — never a
98
+ * wildcard. Applies to action, subscriber, schedule, job, and webhook roles
99
+ * because the runtime exposes `ctx.jobs` to all of them.
100
+ * @param queueArns - Exact ARNs of the domain's declared job queues.
101
+ * @returns Array of policy statements (empty when there are no queues).
102
+ */
103
+ forJobProducer(queueArns) {
104
+ if (queueArns.length === 0)
105
+ return [];
106
+ return [
107
+ new iam.PolicyStatement({
108
+ actions: ['sqs:SendMessage'],
109
+ resources: queueArns,
110
+ }),
111
+ ];
112
+ }
113
+ /**
114
+ * Returns IAM policy statements for the operator DLQ redrive operation
115
+ * (`ctx.jobs.replayDlq`, SQS `StartMessageMoveTask`). Scoped to the exact
116
+ * DLQ ARNs of THIS domain's declared `defineJob` queues — never a wildcard.
117
+ * The move-task destination is derived by SQS from the DLQ's redrive
118
+ * policy, so no destination grant is needed.
119
+ * @param dlqArns - Exact ARNs of the domain's job DLQs.
120
+ * @returns Array of policy statements (empty when there are no DLQs).
121
+ */
122
+ forDlqReplay(dlqArns) {
123
+ if (dlqArns.length === 0)
124
+ return [];
125
+ return [
126
+ new iam.PolicyStatement({
127
+ actions: ['sqs:StartMessageMoveTask'],
128
+ resources: dlqArns,
129
+ }),
130
+ ];
131
+ }
132
+ /**
133
+ * Returns IAM policy statements for SQS KMS envelope encryption on the
134
+ * domain CMK (enableCmk mode). Producers need `kms:GenerateDataKey` to
135
+ * encrypt a message; consumers need `kms:Decrypt` + `kms:GenerateDataKey`
136
+ * to decrypt it. Both are granted on the exact queue key ARN.
137
+ * @param keyArn - ARN of the domain CMK used to encrypt the queues.
138
+ * @returns Array of policy statements (exact KMS operations on the key).
139
+ */
140
+ forSqsKmsEncryption(keyArn) {
141
+ return [
142
+ new iam.PolicyStatement({
143
+ actions: ['kms:Decrypt', 'kms:GenerateDataKey'],
144
+ resources: [keyArn],
145
+ }),
146
+ ];
147
+ }
94
148
  /**
95
149
  * Returns IAM policy statements for callable action handlers.
96
150
  * @param crossDomainActionArns - Optional ARNs of other domain action Lambdas to invoke.
@@ -262,6 +262,13 @@ export interface DomainRegistryEntry extends BaseRegistryEntry {
262
262
  tenancy: string;
263
263
  /** Optional default deployment configuration for the domain. */
264
264
  defaultDeployment?: SerialDeploymentConfig;
265
+ /**
266
+ * Optional transactional outbox table (schema-qualified, e.g. `email.outbox`).
267
+ * When declared, the CDK packer injects `TIB_OUTBOX_TABLE` so the runtime
268
+ * hydrates a real `ctx.outbox` (append/dispatch) for this domain (#5294).
269
+ * The table must be created by the domain's `outbox-table` migration.
270
+ */
271
+ outboxTableName?: string;
265
272
  }
266
273
  /**
267
274
  * Union type of all registry entry kinds.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cdk-packer",
3
- "version": "0.2.103",
3
+ "version": "0.2.105",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",