@fjall/components-infrastructure 14.0.0 → 14.2.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.
@@ -1,14 +1,105 @@
1
1
  import { Fn, Token } from "aws-cdk-lib";
2
2
  import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
3
+ import { CloudFrontTarget, LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
3
4
  import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
5
+ import { AliasRecord } from "../../resources/aws/networking/index.js";
6
+ import { getSafeZoneName, toPascalCase } from "../../utils/capitaliseString.js";
7
+ import { resolvePatternZone, resolvePatternCloudFrontCertificate } from "./patternDomain.js";
4
8
  import { isStorage } from "./storage.js";
9
+ import { CdnEcsOriginResolver, recordLabelWithin, normaliseDnsName } from "./cdnAppOrigin.js";
5
10
  import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
11
+ /**
12
+ * The distribution's own alias names, normalised — consumed by the
13
+ * origin-loop guard and by the ECS origin resolver (auto-resolution excludes
14
+ * them from routed-host candidates, and a resolved origin equal to one is
15
+ * refused as a loop).
16
+ */
17
+ function collectAliasNames(props) {
18
+ const aliasNames = new Set();
19
+ if (props.domainConfig !== undefined) {
20
+ aliasNames.add(normaliseDnsName(props.domainConfig.domainName));
21
+ }
22
+ for (const name of props.domainNames ?? []) {
23
+ if (!Token.isUnresolved(name)) {
24
+ aliasNames.add(normaliseDnsName(name));
25
+ }
26
+ }
27
+ return aliasNames;
28
+ }
6
29
  /**
7
30
  * Validates CDN props — synth-time hard errors for shapes CloudFormation
8
31
  * would only reject at deploy.
9
32
  */
10
33
  function validateCdnProps(props) {
11
34
  const hasCertificate = !!(props.certificate || props.certificateArn);
35
+ // domainConfig is the managed-domain authority for the viewer config —
36
+ // mixing it with the literal surface would give the alias list and the
37
+ // certificate two owners with no defined precedence.
38
+ if (props.domainConfig !== undefined) {
39
+ if (props.domainNames !== undefined || hasCertificate) {
40
+ throw new Error(`CDN 'domainConfig' ('${props.domainConfig.domainName}') cannot be ` +
41
+ "combined with 'domainNames', 'certificate', or 'certificateArn'. " +
42
+ "'domainConfig' derives the alternate domain names and resolves " +
43
+ "the us-east-1 viewer certificate itself — drop the literal props, " +
44
+ "or drop 'domainConfig' and manage both by hand.");
45
+ }
46
+ if (props.app === undefined) {
47
+ throw new Error(`CDN 'domainConfig' ('${props.domainConfig.domainName}') requires ` +
48
+ "the App for certificate placement, but 'app' is unset. Create the " +
49
+ "CDN through the factory path — app.addCdn(CdnFactory.build(...)) " +
50
+ "— which fills it, or pass 'app' explicitly.");
51
+ }
52
+ // Latency region and setIdentifier are literals by contract — inspect
53
+ // them at synth so a config slip fails here with the cure, not at
54
+ // CloudFormation with a raw Route53 InvalidChangeBatch (or, for the
55
+ // empty-string cases, a CDK error about a prop the user never set).
56
+ const region = props.domainConfig.routingPolicy?.region;
57
+ if (region !== undefined && !/^[a-z]{2,4}(-[a-z]+)+-\d+$/.test(region)) {
58
+ throw new Error(`CDN 'domainConfig.routingPolicy.region' ('${region}') is not an ` +
59
+ "AWS region name (expected e.g. 'us-east-1'). Route53 latency " +
60
+ "records carry a literal region label — fix the region string.");
61
+ }
62
+ const setIdentifier = props.domainConfig.setIdentifier;
63
+ if (setIdentifier !== undefined &&
64
+ (setIdentifier.trim() === "" || setIdentifier.length > 128)) {
65
+ throw new Error(`CDN 'domainConfig.setIdentifier' (${JSON.stringify(setIdentifier)}) ` +
66
+ "must be 1-128 non-blank characters (Route53 SetIdentifier limit). " +
67
+ "Omit it to use the derived '<id><region>' value.");
68
+ }
69
+ }
70
+ // Origin-loop guard (design 2026-08-17 cdn-domain-ownership C4): an HTTP
71
+ // origin pointing at one of the distribution's own domain names sends
72
+ // every request CloudFront → DNS → CloudFront until the request-depth
73
+ // limit 508s. Synth can see this shape whenever the origin hostname is a
74
+ // literal, so refuse it with the cure.
75
+ const aliasNames = collectAliasNames(props);
76
+ const defaultOriginHostname = props.originType === "http"
77
+ ? props.domainName
78
+ : props.originType === "auto" && typeof props.origin === "string"
79
+ ? props.origin
80
+ : undefined;
81
+ // Behaviour origins are the same literal-HTTP-origin surface: a string
82
+ // behaviour origin becomes an HTTP origin via detectOriginFromResource, so
83
+ // a self-referential one loops exactly like the default origin — but only
84
+ // for its path pattern, a PARTIAL outage that is even harder to spot.
85
+ const literalOriginHostnames = [
86
+ ...(defaultOriginHostname !== undefined ? [defaultOriginHostname] : []),
87
+ ...(props.behaviours ?? [])
88
+ .map((behaviour) => behaviour.origin)
89
+ .filter((origin) => typeof origin === "string")
90
+ ];
91
+ for (const originHostname of literalOriginHostnames) {
92
+ if (!Token.isUnresolved(originHostname) &&
93
+ aliasNames.has(normaliseDnsName(originHostname))) {
94
+ throw new Error(`CDN origin '${originHostname}' is also one of the distribution's own ` +
95
+ "domain names — once DNS points that name at the distribution, every " +
96
+ "request loops CloudFront → CloudFront. Point the origin at a " +
97
+ "dedicated origin hostname instead (e.g. an ECS service " +
98
+ "'routing[].host' such as 'origin.<zone>' resolving to the load " +
99
+ "balancer, or a domain-stack record targeting the origin resource) " +
100
+ "and keep the public name on the CDN.");
101
+ }
102
+ }
12
103
  // CloudFront accepts viewer certificates from us-east-1 only. A literal
13
104
  // ARN is inspectable at synth (design H3/D3 — the old advisory log is
14
105
  // replaced by real us-east-1 provisioning in the patterns; this assert
@@ -34,18 +125,66 @@ function validateCdnProps(props) {
34
125
  "'certificateArn', or configure the pattern's 'domain' with a zone " +
35
126
  "identity so it provisions one.");
36
127
  }
37
- // Validate ALB origin
128
+ // Validate ALB origin (design 2026-08-18 cdn-app-origin, D4): CloudFront
129
+ // validates the origin certificate against the raw `*.elb.amazonaws.com`
130
+ // hostname, which no ACM certificate ever covers — an effective-HTTPS alb
131
+ // origin can never complete a TLS handshake. E9, with the cure conditional
132
+ // on the listener protocol: HTTP_ONLY only works against a port-80
133
+ // listener, and a cert-bearing cluster has exactly one listener, on 443.
38
134
  if (props.originType === "alb") {
39
- if (isCompute(props.loadBalancer) && isEcsCompute(props.loadBalancer)) {
40
- const lb = props.loadBalancer.getLoadBalancer();
41
- if (!lb) {
42
- throw new Error("Compute resource does not have a load balancer. " +
43
- "Ensure ECS compute has loadBalancer enabled for CDN origin.");
44
- }
135
+ const computeRef = isCompute(props.loadBalancer) && isEcsCompute(props.loadBalancer)
136
+ ? props.loadBalancer
137
+ : undefined;
138
+ if (computeRef !== undefined && !computeRef.getLoadBalancer()) {
139
+ throw new Error("Compute resource does not have a load balancer. " +
140
+ "Ensure ECS compute has loadBalancer enabled for CDN origin.");
141
+ }
142
+ const profile = computeRef?.getIngressProfile();
143
+ if (profile?.internal) {
144
+ // E2 — same defect as the construct-origin lane: no public path in.
145
+ throw new Error("CDN alb origin: the load balancer is internal — CloudFront " +
146
+ "reaches origins over the public internet and cannot address an " +
147
+ "internal ALB. Make the load balancer internet-facing.");
148
+ }
149
+ const effectiveProtocol = props.protocolPolicy ?? "HTTPS_ONLY";
150
+ if (effectiveProtocol !== "HTTP_ONLY") {
151
+ // E9 — effective HTTPS to the raw ELB hostname.
152
+ const cure = profile === undefined
153
+ ? 'If the ALB has an HTTP (port-80) listener, set protocolPolicy: "HTTP_ONLY"; ' +
154
+ 'otherwise use originType "auto" with the compute as origin, ' +
155
+ "which resolves a certificate-covered origin hostname."
156
+ : profile.listenerPort === 80
157
+ ? 'Set protocolPolicy: "HTTP_ONLY" (the cluster\'s listener serves HTTP), ' +
158
+ 'or use originType "auto" with the compute as origin.'
159
+ : 'Use originType "auto" with the compute as origin — the ' +
160
+ "cluster's only listener is HTTPS (443), so the sole working " +
161
+ "path is a certificate-covered origin hostname.";
162
+ throw new Error(`CDN alb origin with protocolPolicy '${effectiveProtocol}' can ` +
163
+ "never complete a TLS handshake: CloudFront validates the origin " +
164
+ "certificate against the raw ELB hostname " +
165
+ "(*.elb.amazonaws.com), which no ACM certificate covers — every " +
166
+ `origin fetch fails with a 502. ${cure}`);
167
+ }
168
+ if (profile !== undefined && profile.listenerPort === 443) {
169
+ // E9 (HTTP_ONLY variant) — nothing listens on 80: a connect-timeout
170
+ // 504 instead of a TLS 502, equally broken.
171
+ throw new Error('CDN alb origin with protocolPolicy "HTTP_ONLY": the cluster\'s ' +
172
+ "only listener is HTTPS (443) — a certificate resolved, so " +
173
+ "nothing listens on port 80 and every origin fetch times out. " +
174
+ 'Use originType "auto" with the compute as origin instead.');
45
175
  }
46
176
  }
47
177
  // Validate smart origin
48
178
  if (props.originType === "auto") {
179
+ if (!(isCompute(props.origin) && isEcsCompute(props.origin)) &&
180
+ (props.originHostname !== undefined || props.originRecord !== undefined)) {
181
+ throw new Error("CDN 'originHostname'/'originRecord' configure the distribution's " +
182
+ "DEFAULT origin, and only when it is an ECS compute — they " +
183
+ "resolve the origin hostname against that compute's ingress " +
184
+ "profile. Drop them for storage, Lambda, and literal-hostname " +
185
+ "default origins; a behaviour's ECS origin takes the same " +
186
+ "overrides on the behaviour entry itself.");
187
+ }
49
188
  if (isCompute(props.origin)) {
50
189
  if (isEcsCompute(props.origin)) {
51
190
  if (!props.origin.getLoadBalancer()) {
@@ -73,20 +212,203 @@ function validateCdnProps(props) {
73
212
  export class Cdn extends CloudFrontDistribution {
74
213
  constructor(scope, id, props) {
75
214
  validateCdnProps(props);
76
- const resolvedProps = Cdn.resolveProps(scope, id, props);
215
+ // Zone + certificate resolve BEFORE super() the viewer certificate is
216
+ // part of the distribution's props — so their constructs land on the CDN
217
+ // stack (`scope`) under `${id}`-prefixed IDs, the pattern convention.
218
+ const domain = props.domainConfig !== undefined && props.app !== undefined
219
+ ? Cdn.resolveDomain(scope, id, props.domainConfig, props.app)
220
+ : undefined;
221
+ // ECS construct-reference origins resolve through one memoized resolver
222
+ // (design 2026-08-18 cdn-app-origin, D3): the default origin and every
223
+ // behaviour referencing the same compute share one resolution and at
224
+ // most one origin record.
225
+ const ecsOriginResolver = Cdn.createEcsOriginResolver(id, props);
226
+ const resolvedProps = Cdn.resolveProps(scope, id, props, domain, ecsOriginResolver);
77
227
  super(scope, id, resolvedProps);
228
+ if (domain !== undefined && props.domainConfig !== undefined) {
229
+ this.createDomainRecord(id, props.domainConfig, domain);
230
+ }
231
+ this.createOriginRecords(ecsOriginResolver.getRecordPlans());
232
+ }
233
+ static createEcsOriginResolver(id, props) {
234
+ return new CdnEcsOriginResolver({
235
+ cdnId: id,
236
+ aliasNames: collectAliasNames(props),
237
+ overrides: Cdn.collectEcsOriginOverrides(id, props)
238
+ });
239
+ }
240
+ /**
241
+ * Assemble the per-compute `originHostname`/`originRecord` overrides the
242
+ * resolver binds by compute identity: the distribution-level props apply
243
+ * to the DEFAULT origin's compute, each behaviour's own overrides apply
244
+ * to that behaviour's compute. Two entries naming the same compute must
245
+ * agree — one alias record cannot carry two spellings.
246
+ */
247
+ static collectEcsOriginOverrides(id, props) {
248
+ const overrides = new Map();
249
+ const claim = (compute, override, site) => {
250
+ const existing = overrides.get(compute) ?? {};
251
+ for (const key of ["originHostname", "originRecord"]) {
252
+ const incoming = override[key];
253
+ if (incoming === undefined)
254
+ continue;
255
+ const held = existing[key];
256
+ if (held !== undefined && held !== incoming) {
257
+ throw new Error(`CDN '${id}': conflicting ${key} overrides for the same ECS ` +
258
+ `compute origin ('${held}' vs '${incoming}' from ${site}) — ` +
259
+ "overrides bind per compute, so every entry naming it must " +
260
+ "agree. Keep one spelling.");
261
+ }
262
+ }
263
+ overrides.set(compute, { ...existing, ...override });
264
+ };
265
+ if (props.originType === "auto") {
266
+ const override = {
267
+ ...(props.originHostname !== undefined && {
268
+ originHostname: props.originHostname
269
+ }),
270
+ ...(props.originRecord !== undefined && {
271
+ originRecord: props.originRecord
272
+ })
273
+ };
274
+ if ((override.originHostname !== undefined ||
275
+ override.originRecord !== undefined) &&
276
+ isCompute(props.origin) &&
277
+ isEcsCompute(props.origin)) {
278
+ claim(props.origin, override, "the distribution props");
279
+ }
280
+ }
281
+ for (const behaviour of props.behaviours ?? []) {
282
+ if (behaviour.originHostname === undefined &&
283
+ behaviour.originRecord === undefined) {
284
+ continue;
285
+ }
286
+ if (typeof behaviour.origin === "string" ||
287
+ !isCompute(behaviour.origin) ||
288
+ !isEcsCompute(behaviour.origin)) {
289
+ throw new Error(`CDN '${id}': behaviour '${behaviour.pathPattern}' sets ` +
290
+ "'originHostname'/'originRecord', but its origin is not an ECS " +
291
+ "compute — they configure how the origin hostname resolves " +
292
+ "against the compute's ingress profile. Drop them for storage, " +
293
+ "Lambda, and literal-hostname origins.");
294
+ }
295
+ claim(behaviour.origin, {
296
+ ...(behaviour.originHostname !== undefined && {
297
+ originHostname: behaviour.originHostname
298
+ }),
299
+ ...(behaviour.originRecord !== undefined && {
300
+ originRecord: behaviour.originRecord
301
+ })
302
+ }, `behaviour '${behaviour.pathPattern}'`);
303
+ }
304
+ return overrides;
305
+ }
306
+ /**
307
+ * Mint the Cdn-owned origin alias records (P1) after the distribution
308
+ * exists, using the compute profile's own zone instance so the DNS claim
309
+ * registry sees the same zone identity as the cluster's records. The
310
+ * distribution depends on each record: the hostname must resolve before a
311
+ * distribution update referencing it propagates.
312
+ */
313
+ createOriginRecords(plans) {
314
+ // Plans are memoized per compute, so two entries with one hostname mean
315
+ // two DIFFERENT computes resolved the same name — an inherently invalid
316
+ // shape (one alias record cannot target two load balancers). Refuse it
317
+ // with the cure instead of letting CDK's duplicate-construct-id throw.
318
+ const byHostname = new Map();
319
+ for (const plan of plans) {
320
+ const key = normaliseDnsName(plan.hostname);
321
+ if (byHostname.has(key)) {
322
+ throw new Error(`CDN '${this.node.id}': two ECS compute origins resolved the ` +
323
+ `same origin hostname '${plan.hostname}' — one alias record ` +
324
+ "cannot target two load balancers. Give one of them a " +
325
+ "distinct originHostname.");
326
+ }
327
+ byHostname.set(key, plan);
328
+ }
329
+ for (const plan of plans) {
330
+ const safeHost = toPascalCase(getSafeZoneName(plan.hostname));
331
+ const record = new AliasRecord(this, `${safeHost}OriginRecord`, {
332
+ zone: plan.hostedZone,
333
+ zoneName: plan.zoneName,
334
+ recordName: plan.recordName,
335
+ target: new LoadBalancerTarget(plan.loadBalancer)
336
+ });
337
+ this.getDistribution().node.addDependency(record);
338
+ }
339
+ }
340
+ /**
341
+ * Resolve a `domainConfig` through the shared pattern-domain helpers
342
+ * (design C1): zone identity via `resolvePatternZone` (explicit
343
+ * `managedDomain` → CLI-injected binding → BYO `zoneName`/`hostedZoneId`),
344
+ * viewer certificate via `resolvePatternCloudFrontCertificate` (binding's
345
+ * literal us-east-1 ARN, else app-owned provisioning). The exact resolution
346
+ * chain staticSite and payload already use — the CDN stops being the one
347
+ * CloudFront-fronted pattern without it.
348
+ */
349
+ static resolveDomain(scope, id, domainConfig, app) {
350
+ const request = {
351
+ scope,
352
+ app,
353
+ idPrefix: id,
354
+ context: `CDN '${id}'`,
355
+ domain: domainConfig.domainName,
356
+ identity: domainConfig
357
+ };
358
+ const zone = resolvePatternZone(request);
359
+ const certificate = resolvePatternCloudFrontCertificate(request, zone);
360
+ return { zone, certificate };
361
+ }
362
+ /**
363
+ * Own the domain's DNS record in the CDN stack (satellite doctrine — the
364
+ * app owns its records, the domain stack owns zone-level records), exactly
365
+ * as the ECS cluster owns its apex alias. Latency routing gets the
366
+ * cluster's setIdentifier derivation (`${id}${region}`) unless overridden;
367
+ * `record: "none"` skips this entirely (pre-flip validation state).
368
+ */
369
+ createDomainRecord(id, domainConfig, domain) {
370
+ if (domainConfig.record === "none") {
371
+ return;
372
+ }
373
+ const region = domainConfig.routingPolicy?.region;
374
+ const setIdentifier = domainConfig.setIdentifier ??
375
+ (region !== undefined ? `${id}${region}` : undefined);
376
+ new AliasRecord(this, "AliasRecord", {
377
+ zone: domain.zone.hostedZone,
378
+ zoneName: domain.zone.zoneName,
379
+ recordName: recordLabelWithin(domainConfig.domainName, domain.zone.zoneName),
380
+ // CloudFrontTarget sets no EvaluateTargetHealth — Route53 requires it
381
+ // absent for CloudFront alias targets.
382
+ target: new CloudFrontTarget(this.getDistribution()),
383
+ ...(region !== undefined && { region }),
384
+ ...(setIdentifier !== undefined && { setIdentifier })
385
+ });
78
386
  }
79
387
  /**
80
388
  * Resolve ICdnProps to CloudFrontDistributionProps.
81
389
  */
82
- static resolveProps(scope, id, props) {
83
- const defaultOrigin = Cdn.resolveDefaultOrigin(props);
84
- const behaviours = Cdn.resolveBehaviours(props.behaviours);
390
+ static resolveProps(scope, id, props, domain, ecsOriginResolver) {
391
+ const defaultOrigin = Cdn.resolveDefaultOrigin(props, ecsOriginResolver);
392
+ const behaviours = Cdn.resolveBehaviours(props.behaviours, ecsOriginResolver);
85
393
  const appName = props.appName;
394
+ const domainNames = props.domainNames ??
395
+ (props.domainConfig !== undefined
396
+ ? [props.domainConfig.domainName]
397
+ : undefined);
398
+ // A default origin resolved through the ECS profile lane serves a
399
+ // hostname that differs from the viewer-facing aliases, so host
400
+ // fidelity via x-forwarded-host is the sensible default (design D3
401
+ // step 9). Explicit `forwardHostHeader: false` wins.
402
+ const impliedForwardHostHeader = props.originType === "auto" &&
403
+ isCompute(props.origin) &&
404
+ isEcsCompute(props.origin) &&
405
+ (domainNames?.length ?? 0) > 0
406
+ ? true
407
+ : undefined;
86
408
  const certificate = props.certificate ??
87
409
  (props.certificateArn
88
410
  ? Certificate.fromCertificateArn(scope, `${id}Certificate`, props.certificateArn)
89
- : undefined);
411
+ : domain?.certificate);
90
412
  const s3Routing = props.originType === "s3"
91
413
  ? {
92
414
  defaultRootObject: props.defaultRootObject,
@@ -102,13 +424,13 @@ export class Cdn extends CloudFrontDistribution {
102
424
  defaultCachePolicy: props.cachePolicy,
103
425
  defaultAllowedMethods: props.defaultAllowedMethods,
104
426
  behaviours,
105
- domainNames: props.domainNames,
427
+ domainNames,
106
428
  certificate,
107
429
  comment: props.comment,
108
430
  enableLogging: props.enableLogging,
109
431
  logBucket: props.logBucket,
110
432
  priceClass: props.priceClass,
111
- forwardHostHeader: props.forwardHostHeader,
433
+ forwardHostHeader: props.forwardHostHeader ?? impliedForwardHostHeader,
112
434
  accessGate: props.accessGate,
113
435
  ...s3Routing
114
436
  };
@@ -116,13 +438,13 @@ export class Cdn extends CloudFrontDistribution {
116
438
  /**
117
439
  * Transform smart behaviours to CdnBehaviour objects.
118
440
  */
119
- static resolveBehaviours(behaviours) {
441
+ static resolveBehaviours(behaviours, ecsOriginResolver) {
120
442
  if (!behaviours || behaviours.length === 0) {
121
443
  return undefined;
122
444
  }
123
445
  return behaviours.map((behaviour) => ({
124
446
  pathPattern: behaviour.pathPattern,
125
- origin: Cdn.detectOriginFromResource(behaviour.origin),
447
+ origin: Cdn.detectOriginFromResource(behaviour.origin, ecsOriginResolver),
126
448
  cachePolicy: behaviour.cachePolicy,
127
449
  allowedMethods: behaviour.allowedMethods
128
450
  }));
@@ -130,7 +452,7 @@ export class Cdn extends CloudFrontDistribution {
130
452
  /**
131
453
  * Resolve the default origin from ICdnProps.
132
454
  */
133
- static resolveDefaultOrigin(props) {
455
+ static resolveDefaultOrigin(props, ecsOriginResolver) {
134
456
  switch (props.originType) {
135
457
  case "s3": {
136
458
  const bucket = isStorage(props.bucket)
@@ -176,7 +498,7 @@ export class Cdn extends CloudFrontDistribution {
176
498
  protocolPolicy: props.protocolPolicy
177
499
  };
178
500
  case "auto":
179
- return Cdn.detectOriginFromResource(props.origin);
501
+ return Cdn.detectOriginFromResource(props.origin, ecsOriginResolver);
180
502
  default: {
181
503
  const _exhaustive = props;
182
504
  throw new Error(`Unsupported CDN origin type: ${props.originType}`);
@@ -186,7 +508,7 @@ export class Cdn extends CloudFrontDistribution {
186
508
  /**
187
509
  * Auto-detect origin configuration from a Fjall resource.
188
510
  */
189
- static detectOriginFromResource(resource) {
511
+ static detectOriginFromResource(resource, ecsOriginResolver) {
190
512
  // String → HTTP origin
191
513
  if (typeof resource === "string") {
192
514
  return {
@@ -201,15 +523,13 @@ export class Cdn extends CloudFrontDistribution {
201
523
  bucket: resource.getBucket()
202
524
  };
203
525
  }
204
- // ECS Compute → ALB origin
526
+ // ECS Compute → HTTP origin on a resolved, certificate-covered origin
527
+ // hostname (design 2026-08-18 cdn-app-origin, D3). The former raw-ELB
528
+ // alb-origin emission was TLS-broken by construction: the resource
529
+ // layer's HTTPS_ONLY default validated the certificate against
530
+ // *.elb.amazonaws.com, which no ACM certificate covers.
205
531
  if (isEcsCompute(resource)) {
206
- const loadBalancer = resource.getLoadBalancer();
207
- if (loadBalancer) {
208
- return {
209
- type: "alb",
210
- loadBalancer
211
- };
212
- }
532
+ return ecsOriginResolver.resolve(resource).originConfig;
213
533
  }
214
534
  // Lambda Compute → HTTP origin (function URL)
215
535
  if (isLambdaCompute(resource)) {
@@ -226,6 +546,10 @@ export class Cdn extends CloudFrontDistribution {
226
546
  domainName
227
547
  };
228
548
  }
549
+ // Targeted error (design D3): a behaviour origin used to fall through
550
+ // to the generic detect throw for this shape.
551
+ throw new Error("Lambda compute must have a function URL for CDN origin. " +
552
+ "Enable functionUrl in your Lambda compute configuration.");
229
553
  }
230
554
  throw new Error(`Unable to detect CDN origin from resource: ${typeof resource}. ` +
231
555
  "Provide explicit origin configuration using originType.");
@@ -265,6 +589,20 @@ export class Cdn extends CloudFrontDistribution {
265
589
  * domainNames: ["app.example.com"],
266
590
  * certificate: myCert
267
591
  * }));
592
+ *
593
+ * @example
594
+ * // Managed domain — the CDN resolves the zone + us-east-1 certificate and
595
+ * // owns the alias record in its own stack (satellite doctrine), like the
596
+ * // ECS cluster's domainConfig.
597
+ * app.addCdn(CdnFactory.build("AppCdn", {
598
+ * originType: "http",
599
+ * domainName: "origin.example.com",
600
+ * forwardHostHeader: true,
601
+ * domainConfig: {
602
+ * domainName: "example.com",
603
+ * routingPolicy: { type: "latency", region: "us-east-1" }
604
+ * }
605
+ * }));
268
606
  */
269
607
  export class CdnFactory {
270
608
  /**
@@ -278,9 +616,12 @@ export class CdnFactory {
278
616
  return (app, scope) => {
279
617
  // The App is the only place the app name is known here; without it the
280
618
  // export falls back to the construct id and the Domain import misses.
619
+ // The App instance itself rides along for domainConfig resolution
620
+ // (certificate placement needs app.getUsEast1CertificateStack()).
281
621
  return new Cdn(scope, id, {
282
622
  ...props,
283
- appName: props.appName ?? app.getName()
623
+ appName: props.appName ?? app.getName(),
624
+ app: props.app ?? app
284
625
  });
285
626
  };
286
627
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * ECS construct-reference origin resolution for the `Cdn` pattern (design
3
+ * 2026-08-18 cdn-app-origin, D3). Given a fronted ECS compute, resolve a
4
+ * TLS-valid ORIGIN HOSTNAME from the compute's own ingress profile, decide
5
+ * who owns its DNS record (P1: the construct that declares a hostname owns
6
+ * its record — a `routing[].host` hostname is the compute's; a derived or
7
+ * free hostname is the Cdn's), and validate certificate coverage and
8
+ * listener forwarding at synth — fail closed where decidable, warn where
9
+ * opaque or partial (P3).
10
+ *
11
+ * The resolver is memoized per compute within one `Cdn`: the default origin
12
+ * and every behaviour referencing the same compute share one resolution and
13
+ * at most one record.
14
+ */
15
+ import type { IHostedZone } from "aws-cdk-lib/aws-route53";
16
+ import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
17
+ import type { CdnOriginConfig } from "../../resources/aws/cdn/index.js";
18
+ import type { IEcsCompute } from "./interfaces/compute.js";
19
+ import { normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
20
+ export { normaliseDnsName };
21
+ /**
22
+ * Relative record label for `domain` within `zoneName` (the staticSite
23
+ * `recordLabelFor` convention): the zone apex maps to the canonical apex
24
+ * label, sub-names drop the zone suffix.
25
+ */
26
+ export declare function recordLabelWithin(domain: string, zoneName: string): string;
27
+ /** A Cdn-owned origin alias record to mint after the distribution exists. */
28
+ export interface EcsOriginRecordPlan {
29
+ hostname: string;
30
+ hostedZone: IHostedZone;
31
+ zoneName: string;
32
+ recordName: string;
33
+ loadBalancer: IApplicationLoadBalancer;
34
+ }
35
+ export interface ResolvedEcsOrigin {
36
+ hostname: string;
37
+ originConfig: CdnOriginConfig;
38
+ /** Present when the Cdn owns the record and `originRecord` is not "none". */
39
+ recordPlan?: EcsOriginRecordPlan;
40
+ /** True when the hostname is a compute-declared routing host (P1). */
41
+ computeOwnsRecord: boolean;
42
+ }
43
+ /**
44
+ * Explicit per-compute origin overrides: the distribution-level
45
+ * `originHostname`/`originRecord` for the default origin's compute, and each
46
+ * behaviour's own `originHostname`/`originRecord` for its compute — so every
47
+ * lane that can throw E3 can also cure it (design D3: behaviour origins get
48
+ * the same targeted validation AND the same override surface).
49
+ */
50
+ export interface EcsOriginOverride {
51
+ originHostname?: string;
52
+ originRecord?: "alias" | "none";
53
+ }
54
+ export interface CdnEcsOriginResolverOptions {
55
+ cdnId: string;
56
+ /** The distribution's own alias names, normalised — the origin-loop guard. */
57
+ aliasNames: ReadonlySet<string>;
58
+ /** Per-compute explicit overrides — assembled by the Cdn from the
59
+ * distribution props (default origin) and each behaviour entry. */
60
+ overrides?: ReadonlyMap<IEcsCompute, EcsOriginOverride>;
61
+ }
62
+ export declare class CdnEcsOriginResolver {
63
+ private readonly options;
64
+ private readonly resolutions;
65
+ private warnedUnknownCoverage;
66
+ constructor(options: CdnEcsOriginResolverOptions);
67
+ resolve(compute: IEcsCompute): ResolvedEcsOrigin;
68
+ /** Every Cdn-owned record the resolutions so far require, one per compute. */
69
+ getRecordPlans(): EcsOriginRecordPlan[];
70
+ private resolveFresh;
71
+ private explicitHostnameFor;
72
+ private effectiveOriginRecord;
73
+ private selectHostname;
74
+ }