@deployfoundation/foundation-deploy 0.2.14 → 0.2.16

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.
@@ -0,0 +1,504 @@
1
+ import * as cdk from "aws-cdk-lib";
2
+ import * as acm from "aws-cdk-lib/aws-certificatemanager";
3
+ import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
4
+ import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
5
+ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
6
+ import * as iam from "aws-cdk-lib/aws-iam";
7
+ import type * as kms from "aws-cdk-lib/aws-kms";
8
+ import * as lambda from "aws-cdk-lib/aws-lambda";
9
+ import * as route53 from "aws-cdk-lib/aws-route53";
10
+ import * as route53Targets from "aws-cdk-lib/aws-route53-targets";
11
+ import * as s3 from "aws-cdk-lib/aws-s3";
12
+ import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
13
+ import type { Construct } from "constructs";
14
+ import { lambdaCode } from "../artifacts.ts";
15
+ import { type Instance, namesFor } from "../names.ts";
16
+ import { pagesViewerRequestCode } from "./pages-edge-function.ts";
17
+
18
+ export interface FoundationPagesProps extends cdk.StackProps {
19
+ instance: Instance;
20
+ /** Encrypts the pages table, like every other Foundation table. */
21
+ dataKey: kms.IKey;
22
+ secrets: {
23
+ /** Verifies the actor capability every Pages API call carries. */
24
+ signing: secretsmanager.ISecret;
25
+ /** Bot token (membership check) plus the app's OIDC `client_id`/`client_secret`. */
26
+ slackApp: secretsmanager.ISecret;
27
+ };
28
+ /**
29
+ * The deploy tool has seen the Foundation-made zone's NS delegation resolve
30
+ * (`-c pagesDomainDelegated=true`), so the certificate can be issued and the
31
+ * domain attached without the deploy hanging on validation. Irrelevant with
32
+ * no `pages.domain`, and implied by `pages.hostedZoneId`.
33
+ */
34
+ domainDelegated?: boolean;
35
+ }
36
+
37
+ /** How the stack serves pages for this instance and deploy. */
38
+ export interface PagesDomainPlan {
39
+ /** The custom domain, attached to the distribution in this deploy. */
40
+ attached?: string;
41
+ /** `pages.domain` is set but waits for its NS delegation. */
42
+ pending?: string;
43
+ /** Foundation creates the zone (no `pages.hostedZoneId`). */
44
+ ownZone: boolean;
45
+ }
46
+
47
+ export function pagesDomainPlan(instance: Instance, delegated: boolean): PagesDomainPlan {
48
+ const { domain, hostedZoneId } = instance.pages;
49
+ if (domain === undefined) return { ownZone: false };
50
+ const ownZone = hostedZoneId === undefined;
51
+ return ownZone && !delegated ? { pending: domain, ownZone } : { attached: domain, ownZone };
52
+ }
53
+
54
+ /**
55
+ * Content-Security-Policy for every hosted page and sign-in response.
56
+ *
57
+ * Pages are static, self-contained bundles: scripts, styles, fonts and data
58
+ * come from the page itself (or inline, or `data:`/`blob:`). `connect-src
59
+ * 'self'` means a page makes no external data calls; Google Fonts is the one
60
+ * outside origin, because decks and brand kits use it for type. Framing is
61
+ * limited to the pages origin so a page cannot be embedded elsewhere.
62
+ */
63
+ export const PAGES_CONTENT_SECURITY_POLICY = [
64
+ "default-src 'self'",
65
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
66
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
67
+ "font-src 'self' data: https://fonts.gstatic.com",
68
+ "img-src 'self' data: blob:",
69
+ "media-src 'self' data: blob:",
70
+ "connect-src 'self' data: blob:",
71
+ "worker-src 'self' blob:",
72
+ "frame-src 'self'",
73
+ "object-src 'none'",
74
+ "base-uri 'self'",
75
+ "form-action 'self'",
76
+ "frame-ancestors 'self'",
77
+ ].join("; ");
78
+
79
+ /**
80
+ * FoundationPages — hosted, private, versioned pages (`docs/pages.md`).
81
+ *
82
+ * S3 (private, versioned) ← OAC ← CloudFront ← viewer function (cookie check)
83
+ * └─ /_auth/* → sign-in Lambda (Function URL, OAC)
84
+ * Pages API Lambda (actor-capability authorized) → S3 + DynamoDB
85
+ *
86
+ * The agent's only grant is to invoke the Pages API (FoundationAgent adds it
87
+ * by function name). Nothing here reads the agent, and the agent is explicitly
88
+ * denied the bucket, the table, the cookie key and the edge key store.
89
+ *
90
+ * SSE-S3 rather than the instance data key on the bucket: CloudFront reads it
91
+ * through origin access control, and SSE-KMS would need the data key's policy
92
+ * (owned by FoundationData) to trust this stack's distribution, a cross-stack
93
+ * key-policy edit for no gain in who can read the objects. The table, which
94
+ * only Foundation's own Lambdas read, uses the data key as the others do.
95
+ */
96
+ export class FoundationPages extends cdk.Stack {
97
+ public readonly bucket: s3.Bucket;
98
+ public readonly table: dynamodb.Table;
99
+ public readonly apiFunction: lambda.Function;
100
+ public readonly distribution: cloudfront.Distribution;
101
+
102
+ constructor(scope: Construct, id: string, props: FoundationPagesProps) {
103
+ super(scope, id, props);
104
+ const { instance } = props;
105
+ if (!instance.integrations.pages)
106
+ throw new Error(`${id}: FoundationPages needs integrations.pages: true`);
107
+ const names = namesFor(instance);
108
+ const display = instance.displayName;
109
+ const plan = pagesDomainPlan(instance, props.domainDelegated === true);
110
+ const domain = plan.attached;
111
+
112
+ this.bucket = new s3.Bucket(this, "PagesBucket", {
113
+ blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
114
+ encryption: s3.BucketEncryption.S3_MANAGED,
115
+ enforceSSL: true,
116
+ versioned: true,
117
+ objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
118
+ // Published pages are company content: they outlive the stack, like the CRM table.
119
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
120
+ lifecycleRules: [
121
+ // Staged chunks of an upload that never committed (the upload row lives an hour).
122
+ { id: "StagedUploads", prefix: "uploads/", expiration: cdk.Duration.days(1) },
123
+ // Versions are immutable by key; S3 versioning only guards against an operator mistake.
124
+ { id: "NoncurrentObjects", noncurrentVersionExpiration: cdk.Duration.days(30) },
125
+ { id: "IncompleteMultipart", abortIncompleteMultipartUploadAfter: cdk.Duration.days(1) },
126
+ ],
127
+ });
128
+
129
+ this.table = new dynamodb.Table(this, "PagesTable", {
130
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
131
+ sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
132
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
133
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
134
+ encryptionKey: props.dataKey,
135
+ pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
136
+ timeToLiveAttribute: "ttl",
137
+ removalPolicy: cdk.RemovalPolicy.RETAIN,
138
+ });
139
+ // A conversation's pages, newest first (a later `page_list`, the phase-2 comment digest).
140
+ this.table.addGlobalSecondaryIndex({
141
+ indexName: "ChannelIndex",
142
+ partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING },
143
+ sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING },
144
+ projectionType: dynamodb.ProjectionType.ALL,
145
+ });
146
+
147
+ // The cookie HMAC key. Generated by Secrets Manager at deploy time, so it
148
+ // is in no template; destroyed with the stack like other opt-in secrets
149
+ // (a new key only means everyone signs in again).
150
+ const cookieKey = new secretsmanager.Secret(this, "CookieKey", {
151
+ secretName: names.secretPagesCookieKey,
152
+ description: `${display} hosted-pages cookie signing key ({ key }); read by the sign-in Lambda and copied to the edge key store`,
153
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
154
+ generateSecretString: {
155
+ secretStringTemplate: "{}",
156
+ generateStringKey: "key",
157
+ passwordLength: 64,
158
+ excludePunctuation: true,
159
+ },
160
+ });
161
+
162
+ const edgeKeys = new cloudfront.KeyValueStore(this, "EdgeKeys", {
163
+ comment: `${display} pages cookie key for the viewer function`,
164
+ });
165
+
166
+ // --- sign-in Lambda --------------------------------------------------
167
+ const authRole = new iam.Role(this, "PagesAuthRole", {
168
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
169
+ description: `${display} pages sign-in Lambda (Slack OIDC + membership check)`,
170
+ managedPolicies: [
171
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
172
+ ],
173
+ });
174
+ authRole.addToPolicy(
175
+ new iam.PolicyStatement({
176
+ sid: "PagesAuthSecrets",
177
+ actions: ["secretsmanager:GetSecretValue"],
178
+ resources: [secretArnPattern(props.secrets.slackApp), cookieKey.secretArn],
179
+ }),
180
+ );
181
+ authRole.addToPolicy(
182
+ new iam.PolicyStatement({
183
+ sid: "PagesAuthPageLookup",
184
+ actions: ["dynamodb:GetItem"],
185
+ resources: [this.table.tableArn],
186
+ }),
187
+ );
188
+ authRole.addToPolicy(
189
+ new iam.PolicyStatement({
190
+ sid: "PagesTableKey",
191
+ actions: ["kms:Decrypt", "kms:DescribeKey"],
192
+ resources: [props.dataKey.keyArn],
193
+ }),
194
+ );
195
+ const authFunction = new lambda.Function(this, "PagesAuthFunction", {
196
+ runtime: lambda.Runtime.NODEJS_22_X,
197
+ handler: "index.handler",
198
+ code: lambdaCode(this, "pages-auth"),
199
+ role: authRole,
200
+ timeout: cdk.Duration.seconds(15),
201
+ memorySize: 256,
202
+ environment: {
203
+ FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
204
+ FOUNDATION_PAGES_COOKIE_SECRET_ID: cookieKey.secretName,
205
+ FOUNDATION_SLACK_TEAM_ID: instance.slack.teamId,
206
+ FOUNDATION_PAGES_TABLE_NAME: this.table.tableName,
207
+ FOUNDATION_DISPLAY_NAME: display,
208
+ ...(domain === undefined ? {} : { FOUNDATION_PAGES_DOMAIN: domain }),
209
+ },
210
+ });
211
+ // IAM auth: only CloudFront, signing through origin access control, can call it.
212
+ const authUrl = authFunction.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM });
213
+
214
+ // --- edge key seeding ------------------------------------------------
215
+ const seedRole = new iam.Role(this, "EdgeKeySeedRole", {
216
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
217
+ description: `${display} copies the pages cookie key into the edge key store`,
218
+ managedPolicies: [
219
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
220
+ ],
221
+ });
222
+ seedRole.addToPolicy(
223
+ new iam.PolicyStatement({
224
+ sid: "ReadCookieKey",
225
+ actions: ["secretsmanager:GetSecretValue"],
226
+ resources: [cookieKey.secretArn],
227
+ }),
228
+ );
229
+ seedRole.addToPolicy(
230
+ new iam.PolicyStatement({
231
+ sid: "WriteEdgeKey",
232
+ actions: [
233
+ "cloudfront-keyvaluestore:DescribeKeyValueStore",
234
+ "cloudfront-keyvaluestore:PutKey",
235
+ ],
236
+ resources: [edgeKeys.keyValueStoreArn],
237
+ }),
238
+ );
239
+ const seedFunction = new lambda.Function(this, "EdgeKeySeedFunction", {
240
+ runtime: lambda.Runtime.NODEJS_22_X,
241
+ handler: "index.edgeKeyHandler",
242
+ code: lambdaCode(this, "pages-auth"),
243
+ role: seedRole,
244
+ timeout: cdk.Duration.seconds(60),
245
+ memorySize: 256,
246
+ });
247
+ new cdk.CustomResource(this, "EdgeKeySeed", {
248
+ serviceToken: seedFunction.functionArn,
249
+ resourceType: "Custom::FoundationPagesEdgeKey",
250
+ properties: {
251
+ SecretId: cookieKey.secretArn,
252
+ KeyValueStoreArn: edgeKeys.keyValueStoreArn,
253
+ // Bump to copy the key again after rotating the secret.
254
+ Generation: "1",
255
+ },
256
+ });
257
+
258
+ // --- CloudFront ------------------------------------------------------
259
+ const viewerRequest = new cloudfront.Function(this, "ViewerRequest", {
260
+ runtime: cloudfront.FunctionRuntime.JS_2_0,
261
+ keyValueStore: edgeKeys,
262
+ comment: `${display} pages: cookie check and path mapping`,
263
+ code: cloudfront.FunctionCode.fromInline(pagesViewerRequestCode(edgeKeys.keyValueStoreId)),
264
+ });
265
+ const headers = new cloudfront.ResponseHeadersPolicy(this, "PagesHeaders", {
266
+ comment: `${display} pages security headers`,
267
+ securityHeadersBehavior: {
268
+ contentSecurityPolicy: {
269
+ contentSecurityPolicy: PAGES_CONTENT_SECURITY_POLICY,
270
+ override: true,
271
+ },
272
+ strictTransportSecurity: {
273
+ accessControlMaxAge: cdk.Duration.days(365),
274
+ includeSubdomains: false,
275
+ override: true,
276
+ },
277
+ contentTypeOptions: { override: true },
278
+ frameOptions: { frameOption: cloudfront.HeadersFrameOption.SAMEORIGIN, override: true },
279
+ referrerPolicy: {
280
+ referrerPolicy: cloudfront.HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN,
281
+ override: true,
282
+ },
283
+ },
284
+ customHeadersBehavior: {
285
+ customHeaders: [{ header: "X-Robots-Tag", value: "noindex, nofollow", override: true }],
286
+ },
287
+ });
288
+ // --- custom domain ---------------------------------------------------
289
+ // Foundation makes a public zone for exactly the pages subdomain, so the
290
+ // company delegates one name (NS records at its DNS provider) and never
291
+ // hands over its main zone. With `pages.hostedZoneId` the parent zone is
292
+ // already in this account and records go straight into it.
293
+ const zoneDomain = instance.pages.domain;
294
+ const zone: route53.IHostedZone | undefined =
295
+ zoneDomain === undefined
296
+ ? undefined
297
+ : instance.pages.hostedZoneId !== undefined
298
+ ? route53.HostedZone.fromHostedZoneId(
299
+ this,
300
+ "PagesParentZone",
301
+ instance.pages.hostedZoneId,
302
+ )
303
+ : new route53.PublicHostedZone(this, "PagesZone", {
304
+ zoneName: zoneDomain,
305
+ comment: `${display} hosted pages (delegated from the company's DNS)`,
306
+ });
307
+ let certificate: acm.ICertificate | undefined;
308
+ if (domain !== undefined && zone !== undefined) {
309
+ const certificateRole = new iam.Role(this, "PagesCertificateRole", {
310
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
311
+ description: `${display} issues the pages domain certificate in us-east-1`,
312
+ managedPolicies: [
313
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
314
+ ],
315
+ });
316
+ certificateRole.addToPolicy(
317
+ new iam.PolicyStatement({
318
+ sid: "RequestCertificate",
319
+ // RequestCertificate has no resource-level scoping.
320
+ actions: ["acm:RequestCertificate"],
321
+ resources: ["*"],
322
+ }),
323
+ );
324
+ certificateRole.addToPolicy(
325
+ new iam.PolicyStatement({
326
+ sid: "ManageCertificate",
327
+ actions: ["acm:DescribeCertificate", "acm:DeleteCertificate"],
328
+ resources: [`arn:${this.partition}:acm:us-east-1:${this.account}:certificate/*`],
329
+ }),
330
+ );
331
+ certificateRole.addToPolicy(
332
+ new iam.PolicyStatement({
333
+ sid: "ValidationRecord",
334
+ actions: ["route53:ChangeResourceRecordSets"],
335
+ resources: [`arn:${this.partition}:route53:::hostedzone/${zone.hostedZoneId}`],
336
+ }),
337
+ );
338
+ const certificateFunction = new lambda.Function(this, "PagesCertificateFunction", {
339
+ runtime: lambda.Runtime.NODEJS_22_X,
340
+ handler: "index.certificateHandler",
341
+ code: lambdaCode(this, "pages-auth"),
342
+ role: certificateRole,
343
+ // Bounded wait for ACM; the resource fails rather than hangs.
344
+ timeout: cdk.Duration.minutes(15),
345
+ memorySize: 256,
346
+ });
347
+ const issued = new cdk.CustomResource(this, "PagesCertificate", {
348
+ serviceToken: certificateFunction.functionArn,
349
+ resourceType: "Custom::FoundationPagesCertificate",
350
+ properties: { DomainName: domain, HostedZoneId: zone.hostedZoneId },
351
+ });
352
+ certificate = acm.Certificate.fromCertificateArn(
353
+ this,
354
+ "PagesCertificateArn",
355
+ issued.getAttString("CertificateArn"),
356
+ );
357
+ }
358
+
359
+ const functionAssociations = [
360
+ { function: viewerRequest, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST },
361
+ ];
362
+ this.distribution = new cloudfront.Distribution(this, "Distribution", {
363
+ comment: `${display} pages`,
364
+ ...(domain === undefined || certificate === undefined
365
+ ? {}
366
+ : { domainNames: [domain], certificate }),
367
+ // Never a bucket listing at `/`, even if the viewer function were detached.
368
+ defaultRootObject: "index.html",
369
+ minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
370
+ httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
371
+ priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
372
+ defaultBehavior: {
373
+ // LIST lets S3 answer a missing key with 404 rather than 403. The
374
+ // function only ever forwards mapped object keys, never a listing.
375
+ origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket, {
376
+ originAccessLevels: [cloudfront.AccessLevel.READ, cloudfront.AccessLevel.LIST],
377
+ }),
378
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
379
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
380
+ // Access is decided by the viewer function before the cache, so the
381
+ // cache key needs no cookie: one cached copy serves every member.
382
+ cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
383
+ responseHeadersPolicy: headers,
384
+ functionAssociations,
385
+ },
386
+ additionalBehaviors: {
387
+ "/_auth/*": {
388
+ origin: origins.FunctionUrlOrigin.withOriginAccessControl(authUrl),
389
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
390
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
391
+ cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
392
+ originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
393
+ responseHeadersPolicy: headers,
394
+ functionAssociations,
395
+ },
396
+ },
397
+ });
398
+ cdk.Annotations.of(this.distribution).acknowledgeWarning(
399
+ "@aws-cdk/aws-cloudfront-origins:listBucketSecurityRisk",
400
+ "The viewer function forwards only mapped object keys and 404s `/`; defaultRootObject is set too. LIST only turns a missing key into 404 instead of 403.",
401
+ );
402
+ if (domain !== undefined && zone !== undefined) {
403
+ const target = route53.RecordTarget.fromAlias(
404
+ new route53Targets.CloudFrontTarget(this.distribution),
405
+ );
406
+ // Fully qualified, so the parent-zone case needs no zone name.
407
+ new route53.ARecord(this, "PagesAliasA", { zone, recordName: `${domain}.`, target });
408
+ new route53.AaaaRecord(this, "PagesAliasAaaa", { zone, recordName: `${domain}.`, target });
409
+ }
410
+ const defaultUrl = `https://${this.distribution.distributionDomainName}`;
411
+ const baseUrl = domain === undefined ? defaultUrl : `https://${domain}`;
412
+
413
+ // --- Pages API proxy -------------------------------------------------
414
+ const apiRole = new iam.Role(this, "PagesApiRole", {
415
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
416
+ description: `${display} Pages API proxy (the only writer of the pages bucket and table)`,
417
+ managedPolicies: [
418
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
419
+ ],
420
+ });
421
+ apiRole.addToPolicy(
422
+ new iam.PolicyStatement({
423
+ sid: "PagesApiSigningSecret",
424
+ actions: ["secretsmanager:GetSecretValue"],
425
+ resources: [secretArnPattern(props.secrets.signing)],
426
+ }),
427
+ );
428
+ apiRole.addToPolicy(
429
+ new iam.PolicyStatement({
430
+ sid: "PagesTableData",
431
+ actions: [
432
+ "dynamodb:GetItem",
433
+ "dynamodb:PutItem",
434
+ "dynamodb:UpdateItem",
435
+ "dynamodb:Query",
436
+ "dynamodb:TransactWriteItems",
437
+ "dynamodb:ConditionCheckItem",
438
+ ],
439
+ resources: [this.table.tableArn],
440
+ }),
441
+ );
442
+ apiRole.addToPolicy(
443
+ new iam.PolicyStatement({
444
+ sid: "PagesTableKey",
445
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
446
+ resources: [props.dataKey.keyArn],
447
+ }),
448
+ );
449
+ apiRole.addToPolicy(
450
+ new iam.PolicyStatement({
451
+ sid: "PagesObjects",
452
+ actions: ["s3:GetObject", "s3:PutObject"],
453
+ resources: [this.bucket.arnForObjects("pages/*"), this.bucket.arnForObjects("uploads/*")],
454
+ }),
455
+ );
456
+ this.apiFunction = new lambda.Function(this, "PagesApiFunction", {
457
+ functionName: names.pagesApiFunctionName,
458
+ runtime: lambda.Runtime.NODEJS_22_X,
459
+ handler: "index.handler",
460
+ code: lambdaCode(this, "pages-api"),
461
+ role: apiRole,
462
+ // A commit reassembles and verifies up to 100 MiB of staged chunks.
463
+ timeout: cdk.Duration.seconds(300),
464
+ memorySize: 1024,
465
+ environment: {
466
+ FOUNDATION_PAGES_TABLE_NAME: this.table.tableName,
467
+ FOUNDATION_PAGES_BUCKET_NAME: this.bucket.bucketName,
468
+ FOUNDATION_PAGES_BASE_URL: baseUrl,
469
+ FOUNDATION_SLACK_TEAM_ID: instance.slack.teamId,
470
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
471
+ },
472
+ });
473
+
474
+ new cdk.CfnOutput(this, "PagesApiFunctionArn", { value: this.apiFunction.functionArn });
475
+ new cdk.CfnOutput(this, "PagesBaseUrl", { value: baseUrl });
476
+ new cdk.CfnOutput(this, "PagesDistributionDomainName", {
477
+ value: this.distribution.distributionDomainName,
478
+ });
479
+ new cdk.CfnOutput(this, "PagesAuthRedirectUrl", {
480
+ description: "Slack OAuth redirect URL for Sign in with Slack on the pages domain",
481
+ value: `${baseUrl}/_auth/callback`,
482
+ });
483
+ // Registered as well, so links from before a custom domain attached keep signing in.
484
+ new cdk.CfnOutput(this, "PagesDefaultAuthRedirectUrl", {
485
+ value: `${defaultUrl}/_auth/callback`,
486
+ });
487
+ new cdk.CfnOutput(this, "PagesCustomDomainAttached", {
488
+ value: domain === undefined ? "false" : "true",
489
+ });
490
+ if (domain !== undefined) new cdk.CfnOutput(this, "PagesCustomDomain", { value: domain });
491
+ if (zone instanceof route53.PublicHostedZone) {
492
+ new cdk.CfnOutput(this, "PagesHostedZoneId", { value: zone.hostedZoneId });
493
+ new cdk.CfnOutput(this, "PagesNameServers", {
494
+ description: `NS records to add for ${zoneDomain} at the company's DNS provider`,
495
+ value: cdk.Fn.join(",", zone.hostedZoneNameServers ?? []),
496
+ });
497
+ }
498
+ }
499
+ }
500
+
501
+ /** Imported secrets carry a partial ARN; match the 6-character suffix IAM needs. */
502
+ function secretArnPattern(secret: secretsmanager.ISecret): string {
503
+ return secret.secretFullArn ?? `${secret.secretArn}-??????`;
504
+ }