@deployfoundation/foundation-deploy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +174 -0
  2. package/agent-image/Dockerfile +254 -0
  3. package/agent-image/bin/aws +36 -0
  4. package/agent-image/bin/gh +193 -0
  5. package/agent-image/bin/git-credential-sky +89 -0
  6. package/agent-image/security-overlay.yml +176 -0
  7. package/cdk.json +6 -0
  8. package/dist/bin/app.js +112 -0
  9. package/dist/bin/foundation-deploy.js +1906 -0
  10. package/dist/bin/release-account.js +154 -0
  11. package/dist/chunk-4aye5cee.js +2416 -0
  12. package/dist/chunk-9ddxyvq2.js +1455 -0
  13. package/dist/chunk-v7tz8g50.js +428 -0
  14. package/dist/src/index.js +88 -0
  15. package/package.json +38 -0
  16. package/pipeline/buildspec.yml +34 -0
  17. package/src/artifacts.ts +318 -0
  18. package/src/deploy/assets/github-app-manifest.yml +29 -0
  19. package/src/deploy/assets/slack-app-manifest.yml +95 -0
  20. package/src/deploy/aws.ts +265 -0
  21. package/src/deploy/cli.ts +212 -0
  22. package/src/deploy/config-sync.ts +93 -0
  23. package/src/deploy/config.ts +29 -0
  24. package/src/deploy/deploy.ts +566 -0
  25. package/src/deploy/endpoint.ts +242 -0
  26. package/src/deploy/github-app-create.ts +154 -0
  27. package/src/deploy/github-app-manifest.ts +53 -0
  28. package/src/deploy/image.ts +80 -0
  29. package/src/deploy/instance.ts +87 -0
  30. package/src/deploy/license-cache.ts +47 -0
  31. package/src/deploy/license.ts +272 -0
  32. package/src/deploy/paths.ts +65 -0
  33. package/src/deploy/post-deploy.ts +97 -0
  34. package/src/deploy/release.ts +282 -0
  35. package/src/deploy/runtime-secret.ts +241 -0
  36. package/src/deploy/setup.ts +393 -0
  37. package/src/deploy/sh.ts +74 -0
  38. package/src/deploy/slack-manifest.ts +112 -0
  39. package/src/deploy/stage-customization.ts +224 -0
  40. package/src/deploy/tracing.ts +243 -0
  41. package/src/deploy-permissions.ts +165 -0
  42. package/src/index.ts +60 -0
  43. package/src/lambda-bundle-context.ts +64 -0
  44. package/src/names.ts +170 -0
  45. package/src/release/kms.ts +86 -0
  46. package/src/release/manifest.ts +265 -0
  47. package/src/stacks/agent-stack.ts +938 -0
  48. package/src/stacks/api-stack.ts +1005 -0
  49. package/src/stacks/ci-stack.ts +96 -0
  50. package/src/stacks/data-stack.ts +446 -0
  51. package/src/stacks/network-stack.ts +282 -0
  52. package/src/stacks/newsletter-stack.ts +572 -0
  53. package/src/stacks/pipeline-stack.ts +242 -0
  54. package/src/stacks/release-account-stack.ts +229 -0
