@fjall/components-infrastructure 2.29.0 → 2.30.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.
- package/dist/lib/app.d.ts +8 -4
- package/dist/lib/app.js +24 -6
- package/dist/lib/lambda-assets/static-site-forms/asset/index.js +175 -0
- package/dist/lib/lambda-assets/static-site-forms/asset/package.json +4 -0
- package/dist/lib/patterns/aws/cdn.d.ts +19 -2
- package/dist/lib/patterns/aws/cdn.js +13 -2
- package/dist/lib/patterns/aws/interfaces/pattern.d.ts +134 -5
- package/dist/lib/patterns/aws/interfaces/pattern.js +6 -0
- package/dist/lib/patterns/aws/pattern.d.ts +20 -5
- package/dist/lib/patterns/aws/pattern.js +12 -6
- package/dist/lib/patterns/aws/staticSite.d.ts +59 -0
- package/dist/lib/patterns/aws/staticSite.js +307 -0
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +35 -2
- package/dist/lib/resources/aws/cdn/cloudFront.js +63 -25
- package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
- package/dist/lib/resources/aws/cdn/index.js +1 -0
- package/dist/lib/resources/aws/cdn/responseHeadersPolicy.d.ts +56 -0
- package/dist/lib/resources/aws/cdn/responseHeadersPolicy.js +92 -0
- package/dist/lib/resources/aws/compute/lambda.d.ts +7 -0
- package/dist/lib/resources/aws/networking/domainCertificate.d.ts +7 -0
- package/dist/lib/resources/aws/networking/domainCertificate.js +9 -7
- package/package.json +7 -5
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-site Pattern.
|
|
3
|
+
*
|
|
4
|
+
* Serves a pre-built folder of files from a private S3 bucket behind CloudFront
|
|
5
|
+
* (OAC), with clean URLs, security headers, an optional custom domain, and an
|
|
6
|
+
* optional contact-form endpoint (Lambda Function URL → SES, CloudFront-fronted).
|
|
7
|
+
*
|
|
8
|
+
* Resources created:
|
|
9
|
+
* - Private S3 bucket + BucketDeployment (asset upload)
|
|
10
|
+
* - CloudFront distribution (OAC, clean-URL function, security-headers policy)
|
|
11
|
+
* - ACM certificate + Route53 alias record (when `domain` is set)
|
|
12
|
+
* - Contact-form Lambda + Function URL (when `forms` is set)
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const site = app.addPattern(PatternFactory.build("LbexcSite", {
|
|
16
|
+
* type: "staticsite",
|
|
17
|
+
* name: "lbexc",
|
|
18
|
+
* source: "../../lbexc-static-export",
|
|
19
|
+
* build: { command: "npm run build", outputDir: "out" },
|
|
20
|
+
* routing: "multipage",
|
|
21
|
+
* security: { headers: true },
|
|
22
|
+
* domain: "lbexc.com",
|
|
23
|
+
* forms: { to: "hello@lbexc.com", corsOrigin: "https://lbexc.com" }
|
|
24
|
+
* }));
|
|
25
|
+
*/
|
|
26
|
+
import { Construct } from "constructs";
|
|
27
|
+
import type App from "../../app.js";
|
|
28
|
+
import { type IStaticSiteProps, type IStaticSite } from "./interfaces/pattern.js";
|
|
29
|
+
import { type Storage } from "./storage.js";
|
|
30
|
+
import { type Cdn } from "./cdn.js";
|
|
31
|
+
import { LambdaFunction } from "../../resources/aws/compute/index.js";
|
|
32
|
+
/**
|
|
33
|
+
* Static-site pattern implementation.
|
|
34
|
+
*/
|
|
35
|
+
export declare class StaticSite extends Construct implements IStaticSite {
|
|
36
|
+
readonly patternType: "staticsite";
|
|
37
|
+
private readonly app;
|
|
38
|
+
private readonly props;
|
|
39
|
+
private readonly pascalName;
|
|
40
|
+
private _bucket;
|
|
41
|
+
private _cdn;
|
|
42
|
+
private _formsFunction?;
|
|
43
|
+
private _headersPolicy?;
|
|
44
|
+
private _resolvedDomain;
|
|
45
|
+
constructor(scope: Construct, id: string, app: App, props: IStaticSiteProps);
|
|
46
|
+
private registerManifest;
|
|
47
|
+
private validateProps;
|
|
48
|
+
private createBucketAndDeployment;
|
|
49
|
+
private createFormsEndpoint;
|
|
50
|
+
private createCdn;
|
|
51
|
+
private buildBehaviours;
|
|
52
|
+
private resolveDomainConfig;
|
|
53
|
+
private resolveSimpleDomain;
|
|
54
|
+
private createDnsRecord;
|
|
55
|
+
private exportPatternOutputs;
|
|
56
|
+
getBucket(): Storage;
|
|
57
|
+
getCdn(): Cdn;
|
|
58
|
+
getFormsFunction(): LambdaFunction | undefined;
|
|
59
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static-site Pattern.
|
|
3
|
+
*
|
|
4
|
+
* Serves a pre-built folder of files from a private S3 bucket behind CloudFront
|
|
5
|
+
* (OAC), with clean URLs, security headers, an optional custom domain, and an
|
|
6
|
+
* optional contact-form endpoint (Lambda Function URL → SES, CloudFront-fronted).
|
|
7
|
+
*
|
|
8
|
+
* Resources created:
|
|
9
|
+
* - Private S3 bucket + BucketDeployment (asset upload)
|
|
10
|
+
* - CloudFront distribution (OAC, clean-URL function, security-headers policy)
|
|
11
|
+
* - ACM certificate + Route53 alias record (when `domain` is set)
|
|
12
|
+
* - Contact-form Lambda + Function URL (when `forms` is set)
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const site = app.addPattern(PatternFactory.build("LbexcSite", {
|
|
16
|
+
* type: "staticsite",
|
|
17
|
+
* name: "lbexc",
|
|
18
|
+
* source: "../../lbexc-static-export",
|
|
19
|
+
* build: { command: "npm run build", outputDir: "out" },
|
|
20
|
+
* routing: "multipage",
|
|
21
|
+
* security: { headers: true },
|
|
22
|
+
* domain: "lbexc.com",
|
|
23
|
+
* forms: { to: "hello@lbexc.com", corsOrigin: "https://lbexc.com" }
|
|
24
|
+
* }));
|
|
25
|
+
*/
|
|
26
|
+
import { Construct } from "constructs";
|
|
27
|
+
import { CfnOutput, Fn, Stack } from "aws-cdk-lib";
|
|
28
|
+
import { dirname, resolve } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { Code, Runtime, Architecture, FunctionUrlAuthType, HttpMethod } from "aws-cdk-lib/aws-lambda";
|
|
31
|
+
import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
|
|
32
|
+
import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
33
|
+
import { HostedZone } from "aws-cdk-lib/aws-route53";
|
|
34
|
+
import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
|
|
35
|
+
import { DNS_APEX, defaultFormsCorsOrigin, defaultFormsFromAddress, isAddressAtDomain } from "@fjall/util";
|
|
36
|
+
import { StorageFactory } from "./storage.js";
|
|
37
|
+
import { CdnFactory } from "./cdn.js";
|
|
38
|
+
import { SecurityHeadersPolicy } from "../../resources/aws/cdn/index.js";
|
|
39
|
+
import { LambdaFunction } from "../../resources/aws/compute/index.js";
|
|
40
|
+
import { DomainCertificate, AliasRecord } from "../../resources/aws/networking/index.js";
|
|
41
|
+
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
42
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
const FORMS_ASSET_DIR = resolve(__dirname, "../../lambda-assets/static-site-forms/asset");
|
|
44
|
+
const FORMS_DEFAULTS = {
|
|
45
|
+
RUNTIME: Runtime.NODEJS_22_X,
|
|
46
|
+
ARCHITECTURE: Architecture.ARM_64,
|
|
47
|
+
TIMEOUT_SECONDS: 10,
|
|
48
|
+
MEMORY_MB: 256,
|
|
49
|
+
MAX_CONCURRENCY: 5
|
|
50
|
+
};
|
|
51
|
+
/** Extract the registrable root (e.g. "a.b.example.com" → "example.com"). */
|
|
52
|
+
function extractRootDomain(domain) {
|
|
53
|
+
const parts = domain.split(".");
|
|
54
|
+
return parts.length > 2 ? parts.slice(-2).join(".") : domain;
|
|
55
|
+
}
|
|
56
|
+
/** Record label relative to the zone — DNS_APEX for the apex. */
|
|
57
|
+
function recordLabelFor(domain, zoneName) {
|
|
58
|
+
if (domain === zoneName)
|
|
59
|
+
return DNS_APEX;
|
|
60
|
+
const suffix = `.${zoneName}`;
|
|
61
|
+
return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Static-site pattern implementation.
|
|
65
|
+
*/
|
|
66
|
+
export class StaticSite extends Construct {
|
|
67
|
+
patternType = "staticsite";
|
|
68
|
+
app;
|
|
69
|
+
props;
|
|
70
|
+
pascalName;
|
|
71
|
+
_bucket;
|
|
72
|
+
_cdn;
|
|
73
|
+
_formsFunction;
|
|
74
|
+
_headersPolicy;
|
|
75
|
+
_resolvedDomain;
|
|
76
|
+
constructor(scope, id, app, props) {
|
|
77
|
+
super(scope, id);
|
|
78
|
+
this.app = app;
|
|
79
|
+
this.props = props;
|
|
80
|
+
this.pascalName = toPascalCase(props.name);
|
|
81
|
+
this.registerManifest();
|
|
82
|
+
this.validateProps();
|
|
83
|
+
this.createBucketAndDeployment();
|
|
84
|
+
// Forms must exist before the CDN — createCdn wires the /api/contact*
|
|
85
|
+
// behaviour to the forms Function URL host (§15).
|
|
86
|
+
this.createFormsEndpoint();
|
|
87
|
+
this.createCdn();
|
|
88
|
+
this.createDnsRecord();
|
|
89
|
+
this.exportPatternOutputs();
|
|
90
|
+
}
|
|
91
|
+
registerManifest() {
|
|
92
|
+
const manifestPattern = {
|
|
93
|
+
type: "staticsite",
|
|
94
|
+
name: this.props.name,
|
|
95
|
+
source: this.props.source
|
|
96
|
+
};
|
|
97
|
+
this.app.getManifestCollector().setPattern(manifestPattern);
|
|
98
|
+
}
|
|
99
|
+
validateProps() {
|
|
100
|
+
const forms = this.props.forms;
|
|
101
|
+
if (!forms)
|
|
102
|
+
return;
|
|
103
|
+
const domain = this.props.domain;
|
|
104
|
+
if (!domain) {
|
|
105
|
+
throw new Error(`Static site '${this.props.name}' has a contact form but no 'domain'. ` +
|
|
106
|
+
"SES sends only from a verified identity, and the site's domain is " +
|
|
107
|
+
"the identity this pattern verifies. Set 'domain' alongside 'forms'.");
|
|
108
|
+
}
|
|
109
|
+
if (forms.from !== undefined && !isAddressAtDomain(forms.from, domain)) {
|
|
110
|
+
throw new Error(`Static site '${this.props.name}' sets forms.from to '${forms.from}', ` +
|
|
111
|
+
`which is not an address at '${domain}'. SES sends only from the ` +
|
|
112
|
+
`verified domain identity — use noreply@${domain} (the default), or ` +
|
|
113
|
+
"another address at that domain. 'forms.to' is where submissions go " +
|
|
114
|
+
"and may be any address.");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
createBucketAndDeployment() {
|
|
118
|
+
const deploymentSource = resolve(this.props.source, this.props.build.outputDir);
|
|
119
|
+
this._bucket = this.app.addStorage(StorageFactory.build(`${this.pascalName}Site`, {
|
|
120
|
+
stackPlacement: "cdn",
|
|
121
|
+
deployment: {
|
|
122
|
+
source: deploymentSource,
|
|
123
|
+
prune: true
|
|
124
|
+
}
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
createFormsEndpoint() {
|
|
128
|
+
const forms = this.props.forms;
|
|
129
|
+
if (!forms)
|
|
130
|
+
return;
|
|
131
|
+
// Validated present by validateProps.
|
|
132
|
+
const domain = this.props.domain;
|
|
133
|
+
const stack = Stack.of(this);
|
|
134
|
+
const sesIdentityArn = `arn:aws:ses:${stack.region}:${stack.account}:identity/${domain}`;
|
|
135
|
+
// The envelope sender must sit at the verified domain; the recipient is
|
|
136
|
+
// wherever the site owner reads mail. Conflating them makes SES reject
|
|
137
|
+
// every send the moment `to` is an address SES has never heard of.
|
|
138
|
+
const contactFrom = forms.from ?? defaultFormsFromAddress(domain);
|
|
139
|
+
const allowedOrigin = forms.corsOrigin ?? defaultFormsCorsOrigin(domain);
|
|
140
|
+
this._formsFunction = new LambdaFunction(this, `${this.pascalName}FormsFn`, {
|
|
141
|
+
code: Code.fromAsset(FORMS_ASSET_DIR),
|
|
142
|
+
handler: "index.handler",
|
|
143
|
+
runtime: FORMS_DEFAULTS.RUNTIME,
|
|
144
|
+
architecture: FORMS_DEFAULTS.ARCHITECTURE,
|
|
145
|
+
timeout: FORMS_DEFAULTS.TIMEOUT_SECONDS,
|
|
146
|
+
memorySize: FORMS_DEFAULTS.MEMORY_MB,
|
|
147
|
+
reservedConcurrentExecutions: forms.maxConcurrency ?? FORMS_DEFAULTS.MAX_CONCURRENCY,
|
|
148
|
+
lambdaDescription: `Contact-form handler for ${this.props.name}`,
|
|
149
|
+
roleDescription: `Contact-form send role for ${this.props.name}`,
|
|
150
|
+
enableFunctionUrl: true,
|
|
151
|
+
functionUrlAuthType: FunctionUrlAuthType.NONE,
|
|
152
|
+
functionUrlCors: {
|
|
153
|
+
allowedOrigins: [allowedOrigin],
|
|
154
|
+
allowedMethods: [HttpMethod.POST],
|
|
155
|
+
allowedHeaders: ["content-type"]
|
|
156
|
+
},
|
|
157
|
+
environment: {
|
|
158
|
+
CONTACT_TO: forms.to,
|
|
159
|
+
CONTACT_FROM: contactFrom,
|
|
160
|
+
ALLOWED_ORIGIN: allowedOrigin
|
|
161
|
+
},
|
|
162
|
+
inlinePolicy: [
|
|
163
|
+
new PolicyStatement({
|
|
164
|
+
effect: Effect.ALLOW,
|
|
165
|
+
actions: ["ses:SendEmail"],
|
|
166
|
+
resources: [sesIdentityArn],
|
|
167
|
+
conditions: {
|
|
168
|
+
StringEquals: { "ses:FromAddress": contactFrom }
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
]
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
createCdn() {
|
|
175
|
+
const { domainNames, certificate } = this.resolveDomainConfig();
|
|
176
|
+
if (this.props.security?.headers === true) {
|
|
177
|
+
this._headersPolicy = new SecurityHeadersPolicy(this, `${this.pascalName}Headers`, {
|
|
178
|
+
comment: `Security headers for ${this.props.name}`,
|
|
179
|
+
...(this.props.security.contentSecurityPolicy !== undefined && {
|
|
180
|
+
contentSecurityPolicy: this.props.security.contentSecurityPolicy
|
|
181
|
+
})
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const behaviours = this.buildBehaviours();
|
|
185
|
+
const cdnProps = {
|
|
186
|
+
originType: "s3",
|
|
187
|
+
bucket: this._bucket,
|
|
188
|
+
originAccess: "oac",
|
|
189
|
+
defaultRootObject: "index.html",
|
|
190
|
+
routing: this.props.routing ?? "multipage",
|
|
191
|
+
cachePolicy: "CACHING_OPTIMIZED",
|
|
192
|
+
priceClass: "PriceClass_100",
|
|
193
|
+
domainNames,
|
|
194
|
+
certificate,
|
|
195
|
+
...(this._headersPolicy && {
|
|
196
|
+
responseHeadersPolicy: this._headersPolicy.getPolicy()
|
|
197
|
+
}),
|
|
198
|
+
...(behaviours.length > 0 && { behaviours })
|
|
199
|
+
};
|
|
200
|
+
this._cdn = this.app.addCdn(CdnFactory.build(this.props.name, cdnProps));
|
|
201
|
+
}
|
|
202
|
+
buildBehaviours() {
|
|
203
|
+
const behaviours = [];
|
|
204
|
+
if (this._formsFunction) {
|
|
205
|
+
const formsUrl = this._formsFunction.getFunctionUrl();
|
|
206
|
+
if (!formsUrl) {
|
|
207
|
+
throw new Error("Forms Function URL unavailable — cannot wire /api/contact behaviour.");
|
|
208
|
+
}
|
|
209
|
+
// Extract the Function-URL host (mirrors CdnFactory Lambda-origin idiom):
|
|
210
|
+
// https://<host>/ → split by "/" → index 2 is the host.
|
|
211
|
+
const formsHost = Fn.select(2, Fn.split("/", formsUrl));
|
|
212
|
+
behaviours.push({
|
|
213
|
+
pathPattern: "/api/contact*",
|
|
214
|
+
origin: formsHost,
|
|
215
|
+
allowedMethods: "ALL",
|
|
216
|
+
cachePolicy: "CACHING_DISABLED"
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
behaviours.push(...(this.props.cdn?.behaviours ?? []));
|
|
220
|
+
return behaviours;
|
|
221
|
+
}
|
|
222
|
+
resolveDomainConfig() {
|
|
223
|
+
if (this._resolvedDomain)
|
|
224
|
+
return this._resolvedDomain;
|
|
225
|
+
this._resolvedDomain = this.props.domain
|
|
226
|
+
? this.resolveSimpleDomain(this.props.domain)
|
|
227
|
+
: {
|
|
228
|
+
domainNames: undefined,
|
|
229
|
+
certificate: undefined,
|
|
230
|
+
hostedZone: undefined
|
|
231
|
+
};
|
|
232
|
+
return this._resolvedDomain;
|
|
233
|
+
}
|
|
234
|
+
resolveSimpleDomain(domain) {
|
|
235
|
+
if (this.props.managedDomain) {
|
|
236
|
+
const managed = this.props.managedDomain;
|
|
237
|
+
const hostedZone = HostedZone.fromHostedZoneAttributes(this, `${this.pascalName}ManagedHostedZone`, {
|
|
238
|
+
hostedZoneId: Fn.importValue(managed.hostedZoneIdExport),
|
|
239
|
+
zoneName: managed.zoneName
|
|
240
|
+
});
|
|
241
|
+
const certificate = Certificate.fromCertificateArn(this, `${this.pascalName}ManagedCertificate`, Fn.importValue(managed.certificateArnExport));
|
|
242
|
+
return { domainNames: [domain], certificate, hostedZone };
|
|
243
|
+
}
|
|
244
|
+
const rootDomain = extractRootDomain(domain);
|
|
245
|
+
const hostedZone = HostedZone.fromLookup(this, `${this.pascalName}HostedZone`, { domainName: rootDomain });
|
|
246
|
+
// Single-stack: the cert is consumed in-stack, so suppress the ARN export
|
|
247
|
+
// to avoid a collision with a managed-domain/apex stack (§12-I).
|
|
248
|
+
const domainCert = new DomainCertificate(this, `${this.pascalName}Certificate`, {
|
|
249
|
+
domainName: domain,
|
|
250
|
+
hostedZone,
|
|
251
|
+
exportCertificateArn: false
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
domainNames: [domain],
|
|
255
|
+
certificate: domainCert.certificate,
|
|
256
|
+
hostedZone
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
createDnsRecord() {
|
|
260
|
+
if (!this.props.domain)
|
|
261
|
+
return;
|
|
262
|
+
const { hostedZone } = this.resolveDomainConfig();
|
|
263
|
+
if (!hostedZone)
|
|
264
|
+
return;
|
|
265
|
+
const zoneName = this.props.managedDomain?.zoneName ??
|
|
266
|
+
extractRootDomain(this.props.domain);
|
|
267
|
+
// Alias record lives in the CDN stack (where the distribution is) so there
|
|
268
|
+
// is no cross-stack cycle back to this pattern node.
|
|
269
|
+
const cdnStack = this.app.getDefaultCdnStack().getStack();
|
|
270
|
+
new AliasRecord(cdnStack, `${this.pascalName}AliasRecord`, {
|
|
271
|
+
zone: hostedZone,
|
|
272
|
+
zoneName,
|
|
273
|
+
recordName: recordLabelFor(this.props.domain, zoneName),
|
|
274
|
+
target: new CloudFrontTarget(this._cdn.getDistribution())
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
exportPatternOutputs() {
|
|
278
|
+
new CfnOutput(this, `${this.pascalName}PatternType`, {
|
|
279
|
+
key: `${this.pascalName}PatternType`,
|
|
280
|
+
value: "staticsite",
|
|
281
|
+
description: `Pattern type for ${this.props.name}`
|
|
282
|
+
});
|
|
283
|
+
new CfnOutput(this, `${this.pascalName}PatternName`, {
|
|
284
|
+
key: `${this.pascalName}PatternName`,
|
|
285
|
+
value: this.props.name,
|
|
286
|
+
description: `Pattern name for ${this.props.name}`
|
|
287
|
+
});
|
|
288
|
+
new CfnOutput(this, `${this.pascalName}PatternResources`, {
|
|
289
|
+
key: `${this.pascalName}PatternResources`,
|
|
290
|
+
value: JSON.stringify({
|
|
291
|
+
storage: [`${this.props.name}-site`],
|
|
292
|
+
cdn: this.props.name,
|
|
293
|
+
...(this._formsFunction && { forms: `${this.props.name}-forms` })
|
|
294
|
+
}),
|
|
295
|
+
description: `Related resource names for pattern ${this.props.name}`
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
getBucket() {
|
|
299
|
+
return this._bucket;
|
|
300
|
+
}
|
|
301
|
+
getCdn() {
|
|
302
|
+
return this._cdn;
|
|
303
|
+
}
|
|
304
|
+
getFormsFunction() {
|
|
305
|
+
return this._formsFunction;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { Distribution, type ICachePolicy } from "aws-cdk-lib/aws-cloudfront";
|
|
2
|
+
import { Distribution, type ICachePolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-cloudfront";
|
|
3
3
|
import { type IBucket } from "aws-cdk-lib/aws-s3";
|
|
4
4
|
import { type IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
|
5
5
|
import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
@@ -8,6 +8,8 @@ export interface S3OriginConfig {
|
|
|
8
8
|
type: "s3";
|
|
9
9
|
bucket: IBucket;
|
|
10
10
|
originPath?: string;
|
|
11
|
+
/** OAI (cross-stack-safe default) or OAC (modern, single-stack only). */
|
|
12
|
+
originAccess?: "oai" | "oac";
|
|
11
13
|
}
|
|
12
14
|
export interface AlbOriginConfig {
|
|
13
15
|
type: "alb";
|
|
@@ -54,6 +56,27 @@ export interface CloudFrontDistributionProps {
|
|
|
54
56
|
/** Adds access gating to the distribution via a CloudFront Function.
|
|
55
57
|
* Set to false to explicitly disable (or omit). */
|
|
56
58
|
accessGate?: false | AccessGateConfig;
|
|
59
|
+
/** Object served for a bare `/` request (static sites: "index.html"). */
|
|
60
|
+
defaultRootObject?: string;
|
|
61
|
+
/** Rewrites extensionless URLs to `.html`/`/index.html` via a CloudFront
|
|
62
|
+
* Function. Dynamic `/api/` paths are carved out so POSTs are not rewritten. */
|
|
63
|
+
cleanUrls?: boolean;
|
|
64
|
+
/** SPA fallback: 403/404 → `/index.html` @200 for client-side routing. */
|
|
65
|
+
spaFallback?: boolean;
|
|
66
|
+
/** Security-headers policy applied to the default + every additional behaviour. */
|
|
67
|
+
responseHeadersPolicy?: IResponseHeadersPolicy;
|
|
68
|
+
/**
|
|
69
|
+
* Origin-request policy for the DEFAULT behaviour. Defaults to
|
|
70
|
+
* `all-viewer-except-host`, which every distribution has had since before the
|
|
71
|
+
* static-site pattern — changing the default silently strips
|
|
72
|
+
* `OriginRequestPolicyId` from live distributions on their next deploy.
|
|
73
|
+
*
|
|
74
|
+
* `none` is for an origin that has no use for viewer headers: an S3 origin
|
|
75
|
+
* serves objects by key and ignores forwarded headers, cookies and query
|
|
76
|
+
* strings. Additional behaviours always forward (a static site's `/api/*`
|
|
77
|
+
* Lambda Function URL origin needs the viewer's headers to serve the form).
|
|
78
|
+
*/
|
|
79
|
+
originRequestPolicy?: "all-viewer-except-host" | "none";
|
|
57
80
|
}
|
|
58
81
|
export declare class CloudFrontDistribution extends Construct {
|
|
59
82
|
readonly id: string;
|
|
@@ -61,10 +84,20 @@ export declare class CloudFrontDistribution extends Construct {
|
|
|
61
84
|
constructor(scope: Construct, id: string, props: CloudFrontDistributionProps);
|
|
62
85
|
/**
|
|
63
86
|
* Build a composable CloudFront Function for VIEWER_REQUEST.
|
|
64
|
-
* Merges accessGate and
|
|
87
|
+
* Merges accessGate, forwardHostHeader and cleanUrls into a single function
|
|
65
88
|
* (CloudFront allows only one function per event type).
|
|
89
|
+
*
|
|
90
|
+
* ORDER IS LOAD-BEARING. The access gate is emitted first because it is the
|
|
91
|
+
* only fragment that can reject a request, and a fragment that returns early
|
|
92
|
+
* ahead of it would silently un-gate whatever paths it short-circuits. The
|
|
93
|
+
* cleanUrls fragment carves `/api/*` out of the `.html` rewriting — that
|
|
94
|
+
* carve-out must therefore be a guard around the rewrite, never an early
|
|
95
|
+
* `return request`, or the whole `/api/*` surface leaves the gate behind it.
|
|
66
96
|
*/
|
|
67
97
|
private buildViewerRequestFunction;
|
|
98
|
+
/** SPA fallback: serve /index.html @200 for 403/404 so client-side routing
|
|
99
|
+
* handles the path. CloudFront returns 403 (not 404) for missing S3 keys. */
|
|
100
|
+
private buildSpaErrorResponses;
|
|
68
101
|
private createOrigin;
|
|
69
102
|
private resolveCachePolicy;
|
|
70
103
|
private resolveAllowedMethods;
|
|
@@ -13,8 +13,9 @@ export class CloudFrontDistribution extends Construct {
|
|
|
13
13
|
const outputName = toPascalCase(id);
|
|
14
14
|
const defaultOrigin = this.createOrigin(props.defaultOrigin);
|
|
15
15
|
const defaultCachePolicy = this.resolveCachePolicy(props.defaultCachePolicy);
|
|
16
|
-
// Build composable viewer request function (accessGate + forwardHostHeader
|
|
17
|
-
|
|
16
|
+
// Build composable viewer request function (accessGate + forwardHostHeader
|
|
17
|
+
// + cleanUrls)
|
|
18
|
+
const viewerRequestFunction = this.buildViewerRequestFunction(id, props.forwardHostHeader, props.accessGate, props.cleanUrls);
|
|
18
19
|
const functionAssociations = viewerRequestFunction
|
|
19
20
|
? [
|
|
20
21
|
{
|
|
@@ -31,9 +32,9 @@ export class CloudFrontDistribution extends Construct {
|
|
|
31
32
|
cachePolicy: this.resolveCachePolicy(behaviour.cachePolicy),
|
|
32
33
|
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
33
34
|
allowedMethods: this.resolveAllowedMethods(behaviour.allowedMethods),
|
|
34
|
-
|
|
35
|
-
...(
|
|
36
|
-
|
|
35
|
+
originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
|
|
36
|
+
...(props.responseHeadersPolicy !== undefined && {
|
|
37
|
+
responseHeadersPolicy: props.responseHeadersPolicy
|
|
37
38
|
}),
|
|
38
39
|
...(functionAssociations !== undefined && { functionAssociations })
|
|
39
40
|
};
|
|
@@ -44,13 +45,22 @@ export class CloudFrontDistribution extends Construct {
|
|
|
44
45
|
origin: defaultOrigin,
|
|
45
46
|
cachePolicy: defaultCachePolicy,
|
|
46
47
|
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
47
|
-
originRequestPolicy
|
|
48
|
+
...(props.originRequestPolicy !== "none" && {
|
|
49
|
+
originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER
|
|
50
|
+
}),
|
|
48
51
|
allowedMethods: this.resolveAllowedMethods(props.defaultAllowedMethods),
|
|
52
|
+
...(props.responseHeadersPolicy !== undefined && {
|
|
53
|
+
responseHeadersPolicy: props.responseHeadersPolicy
|
|
54
|
+
}),
|
|
49
55
|
...(functionAssociations !== undefined && { functionAssociations })
|
|
50
56
|
},
|
|
51
57
|
additionalBehaviors: Object.keys(additionalBehaviors).length > 0
|
|
52
58
|
? additionalBehaviors
|
|
53
59
|
: undefined,
|
|
60
|
+
defaultRootObject: props.defaultRootObject,
|
|
61
|
+
...(props.spaFallback && {
|
|
62
|
+
errorResponses: this.buildSpaErrorResponses()
|
|
63
|
+
}),
|
|
54
64
|
domainNames: props.domainNames,
|
|
55
65
|
certificate: props.certificate,
|
|
56
66
|
comment: props.comment,
|
|
@@ -80,43 +90,71 @@ export class CloudFrontDistribution extends Construct {
|
|
|
80
90
|
}
|
|
81
91
|
/**
|
|
82
92
|
* Build a composable CloudFront Function for VIEWER_REQUEST.
|
|
83
|
-
* Merges accessGate and
|
|
93
|
+
* Merges accessGate, forwardHostHeader and cleanUrls into a single function
|
|
84
94
|
* (CloudFront allows only one function per event type).
|
|
95
|
+
*
|
|
96
|
+
* ORDER IS LOAD-BEARING. The access gate is emitted first because it is the
|
|
97
|
+
* only fragment that can reject a request, and a fragment that returns early
|
|
98
|
+
* ahead of it would silently un-gate whatever paths it short-circuits. The
|
|
99
|
+
* cleanUrls fragment carves `/api/*` out of the `.html` rewriting — that
|
|
100
|
+
* carve-out must therefore be a guard around the rewrite, never an early
|
|
101
|
+
* `return request`, or the whole `/api/*` surface leaves the gate behind it.
|
|
85
102
|
*/
|
|
86
|
-
buildViewerRequestFunction(id, forwardHostHeader, accessGate) {
|
|
87
|
-
if (!accessGate && !forwardHostHeader) {
|
|
103
|
+
buildViewerRequestFunction(id, forwardHostHeader, accessGate, cleanUrls) {
|
|
104
|
+
if (!accessGate && !forwardHostHeader && !cleanUrls) {
|
|
88
105
|
return undefined;
|
|
89
106
|
}
|
|
90
|
-
|
|
107
|
+
const fragments = [];
|
|
91
108
|
if (accessGate) {
|
|
92
109
|
const credentials = Buffer.from(`${accessGate.username}:${accessGate.password}`).toString("base64");
|
|
93
|
-
|
|
94
|
-
`
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
` }; }`;
|
|
110
|
+
fragments.push(` var authHeader = request.headers.authorization;` +
|
|
111
|
+
` if (!authHeader || authHeader.value !== "Basic ${credentials}") {` +
|
|
112
|
+
` return {` +
|
|
113
|
+
` statusCode: 401,` +
|
|
114
|
+
` statusDescription: "Unauthorized",` +
|
|
115
|
+
` headers: { "www-authenticate": { value: 'Basic realm="Restricted"' } },` +
|
|
116
|
+
` body: { encoding: "text", data: "Unauthorized" }` +
|
|
117
|
+
` }; }`);
|
|
102
118
|
}
|
|
103
119
|
if (forwardHostHeader) {
|
|
104
|
-
|
|
105
|
-
" request.headers['x-forwarded-host'] = { value: request.headers.host.value };";
|
|
120
|
+
fragments.push(" request.headers['x-forwarded-host'] = { value: request.headers.host.value };");
|
|
106
121
|
}
|
|
107
|
-
|
|
122
|
+
if (cleanUrls) {
|
|
123
|
+
fragments.push(" var uri = request.uri;" +
|
|
124
|
+
" if (!uri.startsWith('/api/')) {" +
|
|
125
|
+
" if (uri.endsWith('/')) { request.uri = uri + 'index.html'; }" +
|
|
126
|
+
" else { var seg = uri.substring(uri.lastIndexOf('/') + 1);" +
|
|
127
|
+
" if (seg.indexOf('.') === -1) { request.uri = uri + '.html'; } } }");
|
|
128
|
+
}
|
|
129
|
+
const functionBody = "function handler(event) { var request = event.request;" +
|
|
130
|
+
fragments.join("") +
|
|
131
|
+
" return request; }";
|
|
108
132
|
return new CloudFrontFunction(this, `${id}ViewerRequestFn`, {
|
|
109
133
|
code: FunctionCode.fromInline(functionBody),
|
|
110
134
|
runtime: FunctionRuntime.JS_2_0
|
|
111
135
|
});
|
|
112
136
|
}
|
|
137
|
+
/** SPA fallback: serve /index.html @200 for 403/404 so client-side routing
|
|
138
|
+
* handles the path. CloudFront returns 403 (not 404) for missing S3 keys. */
|
|
139
|
+
buildSpaErrorResponses() {
|
|
140
|
+
return [403, 404].map((httpStatus) => ({
|
|
141
|
+
httpStatus,
|
|
142
|
+
responseHttpStatus: 200,
|
|
143
|
+
responsePagePath: "/index.html"
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
113
146
|
createOrigin(config) {
|
|
114
147
|
switch (config.type) {
|
|
115
148
|
case "s3":
|
|
116
|
-
//
|
|
149
|
+
// OAC (single-stack only) is safe when the bucket and distribution live
|
|
150
|
+
// in the same stack — no cross-stack bucket→CDN policy cycle. Default is
|
|
151
|
+
// OAI, which avoids that cycle for cross-stack origins.
|
|
117
152
|
// See: https://github.com/aws/aws-cdk/issues/31462
|
|
118
|
-
|
|
119
|
-
|
|
153
|
+
if (config.originAccess === "oac") {
|
|
154
|
+
return S3BucketOrigin.withOriginAccessControl(config.bucket, {
|
|
155
|
+
originPath: config.originPath
|
|
156
|
+
});
|
|
157
|
+
}
|
|
120
158
|
return S3BucketOrigin.withOriginAccessIdentity(config.bucket, {
|
|
121
159
|
originPath: config.originPath
|
|
122
160
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Construct } from "constructs";
|
|
2
|
+
import { ResponseHeadersPolicy as CdkResponseHeadersPolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-cloudfront";
|
|
3
|
+
/**
|
|
4
|
+
* The Referrer-Policy values CloudFront accepts, as the wire header string.
|
|
5
|
+
* Mirrors `HeadersReferrerPolicy` (whose enum values ARE these strings) so
|
|
6
|
+
* consumers pass the readable header value, not the CDK enum member.
|
|
7
|
+
*/
|
|
8
|
+
export type ReferrerPolicyValue = "no-referrer" | "no-referrer-when-downgrade" | "origin" | "origin-when-cross-origin" | "same-origin" | "strict-origin" | "strict-origin-when-cross-origin" | "unsafe-url";
|
|
9
|
+
/** HTTP Strict-Transport-Security configuration; `false` opts the header out. */
|
|
10
|
+
export interface HstsConfig {
|
|
11
|
+
maxAgeDays?: number;
|
|
12
|
+
includeSubdomains?: boolean;
|
|
13
|
+
preload?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface SecurityHeadersPolicyProps {
|
|
16
|
+
/** CloudFront policy name. Left undefined by default so CloudFront
|
|
17
|
+
* auto-generates a unique name (a fixed name collides across distributions). */
|
|
18
|
+
policyName?: string;
|
|
19
|
+
/** Description carried on the L2 `comment` field (ResponseHeadersPolicy is
|
|
20
|
+
* NOT taggable, so the Fjall description cannot ride on a tag). */
|
|
21
|
+
comment?: string;
|
|
22
|
+
/** HSTS defaults to 730 days + includeSubdomains + preload. `false` opts out. */
|
|
23
|
+
hsts?: false | HstsConfig;
|
|
24
|
+
/** Content-Security-Policy. Absent ⇒ NO CSP header emitted (opt-in only): a
|
|
25
|
+
* wrong `default-src 'self'` silently blocks inline hydration scripts and the
|
|
26
|
+
* site serves but never becomes interactive, so CSP is never a shipped default. */
|
|
27
|
+
contentSecurityPolicy?: string;
|
|
28
|
+
/** X-Frame-Options; defaults to DENY. `false` opts out. */
|
|
29
|
+
frameOptions?: false | "DENY" | "SAMEORIGIN";
|
|
30
|
+
/** Referrer-Policy; defaults to strict-origin-when-cross-origin. `false` opts out. */
|
|
31
|
+
referrerPolicy?: false | ReferrerPolicyValue;
|
|
32
|
+
/** X-Content-Type-Options: nosniff; defaults to on. `false` opts out. */
|
|
33
|
+
contentTypeOptions?: boolean;
|
|
34
|
+
/** Additional custom response headers (all emitted with override:true). */
|
|
35
|
+
customHeaders?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fjall wrapper for `aws-cdk-lib/aws-cloudfront.ResponseHeadersPolicy`.
|
|
39
|
+
*
|
|
40
|
+
* Wrapped on the description + SOC2-defaults trigger (NOT taggability — the
|
|
41
|
+
* resource is not taggable): it owns Fjall's uniform security-header defaults
|
|
42
|
+
* so every consumer inherits the same posture in one edit. Per
|
|
43
|
+
* `.claude/rules/generator-standards.md § Wrapper Routing Discipline`,
|
|
44
|
+
* `lib/{config,patterns}/` instantiate this class, never the raw L2.
|
|
45
|
+
*
|
|
46
|
+
* Defaults (all `override:true`): HSTS 730d+includeSubdomains+preload,
|
|
47
|
+
* X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
|
|
48
|
+
* Referrer-Policy: strict-origin-when-cross-origin. CSP is opt-in only (§5.5).
|
|
49
|
+
*/
|
|
50
|
+
export declare class SecurityHeadersPolicy extends CdkResponseHeadersPolicy {
|
|
51
|
+
constructor(scope: Construct, id: string, props: SecurityHeadersPolicyProps);
|
|
52
|
+
private static buildSecurityHeaders;
|
|
53
|
+
private static buildCustomHeaders;
|
|
54
|
+
/** The underlying policy, for wiring into a distribution behaviour. */
|
|
55
|
+
getPolicy(): IResponseHeadersPolicy;
|
|
56
|
+
}
|