@mettlecast/domain-cdk-packer 0.2.104 → 0.2.106

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';
@@ -204,6 +207,56 @@ export class DomainStack extends cdk.Stack {
204
207
  if (Object.keys(crossDomainActionArns).length > 0) {
205
208
  environment.TIB_ACTION_LAMBDA_ARNS = JSON.stringify(crossDomainActionArns);
206
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
+ }
207
260
  // Deterministic physical name for THIS domain's internal-action grouped
208
261
  // Lambda so other stacks can construct its ARN for cross-domain calls.
209
262
  const actionLambdaName = `${resourceBaseName}-action`;
@@ -555,30 +608,55 @@ export class DomainStack extends cdk.Stack {
555
608
  const jobLambdaById = jobHandlers.byId;
556
609
  jobLambdas = jobHandlers.lambdas;
557
610
  this.lambdaArns[`${domainId}-job`] = jobLambdas[0].functionArn;
558
- // 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)] : [];
559
617
  for (const job of registry.jobs) {
560
618
  const pascalId = toPascalCase(job.id);
561
- // Create dead-letter queue
562
- const dlq = new sqs.Queue(this, `${pascalId}JobDlq`, {
563
- retentionPeriod: cdk.Duration.days(14),
564
- encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
565
- 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,
566
635
  });
567
- allDlqs.push(dlq);
568
- // Create main queue with dead-letter queue configuration
569
- const queue = new sqs.Queue(this, `${pascalId}JobQueue`, {
570
- visibilityTimeout: cdk.Duration.seconds(job.visibilityTimeoutSeconds),
571
- deadLetterQueue: {
572
- queue: dlq,
573
- maxReceiveCount: 5,
574
- },
575
- encryption: enableCmk ? sqs.QueueEncryption.KMS : sqs.QueueEncryption.SQS_MANAGED,
576
- 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,
577
649
  });
578
- // 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).
579
656
  const fn = jobLambdaById.get(job.id);
580
657
  fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
581
658
  batchSize: 1,
659
+ reportBatchItemFailures: true,
582
660
  }));
583
661
  // Grant IAM permissions for this queue
584
662
  const queuePolicies = iamBuilder.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
@@ -654,6 +732,44 @@ export class DomainStack extends cdk.Stack {
654
732
  addRouteToApi(this, fn, `/${domainId}${action.exposure.path}`, [toHttpMethod(action.exposure.method)], this.httpApi.httpApiId, routeAuth);
655
733
  }
656
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
+ }
657
773
  // Grant all domain Lambdas read/write access to the per-domain table and bucket
658
774
  const allDomainLambdas = [
659
775
  ...webhookLambdas, ...subscriberLambdas,
@@ -663,6 +779,18 @@ export class DomainStack extends cdk.Stack {
663
779
  domainTable.grantReadWriteData(fn);
664
780
  domainBucket.grantReadWrite(fn);
665
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
+ }
666
794
  // Grant Cognito Admin permissions when a user pool is configured.
667
795
  // The auth domain's provision-cognito-user internal action needs
668
796
  // AdminCreateUser, AdminSetUserPassword, and AdminGetUser to
@@ -692,14 +820,16 @@ export class DomainStack extends cdk.Stack {
692
820
  }
693
821
  }
694
822
  // Grant SES SendEmail permission when a verified sending address is configured.