@@ -0,0 +1,572 @@
1
+ import { newsletterManifestFor } from "@deployfoundation/foundation-core/instance";
2
+ import * as cdk from "aws-cdk-lib";
3
+ import * as apigateway from "aws-cdk-lib/aws-apigateway";
4
+ import * as acm from "aws-cdk-lib/aws-certificatemanager";
5
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
6
+ import * as cloudwatchActions from "aws-cdk-lib/aws-cloudwatch-actions";
7
+ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
8
+ import * as iam from "aws-cdk-lib/aws-iam";
9
+ import * as kms from "aws-cdk-lib/aws-kms";
10
+ import * as lambda from "aws-cdk-lib/aws-lambda";
11
+ import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources";
12
+ import * as route53 from "aws-cdk-lib/aws-route53";
13
+ import * as route53Targets from "aws-cdk-lib/aws-route53-targets";
14
+ import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
15
+ import * as sns from "aws-cdk-lib/aws-sns";
16
+ import * as snsSubscriptions from "aws-cdk-lib/aws-sns-subscriptions";
17
+ import * as sqs from "aws-cdk-lib/aws-sqs";
18
+ import * as wafv2 from "aws-cdk-lib/aws-wafv2";
19
+ import type { Construct } from "constructs";
20
+ import { lambdaCode } from "../artifacts.ts";
21
+ import { type Instance, namesFor } from "../names.ts";
22
+
23
+ const ACTIVE_SUBSCRIBER_INDEX = "ScopeStateIndex";
24
+
25
+ export interface NewsletterStackProps extends cdk.StackProps {
26
+ instance: Instance;
27
+ /** Optional email recipient for operational alarms, matching the core API stack. */
28
+ alarmEmail?: string;
29
+ }
30
+
31
+ /**
32
+ * Dedicated public email-marketing boundary. It deliberately does not import
33
+ * FoundationData, FoundationApi, or an agent role: subscriber PII, token material and SES
34
+ * permissions are isolated from the conversational runtime.
35
+ */
36
+ export class NewsletterStack extends cdk.Stack {
37
+ public readonly subscriberTable: dynamodb.Table;
38
+ public readonly campaignTable: dynamodb.Table;
39
+ public readonly campaignQueue: sqs.Queue;
40
+ public readonly campaignDlq: sqs.Queue;
41
+ public readonly suppressionDlq: sqs.Queue;
42
+ public readonly api: apigateway.RestApi;
43
+ public readonly tokenSigningSecret: secretsmanager.Secret;
44
+ public readonly campaignOperatorRole: iam.Role;
45
+
46
+ constructor(scope: Construct, id: string, props: NewsletterStackProps) {
47
+ super(scope, id, props);
48
+ if (!props.instance.newsletter.enabled)
49
+ throw new Error(
50
+ `cannot create NewsletterStack: newsletter is disabled for ${props.instance.name}`,
51
+ );
52
+ const config = props.instance.newsletter;
53
+ const names = namesFor(props.instance);
54
+ const manifest = JSON.stringify(newsletterManifestFor(props.instance));
55
+ if (Buffer.byteLength(manifest, "utf8") > 3_000)
56
+ throw new Error("newsletter manifest exceeds the safe Lambda environment limit");
57
+ const publicPartitionKeys = config.scopes.flatMap(({ businessId, newsletterId }) => [
58
+ `SCOPE#${businessId}#${newsletterId}`,
59
+ `QUOTA#${businessId}#${newsletterId}`,
60
+ ]);
61
+ const senderAddress = senderAddressFor(config.sender);
62
+ const identityArn = `arn:${this.partition}:ses:${this.region}:${this.account}:identity/${config.domainIdentity}`;
63
+ const configurationSetArn = `arn:${this.partition}:ses:${this.region}:${this.account}:configuration-set/${names.newsletterConfigurationSet}`;
64
+ const zone = route53.HostedZone.fromHostedZoneAttributes(this, "NewsletterHostedZone", {
65
+ hostedZoneId: config.hostedZoneId,
66
+ zoneName: config.hostedZoneName,
67
+ });
68
+
69
+ const newsletterKey = new kms.Key(this, "NewsletterKey", {
70
+ description: `${props.instance.displayName} newsletter PII and queue CMK`,
71
+ enableKeyRotation: true,
72
+ alias: names.newsletterKeyAlias,
73
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
74
+ });
75
+
76
+ this.subscriberTable = new dynamodb.Table(this, "SubscriberTable", {
77
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
78
+ sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
79
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
80
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
81
+ encryptionKey: newsletterKey,
82
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
83
+ timeToLiveAttribute: "ttl",
84
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
85
+ });
86
+ this.subscriberTable.addGlobalSecondaryIndex({
87
+ indexName: ACTIVE_SUBSCRIBER_INDEX,
88
+ partitionKey: { name: "scopeState", type: dynamodb.AttributeType.STRING },
89
+ sortKey: { name: "emailHash", type: dynamodb.AttributeType.STRING },
90
+ projectionType: dynamodb.ProjectionType.ALL,
91
+ });
92
+
93
+ this.campaignTable = new dynamodb.Table(this, "CampaignTable", {
94
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
95
+ sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
96
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
97
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
98
+ encryptionKey: newsletterKey,
99
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
100
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
101
+ });
102
+
103
+ this.tokenSigningSecret = new secretsmanager.Secret(this, "TokenSigningSecret", {
104
+ secretName: names.newsletterTokenSigningSecret,
105
+ description: "Newsletter confirmation and unsubscribe token HMAC secret",
106
+ encryptionKey: newsletterKey,
107
+ generateSecretString: { passwordLength: 64, excludePunctuation: true },
108
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
109
+ });
110
+
111
+ this.campaignDlq = new sqs.Queue(this, "CampaignDlq", {
112
+ queueName: `${props.instance.naming.secretPrefix}-newsletter-campaign-dlq.fifo`,
113
+ fifo: true,
114
+ encryption: sqs.QueueEncryption.KMS,
115
+ encryptionMasterKey: newsletterKey,
116
+ enforceSSL: true,
117
+ retentionPeriod: cdk.Duration.days(14),
118
+ });
119
+ this.campaignQueue = new sqs.Queue(this, "CampaignQueue", {
120
+ queueName: `${props.instance.naming.secretPrefix}-newsletter-campaign.fifo`,
121
+ fifo: true,
122
+ contentBasedDeduplication: false,
123
+ encryption: sqs.QueueEncryption.KMS,
124
+ encryptionMasterKey: newsletterKey,
125
+ enforceSSL: true,
126
+ visibilityTimeout: cdk.Duration.minutes(15),
127
+ retentionPeriod: cdk.Duration.days(14),
128
+ deadLetterQueue: { queue: this.campaignDlq, maxReceiveCount: 3 },
129
+ });
130
+ this.suppressionDlq = new sqs.Queue(this, "SuppressionDlq", {
131
+ queueName: `${props.instance.naming.secretPrefix}-newsletter-suppression-dlq`,
132
+ encryption: sqs.QueueEncryption.KMS,
133
+ encryptionMasterKey: newsletterKey,
134
+ enforceSSL: true,
135
+ retentionPeriod: cdk.Duration.days(14),
136
+ });
137
+ const suppressionQueue = new sqs.Queue(this, "SuppressionQueue", {
138
+ queueName: `${props.instance.naming.secretPrefix}-newsletter-suppression`,
139
+ encryption: sqs.QueueEncryption.KMS,
140
+ encryptionMasterKey: newsletterKey,
141
+ enforceSSL: true,
142
+ visibilityTimeout: cdk.Duration.minutes(5),
143
+ retentionPeriod: cdk.Duration.days(14),
144
+ deadLetterQueue: { queue: this.suppressionDlq, maxReceiveCount: 3 },
145
+ });
146
+
147
+ const publicRole = lambdaRole(this, "PublicFunctionRole", "public newsletter opt-in Lambda");
148
+ publicRole.addToPolicy(
149
+ new iam.PolicyStatement({
150
+ sid: "SubscriberOptInState",
151
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
152
+ resources: [this.subscriberTable.tableArn],
153
+ conditions: {
154
+ "ForAllValues:StringEquals": {
155
+ "dynamodb:LeadingKeys": publicPartitionKeys,
156
+ },
157
+ },
158
+ }),
159
+ );
160
+ this.tokenSigningSecret.grantRead(publicRole);
161
+ this.campaignQueue.grantSendMessages(publicRole);
162
+
163
+ const suppressionRole = lambdaRole(
164
+ this,
165
+ "SuppressionFunctionRole",
166
+ "SES newsletter suppression Lambda",
167
+ );
168
+ suppressionRole.addToPolicy(
169
+ new iam.PolicyStatement({
170
+ sid: "SubscriberSuppressionState",
171
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem"],
172
+ resources: [this.subscriberTable.tableArn],
173
+ }),
174
+ );
175
+
176
+ const campaignRole = lambdaRole(
177
+ this,
178
+ "CampaignFunctionRole",
179
+ "newsletter campaign worker Lambda",
180
+ );
181
+ campaignRole.addToPolicy(
182
+ new iam.PolicyStatement({
183
+ sid: "ReadActiveSubscribers",
184
+ actions: ["dynamodb:GetItem", "dynamodb:Query"],
185
+ resources: [
186
+ this.subscriberTable.tableArn,
187
+ `${this.subscriberTable.tableArn}/index/${ACTIVE_SUBSCRIBER_INDEX}`,
188
+ ],
189
+ }),
190
+ );
191
+ campaignRole.addToPolicy(
192
+ new iam.PolicyStatement({
193
+ sid: "CampaignDeliveryState",
194
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
195
+ resources: [this.campaignTable.tableArn],
196
+ }),
197
+ );
198
+ this.campaignQueue.grantConsumeMessages(campaignRole);
199
+ this.campaignQueue.grantSendMessages(campaignRole);
200
+ this.tokenSigningSecret.grantRead(campaignRole);
201
+ grantSesSend(campaignRole, identityArn, senderAddress);
202
+
203
+ const publicFunction = new lambda.Function(this, "PublicFunction", {
204
+ runtime: lambda.Runtime.NODEJS_22_X,
205
+ handler: "index.handler",
206
+ code: lambdaCode(this, "newsletter-public"),
207
+ role: publicRole,
208
+ timeout: cdk.Duration.seconds(15),
209
+ memorySize: 256,
210
+ environment: {
211
+ NEWSLETTER_MANIFEST: manifest,
212
+ NEWSLETTER_SUBSCRIBER_TABLE_NAME: this.subscriberTable.tableName,
213
+ NEWSLETTER_TOKEN_SIGNING_SECRET_ID: this.tokenSigningSecret.secretName,
214
+ NEWSLETTER_CAMPAIGN_QUEUE_URL: this.campaignQueue.queueUrl,
215
+ },
216
+ });
217
+
218
+ const suppressionFunction = new lambda.Function(this, "SuppressionFunction", {
219
+ runtime: lambda.Runtime.NODEJS_22_X,
220
+ handler: "index.handler",
221
+ code: lambdaCode(this, "newsletter-ses-events"),
222
+ role: suppressionRole,
223
+ timeout: cdk.Duration.seconds(30),
224
+ memorySize: 256,
225
+ environment: {
226
+ NEWSLETTER_MANIFEST: manifest,
227
+ NEWSLETTER_SUBSCRIBER_TABLE_NAME: this.subscriberTable.tableName,
228
+ },
229
+ });
230
+ suppressionFunction.addEventSource(
231
+ new lambdaEventSources.SqsEventSource(suppressionQueue, {
232
+ batchSize: 1,
233
+ reportBatchItemFailures: false,
234
+ }),
235
+ );
236
+
237
+ const campaignFunction = new lambda.Function(this, "CampaignFunction", {
238
+ runtime: lambda.Runtime.NODEJS_22_X,
239
+ handler: "index.handler",
240
+ code: lambdaCode(this, "newsletter-campaign"),
241
+ role: campaignRole,
242
+ timeout: cdk.Duration.minutes(15),
243
+ memorySize: 512,
244
+ environment: {
245
+ NEWSLETTER_MANIFEST: manifest,
246
+ NEWSLETTER_SUBSCRIBER_TABLE_NAME: this.subscriberTable.tableName,
247
+ NEWSLETTER_CAMPAIGN_TABLE_NAME: this.campaignTable.tableName,
248
+ NEWSLETTER_CAMPAIGN_QUEUE_URL: this.campaignQueue.queueUrl,
249
+ NEWSLETTER_TOKEN_SIGNING_SECRET_ID: this.tokenSigningSecret.secretName,
250
+ NEWSLETTER_CONFIGURATION_SET: names.newsletterConfigurationSet,
251
+ },
252
+ });
253
+ campaignFunction.addEventSource(
254
+ new lambdaEventSources.SqsEventSource(this.campaignQueue, {
255
+ batchSize: 1,
256
+ reportBatchItemFailures: false,
257
+ maxConcurrency: 2,
258
+ }),
259
+ );
260
+
261
+ const eventsTopicName = `${props.instance.naming.secretPrefix}-newsletter-ses-events`;
262
+ const eventsTopic = new sns.Topic(this, "SesEventsTopic", {
263
+ displayName: `${props.instance.displayName} newsletter SES events`,
264
+ topicName: eventsTopicName,
265
+ masterKey: newsletterKey,
266
+ });
267
+ newsletterKey.addToResourcePolicy(
268
+ new iam.PolicyStatement({
269
+ sid: "AllowSesToEncryptNewsletterEvents",
270
+ principals: [new iam.ServicePrincipal("ses.amazonaws.com")],
271
+ actions: ["kms:GenerateDataKey*", "kms:Decrypt"],
272
+ resources: ["*"],
273
+ conditions: {
274
+ StringEquals: {
275
+ "aws:SourceAccount": this.account,
276
+ "kms:EncryptionContext:aws:sns:topicArn": `arn:${this.partition}:sns:${this.region}:${this.account}:${eventsTopicName}`,
277
+ },
278
+ },
279
+ }),
280
+ );
281
+ eventsTopic.addToResourcePolicy(
282
+ new iam.PolicyStatement({
283
+ sid: "AllowOnlyThisSesIdentity",
284
+ effect: iam.Effect.ALLOW,
285
+ principals: [new iam.ServicePrincipal("ses.amazonaws.com")],
286
+ actions: ["sns:Publish"],
287
+ resources: [eventsTopic.topicArn],
288
+ conditions: {
289
+ StringEquals: { "aws:SourceAccount": this.account },
290
+ ArnLike: { "aws:SourceArn": configurationSetArn },
291
+ },
292
+ }),
293
+ );
294
+ eventsTopic.addSubscription(
295
+ new snsSubscriptions.SqsSubscription(suppressionQueue, {
296
+ rawMessageDelivery: true,
297
+ }),
298
+ );
299
+
300
+ const identity = new cdk.CfnResource(this, "NewsletterDomainIdentity", {
301
+ type: "AWS::SES::EmailIdentity",
302
+ properties: {
303
+ EmailIdentity: config.domainIdentity,
304
+ DkimAttributes: { SigningEnabled: true },
305
+ },
306
+ });
307
+ const configurationSet = new cdk.CfnResource(this, "NewsletterConfigurationSet", {
308
+ type: "AWS::SES::ConfigurationSet",
309
+ properties: { Name: names.newsletterConfigurationSet },
310
+ });
311
+ const eventDestination = new cdk.CfnResource(this, "NewsletterEventDestination", {
312
+ type: "AWS::SES::ConfigurationSetEventDestination",
313
+ properties: {
314
+ ConfigurationSetName: names.newsletterConfigurationSet,
315
+ EventDestination: {
316
+ Name: "NewsletterDeliveryAndSuppressionEvents",
317
+ Enabled: true,
318
+ MatchingEventTypes: ["DELIVERY", "BOUNCE", "COMPLAINT"],
319
+ SnsDestination: { TopicARN: eventsTopic.topicArn },
320
+ },
321
+ },
322
+ });
323
+ eventDestination.addResourceDependency(configurationSet);
324
+ eventDestination.node.addDependency(eventsTopic);
325
+ for (const token of ["1", "2", "3"] as const)
326
+ new route53.CfnRecordSet(this, `NewsletterDkim${token}`, {
327
+ hostedZoneId: config.hostedZoneId,
328
+ name: identity.getAtt(`DkimDNSTokenName${token}`).toString(),
329
+ type: "CNAME",
330
+ ttl: "1800",
331
+ resourceRecords: [identity.getAtt(`DkimDNSTokenValue${token}`).toString()],
332
+ });
333
+ configurationSet.node.addDependency(identity);
334
+
335
+ this.api = new apigateway.RestApi(this, "NewsletterApi", {
336
+ restApiName: `${props.instance.naming.secretPrefix}-newsletter`,
337
+ description: `${props.instance.displayName} public confirmed newsletter API`,
338
+ endpointTypes: [apigateway.EndpointType.REGIONAL],
339
+ disableExecuteApiEndpoint: true,
340
+ deployOptions: {
341
+ stageName: "prod",
342
+ throttlingRateLimit: 2,
343
+ throttlingBurstLimit: 4,
344
+ metricsEnabled: true,
345
+ },
346
+ defaultCorsPreflightOptions: {
347
+ allowOrigins: [...config.allowedOrigins],
348
+ allowMethods: ["GET", "POST", "OPTIONS"],
349
+ allowHeaders: ["Content-Type"],
350
+ },
351
+ cloudWatchRole: false,
352
+ });
353
+ const v1 = this.api.root.addResource("v1");
354
+ const businesses = v1.addResource("businesses");
355
+ const business = businesses.addResource("{businessId}");
356
+ const newsletters = business.addResource("newsletters");
357
+ const newsletter = newsletters.addResource("{newsletterId}");
358
+ const integration = new apigateway.LambdaIntegration(publicFunction);
359
+ newsletter.addResource("subscribe").addMethod("POST", integration);
360
+ newsletter.addResource("confirm").addMethod("GET", integration);
361
+ newsletter.addResource("unsubscribe").addMethod("GET", integration);
362
+ newsletter.getResource("confirm")?.addMethod("POST", integration);
363
+ newsletter.getResource("unsubscribe")?.addMethod("POST", integration);
364
+ const webAcl = new wafv2.CfnWebACL(this, "NewsletterWebAcl", {
365
+ scope: "REGIONAL",
366
+ defaultAction: { allow: {} },
367
+ visibilityConfig: {
368
+ cloudWatchMetricsEnabled: true,
369
+ metricName: `${props.instance.naming.secretPrefix}-newsletter-api`,
370
+ sampledRequestsEnabled: false,
371
+ },
372
+ rules: [
373
+ {
374
+ name: "PerIpSubscribeAbuseLimit",
375
+ priority: 0,
376
+ action: { block: {} },
377
+ statement: {
378
+ rateBasedStatement: {
379
+ aggregateKeyType: "IP",
380
+ limit: 10,
381
+ scopeDownStatement: {
382
+ byteMatchStatement: {
383
+ fieldToMatch: { uriPath: {} },
384
+ positionalConstraint: "ENDS_WITH",
385
+ searchString: "/subscribe",
386
+ textTransformations: [{ priority: 0, type: "NONE" }],
387
+ },
388
+ },
389
+ },
390
+ },
391
+ visibilityConfig: {
392
+ cloudWatchMetricsEnabled: true,
393
+ metricName: `${props.instance.naming.secretPrefix}-newsletter-subscribe-rate`,
394
+ sampledRequestsEnabled: false,
395
+ },
396
+ },
397
+ ],
398
+ });
399
+ const webAclAssociation = new wafv2.CfnWebACLAssociation(this, "NewsletterWebAclAssociation", {
400
+ resourceArn: `arn:${this.partition}:apigateway:${this.region}::/restapis/${this.api.restApiId}/stages/${this.api.deploymentStage.stageName}`,
401
+ webAclArn: webAcl.attrArn,
402
+ });
403
+ webAclAssociation.node.addDependency(this.api.deploymentStage);
404
+
405
+ const certificate = new acm.Certificate(this, "NewsletterCertificate", {
406
+ domainName: config.apiDomain,
407
+ validation: acm.CertificateValidation.fromDns(zone),
408
+ });
409
+ const domain = new apigateway.DomainName(this, "NewsletterDomain", {
410
+ domainName: config.apiDomain,
411
+ certificate,
412
+ endpointType: apigateway.EndpointType.REGIONAL,
413
+ securityPolicy: apigateway.SecurityPolicy.TLS_1_2,
414
+ });
415
+ new apigateway.BasePathMapping(this, "NewsletterBasePath", {
416
+ domainName: domain,
417
+ restApi: this.api,
418
+ });
419
+ new route53.ARecord(this, "NewsletterApiAlias", {
420
+ zone,
421
+ recordName: config.apiDomain,
422
+ target: route53.RecordTarget.fromAlias(new route53Targets.ApiGatewayDomain(domain)),
423
+ });
424
+
425
+ this.campaignOperatorRole = new iam.Role(this, "CampaignOperatorRole", {
426
+ roleName: names.newsletterCampaignOperatorRole,
427
+ description: "Human-only newsletter campaign draft, preview and approval role",
428
+ assumedBy: new iam.AccountPrincipal(this.account),
429
+ maxSessionDuration: cdk.Duration.hours(1),
430
+ });
431
+ this.campaignOperatorRole.addToPolicy(
432
+ new iam.PolicyStatement({
433
+ sid: "CampaignDraftAndApproval",
434
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
435
+ resources: [this.campaignTable.tableArn],
436
+ }),
437
+ );
438
+ this.campaignQueue.grantSendMessages(this.campaignOperatorRole);
439
+ this.campaignDlq.grantConsumeMessages(this.campaignOperatorRole);
440
+ this.campaignOperatorRole.addToPolicy(
441
+ new iam.PolicyStatement({
442
+ sid: "RedriveCampaignFailures",
443
+ actions: [
444
+ "sqs:StartMessageMoveTask",
445
+ "sqs:CancelMessageMoveTask",
446
+ "sqs:ListMessageMoveTasks",
447
+ ],
448
+ resources: [this.campaignDlq.queueArn],
449
+ }),
450
+ );
451
+ this.campaignOperatorRole.addToPolicy(
452
+ new iam.PolicyStatement({
453
+ sid: "ReadNewsletterStackOutputs",
454
+ actions: ["cloudformation:DescribeStacks"],
455
+ resources: ["*"],
456
+ }),
457
+ );
458
+ this.campaignOperatorRole.addToPolicy(
459
+ new iam.PolicyStatement({
460
+ sid: "RecordApprovalActor",
461
+ actions: ["sts:GetCallerIdentity"],
462
+ resources: ["*"],
463
+ }),
464
+ );
465
+
466
+ const alarmTopic = new sns.Topic(this, "NewsletterAlarmTopic", {
467
+ displayName: `${props.instance.displayName} newsletter alarms`,
468
+ });
469
+ if (props.alarmEmail !== undefined)
470
+ new sns.Subscription(this, "NewsletterAlarmEmail", {
471
+ topic: alarmTopic,
472
+ protocol: sns.SubscriptionProtocol.EMAIL,
473
+ endpoint: props.alarmEmail,
474
+ });
475
+ const alarmAction = new cloudwatchActions.SnsAction(alarmTopic);
476
+ const alarms = [
477
+ new cloudwatch.Alarm(this, "CampaignDlqDepthAlarm", {
478
+ alarmDescription: "Newsletter campaign messages are waiting in the DLQ",
479
+ metric: this.campaignDlq.metricApproximateNumberOfMessagesVisible({
480
+ period: cdk.Duration.minutes(5),
481
+ statistic: "Maximum",
482
+ }),
483
+ threshold: 1,
484
+ evaluationPeriods: 1,
485
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
486
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
487
+ }),
488
+ new cloudwatch.Alarm(this, "SuppressionDlqDepthAlarm", {
489
+ alarmDescription: "Newsletter suppression events are waiting in the DLQ",
490
+ metric: this.suppressionDlq.metricApproximateNumberOfMessagesVisible({
491
+ period: cdk.Duration.minutes(5),
492
+ statistic: "Maximum",
493
+ }),
494
+ threshold: 1,
495
+ evaluationPeriods: 1,
496
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
497
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
498
+ }),
499
+ new cloudwatch.Alarm(this, "CampaignWorkerErrorsAlarm", {
500
+ alarmDescription: "Newsletter campaign worker is failing",
501
+ metric: campaignFunction.metricErrors({
502
+ period: cdk.Duration.minutes(5),
503
+ statistic: "Sum",
504
+ }),
505
+ threshold: 1,
506
+ evaluationPeriods: 1,
507
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
508
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
509
+ }),
510
+ new cloudwatch.Alarm(this, "SuppressionWorkerErrorsAlarm", {
511
+ alarmDescription: "Newsletter suppression worker is failing",
512
+ metric: suppressionFunction.metricErrors({
513
+ period: cdk.Duration.minutes(5),
514
+ statistic: "Sum",
515
+ }),
516
+ threshold: 1,
517
+ evaluationPeriods: 1,
518
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
519
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
520
+ }),
521
+ new cloudwatch.Alarm(this, "PublicFunctionErrorsAlarm", {
522
+ alarmDescription: "Newsletter public Lambda is failing",
523
+ metric: publicFunction.metricErrors({ period: cdk.Duration.minutes(5), statistic: "Sum" }),
524
+ threshold: 1,
525
+ evaluationPeriods: 1,
526
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
527
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
528
+ }),
529
+ ];
530
+ for (const alarm of alarms) alarm.addAlarmAction(alarmAction);
531
+
532
+ new cdk.CfnOutput(this, "NewsletterApiUrl", { value: `https://${config.apiDomain}/v1` });
533
+ new cdk.CfnOutput(this, "SubscriberTableName", { value: this.subscriberTable.tableName });
534
+ new cdk.CfnOutput(this, "CampaignTableName", { value: this.campaignTable.tableName });
535
+ new cdk.CfnOutput(this, "CampaignQueueUrl", { value: this.campaignQueue.queueUrl });
536
+ new cdk.CfnOutput(this, "CampaignDlqArn", { value: this.campaignDlq.queueArn });
537
+ new cdk.CfnOutput(this, "CampaignDlqUrl", { value: this.campaignDlq.queueUrl });
538
+ new cdk.CfnOutput(this, "CampaignOperatorRoleArn", {
539
+ value: this.campaignOperatorRole.roleArn,
540
+ });
541
+ new cdk.CfnOutput(this, "NewsletterConfigurationSetName", {
542
+ value: names.newsletterConfigurationSet,
543
+ });
544
+ }
545
+ }
546
+
547
+ function lambdaRole(scope: Construct, id: string, description: string): iam.Role {
548
+ return new iam.Role(scope, id, {
549
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
550
+ description,
551
+ managedPolicies: [
552
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
553
+ ],
554
+ });
555
+ }
556
+
557
+ function grantSesSend(role: iam.Role, identityArn: string, senderAddress: string): void {
558
+ role.addToPolicy(
559
+ new iam.PolicyStatement({
560
+ sid: "SendFromNewsletterIdentity",
561
+ actions: ["ses:SendEmail"],
562
+ resources: [identityArn],
563
+ conditions: { StringEquals: { "ses:FromAddress": senderAddress } },
564
+ }),
565
+ );
566
+ }
567
+
568
+ function senderAddressFor(sender: string): string {
569
+ const address = /<([^>]+)>$/.exec(sender)?.[1];
570
+ if (address === undefined) throw new Error("newsletter sender is not a mailbox");
571
+ return address;
572
+ }