@fjall/components-infrastructure 14.1.0 → 14.3.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.
Files changed (32) hide show
  1. package/dist/lib/patterns/aws/apexDomainPattern.js +1 -1
  2. package/dist/lib/patterns/aws/cdn.d.ts +83 -4
  3. package/dist/lib/patterns/aws/cdn.js +279 -69
  4. package/dist/lib/patterns/aws/cdnAppOrigin.d.ts +83 -0
  5. package/dist/lib/patterns/aws/cdnAppOrigin.js +252 -0
  6. package/dist/lib/patterns/aws/computeEcs.d.ts +9 -1
  7. package/dist/lib/patterns/aws/computeEcs.js +10 -0
  8. package/dist/lib/patterns/aws/delegatedDomainPattern.js +1 -1
  9. package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +13 -4
  10. package/dist/lib/patterns/aws/dnsRecordComposer.js +88 -6
  11. package/dist/lib/patterns/aws/domainCertificateComposer.js +66 -1
  12. package/dist/lib/patterns/aws/interfaces/compute.d.ts +7 -0
  13. package/dist/lib/patterns/aws/interfaces/domain.d.ts +32 -0
  14. package/dist/lib/patterns/aws/patternDomain.d.ts +9 -0
  15. package/dist/lib/patterns/aws/patternDomain.js +60 -27
  16. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +37 -0
  17. package/dist/lib/resources/aws/cdn/cloudFront.js +96 -4
  18. package/dist/lib/resources/aws/compute/ecs.d.ts +11 -0
  19. package/dist/lib/resources/aws/compute/ecs.js +27 -0
  20. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +2 -0
  21. package/dist/lib/resources/aws/compute/ecsNetworking.js +117 -61
  22. package/dist/lib/resources/aws/compute/ingressProfile.d.ts +203 -0
  23. package/dist/lib/resources/aws/compute/ingressProfile.js +215 -0
  24. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +10 -2
  25. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +18 -9
  26. package/dist/lib/utils/dnsRecordRegistry.d.ts +3 -5
  27. package/dist/lib/utils/dnsRecordRegistry.js +11 -14
  28. package/dist/lib/utils/domainTypes.d.ts +11 -0
  29. package/dist/lib/utils/domainTypes.js +17 -0
  30. package/dist/lib/utils/managedDomainContext.d.ts +58 -1
  31. package/dist/lib/utils/managedDomainContext.js +96 -0
  32. package/package.json +3 -3
@@ -44,7 +44,7 @@ export function composeApexDomain(scope, props) {
44
44
  costAllocationDomain: props.zoneName
45
45
  });
46
46
  if (props.records && props.records.length > 0) {
47
- composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, props.zoneName, props.records);
47
+ composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, props.zoneName, props.records, props.recordIds);
48
48
  }
