@mettlecast/domain-cdk-packer 0.2.111 → 0.2.112

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.
@@ -664,6 +664,22 @@ export class DomainStack extends cdk.Stack {
664
664
  const queuePolicies = iamBuilder.forJob({ queueArn: queue.queueArn, dlqArn: dlq.queueArn });
665
665
  queuePolicies.forEach(statement => fn.addToRolePolicy(statement));
666
666
  }
667
+ // Cross-domain action dispatch from jobs (#5389): job consumers are
668
+ // first-class orchestrators — the claim-guarded delivery job invokes
669
+ // `email.send-template-email` through `ctx.actions`, exactly like the
670
+ // subscriber path (#5226). Without this grant the durable delivery
671
+ // path would fail with AccessDenied on every attempt and land
672
+ // everything in the job DLQ. Same resolved deterministic-ARN list the
673
+ // subscriber wiring uses — never a wildcard.
674
+ const jobCrossDomainActionArns = Object.values(crossDomainActionArns);
675
+ if (jobCrossDomainActionArns.length > 0) {
676
+ jobLambdas.forEach((fn) => {
677
+ fn.addToRolePolicy(new iam.PolicyStatement({
678
+ actions: ['lambda:InvokeFunction'],
679
+ resources: jobCrossDomainActionArns,
680
+ }));
681
+ });
682
+ }
667
683
  // Add dbSecretArn grant if provided
