@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.
@@ -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 formula (`${safeZone}${safeName}${type}Record${index}`) is
20
- * byte-identical to the legacy composer this is an eject-contract
21
- * invariant (Phase 3 depends on stable IDs).
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 = toPascalCase(record.name === DNS_APEX ? "Apex" : record.name);
27
- const constructId = `${safeZone}${safeName}${record.type}Record${index}`;
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
- const serialisedHosts = JSON.stringify(certificateHosts(normalised));
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
- * Guard on CloudFormation's 1024-byte output-value limit (1000-byte
140
- * threshold for headroom under the hard cap): a certificate with enough
141
- * or long enough SANs would otherwise fail the WHOLE domain-stack deploy
142
- * with an opaque CFN error. Per the D5 fail-open contract the hosts output
143
- * is SKIPPED instead: the deploy keeps succeeding, and consumers see
144
- * coverage-unknown for this certificate (W1 its coverage stays
145
- * unverifiable at CDN synth), never a false verdict.
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
- const HOSTS_OUTPUT_MAX_BYTES = 1000;
148
- function hostsOutputWithinCfnLimit(serialisedHosts, certificateDomainName) {
149
- const bytes = Buffer.byteLength(serialisedHosts, "utf8");
150
- if (bytes <= HOSTS_OUTPUT_MAX_BYTES)
151
- return true;
152
- FjallLogger.warn(`Certificate '${certificateDomainName}': its hosts list serialises to ` +
153
- `${bytes} bytes, over the ${HOSTS_OUTPUT_MAX_BYTES}-byte guard for ` +
154
- "CloudFormation's 1024-byte output-value limit, so its " +
155
- "certificate-hosts output is skipped and the certificate's coverage " +
156
- "stays unverifiable at CDN synth (W1) — the deploy itself keeps " +
157
- "succeeding. Trim the certificate's SAN list, or split the hosts " +
158
- "across multiple certificates.");
159
- return false;
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
- new CfnOutput(scope, `${composition.safeZone}UsEast1CertificateHosts`, {
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 { readInjectedManagedDomainBinding } from "../../utils/managedDomainContext.js";
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 the
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 managed = resolveEffectiveManagedDomain(request);
54
- if (managed !== undefined) {
55
- assertDomainWithinZone(context, domain, managed.zoneName);
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`, { hostedZoneId, zoneName: managed.zoneName }),
63
- zoneName: managed.zoneName
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 managed = resolveEffectiveManagedDomain(request);
114
- if (managed !== undefined && isManagedDomainBinding(managed)) {
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 && (region === undefined || region === US_EAST_1)) {
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, {