695
- // Scoped to the SES identity ARN derived from SES_FROM_EMAIL (e.g. an
696
- // address or domain identity) — least-privilege. Falls back to 'arn:aws:ses:*:*:*'
697
- // only when the address is not address-shaped (tests/local).
823
+ // Scoped to the SES identity ARN derived from SES_FROM_EMAIL — least-privilege.
824
+ // SES_FROM_EMAIL may be address-shaped (noreply@example.com) or a bare domain
825
+ // (example.com). The Email Domain settings tab always creates a *domain*
826
+ // identity, so an address-shaped value must be reduced to its domain part:
827
+ // scoping to `identity/noreply@example.com` would not match the created
828
+ // `identity/example.com` and ses:SendEmail would be denied at runtime.
698
829
  if (process.env['SES_FROM_EMAIL']) {
699
830
  const sesFrom = process.env['SES_FROM_EMAIL'];
700
- const sesIdentity = sesFrom.includes('@')
701
- ? `arn:aws:ses:${this.region}:${this.account}:identity/${sesFrom}`
702
- : `arn:aws:ses:${this.region}:${this.account}:identity/${sesFrom}`;
831
+ const sesIdentityDomain = (sesFrom.includes('@') ? sesFrom.split('@')[1] : sesFrom).toLowerCase();
832
+ const sesIdentity = `arn:aws:ses:${this.region}:${this.account}:identity/${sesIdentityDomain}`;
703
833
  for (const fn of allDomainLambdas) {
704
834
  fn.addToRolePolicy(new iam.PolicyStatement({
705
835
  actions: ['ses:SendEmail', 'ses:SendRawEmail'],
@@ -823,6 +953,19 @@ function toPascalCase(s) {
823
953
  .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
824
954
  .join('');
825
955
  }
956
+ /**
957
+ * Map `defineJob.maxRetries` to the SQS redrive policy `maxReceiveCount`.
958
+ *
959
+ * `maxRetries` is documented as the number of RETRY ATTEMPTS AFTER THE FIRST
960
+ * DELIVERY, so the receive count is `maxRetries + 1`. Zero retries is valid
961
+ * (exactly one delivery) and the receive count is clamped to a minimum of 1
962
+ * (SQS rejects 0). This mirrors the domain-runtime `defineJob` contract
963
+ * (issue #5294).
964
+ */
965
+ function maxReceiveCountForJob(maxRetries) {
966
+ const retries = Number.isFinite(maxRetries) && maxRetries !== undefined ? maxRetries : 3;
967
+ return Math.max(1, retries + 1);
968
+ }
826
969
  /**
827
970
  * Convert HTTP method string to ApiGatewayV2 HttpMethod enum.
828
971
  * @param method - HTTP method string (e.g., 'GET', 'POST').
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterAll } from 'vitest';
1
+ import { describe, it, expect, afterAll, afterEach } from 'vitest';
2
2
  import * as cdk from 'aws-cdk-lib';
3
3
  import { Template } from 'aws-cdk-lib/assertions';
4
4
  import fs from 'node:fs';
@@ -633,4 +633,68 @@ describe('DomainStack', () => {
633
633
  expect(hasScopedPutEvents).toBe(true);
634
634
  });
635
635
  });
636
+ /**
637
+ * Review finding H1 (#5292): the ses:SendEmail/ses:SendRawEmail grant must
638
+ * scope to the *domain* SES identity that the Email Domain settings tab
639
+ * creates. SES_FROM_EMAIL is address-shaped (`noreply@example.com`) after
640
+ * sender activation — building `identity/noreply@example.com` would never
641
+ * match the created `identity/example.com` and email would be denied.
642
+ */
643
+ describe('SES send grant scoped to the domain identity (#5292 H1)', () => {
644
+ const originalSesFromEmail = process.env.SES_FROM_EMAIL;
645
+ afterEach(() => {
646
+ if (originalSesFromEmail === undefined)
647
+ delete process.env.SES_FROM_EMAIL;
648
+ else
649
+ process.env.SES_FROM_EMAIL = originalSesFromEmail;
650
+ });
651
+ const sesResourceStrings = (template) => Object.values(template.findResources('AWS::IAM::Policy')).flatMap(p => p.Properties.PolicyDocument.Statement
652
+ .filter(s => s.Action?.includes('ses:SendEmail'))
653
+ .map(s => JSON.stringify(s.Resource)));
654
+ it('scopes an address-shaped SES_FROM_EMAIL to identity/example.com, never identity/noreply@example.com', () => {
655
+ process.env.SES_FROM_EMAIL = 'noreply@example.com';
656
+ const app = new cdk.App();
657
+ const stack = new DomainStack(app, 'TestDomainStackSesAddress', {
658
+ registry: minimalRegistry,
659
+ eventBusArn,
660
+ projectId: 'Test',
661
+ envCode: 'Dev',
662
+ });
663
+ const template = Template.fromStack(stack);
664
+ const resources = sesResourceStrings(template);
665
+ expect(resources.length).toBeGreaterThan(0);
666
+ for (const resource of resources) {
667
+ expect(resource).toContain('identity/example.com');
668
+ expect(resource).not.toContain('identity/noreply@example.com');
669
+ }
670
+ });
671
+ it('keeps plain-domain behavior: a bare domain SES_FROM_EMAIL scopes to identity/example.com', () => {
672
+ process.env.SES_FROM_EMAIL = 'example.com';
673
+ const app = new cdk.App();
674
+ const stack = new DomainStack(app, 'TestDomainStackSesDomain', {
675
+ registry: minimalRegistry,
676
+ eventBusArn,
677
+ projectId: 'Test',
678
+ envCode: 'Dev',
679
+ });
680
+ const template = Template.fromStack(stack);
681
+ const resources = sesResourceStrings(template);
682
+ expect(resources.length).toBeGreaterThan(0);
683
+ for (const resource of resources) {
684
+ expect(resource).toContain('identity/example.com');
685
+ }
686
+ });
687
+ it('omits the SES grant when SES_FROM_EMAIL is not set', () => {
688
+ delete process.env.SES_FROM_EMAIL;
689
+ const app = new cdk.App();
690
+ const stack = new DomainStack(app, 'TestDomainStackSesUnset', {
691
+ registry: minimalRegistry,
692
+ eventBusArn,
693
+ projectId: 'Test',
694
+ envCode: 'Dev',
695
+ });
696
+ const template = Template.fromStack(stack);
697
+ expect(sesResourceStrings(template)).toHaveLength(0);
698
+ });
699
+ });
636
700
  });
@@ -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
@@ -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.104",
3
+ "version": "0.2.106",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",