668
684
  if (dbSecretArn) {
669
685
  jobLambdas.forEach(fn => fn.addToRolePolicy(new iam.PolicyStatement({
@@ -0,0 +1,170 @@
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
+ /**
8
+ * #5389 — synth-level contract for the durable invitation-delivery wiring
9
+ * in the auth domain stack:
10
+ *
11
+ * 1. the domain declares `auth.outbox`, so every Lambda gets
12
+ * TIB_OUTBOX_TABLE (the dispatcher schedule needs a real ctx.outbox);
13
+ * 2. the claim-guarded `deliver-invitation-email` job gets its encrypted
14
+ * queue + DLQ, redrive `maxReceiveCount = maxRetries + 1`, and the
15
+ * standard DLQ depth/age alarms wired to the SNS topic;
16
+ * 3. the JOB Lambda role receives the cross-domain
17
+ * `lambda:InvokeFunction` grant scoped to the resolved internal-action
18
+ * ARNs — without it, the job's ctx.actions.email['send-template-email']
19
+ * call would AccessDenied on every attempt (the gap this issue found:
20
+ * jobs were never granted what subscribers already had);
21
+ * 4. reconciliation + dispatch schedules synthesize EventBridge Scheduler
22
+ * rules.
23
+ */
24
+ const DOMAIN_ROOT = path.join(process.cwd(), '.test-domain-cdk-packer-5389');
25
+ const handlerDir = path.join(DOMAIN_ROOT, 'subscribers');
26
+ fs.mkdirSync(handlerDir, { recursive: true });
27
+ fs.mkdirSync(path.join(DOMAIN_ROOT, 'jobs'), { recursive: true });
28
+ fs.mkdirSync(path.join(DOMAIN_ROOT, 'schedules'), { recursive: true });
29
+ fs.writeFileSync(path.join(handlerDir, 'on-member-invited.ts'), 'export const onMemberInvited = { id: "on-member-invited", _kind: "subscriber", event: "auth.member.invited", semverRange: ">=1", handler: async () => undefined };\n');
30
+ fs.writeFileSync(path.join(DOMAIN_ROOT, 'jobs', 'deliver-invitation-email.ts'), 'export const deliverInvitationEmail = { id: "deliver-invitation-email", _kind: "job", maxRetries: 5, visibilityTimeoutSeconds: 300, handler: async () => undefined };\n');
31
+ fs.writeFileSync(path.join(DOMAIN_ROOT, 'schedules', 'dispatch-invitation-outbox.ts'), 'export const dispatchInvitationOutbox = { id: "dispatch-invitation-outbox", _kind: "schedule", cron: "rate(1 minute)", enabled: true, handler: async () => undefined };\n');
32
+ fs.writeFileSync(path.join(DOMAIN_ROOT, 'schedules', 'reconcile-invitation-delivery.ts'), 'export const reconcileInvitationDelivery = { id: "reconcile-invitation-delivery", _kind: "schedule", cron: "rate(15 minutes)", enabled: true, handler: async () => undefined };\n');
33
+ // Grouped Lambdas use Code.fromAsset('dist/domains/{domain}/{type}') — stub
34
+ // the asset dirs so Template.fromStack can synth without a real bundle.
35
+ const assetDirs = ['subscriber', 'schedule', 'job'].map(type => path.join(process.cwd(), 'dist', 'domains', 'auth', type));
36
+ for (const dir of assetDirs) {
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ fs.writeFileSync(path.join(dir, 'index.js'), 'exports.handler = async () => ({});\n');
39
+ }
40
+ afterAll(() => {
41
+ fs.rmSync(DOMAIN_ROOT, { recursive: true, force: true });
42
+ for (const dir of assetDirs) {
43
+ fs.rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+ const EMAIL_ACTION_ARN = 'arn:aws:lambda:eu-north-1:123456789012:function:MTC-Dev-domain-email-actionapi';
47
+ const authRegistry = {
48
+ schemaVersion: '1',
49
+ domainRoot: DOMAIN_ROOT,
50
+ domain: {
51
+ id: 'auth',
52
+ kind: 'domain',
53
+ name: 'Auth',
54
+ tenancy: 'required',
55
+ outboxTableName: 'auth.outbox',
56
+ },
57
+ webhooks: [],
58
+ subscribers: [
59
+ {
60
+ id: 'on-member-invited', kind: 'subscriber', handlerFile: 'subscribers/on-member-invited.ts',
61
+ event: 'auth.member.invited', semverRange: '>=1',
62
+ },
63
+ ],
64
+ schedules: [
65
+ {
66
+ id: 'dispatch-invitation-outbox', kind: 'schedule', handlerFile: 'schedules/dispatch-invitation-outbox.ts',
67
+ cron: 'rate(1 minute)', enabled: true,
68
+ },
69
+ {
70
+ id: 'reconcile-invitation-delivery', kind: 'schedule', handlerFile: 'schedules/reconcile-invitation-delivery.ts',
71
+ cron: 'rate(15 minutes)', enabled: true,
72
+ },
73
+ ],
74
+ jobs: [
75
+ {
76
+ id: 'deliver-invitation-email', kind: 'job', handlerFile: 'jobs/deliver-invitation-email.ts',
77
+ maxRetries: 5, visibilityTimeoutSeconds: 300,
78
+ },
79
+ ],
80
+ actions: [],
81
+ integrations: [],
82
+ events: [],
83
+ };
84
+ const app = new cdk.App();
85
+ const stack = new DomainStack(app, 'InvitationDelivery5389Stack', {
86
+ registry: authRegistry,
87
+ eventBusArn: 'arn:aws:events:eu-north-1:123456789012:event-bus/tib-event-bus',
88
+ projectId: 'Test',
89
+ envCode: 'Dev',
90
+ alarmSnsTopicArn: 'arn:aws:sns:eu-north-1:123456789012:alerts',
91
+ crossDomainActionArns: { email: EMAIL_ACTION_ARN },
92
+ });
93
+ const template = Template.fromStack(stack);
94
+ /**
95
+ * Identify the delivery JOB Lambda via its SQS event-source mapping (only
96
+ * the job consumes the JobQueue; env-var maps are injected into every
97
+ * domain Lambda, so they cannot identify it), then resolve its execution
98
+ * role logical id through the Lambda's Fn::GetAtt.
99
+ */
100
+ function jobLambdaRoleLogicalId() {
101
+ const mappings = Object.values(template.findResources('AWS::Lambda::EventSourceMapping'));
102
+ const jobMapping = mappings.find(m => JSON.stringify(m.Properties.EventSourceArn ?? '').includes('JobQueue'));
103
+ expect(jobMapping, 'job SQS event-source mapping must exist').toBeDefined();
104
+ const jobLambdaLogicalId = jobMapping.Properties.FunctionName.Ref;
105
+ const lambdas = template.findResources('AWS::Lambda::Function');
106
+ const jobLambda = lambdas[jobLambdaLogicalId];
107
+ expect(jobLambda, `job Lambda resource ${jobLambdaLogicalId} must exist`).toBeDefined();
108
+ const roleRef = jobLambda.Properties.Role;
109
+ expect(roleRef['Fn::GetAtt'], 'job Lambda role must be a GetAtt reference').toBeDefined();
110
+ return roleRef['Fn::GetAtt'][0];
111
+ }
112
+ describe('DomainStack invitation-delivery wiring (#5389)', () => {
113
+ it('injects TIB_OUTBOX_TABLE=auth.outbox into the domain Lambdas', () => {
114
+ const envs = Object.values(template.findResources('AWS::Lambda::Function'))
115
+ .map(res => (res.Properties.Environment?.Variables ?? {}))
116
+ .filter(env => env.TIB_OUTBOX_TABLE !== undefined);
117
+ expect(envs.length).toBeGreaterThan(0);
118
+ for (const env of envs) {
119
+ expect(env.TIB_OUTBOX_TABLE).toBe('auth.outbox');
120
+ }
121
+ });
122
+ it('gives the delivery job queue+DLQ with maxReceiveCount = maxRetries + 1 (6)', () => {
123
+ const queues = Object.values(template.findResources('AWS::SQS::Queue'));
124
+ const redrive = queues
125
+ .map(q => q.Properties.RedrivePolicy?.maxReceiveCount)
126
+ .filter(v => v !== undefined)
127
+ .map(v => String(v));
128
+ expect(redrive).toContain('6');
129
+ // the job queue keeps its configured visibility timeout
130
+ expect(queues.some(q => q.Properties.VisibilityTimeout === 300)).toBe(true);
131
+ });
132
+ it('creates DLQ depth + oldest-age alarms for the delivery job wired to SNS', () => {
133
+ const alarms = Object.values(template.findResources('AWS::CloudWatch::Alarm'));
134
+ const depth = alarms.find(a => a.Properties.AlarmName?.includes('-job-deliver-invitation-email-dlq-depth'));
135
+ const age = alarms.find(a => a.Properties.AlarmName?.includes('-job-deliver-invitation-email-dlq-oldest-age'));
136
+ expect(depth).toBeDefined();
137
+ expect(age).toBeDefined();
138
+ expect(JSON.stringify(depth.Properties.AlarmActions)).toContain('alerts');
139
+ });
140
+ it('grants the JOB Lambda role cross-domain lambda:InvokeFunction on the resolved action ARNs (#5389 gap)', () => {
141
+ const jobRoleLogicalId = jobLambdaRoleLogicalId();
142
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
143
+ const jobPolicy = policies.find(p => {
144
+ const roles = p.Properties.Roles;
145
+ return roles.some(r => (typeof r === 'string' ? r : r.Ref) === jobRoleLogicalId);
146
+ });
147
+ expect(jobPolicy, 'IAM policy attached to the job Lambda role must exist').toBeDefined();
148
+ const statements = jobPolicy.Properties.PolicyDocument.Statement;
149
+ const invokeStatements = statements.filter(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('lambda:InvokeFunction'));
150
+ expect(invokeStatements.length).toBeGreaterThan(0);
151
+ const resources = JSON.stringify(invokeStatements.map(s => s.Resource));
152
+ expect(resources).toContain('MTC-Dev-domain-email-actionapi');
153
+ // scoped to exact ARNs, never a wildcard
154
+ expect(resources).not.toContain('"*"');
155
+ });
156
+ it('synthesizes EventBridge Scheduler rules for the dispatcher and reconciliation schedules', () => {
157
+ const schedules = Object.values(template.findResources('AWS::Scheduler::Schedule'));
158
+ const expressions = schedules.map(s => s.Properties.ScheduleExpression);
159
+ expect(expressions).toContain('rate(1 minute)');
160
+ expect(expressions).toContain('rate(15 minutes)');
161
+ });
162
+ it('keeps the subscriber cross-domain grant (delivery mirror path) intact', () => {
163
+ const policies = Object.values(template.findResources('AWS::IAM::Policy'));
164
+ const invokeForSubscriber = policies.some(p => {
165
+ const statements = p.Properties.PolicyDocument.Statement;
166
+ return statements.some(s => (Array.isArray(s.Action) ? s.Action : [s.Action]).includes('lambda:InvokeFunction'));
167
+ });
168
+ expect(invokeForSubscriber).toBe(true);
169
+ });
170
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cdk-packer",
3
- "version": "0.2.111",
3
+ "version": "0.2.112",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",