@fjall/components-infrastructure 14.2.0 → 15.0.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/patterns/aws/apexDomainPattern.js +1 -1
- package/dist/lib/patterns/aws/cdn.d.ts +28 -3
- package/dist/lib/patterns/aws/cdn.js +93 -51
- package/dist/lib/patterns/aws/cdnAppOrigin.d.ts +16 -7
- package/dist/lib/patterns/aws/cdnAppOrigin.js +43 -42
- package/dist/lib/patterns/aws/delegatedDomainPattern.js +1 -1
- package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +13 -4
- package/dist/lib/patterns/aws/dnsRecordComposer.js +88 -6
- package/dist/lib/patterns/aws/domainCertificateComposer.js +35 -39
- package/dist/lib/patterns/aws/interfaces/domain.d.ts +32 -0
- package/dist/lib/patterns/aws/patternDomain.d.ts +9 -0
- package/dist/lib/patterns/aws/patternDomain.js +60 -27
- package/dist/lib/resources/aws/cdn/cloudFront.d.ts +37 -0
- package/dist/lib/resources/aws/cdn/cloudFront.js +96 -4
- package/dist/lib/resources/aws/compute/ecsNetworking.js +74 -79
- package/dist/lib/resources/aws/compute/ingressProfile.d.ts +44 -15
- package/dist/lib/resources/aws/compute/ingressProfile.js +45 -25
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +10 -2
- package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +18 -9
- package/dist/lib/utils/dnsRecordRegistry.d.ts +3 -5
- package/dist/lib/utils/dnsRecordRegistry.js +11 -14
- package/dist/lib/utils/domainTypes.d.ts +11 -0
- package/dist/lib/utils/domainTypes.js +17 -0
- package/dist/lib/utils/managedDomainContext.d.ts +33 -1
- package/dist/lib/utils/managedDomainContext.js +29 -2
- package/package.json +3 -3
|
@@ -16,15 +16,29 @@ import { DNS_APEX } from "@fjall/util";
|
|
|
16
16
|
* runtime guard catches user-crafted `{ kind, ... }` literals that lack
|
|
17
17
|
* `bind()`.
|
|
18
18
|
*
|
|
19
|
-
* Construct-id
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Construct-id formulas (`recordIds` on `DomainCommonProps`):
|
|
20
|
+
* - `"indexed"` (default): `${safeZone}${safeName}${type}Record${index}` —
|
|
21
|
+
* byte-identical to the legacy composer, an eject-contract invariant
|
|
22
|
+
* (Phase 3 depends on stable IDs). Position-coupled, so the records list
|
|
23
|
+
* is append-only for deployed zones.
|
|
24
|
+
* - `"stable"`: `${safeZone}${safeName}${type}Record` — position-free.
|
|
25
|
+
* {@link assertStableIdsDerivable} refuses the two shapes that would
|
|
26
|
+
* collide, with cures, before CDK's opaque duplicate-construct-id error
|
|
27
|
+
* can fire.
|
|
28
|
+
* A per-record `id` (PascalCase alphanumeric) replaces the `safeName`
|
|
29
|
+
* segment in either mode — the escape hatch the stable-mode collision
|
|
30
|
+
* refusal names.
|
|
22
31
|
*/
|
|
23
|
-
export function composeTypedDnsRecords(scope, zone, zoneName, records) {
|
|
32
|
+
export function composeTypedDnsRecords(scope, zone, zoneName, records, recordIds = "indexed") {
|
|
24
33
|
const safeZone = toPascalCase(getSafeZoneName(zoneName));
|
|
34
|
+
if (recordIds === "stable") {
|
|
35
|
+
assertStableIdsDerivable(zoneName, records);
|
|
36
|
+
}
|
|
25
37
|
records.forEach((record, index) => {
|
|
26
|
-
const safeName =
|
|
27
|
-
const constructId =
|
|
38
|
+
const safeName = recordIdNameSegment(record);
|
|
39
|
+
const constructId = recordIds === "stable"
|
|
40
|
+
? `${safeZone}${safeName}${record.type}Record`
|
|
41
|
+
: `${safeZone}${safeName}${record.type}Record${index}`;
|
|
28
42
|
const common = {
|
|
29
43
|
zone,
|
|
30
44
|
zoneName,
|
|
@@ -40,6 +54,9 @@ export function composeTypedDnsRecords(scope, zone, zoneName, records) {
|
|
|
40
54
|
}
|
|
41
55
|
new AliasRecord(scope, constructId, {
|
|
42
56
|
...common,
|
|
57
|
+
// A declared AAAA alias must DEPLOY as AAAA — the resource defaults
|
|
58
|
+
// to "A" for its pattern-internal (IPv4) call sites only.
|
|
59
|
+
recordType: record.type,
|
|
43
60
|
target: target
|
|
44
61
|
});
|
|
45
62
|
return;
|
|
@@ -102,6 +119,71 @@ export function composeTypedDnsRecords(scope, zone, zoneName, records) {
|
|
|
102
119
|
}
|
|
103
120
|
});
|
|
104
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Construct-id name segment: an explicit `id` verbatim (validated — it
|
|
124
|
+
* becomes a CloudFormation logical-ID segment), else the pascal-cased
|
|
125
|
+
* record name. The no-`id` derivation is byte-identical to the legacy
|
|
126
|
+
* inline formula (eject contract).
|
|
127
|
+
*/
|
|
128
|
+
function recordIdNameSegment(record) {
|
|
129
|
+
if (record.id !== undefined) {
|
|
130
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(record.id)) {
|
|
131
|
+
throw new Error(`DNS record '${record.name}' (${record.type}): 'id' must be ` +
|
|
132
|
+
`alphanumeric starting with a letter (got '${record.id}') — it ` +
|
|
133
|
+
"becomes a CloudFormation logical-ID segment.");
|
|
134
|
+
}
|
|
135
|
+
return record.id;
|
|
136
|
+
}
|
|
137
|
+
return toPascalCase(record.name === DNS_APEX ? "Apex" : record.name);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Stable-mode pre-flight: refuse the two list shapes whose derived
|
|
141
|
+
* construct ids would collide, each with its own cure, BEFORE CDK's opaque
|
|
142
|
+
* "already a Construct with name" error can fire.
|
|
143
|
+
*
|
|
144
|
+
* - Duplicate (name, type) pairs are illegal Route53 regardless of id
|
|
145
|
+
* scheme (one record set per (zone, name, type) — the same doctrine the
|
|
146
|
+
* DNS claim registry enforces across constructs; this vocabulary cannot
|
|
147
|
+
* express routing-policy variants). Indexed mode surfaces them via the
|
|
148
|
+
* registry at claim time; stable mode must refuse before construction.
|
|
149
|
+
* - DISTINCT records whose derived construct ids concatenate to one string
|
|
150
|
+
* — same-type names that sanitise to one PascalCase segment (e.g.
|
|
151
|
+
* 'mail-eu' and 'mailEu'), or cross-type segment/type ambiguity (id
|
|
152
|
+
* 'ApiAAA' + type 'A' vs segment 'Api' + type 'AAAA' both derive
|
|
153
|
+
* 'ApiAAAARecord') — are legal Route53 but collide as construct ids; the
|
|
154
|
+
* per-record `id` escape hatch disambiguates. Keyed on the FULL derived
|
|
155
|
+
* id, never on (segment, type) pairs, precisely so the concatenation
|
|
156
|
+
* ambiguity cannot slip past to CDK.
|
|
157
|
+
*/
|
|
158
|
+
function assertStableIdsDerivable(zoneName, records) {
|
|
159
|
+
const byRecordSet = new Map();
|
|
160
|
+
const byConstructId = new Map();
|
|
161
|
+
for (const record of records) {
|
|
162
|
+
const setKey = [
|
|
163
|
+
record.name.toLowerCase().replace(/\.$/, ""),
|
|
164
|
+
record.type
|
|
165
|
+
].join("|");
|
|
166
|
+
const priorSet = byRecordSet.get(setKey);
|
|
167
|
+
if (priorSet !== undefined) {
|
|
168
|
+
throw new Error(`DNS record '${record.name}' (${record.type}) in zone '${zoneName}': ` +
|
|
169
|
+
"declared twice in this records list. Route53 allows one record " +
|
|
170
|
+
"set per (zone, name, type); CloudFormation would reject this at " +
|
|
171
|
+
"deploy. Merge the values into one entry (multi-value records " +
|
|
172
|
+
"take a string array), or remove one.");
|
|
173
|
+
}
|
|
174
|
+
byRecordSet.set(setKey, record);
|
|
175
|
+
const constructKey = `${recordIdNameSegment(record)}${record.type}Record`;
|
|
176
|
+
const prior = byConstructId.get(constructKey);
|
|
177
|
+
if (prior !== undefined) {
|
|
178
|
+
throw new Error(`DNS records '${prior.name}' (${prior.type}) and '${record.name}' ` +
|
|
179
|
+
`(${record.type}) in zone '${zoneName}': both derive record ` +
|
|
180
|
+
`construct id '${constructKey}' under recordIds: "stable". Give ` +
|
|
181
|
+
"one of them a distinct per-record 'id' (PascalCase " +
|
|
182
|
+
"alphanumeric) to disambiguate.");
|
|
183
|
+
}
|
|
184
|
+
byConstructId.set(constructKey, record);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
105
187
|
// Parse "10 mail.example.com" → { priority: 10, hostName: "mail.example.com" }.
|
|
106
188
|
function parseMxValue(raw) {
|
|
107
189
|
const parts = raw.trim().split(/\s+/);
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { CfnOutput, Stack, Stage, Token } from "aws-cdk-lib";
|
|
2
2
|
import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
|
|
3
|
-
import { getDomainExportNames, getDomainUsEast1CertificatesStackName } from "@fjall/util";
|
|
3
|
+
import { getDomainExportNames, getDomainUsEast1CertificatesStackName, hostsExportPartName, serialiseHostsChunks } from "@fjall/util";
|
|
4
4
|
import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
|
|
5
5
|
import { toPascalCase } from "../../utils/capitaliseString.js";
|
|
6
|
-
import { FjallLogger } from "../../utils/validationLogger.js";
|
|
7
6
|
const US_EAST_1 = "us-east-1";
|
|
8
7
|
/**
|
|
9
8
|
* Certificate composition shared by `composeApexDomain` and
|
|
@@ -49,15 +48,7 @@ export function composeDomainCertificates(scope, composition) {
|
|
|
49
48
|
// certificates whose ARNs it binds, never a zone-level aggregate
|
|
50
49
|
// (which would list hostnames of certificates that are not on the
|
|
51
50
|
// consumer's listener and over-claim coverage).
|
|
52
|
-
|
|
53
|
-
if (hostsOutputWithinCfnLimit(serialisedHosts, normalised.domainName)) {
|
|
54
|
-
new CfnOutput(scope, `${certId}Hosts`, {
|
|
55
|
-
key: `${certId}Hosts`,
|
|
56
|
-
value: serialisedHosts,
|
|
57
|
-
exportName: getDomainExportNames(normalised.domainName)
|
|
58
|
-
.certificateHosts
|
|
59
|
-
});
|
|
60
|
-
}
|
|
51
|
+
emitHostsOutputs(scope, `${certId}Hosts`, getDomainExportNames(normalised.domainName).certificateHosts, certificateHosts(normalised));
|
|
61
52
|
certificates.set(normalised.domainName, dc.certificate);
|
|
62
53
|
return;
|
|
63
54
|
}
|
|
@@ -136,27 +127,39 @@ function certificateHosts(cert) {
|
|
|
136
127
|
];
|
|
137
128
|
}
|
|
138
129
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
130
|
+
* CloudFormation caps output values at 1024 bytes: a certificate with
|
|
131
|
+
* enough — or long enough — SANs would fail the WHOLE domain-stack deploy
|
|
132
|
+
* with an opaque CFN error if its hosts list rode one output. Hosts lists
|
|
133
|
+
* that fit keep the base export name and construct ID byte-identical to
|
|
134
|
+
* the single-output era (no logical-ID churn on deployed stacks);
|
|
135
|
+
* oversized lists are emitted as a part family (`<base>-1`, `<base>-2`, …,
|
|
136
|
+
* each a self-contained JSON array under the threshold) with the base name
|
|
137
|
+
* OMITTED — a consumer that only knows the base name reads absence as
|
|
138
|
+
* coverage-unknown, never a partial list it would misread as complete (see
|
|
139
|
+
* `hostsExportPartName`). Coverage is therefore always published; the old
|
|
140
|
+
* over-limit fail-open (skip the output, coverage permanently
|
|
141
|
+
* unverifiable) is gone. The chunking algorithm (`serialiseHostsChunks`)
|
|
142
|
+
* is homed in @fjall/util so the CLI's eject templates split identically.
|
|
146
143
|
*/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
144
|
+
function emitHostsOutputs(scope, keyPrefix, baseExportName, hosts) {
|
|
145
|
+
const chunks = serialiseHostsChunks(hosts);
|
|
146
|
+
const single = chunks.length === 1 ? chunks[0] : undefined;
|
|
147
|
+
if (single !== undefined) {
|
|
148
|
+
new CfnOutput(scope, keyPrefix, {
|
|
149
|
+
key: keyPrefix,
|
|
150
|
+
value: single,
|
|
151
|
+
exportName: baseExportName
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
chunks.forEach((chunk, index) => {
|
|
156
|
+
const id = `${keyPrefix}${index + 1}`;
|
|
157
|
+
new CfnOutput(scope, id, {
|
|
158
|
+
key: id,
|
|
159
|
+
value: chunk,
|
|
160
|
+
exportName: hostsExportPartName(baseExportName, index + 1)
|
|
161
|
+
});
|
|
162
|
+
});
|
|
160
163
|
}
|
|
161
164
|
/**
|
|
162
165
|
* The zone-level `<zone>-us-east-1-certificate-hosts` output (D5 companion
|
|
@@ -165,15 +168,8 @@ function hostsOutputWithinCfnLimit(serialisedHosts, certificateDomainName) {
|
|
|
165
168
|
* reaches it in one call regardless of where the certificate itself lives.
|
|
166
169
|
*/
|
|
167
170
|
function emitUsEast1HostsOutput(scope, composition, cert) {
|
|
168
|
-
const serialisedHosts = JSON.stringify(certificateHosts(cert));
|
|
169
|
-
if (!hostsOutputWithinCfnLimit(serialisedHosts, cert.domainName))
|
|
170
|
-
return;
|
|
171
171
|
const exports = getDomainExportNames(composition.effectiveZoneName);
|
|
172
|
-
|
|
173
|
-
key: `${composition.safeZone}UsEast1CertificateHosts`,
|
|
174
|
-
value: serialisedHosts,
|
|
175
|
-
exportName: exports.usEast1CertificateHosts
|
|
176
|
-
});
|
|
172
|
+
emitHostsOutputs(scope, `${composition.safeZone}UsEast1CertificateHosts`, exports.usEast1CertificateHosts, certificateHosts(cert));
|
|
177
173
|
}
|
|
178
174
|
/**
|
|
179
175
|
* The zone-level `<zone>-us-east-1-certificate-arn` export
|
|
@@ -33,12 +33,22 @@ export interface StandardRecord {
|
|
|
33
33
|
readonly name: string;
|
|
34
34
|
readonly value: string | string[];
|
|
35
35
|
readonly ttl?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Overrides the record's derived construct-id name segment (PascalCase
|
|
38
|
+
* alphanumeric). The rare escape hatch for two record names that sanitise
|
|
39
|
+
* to the same PascalCase segment under `recordIds: "stable"` — the
|
|
40
|
+
* composer's collision refusal names it. Round-tripped by the generator's
|
|
41
|
+
* zone importer and the webapp record splice.
|
|
42
|
+
*/
|
|
43
|
+
readonly id?: string;
|
|
36
44
|
}
|
|
37
45
|
export interface AliasRecord {
|
|
38
46
|
readonly type: "A" | "AAAA";
|
|
39
47
|
readonly name: string;
|
|
40
48
|
readonly target: FjallTarget;
|
|
41
49
|
readonly ttl?: never;
|
|
50
|
+
/** Same construct-id override as {@link StandardRecord.id}. */
|
|
51
|
+
readonly id?: string;
|
|
42
52
|
}
|
|
43
53
|
export type DnsRecord = StandardRecord | AliasRecord;
|
|
44
54
|
export type Certificate = string | {
|
|
@@ -65,6 +75,28 @@ export type Certificate = string | {
|
|
|
65
75
|
export interface DomainCommonProps {
|
|
66
76
|
readonly zoneName: string;
|
|
67
77
|
readonly records?: DnsRecord[];
|
|
78
|
+
/**
|
|
79
|
+
* Construct-id scheme for the records list.
|
|
80
|
+
*
|
|
81
|
+
* `"indexed"` (default) embeds each record's LIST POSITION in its logical
|
|
82
|
+
* ID (`...Record0`, `...Record1`, …) — the legacy eject-contract formula,
|
|
83
|
+
* byte-frozen for deployed stacks. Under it the records list is
|
|
84
|
+
* APPEND-ONLY: inserting or removing an entry renumbers every later
|
|
85
|
+
* record, which CloudFormation executes as delete + recreate of live
|
|
86
|
+
* record sets.
|
|
87
|
+
*
|
|
88
|
+
* `"stable"` derives position-independent IDs
|
|
89
|
+
* (`<Zone><Name><Type>Record`) so entries can be inserted, removed and
|
|
90
|
+
* reordered freely. Collision-free by the same doctrine the DNS claim
|
|
91
|
+
* registry enforces (one owner per (zone, name, type); this vocabulary
|
|
92
|
+
* cannot express routing-policy variants) — the composer refuses
|
|
93
|
+
* duplicate (name, type) pairs and sanitised-name collisions at synth,
|
|
94
|
+
* naming the per-record `id` escape hatch. `fjall domain import` emits it
|
|
95
|
+
* for fresh zones. Flipping an ALREADY-DEPLOYED zone renames every
|
|
96
|
+
* record's logical ID (delete + recreate at deploy) — a deliberate
|
|
97
|
+
* two-deploy migration, never a casual edit.
|
|
98
|
+
*/
|
|
99
|
+
readonly recordIds?: "indexed" | "stable";
|
|
68
100
|
readonly certificates?: Certificate[];
|
|
69
101
|
readonly tags?: Record<string, string>;
|
|
70
102
|
readonly description?: string;
|
|
@@ -57,6 +57,15 @@ export interface ResolvedPatternZone {
|
|
|
57
57
|
readonly hostedZone: IHostedZone;
|
|
58
58
|
readonly zoneName: string;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the hosted zone for a pattern domain from explicit identity only
|
|
62
|
+
* (H2). Throws when no identity is given or when the domain falls outside
|
|
63
|
+
* the declared zone. The D2 precedence (explicit `managedDomain` beats the
|
|
64
|
+
* CLI-injected binding, ahead of the BYO `zoneName`/`hostedZoneId` chain)
|
|
65
|
+
* and the binding-vs-exports zone-id branch live in the shared
|
|
66
|
+
* `resolveEffectiveManagedDomain` — the same resolution the ECS networking
|
|
67
|
+
* lane consumes.
|
|
68
|
+
*/
|
|
60
69
|
export declare function resolvePatternZone(request: PatternDomainRequest): ResolvedPatternZone;
|
|
61
70
|
/**
|
|
62
71
|
* Resolve the CloudFront viewer certificate for a pattern domain (H3/D3).
|
|
@@ -30,37 +30,30 @@ import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
|
|
|
30
30
|
import { HostedZone } from "aws-cdk-lib/aws-route53";
|
|
31
31
|
import { DomainCertificate } from "../../resources/aws/networking/index.js";
|
|
32
32
|
import { isManagedDomainBinding, isWithinZone } from "../../utils/domainTypes.js";
|
|
33
|
-
import {
|
|
33
|
+
import { readInjectedManagedDomainCoverage, resolveEffectiveManagedDomain } from "../../utils/managedDomainContext.js";
|
|
34
|
+
import { certNameMatches } from "../../resources/aws/compute/ingressProfile.js";
|
|
35
|
+
import { cdnOriginRefusal } from "./cdnAppOrigin.js";
|
|
34
36
|
const US_EAST_1 = "us-east-1";
|
|
35
37
|
/**
|
|
36
38
|
* Resolve the hosted zone for a pattern domain from explicit identity only
|
|
37
|
-
* (H2). Throws when no identity is given or when the domain falls outside
|
|
38
|
-
* declared zone.
|
|
39
|
+
* (H2). Throws when no identity is given or when the domain falls outside
|
|
40
|
+
* the declared zone. The D2 precedence (explicit `managedDomain` beats the
|
|
41
|
+
* CLI-injected binding, ahead of the BYO `zoneName`/`hostedZoneId` chain)
|
|
42
|
+
* and the binding-vs-exports zone-id branch live in the shared
|
|
43
|
+
* `resolveEffectiveManagedDomain` — the same resolution the ECS networking
|
|
44
|
+
* lane consumes.
|
|
39
45
|
*/
|
|
40
|
-
/**
|
|
41
|
-
* Effective managed-domain identity for a request (D2): an EXPLICIT
|
|
42
|
-
* `managedDomain` prop wins over the CLI-injected context binding (explicit
|
|
43
|
-
* beats injected), and the context read sits ahead of the BYO
|
|
44
|
-
* `zoneName`/`hostedZoneId` chain. Bare-CDK synth carries no context entry
|
|
45
|
-
* and falls straight through to the explicit props.
|
|
46
|
-
*/
|
|
47
|
-
function resolveEffectiveManagedDomain(request) {
|
|
48
|
-
return (request.identity.managedDomain ??
|
|
49
|
-
readInjectedManagedDomainBinding(request.scope.node, request.domain, request.context));
|
|
50
|
-
}
|
|
51
46
|
export function resolvePatternZone(request) {
|
|
52
47
|
const { scope, idPrefix, context, domain, identity } = request;
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
55
|
-
assertDomainWithinZone(context, domain,
|
|
56
|
-
const hostedZoneId = isManagedDomainBinding(managed)
|
|
57
|
-
? // CLI-injected literal (D2) — carries no Fn.importValue constraint.
|
|
58
|
-
managed.hostedZoneId
|
|
59
|
-
: // Legacy export-name fallback — same-account, same-region only.
|
|
60
|
-
Fn.importValue(managed.hostedZoneIdExport);
|
|
48
|
+
const effective = effectiveManagedDomainFor(request);
|
|
49
|
+
if (effective !== undefined) {
|
|
50
|
+
assertDomainWithinZone(context, domain, effective.zoneName);
|
|
61
51
|
return {
|
|
62
|
-
hostedZone: HostedZone.fromHostedZoneAttributes(scope, `${idPrefix}ManagedHostedZone`, {
|
|
63
|
-
|
|
52
|
+
hostedZone: HostedZone.fromHostedZoneAttributes(scope, `${idPrefix}ManagedHostedZone`, {
|
|
53
|
+
hostedZoneId: effective.hostedZoneId,
|
|
54
|
+
zoneName: effective.zoneName
|
|
55
|
+
}),
|
|
56
|
+
zoneName: effective.zoneName
|
|
64
57
|
};
|
|
65
58
|
}
|
|
66
59
|
if (identity.zoneName !== undefined) {
|
|
@@ -110,8 +103,11 @@ export function resolvePatternZone(request) {
|
|
|
110
103
|
*/
|
|
111
104
|
export function resolvePatternCloudFrontCertificate(request, zone) {
|
|
112
105
|
const { scope, app, idPrefix, context, domain, identity } = request;
|
|
113
|
-
const
|
|
114
|
-
|
|
106
|
+
const effective = effectiveManagedDomainFor(request);
|
|
107
|
+
const managed = effective?.managed;
|
|
108
|
+
if (effective !== undefined &&
|
|
109
|
+
managed !== undefined &&
|
|
110
|
+
isManagedDomainBinding(managed)) {
|
|
115
111
|
const arn = managed.usEast1CertificateArn;
|
|
116
112
|
if (arn === undefined) {
|
|
117
113
|
throw new Error(`${context}: managed domain binding for zone '${managed.zoneName}' ` +
|
|
@@ -122,10 +118,13 @@ export function resolvePatternCloudFrontCertificate(request, zone) {
|
|
|
122
118
|
"'zoneName' to provision an app-owned us-east-1 certificate.");
|
|
123
119
|
}
|
|
124
120
|
assertUsEast1CertificateArn(context, arn);
|
|
121
|
+
assertViewerHostCovered(request, effective);
|
|
125
122
|
return Certificate.fromCertificateArn(scope, `${idPrefix}ManagedCertificate`, arn);
|
|
126
123
|
}
|
|
127
124
|
const region = resolvedRegionOf(scope);
|
|
128
|
-
if (managed !== undefined &&
|
|
125
|
+
if (managed !== undefined &&
|
|
126
|
+
!isManagedDomainBinding(managed) &&
|
|
127
|
+
(region === undefined || region === US_EAST_1)) {
|
|
129
128
|
// Legacy export-name form: Fn.importValue resolves same-account,
|
|
130
129
|
// same-region only, so the imported certificate satisfies CloudFront
|
|
131
130
|
// exactly when the app itself deploys to us-east-1. A region-unresolved
|
|
@@ -170,6 +169,40 @@ export function resolvePatternCloudFrontCertificate(request, zone) {
|
|
|
170
169
|
exportCertificateArn: false
|
|
171
170
|
}).certificate;
|
|
172
171
|
}
|
|
172
|
+
function effectiveManagedDomainFor(request) {
|
|
173
|
+
return resolveEffectiveManagedDomain(request.scope.node, request.identity.managedDomain, request.domain, request.context);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* E11 — the requested viewer hostname against the managed us-east-1 viewer
|
|
177
|
+
* certificate's declared hosts (D5 coverage context). A definite miss fails
|
|
178
|
+
* CloudFront's own alias-vs-certificate validation at deploy with an opaque
|
|
179
|
+
* InvalidViewerCertificate — refuse at synth with the cure instead. Absent
|
|
180
|
+
* or provenance-gated coverage stays SILENT (no warning): CloudFront
|
|
181
|
+
* validates at deploy regardless, so unknown coverage carries no runtime
|
|
182
|
+
* risk here — unlike the origin lane's W1, where an uncovered hostname only
|
|
183
|
+
* fails at request time.
|
|
184
|
+
*/
|
|
185
|
+
function assertViewerHostCovered(request, effective) {
|
|
186
|
+
// D5 provenance gate: an explicit pinned binding may name an OLDER
|
|
187
|
+
// certificate the current coverage does not describe.
|
|
188
|
+
if (effective.explicitPinnedBinding)
|
|
189
|
+
return;
|
|
190
|
+
const coverage = readInjectedManagedDomainCoverage(request.scope.node, effective.zoneName, request.context);
|
|
191
|
+
const hosts = coverage?.usEast1CertificateHosts;
|
|
192
|
+
if (hosts === undefined || hosts.length === 0)
|
|
193
|
+
return;
|
|
194
|
+
if (hosts.some((pattern) => certNameMatches(pattern, request.domain))) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
throw cdnOriginRefusal("E11", `${request.context}: the managed domain's us-east-1 viewer certificate ` +
|
|
198
|
+
`covers ${hosts.join(", ")} — none matches '${request.domain}'. ` +
|
|
199
|
+
"CloudFront rejects a distribution whose alias its viewer " +
|
|
200
|
+
"certificate does not cover (InvalidViewerCertificate, at deploy). " +
|
|
201
|
+
`Declare '${request.domain}' (or a covering wildcard) on the domain ` +
|
|
202
|
+
"stack's cloudFront certificate and redeploy it, or set 'zoneName' " +
|
|
203
|
+
"instead of the managed domain to provision an app-owned us-east-1 " +
|
|
204
|
+
"certificate.");
|
|
205
|
+
}
|
|
173
206
|
function assertDomainWithinZone(context, domain, zoneName) {
|
|
174
207
|
if (!isWithinZone(domain, zoneName)) {
|
|
175
208
|
throw new Error(`${context}: domain '${domain}' is not within zone '${zoneName}' ` +
|
|
@@ -90,6 +90,11 @@ export interface CloudFrontDistributionProps {
|
|
|
90
90
|
export declare class CloudFrontDistribution extends Construct {
|
|
91
91
|
readonly id: string;
|
|
92
92
|
private distribution;
|
|
93
|
+
private readonly origins;
|
|
94
|
+
/** Shared S3 access identities, keyed by the owning stack (see
|
|
95
|
+
* `s3OriginAccessIdentityFor` for why the key is a Stack). */
|
|
96
|
+
private readonly s3AccessIdentities;
|
|
97
|
+
private s3AccessControl?;
|
|
93
98
|
constructor(scope: Construct, id: string, props: CloudFrontDistributionProps);
|
|
94
99
|
/**
|
|
95
100
|
* Build a composable CloudFront Function for VIEWER_REQUEST.
|
|
@@ -107,7 +112,39 @@ export declare class CloudFrontDistribution extends Construct {
|
|
|
107
112
|
/** SPA fallback: serve /index.html @200 for 403/404 so client-side routing
|
|
108
113
|
* handles the path. CloudFront returns 403 (not 404) for missing S3 keys. */
|
|
109
114
|
private buildSpaErrorResponses;
|
|
115
|
+
/**
|
|
116
|
+
* One `IOrigin` per distinct origin configuration: behaviours that
|
|
117
|
+
* resolve to the same origin (same bucket/ALB/hostname with the same
|
|
118
|
+
* options) share a single distribution origin entry instead of minting an
|
|
119
|
+
* identical one per behaviour. Token-bearing hostnames (e.g. a Lambda
|
|
120
|
+
* function URL) memoize per token instance — two independently-derived
|
|
121
|
+
* tokens never share, which is the safe direction.
|
|
122
|
+
*/
|
|
110
123
|
private createOrigin;
|
|
124
|
+
private static originKey;
|
|
125
|
+
/**
|
|
126
|
+
* S3 access identities are created HERE, at a position-independent scope,
|
|
127
|
+
* and handed to `S3BucketOrigin` — never left to CDK's default, which
|
|
128
|
+
* creates them under the distribution's positional `Origin${n}` child.
|
|
129
|
+
* An OAI/OAC is semantically "the identity this distribution presents to
|
|
130
|
+
* S3": one per distribution, shared by every S3 origin. Position-scoped
|
|
131
|
+
* identities couple a DEPLOYED IAM resource's logical ID to the origin
|
|
132
|
+
* list's order — origin dedup (or any behaviour insertion) would then
|
|
133
|
+
* silently delete/repurpose a live OAI and atomically swap the bucket
|
|
134
|
+
* policy it gates, 403-ing the paths the edge still serves with the old
|
|
135
|
+
* identity until the distribution update propagates.
|
|
136
|
+
*
|
|
137
|
+
* Cross-stack buckets get their identity in the BUCKET's stack (same rule
|
|
138
|
+
* CDK's default applies): the bucket policy must reference the OAI, and a
|
|
139
|
+
* distribution-stack OAI would complete a cycle with the CDN stack's
|
|
140
|
+
* reference to the bucket.
|
|
141
|
+
*/
|
|
142
|
+
private s3OriginAccessIdentityFor;
|
|
143
|
+
/** OAC twin of `s3OriginAccessIdentityFor` — an OAC is a signing config,
|
|
144
|
+
* not a bucket grant (bucket policies key on the distribution ARN), so a
|
|
145
|
+
* single distribution-scoped one serves every OAC origin. */
|
|
146
|
+
private s3OriginAccessControl;
|
|
147
|
+
private buildOrigin;
|
|
111
148
|
private resolveCachePolicy;
|
|
112
149
|
private resolveAllowedMethods;
|
|
113
150
|
private resolveOriginProtocolPolicy;
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { Construct } from "constructs";
|
|
2
|
-
import { CfnOutput } from "aws-cdk-lib";
|
|
2
|
+
import { CfnOutput, Names, Stack } from "aws-cdk-lib";
|
|
3
3
|
import { toPascalCase } from "../../../utils/capitaliseString.js";
|
|
4
4
|
import { cdnDomainExportName } from "@fjall/util";
|
|
5
|
-
import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType } from "aws-cdk-lib/aws-cloudfront";
|
|
5
|
+
import { Distribution, PriceClass, ViewerProtocolPolicy, CachePolicy, OriginRequestPolicy, AllowedMethods, OriginProtocolPolicy, Function as CloudFrontFunction, FunctionCode, FunctionRuntime, FunctionEventType, OriginAccessIdentity, S3OriginAccessControl } from "aws-cdk-lib/aws-cloudfront";
|
|
6
6
|
import { S3BucketOrigin, LoadBalancerV2Origin, HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
|
|
7
7
|
export class CloudFrontDistribution extends Construct {
|
|
8
8
|
id;
|
|
9
9
|
distribution;
|
|
10
|
+
origins = new Map();
|
|
11
|
+
/** Shared S3 access identities, keyed by the owning stack (see
|
|
12
|
+
* `s3OriginAccessIdentityFor` for why the key is a Stack). */
|
|
13
|
+
s3AccessIdentities = new Map();
|
|
14
|
+
s3AccessControl;
|
|
10
15
|
constructor(scope, id, props) {
|
|
11
16
|
super(scope, id);
|
|
12
17
|
this.id = id;
|
|
@@ -144,7 +149,92 @@ export class CloudFrontDistribution extends Construct {
|
|
|
144
149
|
responsePagePath: "/index.html"
|
|
145
150
|
}));
|
|
146
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* One `IOrigin` per distinct origin configuration: behaviours that
|
|
154
|
+
* resolve to the same origin (same bucket/ALB/hostname with the same
|
|
155
|
+
* options) share a single distribution origin entry instead of minting an
|
|
156
|
+
* identical one per behaviour. Token-bearing hostnames (e.g. a Lambda
|
|
157
|
+
* function URL) memoize per token instance — two independently-derived
|
|
158
|
+
* tokens never share, which is the safe direction.
|
|
159
|
+
*/
|
|
147
160
|
createOrigin(config) {
|
|
161
|
+
const key = CloudFrontDistribution.originKey(config);
|
|
162
|
+
const memoized = this.origins.get(key);
|
|
163
|
+
if (memoized !== undefined) {
|
|
164
|
+
return memoized;
|
|
165
|
+
}
|
|
166
|
+
const origin = this.buildOrigin(config);
|
|
167
|
+
this.origins.set(key, origin);
|
|
168
|
+
return origin;
|
|
169
|
+
}
|
|
170
|
+
static originKey(config) {
|
|
171
|
+
switch (config.type) {
|
|
172
|
+
case "s3":
|
|
173
|
+
return [
|
|
174
|
+
"s3",
|
|
175
|
+
config.bucket.node.addr,
|
|
176
|
+
config.originPath ?? "",
|
|
177
|
+
config.originAccess ?? ""
|
|
178
|
+
].join("|");
|
|
179
|
+
case "alb":
|
|
180
|
+
return [
|
|
181
|
+
"alb",
|
|
182
|
+
config.loadBalancer.node.addr,
|
|
183
|
+
config.httpPort ?? "",
|
|
184
|
+
config.httpsPort ?? "",
|
|
185
|
+
config.protocolPolicy ?? ""
|
|
186
|
+
].join("|");
|
|
187
|
+
case "http":
|
|
188
|
+
return [
|
|
189
|
+
"http",
|
|
190
|
+
config.domainName,
|
|
191
|
+
config.originPath ?? "",
|
|
192
|
+
config.httpPort ?? "",
|
|
193
|
+
config.httpsPort ?? "",
|
|
194
|
+
config.protocolPolicy ?? ""
|
|
195
|
+
].join("|");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* S3 access identities are created HERE, at a position-independent scope,
|
|
200
|
+
* and handed to `S3BucketOrigin` — never left to CDK's default, which
|
|
201
|
+
* creates them under the distribution's positional `Origin${n}` child.
|
|
202
|
+
* An OAI/OAC is semantically "the identity this distribution presents to
|
|
203
|
+
* S3": one per distribution, shared by every S3 origin. Position-scoped
|
|
204
|
+
* identities couple a DEPLOYED IAM resource's logical ID to the origin
|
|
205
|
+
* list's order — origin dedup (or any behaviour insertion) would then
|
|
206
|
+
* silently delete/repurpose a live OAI and atomically swap the bucket
|
|
207
|
+
* policy it gates, 403-ing the paths the edge still serves with the old
|
|
208
|
+
* identity until the distribution update propagates.
|
|
209
|
+
*
|
|
210
|
+
* Cross-stack buckets get their identity in the BUCKET's stack (same rule
|
|
211
|
+
* CDK's default applies): the bucket policy must reference the OAI, and a
|
|
212
|
+
* distribution-stack OAI would complete a cycle with the CDN stack's
|
|
213
|
+
* reference to the bucket.
|
|
214
|
+
*/
|
|
215
|
+
s3OriginAccessIdentityFor(bucket) {
|
|
216
|
+
const bucketStack = Stack.of(bucket);
|
|
217
|
+
const existing = this.s3AccessIdentities.get(bucketStack);
|
|
218
|
+
if (existing !== undefined) {
|
|
219
|
+
return existing;
|
|
220
|
+
}
|
|
221
|
+
const sameStack = bucketStack === Stack.of(this);
|
|
222
|
+
const identity = sameStack
|
|
223
|
+
? new OriginAccessIdentity(this, "S3OriginAccessIdentity", {
|
|
224
|
+
comment: `Identity for ${this.node.path}`
|
|
225
|
+
})
|
|
226
|
+
: new OriginAccessIdentity(bucketStack, `${Names.uniqueId(this)}S3OriginAccessIdentity`, { comment: `Identity for ${this.node.path}` });
|
|
227
|
+
this.s3AccessIdentities.set(bucketStack, identity);
|
|
228
|
+
return identity;
|
|
229
|
+
}
|
|
230
|
+
/** OAC twin of `s3OriginAccessIdentityFor` — an OAC is a signing config,
|
|
231
|
+
* not a bucket grant (bucket policies key on the distribution ARN), so a
|
|
232
|
+
* single distribution-scoped one serves every OAC origin. */
|
|
233
|
+
s3OriginAccessControl() {
|
|
234
|
+
this.s3AccessControl ??= new S3OriginAccessControl(this, "S3OriginAccessControl");
|
|
235
|
+
return this.s3AccessControl;
|
|
236
|
+
}
|
|
237
|
+
buildOrigin(config) {
|
|
148
238
|
switch (config.type) {
|
|
149
239
|
case "s3":
|
|
150
240
|
// OAC (single-stack only) is safe when the bucket and distribution live
|
|
@@ -153,11 +243,13 @@ export class CloudFrontDistribution extends Construct {
|
|
|
153
243
|
// See: https://github.com/aws/aws-cdk/issues/31462
|
|
154
244
|
if (config.originAccess === "oac") {
|
|
155
245
|
return S3BucketOrigin.withOriginAccessControl(config.bucket, {
|
|
156
|
-
originPath: config.originPath
|
|
246
|
+
originPath: config.originPath,
|
|
247
|
+
originAccessControl: this.s3OriginAccessControl()
|
|
157
248
|
});
|
|
158
249
|
}
|
|
159
250
|
return S3BucketOrigin.withOriginAccessIdentity(config.bucket, {
|
|
160
|
-
originPath: config.originPath
|
|
251
|
+
originPath: config.originPath,
|
|
252
|
+
originAccessIdentity: this.s3OriginAccessIdentityFor(config.bucket)
|
|
161
253
|
});
|
|
162
254
|
case "alb":
|
|
163
255
|
return new LoadBalancerV2Origin(config.loadBalancer, {
|