49
49
  return {
50
50
  hostedZone: hostedZoneConstruct.hostedZone,
@@ -130,11 +130,36 @@ export interface HttpCdnProps extends BaseCdnProps {
130
130
  protocolPolicy?: "HTTP_ONLY" | "HTTPS_ONLY" | "MATCH_VIEWER";
131
131
  }
132
132
  /**
133
- * Smart origin CDN props - auto-detect origin type from Fjall resource
133
+ * Smart origin CDN props - auto-detect origin type from Fjall resource.
134
+ *
135
+ * ECS compute origins (design 2026-08-18 cdn-app-origin, D3) resolve a
136
+ * TLS-valid ORIGIN HOSTNAME from the compute's own ingress profile instead
137
+ * of the raw ELB hostname (which no ACM certificate ever covers): a routing
138
+ * host when the compute declares exactly one, else `origin.<domain>`,
139
+ * derived. Certificate coverage and listener forwarding are validated at
140
+ * synth; when the Cdn owns the hostname it mints the alias record in this
141
+ * stack, record-before-distribution ordered.
134
142
  */
135
143
  export interface SmartCdnProps extends BaseCdnProps {
136
144
  originType: "auto";
137
145
  origin: Storage | AnyCompute | string;
146
+ /**
147
+ * Origin hostname override for the DEFAULT origin's ECS compute (a
148
+ * behaviour's ECS origin takes the same override on the behaviour
149
+ * entry). A literal hostname: a compute-declared routing host (the
150
+ * compute keeps owning its record), or a free name inside the cluster's
151
+ * zone (the Cdn mints its record). Refused for `redirectHosts` entries —
152
+ * they 301, never serve. Default: auto-resolution (sole routing host,
153
+ * else `origin.<domain>`).
154
+ */
155
+ originHostname?: string;
156
+ /**
157
+ * Whether the Cdn mints the origin alias record when it owns the DEFAULT
158
+ * origin's hostname (behaviour entries carry their own). "none" = the
159
+ * record is managed elsewhere (BYO or a migration state). Refused when
160
+ * the hostname is a routing host — the compute owns that record (P1).
161
+ */
162
+ originRecord?: "alias" | "none";
138
163
  }
139
164
  export type ICdnProps = S3CdnProps | AlbCdnProps | HttpCdnProps | SmartCdnProps;
140
165
  /**
@@ -143,15 +168,53 @@ export type ICdnProps = S3CdnProps | AlbCdnProps | HttpCdnProps | SmartCdnProps;
143
168
  */
144
169
  export interface SmartCdnBehaviour {
145
170
  pathPattern: string;
146
- origin: Storage | AnyCompute | string;
171
+ /**
172
+ * Origin for this path pattern. Omit to route the pattern to the
173
+ * distribution's DEFAULT origin — the common shape where behaviours exist
174
+ * to vary caching or allowed methods per path, not to change origins. An
175
+ * origin-less behaviour is the default behaviour varied per path, so its
176
+ * omitted `cachePolicy`/`allowedMethods` inherit the distribution's
177
+ * `cachePolicy`/`defaultAllowedMethods` (a behaviour with its own origin
178
+ * is an independent config and keeps the resource-layer fallbacks).
179
+ */
180
+ origin?: Storage | AnyCompute | string;
147
181
  cachePolicy?: CachePolicyPreset | ICachePolicy;
148
182
  allowedMethods?: "GET_HEAD" | "GET_HEAD_OPTIONS" | "ALL";
183
+ /**
184
+ * Origin hostname override for THIS behaviour's ECS compute origin — the
185
+ * behaviour-level counterpart of the distribution's `originHostname`
186
+ * (same semantics; see {@link SmartCdnProps.originHostname}). Overrides
187
+ * bind per compute: two entries naming the same compute must agree.
188
+ */
189
+ originHostname?: string;
190
+ /**
191
+ * Record-minting override for THIS behaviour's ECS compute origin — the
192
+ * behaviour-level counterpart of {@link SmartCdnProps.originRecord}.
193
+ */
194
+ originRecord?: "alias" | "none";
149
195
  }
150
196
  /**
151
197
  * CDN wrapper class that extends CloudFrontDistribution with Fjall patterns.
152
198
  */
153
199
  export declare class Cdn extends CloudFrontDistribution implements ICdn {
154
200
  constructor(scope: Construct, id: string, props: ICdnProps);
201
+ private static createEcsOriginResolver;
202
+ /**
203
+ * Assemble the per-compute `originHostname`/`originRecord` overrides the
204
+ * resolver binds by compute identity: the distribution-level props apply
205
+ * to the DEFAULT origin's compute, each behaviour's own overrides apply
206
+ * to that behaviour's compute. Two entries naming the same compute must
207
+ * agree — one alias record cannot carry two spellings.
208
+ */
209
+ private static collectEcsOriginOverrides;
210
+ /**
211
+ * Mint the Cdn-owned origin alias records (P1) after the distribution
212
+ * exists, using the compute profile's own zone instance so the DNS claim
213
+ * registry sees the same zone identity as the cluster's records. The
214
+ * distribution depends on each record: the hostname must resolve before a
215
+ * distribution update referencing it propagates.
216
+ */
217
+ private createOriginRecords;
155
218
  /**
156
219
  * Resolve a `domainConfig` through the shared pattern-domain helpers
157
220
  * (design C1): zone identity via `resolvePatternZone` (explicit
@@ -171,11 +234,27 @@ export declare class Cdn extends CloudFrontDistribution implements ICdn {
171
234
  */
172
235
  private createDomainRecord;
173
236
  /**
174
- * Resolve ICdnProps to CloudFrontDistributionProps.
237
+ * Resolve ICdnProps to the distribution props PLUS the Cdn-owned origin
238
+ * record plans the resolution produced — one resolution pass, one result.
175
239
  */
176
240
  private static resolveProps;
177
241
  /**
178
- * Transform smart behaviours to CdnBehaviour objects.
242
+ * Transform smart behaviours to CdnBehaviour objects. A behaviour with no
243
+ * origin routes to the distribution's default origin — the same resolved
244
+ * config, so the resource layer memoizes them onto one distribution
245
+ * origin entry.
246
+ *
247
+ * Knob inheritance is asymmetric BY CONTRACT. An origin-less behaviour IS
248
+ * the default behaviour varied per path (the shape
249
+ * `SmartCdnBehaviour.origin` advertises), so its omitted
250
+ * `cachePolicy`/`allowedMethods` inherit the DISTRIBUTION's defaults —
251
+ * otherwise a caching-only behaviour on an `allowedMethods: "ALL"` API
252
+ * distribution would snap its path back to the resource layer's GET/HEAD
253
+ * fallback and 403 every POST at the edge. A behaviour WITH its own
254
+ * origin keeps the legacy resource-layer fallbacks (GET_HEAD /
255
+ * CACHING_OPTIMIZED): those deployed behaviours are byte-frozen, and an
256
+ * independent origin is an independent config, not a variation of the
257
+ * default behaviour.
179
258
  */
180
259
  private static resolveBehaviours;
181
260
  /**
@@ -1,27 +1,31 @@
1
1
  import { Fn, Token } from "aws-cdk-lib";
2
2
  import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
3
- import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
3
+ import { CloudFrontTarget, LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
4
4
  import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
5
5
  import { AliasRecord } from "../../resources/aws/networking/index.js";
6
- import { DNS_APEX } from "@fjall/util";
6
+ import { getSafeZoneName, toPascalCase } from "../../utils/capitaliseString.js";
7
+ import { recordLabelWithin } from "../../utils/domainTypes.js";
7
8
  import { resolvePatternZone, resolvePatternCloudFrontCertificate } from "./patternDomain.js";
8
9
  import { isStorage } from "./storage.js";
10
+ import { CdnEcsOriginResolver, cdnOriginRefusal, internalAlbMessage, normaliseDnsName } from "./cdnAppOrigin.js";
9
11
  import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
10
12
  /**
11
- * Relative record label for `domain` within `zoneName` (the staticSite
12
- * `recordLabelFor` convention): the zone apex maps to the canonical apex
13
- * label, sub-names drop the zone suffix.
13
+ * The distribution's own alias names, normalised consumed by the
14
+ * origin-loop guard and by the ECS origin resolver (auto-resolution excludes
15
+ * them from routed-host candidates, and a resolved origin equal to one is
16
+ * refused as a loop).
14
17
  */
15
- function recordLabelWithin(domain, zoneName) {
16
- if (domain === zoneName) {
17
- return DNS_APEX;
18
+ function collectAliasNames(props) {
19
+ const aliasNames = new Set();
20
+ if (props.domainConfig !== undefined) {
21
+ aliasNames.add(normaliseDnsName(props.domainConfig.domainName));
18
22
  }
19
- const suffix = `.${zoneName}`;
20
- return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
21
- }
22
- /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
23
- function normaliseDnsName(name) {
24
- return name.toLowerCase().replace(/\.$/, "");
23
+ for (const name of props.domainNames ?? []) {
24
+ if (!Token.isUnresolved(name)) {
25
+ aliasNames.add(normaliseDnsName(name));
26
+ }
27
+ }
28
+ return aliasNames;
25
29
  }
26
30
  /**
27
31
  * Validates CDN props — synth-time hard errors for shapes CloudFormation
@@ -69,15 +73,7 @@ function validateCdnProps(props) {
69
73
  // every request CloudFront → DNS → CloudFront until the request-depth
70
74
  // limit 508s. Synth can see this shape whenever the origin hostname is a
71
75
  // literal, so refuse it with the cure.
72
- const aliasNames = new Set();
73
- if (props.domainConfig !== undefined) {
74
- aliasNames.add(normaliseDnsName(props.domainConfig.domainName));
75
- }
76
- for (const name of props.domainNames ?? []) {
77
- if (!Token.isUnresolved(name)) {
78
- aliasNames.add(normaliseDnsName(name));
79
- }
80
- }
76
+ const aliasNames = collectAliasNames(props);
81
77
  const defaultOriginHostname = props.originType === "http"
82
78
  ? props.domainName
83
79
  : props.originType === "auto" && typeof props.origin === "string"
@@ -96,7 +92,7 @@ function validateCdnProps(props) {
96
92
  for (const originHostname of literalOriginHostnames) {
97
93
  if (!Token.isUnresolved(originHostname) &&
98
94
  aliasNames.has(normaliseDnsName(originHostname))) {
99
- throw new Error(`CDN origin '${originHostname}' is also one of the distribution's own ` +
95
+ throw cdnOriginRefusal("C4", `CDN origin '${originHostname}' is also one of the distribution's own ` +
100
96
  "domain names — once DNS points that name at the distribution, every " +
101
97
  "request loops CloudFront → CloudFront. Point the origin at a " +
102
98
  "dedicated origin hostname instead (e.g. an ECS service " +
@@ -130,18 +126,64 @@ function validateCdnProps(props) {
130
126
  "'certificateArn', or configure the pattern's 'domain' with a zone " +
131
127
  "identity so it provisions one.");
132
128
  }
133
- // Validate ALB origin
129
+ // Validate ALB origin (design 2026-08-18 cdn-app-origin, D4): CloudFront
130
+ // validates the origin certificate against the raw `*.elb.amazonaws.com`
131
+ // hostname, which no ACM certificate ever covers — an effective-HTTPS alb
132
+ // origin can never complete a TLS handshake. E9, with the cure conditional
133
+ // on the listener protocol: HTTP_ONLY only works against a port-80
134
+ // listener, and a cert-bearing cluster has exactly one listener, on 443.
134
135
  if (props.originType === "alb") {
135
- if (isCompute(props.loadBalancer) && isEcsCompute(props.loadBalancer)) {
136
- const lb = props.loadBalancer.getLoadBalancer();
137
- if (!lb) {
138
- throw new Error("Compute resource does not have a load balancer. " +
139
- "Ensure ECS compute has loadBalancer enabled for CDN origin.");
140
- }
136
+ const computeRef = isCompute(props.loadBalancer) && isEcsCompute(props.loadBalancer)
137
+ ? props.loadBalancer
138
+ : undefined;
139
+ if (computeRef !== undefined && !computeRef.getLoadBalancer()) {
140
+ throw new Error("Compute resource does not have a load balancer. " +
141
+ "Ensure ECS compute has loadBalancer enabled for CDN origin.");
142
+ }
143
+ const profile = computeRef?.getIngressProfile();
144
+ if (profile?.internal) {
145
+ // E2 — same defect as the construct-origin lane, same shared message.
146
+ throw cdnOriginRefusal("E2", internalAlbMessage("CDN alb origin: the load balancer", "Make the load balancer internet-facing."));
147
+ }
148
+ const effectiveProtocol = props.protocolPolicy ?? "HTTPS_ONLY";
149
+ if (effectiveProtocol !== "HTTP_ONLY") {
150
+ // E9 — effective HTTPS to the raw ELB hostname.
151
+ const cure = profile === undefined
152
+ ? 'If the ALB has an HTTP (port-80) listener, set protocolPolicy: "HTTP_ONLY"; ' +
153
+ 'otherwise use originType "auto" with the compute as origin, ' +
154
+ "which resolves a certificate-covered origin hostname."
155
+ : profile.listenerPort === 80
156
+ ? 'Set protocolPolicy: "HTTP_ONLY" (the cluster\'s listener serves HTTP), ' +
157
+ 'or use originType "auto" with the compute as origin.'
158
+ : 'Use originType "auto" with the compute as origin — the ' +
159
+ "cluster's only listener is HTTPS (443), so the sole working " +
160
+ "path is a certificate-covered origin hostname.";
161
+ throw cdnOriginRefusal("E9", `CDN alb origin with protocolPolicy '${effectiveProtocol}' can ` +
162
+ "never complete a TLS handshake: CloudFront validates the origin " +
163
+ "certificate against the raw ELB hostname " +
164
+ "(*.elb.amazonaws.com), which no ACM certificate covers — every " +
165
+ `origin fetch fails with a 502. ${cure}`);
166
+ }
167
+ if (profile !== undefined && profile.listenerPort === 443) {
168
+ // E9b (HTTP_ONLY variant) — nothing listens on 80: a connect-timeout
169
+ // 504 instead of a TLS 502, equally broken.
170
+ throw cdnOriginRefusal("E9b", 'CDN alb origin with protocolPolicy "HTTP_ONLY": the cluster\'s ' +
171
+ "only listener is HTTPS (443) — a certificate resolved, so " +
172
+ "nothing listens on port 80 and every origin fetch times out. " +
173
+ 'Use originType "auto" with the compute as origin instead.');
141
174
  }
142
175
  }
143
176
  // Validate smart origin
144
177
  if (props.originType === "auto") {
178
+ if (!(isCompute(props.origin) && isEcsCompute(props.origin)) &&
179
+ (props.originHostname !== undefined || props.originRecord !== undefined)) {
180
+ throw new Error("CDN 'originHostname'/'originRecord' configure the distribution's " +
181
+ "DEFAULT origin, and only when it is an ECS compute — they " +
182
+ "resolve the origin hostname against that compute's ingress " +
183
+ "profile. Drop them for storage, Lambda, and literal-hostname " +
184
+ "default origins; a behaviour's ECS origin takes the same " +
185
+ "overrides on the behaviour entry itself.");
186
+ }
145
187
  if (isCompute(props.origin)) {
146
188
  if (isEcsCompute(props.origin)) {
147
189
  if (!props.origin.getLoadBalancer()) {
@@ -175,11 +217,133 @@ export class Cdn extends CloudFrontDistribution {
175
217
  const domain = props.domainConfig !== undefined && props.app !== undefined
176
218
  ? Cdn.resolveDomain(scope, id, props.domainConfig, props.app)
177
219
  : undefined;
178
- const resolvedProps = Cdn.resolveProps(scope, id, props, domain);
179
- super(scope, id, resolvedProps);
220
+ // ECS construct-reference origins resolve through one memoized resolver
221
+ // (design 2026-08-18 cdn-app-origin, D3): the default origin and every
222
+ // behaviour referencing the same compute share one resolution and at
223
+ // most one origin record. The record plans ride the resolution RESULT —
224
+ // they exist only once every origin has resolved, so returning them from
225
+ // resolveProps makes the ordering a property of the data flow rather
226
+ // than a call-sequence convention on the resolver instance.
227
+ const resolved = Cdn.resolveProps(scope, id, props, domain);
228
+ super(scope, id, resolved.distributionProps);
180
229
  if (domain !== undefined && props.domainConfig !== undefined) {
181
230
  this.createDomainRecord(id, props.domainConfig, domain);
182
231
  }
232
+ this.createOriginRecords(resolved.recordPlans);
233
+ }
234
+ static createEcsOriginResolver(id, props) {
235
+ return new CdnEcsOriginResolver({
236
+ cdnId: id,
237
+ aliasNames: collectAliasNames(props),
238
+ overrides: Cdn.collectEcsOriginOverrides(id, props)
239
+ });
240
+ }
241
+ /**
242
+ * Assemble the per-compute `originHostname`/`originRecord` overrides the
243
+ * resolver binds by compute identity: the distribution-level props apply
244
+ * to the DEFAULT origin's compute, each behaviour's own overrides apply
245
+ * to that behaviour's compute. Two entries naming the same compute must
246
+ * agree — one alias record cannot carry two spellings.
247
+ */
248
+ static collectEcsOriginOverrides(id, props) {
249
+ const overrides = new Map();
250
+ const claim = (compute, override, site) => {
251
+ const existing = overrides.get(compute) ?? {};
252
+ for (const key of ["originHostname", "originRecord"]) {
253
+ const incoming = override[key];
254
+ if (incoming === undefined)
255
+ continue;
256
+ const held = existing[key];
257
+ if (held !== undefined && held !== incoming) {
258
+ throw new Error(`CDN '${id}': conflicting ${key} overrides for the same ECS ` +
259
+ `compute origin ('${held}' vs '${incoming}' from ${site}) — ` +
260
+ "overrides bind per compute, so every entry naming it must " +
261
+ "agree. Keep one spelling.");
262
+ }
263
+ }
264
+ overrides.set(compute, { ...existing, ...override });
265
+ };
266
+ if (props.originType === "auto") {
267
+ const override = {
268
+ ...(props.originHostname !== undefined && {
269
+ originHostname: props.originHostname
270
+ }),
271
+ ...(props.originRecord !== undefined && {
272
+ originRecord: props.originRecord
273
+ })
274
+ };
275
+ if ((override.originHostname !== undefined ||
276
+ override.originRecord !== undefined) &&
277
+ isCompute(props.origin) &&
278
+ isEcsCompute(props.origin)) {
279
+ claim(props.origin, override, "the distribution props");
280
+ }
281
+ }
282
+ for (const behaviour of props.behaviours ?? []) {
283
+ if (behaviour.originHostname === undefined &&
284
+ behaviour.originRecord === undefined) {
285
+ continue;
286
+ }
287
+ if (behaviour.origin === undefined) {
288
+ throw new Error(`CDN '${id}': behaviour '${behaviour.pathPattern}' sets ` +
289
+ "'originHostname'/'originRecord' but no 'origin' — an " +
290
+ "origin-less behaviour routes to the distribution's default " +
291
+ "origin, whose overrides live on the distribution props. Move " +
292
+ "the override there, or give the behaviour its own origin.");
293
+ }
294
+ if (typeof behaviour.origin === "string" ||
295
+ !isCompute(behaviour.origin) ||
296
+ !isEcsCompute(behaviour.origin)) {
297
+ throw new Error(`CDN '${id}': behaviour '${behaviour.pathPattern}' sets ` +
298
+ "'originHostname'/'originRecord', but its origin is not an ECS " +
299
+ "compute — they configure how the origin hostname resolves " +
300
+ "against the compute's ingress profile. Drop them for storage, " +
301
+ "Lambda, and literal-hostname origins.");
302
+ }
303
+ claim(behaviour.origin, {
304
+ ...(behaviour.originHostname !== undefined && {
305
+ originHostname: behaviour.originHostname
306
+ }),
307
+ ...(behaviour.originRecord !== undefined && {
308
+ originRecord: behaviour.originRecord
309
+ })
310
+ }, `behaviour '${behaviour.pathPattern}'`);
311
+ }
312
+ return overrides;
313
+ }
314
+ /**
315
+ * Mint the Cdn-owned origin alias records (P1) after the distribution
316
+ * exists, using the compute profile's own zone instance so the DNS claim
317
+ * registry sees the same zone identity as the cluster's records. The
318
+ * distribution depends on each record: the hostname must resolve before a
319
+ * distribution update referencing it propagates.
320
+ */
321
+ createOriginRecords(plans) {
322
+ // Plans are memoized per compute, so two entries with one hostname mean
323
+ // two DIFFERENT computes resolved the same name — an inherently invalid
324
+ // shape (one alias record cannot target two load balancers). Refuse it
325
+ // with the cure instead of letting CDK's duplicate-construct-id throw.
326
+ const seenHostnames = new Set();
327
+ for (const plan of plans) {
328
+ const key = normaliseDnsName(plan.hostname);
329
+ if (seenHostnames.has(key)) {
330
+ throw new Error(`CDN '${this.node.id}': two ECS compute origins resolved the ` +
331
+ `same origin hostname '${plan.hostname}' — one alias record ` +
332
+ "cannot target two load balancers. Give one of them a " +
333
+ "distinct originHostname.");
334
+ }
335
+ seenHostnames.add(key);
336
+ }
337
+ for (const plan of plans) {
338
+ const safeHost = toPascalCase(getSafeZoneName(plan.hostname));
339
+ const record = new AliasRecord(this, `${safeHost}OriginRecord`, {
340
+ zone: plan.hostedZone,
341
+ zoneName: plan.zoneName,
342
+ recordName: plan.recordName,
343
+ target: new LoadBalancerTarget(plan.loadBalancer)
344
+ });
345
+ this.getDistribution().node.addDependency(record);
346
+ }
183
347
  }
184
348
  /**
185
349
  * Resolve a `domainConfig` through the shared pattern-domain helpers
@@ -229,16 +393,35 @@ export class Cdn extends CloudFrontDistribution {
229
393
  });
230
394
  }
231
395
  /**
232
- * Resolve ICdnProps to CloudFrontDistributionProps.
396
+ * Resolve ICdnProps to the distribution props PLUS the Cdn-owned origin
397
+ * record plans the resolution produced — one resolution pass, one result.
233
398
  */
234
399
  static resolveProps(scope, id, props, domain) {
235
- const defaultOrigin = Cdn.resolveDefaultOrigin(props);
236
- const behaviours = Cdn.resolveBehaviours(props.behaviours);
400
+ const ecsOriginResolver = Cdn.createEcsOriginResolver(id, props);
401
+ const defaultOrigin = Cdn.resolveDefaultOrigin(props, ecsOriginResolver);
402
+ const behaviours = Cdn.resolveBehaviours(props.behaviours, defaultOrigin, ecsOriginResolver, {
403
+ cachePolicy: props.cachePolicy,
404
+ allowedMethods: props.defaultAllowedMethods
405
+ });
237
406
  const appName = props.appName;
238
407
  const domainNames = props.domainNames ??
239
408
  (props.domainConfig !== undefined
240
409
  ? [props.domainConfig.domainName]
241
410
  : undefined);
411
+ // ANY origin resolved through the ECS profile lane serves a hostname
412
+ // that differs from the viewer-facing aliases, so host fidelity via
413
+ // x-forwarded-host is the sensible default (design D3 step 9) — for a
414
+ // behaviour's ECS origin exactly as for the default origin, since the
415
+ // one viewer-request function rides every behaviour. Explicit
416
+ // `forwardHostHeader: false` wins.
417
+ const hasEcsOrigin = (props.originType === "auto" &&
418
+ isCompute(props.origin) &&
419
+ isEcsCompute(props.origin)) ||
420
+ (props.behaviours ?? []).some((behaviour) => behaviour.origin !== undefined &&
421
+ typeof behaviour.origin !== "string" &&
422
+ isCompute(behaviour.origin) &&
423
+ isEcsCompute(behaviour.origin));
424
+ const impliedForwardHostHeader = hasEcsOrigin && (domainNames?.length ?? 0) > 0 ? true : undefined;
242
425
  const certificate = props.certificate ??
243
426
  (props.certificateArn
244
427
  ? Certificate.fromCertificateArn(scope, `${id}Certificate`, props.certificateArn)
@@ -253,40 +436,65 @@ export class Cdn extends CloudFrontDistribution {
253
436
  }
254
437
  : {};
255
438
  return {
256
- appName,
257
- defaultOrigin,
258
- defaultCachePolicy: props.cachePolicy,
259
- defaultAllowedMethods: props.defaultAllowedMethods,
260
- behaviours,
261
- domainNames,
262
- certificate,
263
- comment: props.comment,
264
- enableLogging: props.enableLogging,
265
- logBucket: props.logBucket,
266
- priceClass: props.priceClass,
267
- forwardHostHeader: props.forwardHostHeader,
268
- accessGate: props.accessGate,
269
- ...s3Routing
439
+ distributionProps: {
440
+ appName,
441
+ defaultOrigin,
442
+ defaultCachePolicy: props.cachePolicy,
443
+ defaultAllowedMethods: props.defaultAllowedMethods,
444
+ behaviours,
445
+ domainNames,
446
+ certificate,
447
+ comment: props.comment,
448
+ enableLogging: props.enableLogging,
449
+ logBucket: props.logBucket,
450
+ priceClass: props.priceClass,
451
+ forwardHostHeader: props.forwardHostHeader ?? impliedForwardHostHeader,
452
+ accessGate: props.accessGate,
453
+ ...s3Routing
454
+ },
455
+ recordPlans: ecsOriginResolver.getRecordPlans()
270
456
  };
271
457
  }
272
458
  /**
273
- * Transform smart behaviours to CdnBehaviour objects.
459
+ * Transform smart behaviours to CdnBehaviour objects. A behaviour with no
460
+ * origin routes to the distribution's default origin — the same resolved
461
+ * config, so the resource layer memoizes them onto one distribution
462
+ * origin entry.
463
+ *
464
+ * Knob inheritance is asymmetric BY CONTRACT. An origin-less behaviour IS
465
+ * the default behaviour varied per path (the shape
466
+ * `SmartCdnBehaviour.origin` advertises), so its omitted
467
+ * `cachePolicy`/`allowedMethods` inherit the DISTRIBUTION's defaults —
468
+ * otherwise a caching-only behaviour on an `allowedMethods: "ALL"` API
469
+ * distribution would snap its path back to the resource layer's GET/HEAD
470
+ * fallback and 403 every POST at the edge. A behaviour WITH its own
471
+ * origin keeps the legacy resource-layer fallbacks (GET_HEAD /
472
+ * CACHING_OPTIMIZED): those deployed behaviours are byte-frozen, and an
473
+ * independent origin is an independent config, not a variation of the
474
+ * default behaviour.
274
475
  */
275
- static resolveBehaviours(behaviours) {
476
+ static resolveBehaviours(behaviours, defaultOrigin, ecsOriginResolver, distributionDefaults) {
276
477
  if (!behaviours || behaviours.length === 0) {
277
478
  return undefined;
278
479
  }
279
- return behaviours.map((behaviour) => ({
280
- pathPattern: behaviour.pathPattern,
281
- origin: Cdn.detectOriginFromResource(behaviour.origin),
282
- cachePolicy: behaviour.cachePolicy,
283
- allowedMethods: behaviour.allowedMethods
284
- }));
480
+ return behaviours.map((behaviour) => behaviour.origin === undefined
481
+ ? {
482
+ pathPattern: behaviour.pathPattern,
483
+ origin: defaultOrigin,
484
+ cachePolicy: behaviour.cachePolicy ?? distributionDefaults.cachePolicy,
485
+ allowedMethods: behaviour.allowedMethods ?? distributionDefaults.allowedMethods
486
+ }
487
+ : {
488
+ pathPattern: behaviour.pathPattern,
489
+ origin: Cdn.detectOriginFromResource(behaviour.origin, ecsOriginResolver),
490
+ cachePolicy: behaviour.cachePolicy,
491
+ allowedMethods: behaviour.allowedMethods
492
+ });
285
493
  }
286
494
  /**
287
495
  * Resolve the default origin from ICdnProps.
288
496
  */
289
- static resolveDefaultOrigin(props) {
497
+ static resolveDefaultOrigin(props, ecsOriginResolver) {
290
498
  switch (props.originType) {
291
499
  case "s3": {
292
500
  const bucket = isStorage(props.bucket)
@@ -332,7 +540,7 @@ export class Cdn extends CloudFrontDistribution {
332
540
  protocolPolicy: props.protocolPolicy
333
541
  };
334
542
  case "auto":
335
- return Cdn.detectOriginFromResource(props.origin);
543
+ return Cdn.detectOriginFromResource(props.origin, ecsOriginResolver);
336
544
  default: {
337
545
  const _exhaustive = props;
338
546
  throw new Error(`Unsupported CDN origin type: ${props.originType}`);
@@ -342,7 +550,7 @@ export class Cdn extends CloudFrontDistribution {
342
550
  /**
343
551
  * Auto-detect origin configuration from a Fjall resource.
344
552
  */
345
- static detectOriginFromResource(resource) {
553
+ static detectOriginFromResource(resource, ecsOriginResolver) {
346
554
  // String → HTTP origin
347
555
  if (typeof resource === "string") {
348
556
  return {
@@ -357,15 +565,13 @@ export class Cdn extends CloudFrontDistribution {
357
565
  bucket: resource.getBucket()
358
566
  };
359
567
  }
360
- // ECS Compute → ALB origin
568
+ // ECS Compute → HTTP origin on a resolved, certificate-covered origin
569
+ // hostname (design 2026-08-18 cdn-app-origin, D3). The former raw-ELB
570
+ // alb-origin emission was TLS-broken by construction: the resource
571
+ // layer's HTTPS_ONLY default validated the certificate against
572
+ // *.elb.amazonaws.com, which no ACM certificate covers.
361
573
  if (isEcsCompute(resource)) {
362
- const loadBalancer = resource.getLoadBalancer();
363
- if (loadBalancer) {
364
- return {
365
- type: "alb",
366
- loadBalancer
367
- };
368
- }
574
+ return ecsOriginResolver.resolve(resource).originConfig;
369
575
  }
370
576
  // Lambda Compute → HTTP origin (function URL)
371
577
  if (isLambdaCompute(resource)) {
@@ -382,6 +588,10 @@ export class Cdn extends CloudFrontDistribution {
382
588
  domainName
383
589
  };
384
590
  }
591
+ // Targeted error (design D3): a behaviour origin used to fall through
592
+ // to the generic detect throw for this shape.
593
+ throw new Error("Lambda compute must have a function URL for CDN origin. " +
594
+ "Enable functionUrl in your Lambda compute configuration.");
385
595
  }
386
596
  throw new Error(`Unable to detect CDN origin from resource: ${typeof resource}. ` +
387
597
  "Provide explicit origin configuration using originType.");