@fjall/components-infrastructure 2.27.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.
@@ -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 forwardHostHeader into a single function
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
- const viewerRequestFunction = this.buildViewerRequestFunction(id, props.forwardHostHeader, props.accessGate);
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
- // Forward viewer headers (cookies, auth) to non-S3 origins
35
- ...(behaviour.origin.type !== "s3" && {
36
- originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER
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: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
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 forwardHostHeader into a single function
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
- let functionBody = "function handler(event) { var request = event.request;";
107
+ const fragments = [];
91
108
  if (accessGate) {
92
109
  const credentials = Buffer.from(`${accessGate.username}:${accessGate.password}`).toString("base64");
93
- functionBody +=
94
- ` var authHeader = request.headers.authorization;` +
95
- ` if (!authHeader || authHeader.value !== "Basic ${credentials}") {` +
96
- ` return {` +
97
- ` statusCode: 401,` +
98
- ` statusDescription: "Unauthorized",` +
99
- ` headers: { "www-authenticate": { value: 'Basic realm="Restricted"' } },` +
100
- ` body: { encoding: "text", data: "Unauthorized" }` +
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
- functionBody +=
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
- functionBody += " return request; }";
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
- // Using OAI instead of OAC to avoid cross-stack circular dependencies
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
- // OAC creates bucket → CDN dependency (bucket policy references distribution)
119
- // OAI works across stacks without triggering cyclic dependency errors
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
  });
@@ -1 +1,2 @@
1
1
  export * from "./cloudFront.js";
2
+ export * from "./responseHeadersPolicy.js";
@@ -1 +1,2 @@
1
1
  export * from "./cloudFront.js";
2
+ export * from "./responseHeadersPolicy.js";
@@ -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
+ }