@fjall/components-infrastructure 14.2.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.
@@ -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,
@@ -168,7 +168,16 @@ export type ICdnProps = S3CdnProps | AlbCdnProps | HttpCdnProps | SmartCdnProps;
168
168
  */
169
169
  export interface SmartCdnBehaviour {
170
170
  pathPattern: string;
171
- 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;
172
181
  cachePolicy?: CachePolicyPreset | ICachePolicy;
173
182
  allowedMethods?: "GET_HEAD" | "GET_HEAD_OPTIONS" | "ALL";
174
183
  /**
@@ -225,11 +234,27 @@ export declare class Cdn extends CloudFrontDistribution implements ICdn {
225
234
  */
226
235
  private createDomainRecord;
227
236
  /**
228
- * 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.
229
239
  */
230
240
  private static resolveProps;
231
241
  /**
232
- * 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.
233
258
  */
234
259
  private static resolveBehaviours;
235
260
  /**
@@ -4,9 +4,10 @@ import { CloudFrontTarget, LoadBalancerTarget } from "aws-cdk-lib/aws-route53-ta
4
4
  import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
5
5
  import { AliasRecord } from "../../resources/aws/networking/index.js";
6
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";
9
- import { CdnEcsOriginResolver, recordLabelWithin, normaliseDnsName } from "./cdnAppOrigin.js";
10
+ import { CdnEcsOriginResolver, cdnOriginRefusal, internalAlbMessage, normaliseDnsName } from "./cdnAppOrigin.js";
10
11
  import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
11
12
  /**
12
13
  * The distribution's own alias names, normalised — consumed by the
@@ -91,7 +92,7 @@ function validateCdnProps(props) {
91
92
  for (const originHostname of literalOriginHostnames) {
92
93
  if (!Token.isUnresolved(originHostname) &&
93
94
  aliasNames.has(normaliseDnsName(originHostname))) {
94
- 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 ` +
95
96
  "domain names — once DNS points that name at the distribution, every " +
96
97
  "request loops CloudFront → CloudFront. Point the origin at a " +
97
98
  "dedicated origin hostname instead (e.g. an ECS service " +
@@ -141,10 +142,8 @@ function validateCdnProps(props) {
141
142
  }
142
143
  const profile = computeRef?.getIngressProfile();
143
144
  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.");
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."));
148
147
  }
149
148
  const effectiveProtocol = props.protocolPolicy ?? "HTTPS_ONLY";
150
149
  if (effectiveProtocol !== "HTTP_ONLY") {
@@ -159,16 +158,16 @@ function validateCdnProps(props) {
159
158
  : 'Use originType "auto" with the compute as origin — the ' +
160
159
  "cluster's only listener is HTTPS (443), so the sole working " +
161
160
  "path is a certificate-covered origin hostname.";
162
- throw new Error(`CDN alb origin with protocolPolicy '${effectiveProtocol}' can ` +
161
+ throw cdnOriginRefusal("E9", `CDN alb origin with protocolPolicy '${effectiveProtocol}' can ` +
163
162
  "never complete a TLS handshake: CloudFront validates the origin " +
164
163
  "certificate against the raw ELB hostname " +
165
164
  "(*.elb.amazonaws.com), which no ACM certificate covers — every " +
166
165
  `origin fetch fails with a 502. ${cure}`);
167
166
  }
168
167
  if (profile !== undefined && profile.listenerPort === 443) {
169
- // E9 (HTTP_ONLY variant) — nothing listens on 80: a connect-timeout
168
+ // E9b (HTTP_ONLY variant) — nothing listens on 80: a connect-timeout
170
169
  // 504 instead of a TLS 502, equally broken.
171
- throw new Error('CDN alb origin with protocolPolicy "HTTP_ONLY": the cluster\'s ' +
170
+ throw cdnOriginRefusal("E9b", 'CDN alb origin with protocolPolicy "HTTP_ONLY": the cluster\'s ' +
172
171
  "only listener is HTTPS (443) — a certificate resolved, so " +
173
172
  "nothing listens on port 80 and every origin fetch times out. " +
174
173
  'Use originType "auto" with the compute as origin instead.');
@@ -221,14 +220,16 @@ export class Cdn extends CloudFrontDistribution {
221
220
  // ECS construct-reference origins resolve through one memoized resolver
222
221
  // (design 2026-08-18 cdn-app-origin, D3): the default origin and every
223
222
  // 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);
227
- super(scope, id, resolvedProps);
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);
228
229
  if (domain !== undefined && props.domainConfig !== undefined) {
229
230
  this.createDomainRecord(id, props.domainConfig, domain);
230
231
  }
231
- this.createOriginRecords(ecsOriginResolver.getRecordPlans());
232
+ this.createOriginRecords(resolved.recordPlans);
232
233
  }
233
234
  static createEcsOriginResolver(id, props) {
234
235
  return new CdnEcsOriginResolver({
@@ -283,6 +284,13 @@ export class Cdn extends CloudFrontDistribution {
283
284
  behaviour.originRecord === undefined) {
284
285
  continue;
285
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
+ }
286
294
  if (typeof behaviour.origin === "string" ||
287
295
  !isCompute(behaviour.origin) ||
288
296
  !isEcsCompute(behaviour.origin)) {
@@ -315,16 +323,16 @@ export class Cdn extends CloudFrontDistribution {
315
323
  // two DIFFERENT computes resolved the same name — an inherently invalid
316
324
  // shape (one alias record cannot target two load balancers). Refuse it
317
325
  // with the cure instead of letting CDK's duplicate-construct-id throw.
318
- const byHostname = new Map();
326
+ const seenHostnames = new Set();
319
327
  for (const plan of plans) {
320
328
  const key = normaliseDnsName(plan.hostname);
321
- if (byHostname.has(key)) {
329
+ if (seenHostnames.has(key)) {
322
330
  throw new Error(`CDN '${this.node.id}': two ECS compute origins resolved the ` +
323
331
  `same origin hostname '${plan.hostname}' — one alias record ` +
324
332
  "cannot target two load balancers. Give one of them a " +
325
333
  "distinct originHostname.");
326
334
  }
327
- byHostname.set(key, plan);
335
+ seenHostnames.add(key);
328
336
  }
329
337
  for (const plan of plans) {
330
338
  const safeHost = toPascalCase(getSafeZoneName(plan.hostname));
@@ -385,26 +393,35 @@ export class Cdn extends CloudFrontDistribution {
385
393
  });
386
394
  }
387
395
  /**
388
- * 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.
389
398
  */
390
- static resolveProps(scope, id, props, domain, ecsOriginResolver) {
399
+ static resolveProps(scope, id, props, domain) {
400
+ const ecsOriginResolver = Cdn.createEcsOriginResolver(id, props);
391
401
  const defaultOrigin = Cdn.resolveDefaultOrigin(props, ecsOriginResolver);
392
- const behaviours = Cdn.resolveBehaviours(props.behaviours, ecsOriginResolver);
402
+ const behaviours = Cdn.resolveBehaviours(props.behaviours, defaultOrigin, ecsOriginResolver, {
403
+ cachePolicy: props.cachePolicy,
404
+ allowedMethods: props.defaultAllowedMethods
405
+ });
393
406
  const appName = props.appName;
394
407
  const domainNames = props.domainNames ??
395
408
  (props.domainConfig !== undefined
396
409
  ? [props.domainConfig.domainName]
397
410
  : 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" &&
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" &&
403
418
  isCompute(props.origin) &&
404
- isEcsCompute(props.origin) &&
405
- (domainNames?.length ?? 0) > 0
406
- ? true
407
- : undefined;
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;
408
425
  const certificate = props.certificate ??
409
426
  (props.certificateArn
410
427
  ? Certificate.fromCertificateArn(scope, `${id}Certificate`, props.certificateArn)
@@ -419,35 +436,60 @@ export class Cdn extends CloudFrontDistribution {
419
436
  }
420
437
  : {};
421
438
  return {
422
- appName,
423
- defaultOrigin,
424
- defaultCachePolicy: props.cachePolicy,
425
- defaultAllowedMethods: props.defaultAllowedMethods,
426
- behaviours,
427
- domainNames,
428
- certificate,
429
- comment: props.comment,
430
- enableLogging: props.enableLogging,
431
- logBucket: props.logBucket,
432
- priceClass: props.priceClass,
433
- forwardHostHeader: props.forwardHostHeader ?? impliedForwardHostHeader,
434
- accessGate: props.accessGate,
435
- ...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()
436
456
  };
437
457
  }
438
458
  /**
439
- * 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.
440
475
  */
441
- static resolveBehaviours(behaviours, ecsOriginResolver) {
476
+ static resolveBehaviours(behaviours, defaultOrigin, ecsOriginResolver, distributionDefaults) {
442
477
  if (!behaviours || behaviours.length === 0) {
443
478
  return undefined;
444
479
  }
445
- return behaviours.map((behaviour) => ({
446
- pathPattern: behaviour.pathPattern,
447
- origin: Cdn.detectOriginFromResource(behaviour.origin, ecsOriginResolver),
448
- cachePolicy: behaviour.cachePolicy,
449
- allowedMethods: behaviour.allowedMethods
450
- }));
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
+ });
451
493
  }
452
494
  /**
453
495
  * Resolve the default origin from ICdnProps.
@@ -18,12 +18,6 @@ import type { CdnOriginConfig } from "../../resources/aws/cdn/index.js";
18
18
  import type { IEcsCompute } from "./interfaces/compute.js";
19
19
  import { normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
20
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
21
  /** A Cdn-owned origin alias record to mint after the distribution exists. */
28
22
  export interface EcsOriginRecordPlan {
29
23
  hostname: string;
@@ -59,10 +53,25 @@ export interface CdnEcsOriginResolverOptions {
59
53
  * distribution props (default origin) and each behaviour entry. */
60
54
  overrides?: ReadonlyMap<IEcsCompute, EcsOriginOverride>;
61
55
  }
56
+ /**
57
+ * The origin-lane refusal/warning catalogue (design 2026-08-18
58
+ * cdn-app-origin § E-catalogue; C4 is the origin-loop guard from design
59
+ * 2026-08-17 cdn-domain-ownership). Every catalogue verdict is thrown or
60
+ * logged through the helpers below so its code rides at the head of the
61
+ * message — a synth or deploy log line maps straight back to the catalogue
62
+ * entry, and its cure, without a source dive.
63
+ */
64
+ export type CdnOriginIssueCode = "C4" | "E1" | "E2" | "E3" | "E4" | "E5" | "E6" | "E7" | "E8" | "E9" | "E9b" | "E10" | "E11" | "W1" | "W2";
65
+ export declare function cdnOriginRefusal(code: CdnOriginIssueCode, message: string): Error;
66
+ export declare function cdnOriginWarning(code: CdnOriginIssueCode, message: string): void;
67
+ /**
68
+ * E2's invariant middle, shared by the construct-origin and alb lanes —
69
+ * the lanes differ only in subject and cure, never in the defect statement.
70
+ */
71
+ export declare function internalAlbMessage(subject: string, cure: string): string;
62
72
  export declare class CdnEcsOriginResolver {
63
73
  private readonly options;
64
74
  private readonly resolutions;
65
- private warnedUnknownCoverage;
66
75
  constructor(options: CdnEcsOriginResolverOptions);
67
76
  resolve(compute: IEcsCompute): ResolvedEcsOrigin;
68
77
  /** Every Cdn-owned record the resolutions so far require, one per compute. */
@@ -15,21 +15,8 @@
15
15
  import { Token } from "aws-cdk-lib";
16
16
  import { certificateCovers, forwardingVerdict, normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
17
17
  export { normaliseDnsName };
18
- import { DNS_APEX } from "@fjall/util";
19
- import { isWithinZone } from "../../utils/domainTypes.js";
18
+ import { isWithinZone, recordLabelWithin } from "../../utils/domainTypes.js";
20
19
  import { FjallLogger } from "../../utils/validationLogger.js";
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 function recordLabelWithin(domain, zoneName) {
27
- if (domain === zoneName) {
28
- return DNS_APEX;
29
- }
30
- const suffix = `.${zoneName}`;
31
- return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
32
- }
33
20
  const SHARED_CURE = "declare the hostname as a routing host on the receiving service " +
34
21
  "(services[].routing[].host) — one declaration adds the certificate SAN " +
35
22
  "(cluster-minted certificates), the listener rule, and the DNS record. " +
@@ -37,10 +24,23 @@ const SHARED_CURE = "declare the hostname as a routing host on the receiving ser
37
24
  "default action from forward to fixed-404, so a cluster serving its " +
38
25
  "domain through the default action must keep a forwarding path for it, " +
39
26
  'e.g. routing: [{ path: "/*" }, { host: "<origin hostname>" }].';
27
+ export function cdnOriginRefusal(code, message) {
28
+ return new Error(`[${code}] ${message}`);
29
+ }
30
+ export function cdnOriginWarning(code, message) {
31
+ FjallLogger.warn(`[${code}] ${message}`);
32
+ }
33
+ /**
34
+ * E2's invariant middle, shared by the construct-origin and alb lanes —
35
+ * the lanes differ only in subject and cure, never in the defect statement.
36
+ */
37
+ export function internalAlbMessage(subject, cure) {
38
+ return (`${subject} is internal — CloudFront reaches origins over the public ` +
39
+ `internet and cannot address an internal ALB. ${cure}`);
40
+ }
40
41
  export class CdnEcsOriginResolver {
41
42
  options;
42
43
  resolutions = new Map();
43
- warnedUnknownCoverage = false;
44
44
  constructor(options) {
45
45
  this.options = options;
46
46
  }
@@ -68,32 +68,31 @@ export class CdnEcsOriginResolver {
68
68
  const profile = compute.getIngressProfile();
69
69
  if (profile === undefined) {
70
70
  // E1 — pre-existing shape, kept: a cluster with no ALB cannot origin.
71
- throw new Error(`CDN '${cdnId}': the ECS compute origin has no load balancer. ` +
71
+ throw cdnOriginRefusal("E1", `CDN '${cdnId}': the ECS compute origin has no load balancer. ` +
72
72
  "Enable loadBalancer in the compute configuration, or front a " +
73
73
  "different resource.");
74
74
  }
75
75
  if (profile.internal) {
76
76
  // E2 — CloudFront has no path into a VPC-internal ALB.
77
- throw new Error(`CDN '${cdnId}': the ECS compute origin's load balancer is internal ` +
78
- " CloudFront reaches origins over the public internet and cannot " +
79
- "address an internal ALB. Make the cluster's load balancer " +
80
- 'internet-facing (drop loadBalancer: "internal"), or front a ' +
81
- "different resource.");
77
+ throw cdnOriginRefusal("E2", internalAlbMessage(`CDN '${cdnId}': the ECS compute origin's load balancer`, "Make the cluster's load balancer internet-facing (drop " +
78
+ 'loadBalancer: "internal"), or front a different resource.'));
82
79
  }
83
80
  const { hostname, computeOwnsRecord } = this.selectHostname(compute, profile);
84
81
  // Origin-loop guard (C4, extended): the resolved hostname must not be
85
82
  // one of the distribution's own alias names.
86
83
  if (this.options.aliasNames.has(normaliseDnsName(hostname))) {
87
- throw new Error(`CDN '${cdnId}': resolved origin hostname '${hostname}' is also one ` +
84
+ throw cdnOriginRefusal("C4", `CDN '${cdnId}': resolved origin hostname '${hostname}' is also one ` +
88
85
  "of the distribution's own domain names — once DNS points that " +
89
86
  "name at the distribution, every request loops CloudFront → " +
90
87
  "CloudFront. Pick a dedicated origin hostname (originHostname), " +
91
88
  "or serve the alias from the compute directly.");
92
89
  }
93
90
  if (profile.listenerPort === 80) {
94
- // E4 no certificate anywhere: the listener serves HTTP only, and a
95
- // CloudFront origin fetch over HTTPS has nothing to shake hands with.
96
- throw new Error(`CDN '${cdnId}': the ECS compute origin's listener serves HTTP only ` +
91
+ // E4 (port-80 arm) the same designed condition as the no-domain arm
92
+ // in selectHostname: no domain anywhere no certificate the
93
+ // listener serves HTTP only, and a CloudFront origin fetch over HTTPS
94
+ // has nothing to shake hands with. One code, one cure.
95
+ throw cdnOriginRefusal("E4", `CDN '${cdnId}': the ECS compute origin's listener serves HTTP only ` +
97
96
  "(the cluster has no domain, so no certificate resolved). A " +
98
97
  "CloudFront origin needs a TLS-valid HTTPS origin. Give the " +
99
98
  "cluster a domain (cluster.domainConfig), or use originType " +
@@ -112,7 +111,7 @@ export class CdnEcsOriginResolver {
112
111
  profile.zoneName === undefined ||
113
112
  !isWithinZone(hostname, profile.zoneName)) {
114
113
  // E5 — a record we cannot mint: outside the cluster's zone.
115
- throw new Error(`CDN '${cdnId}': originHostname '${hostname}' is outside the ECS ` +
114
+ throw cdnOriginRefusal("E5", `CDN '${cdnId}': originHostname '${hostname}' is outside the ECS ` +
116
115
  `origin's hosted zone ('${profile.zoneName ?? "none"}'), so its ` +
117
116
  "record cannot be minted here. Use a hostname inside the zone, " +
118
117
  'or set originRecord: "none" and manage the record where the ' +
@@ -130,15 +129,17 @@ export class CdnEcsOriginResolver {
130
129
  if (coverage === "not-covered") {
131
130
  // E6 — provably uncovered: every attached certificate is enumerated
132
131
  // and none matches. TLS to the origin fails on every request.
133
- throw new Error(`CDN '${cdnId}': no certificate on the ECS origin's listener covers ` +
132
+ throw cdnOriginRefusal("E6", `CDN '${cdnId}': no certificate on the ECS origin's listener covers ` +
134
133
  `'${hostname}' (attached certificates cover: ` +
135
134
  `${profile.certificateCoverage.kind === "unknown" ? "unknown" : profile.certificateCoverage.hostnames.join(", ") || "nothing"}). ` +
136
135
  `Every origin fetch would fail TLS validation. Cure: ${SHARED_CURE}`);
137
136
  }
138
- if (coverage === "unknown" && !this.warnedUnknownCoverage) {
139
- this.warnedUnknownCoverage = true;
140
- // W1 opaque certificates: not decidable at synth, so warn once.
141
- FjallLogger.warn(`CDN '${cdnId}': cannot verify that the ECS origin's listener ` +
137
+ if (coverage === "unknown") {
138
+ // W1 — opaque certificates: not decidable at synth. Once per compute
139
+ // by construction: resolveFresh is memoized per compute, so every
140
+ // opaque compute names its own hostname exactly once (the old
141
+ // resolver-wide latch swallowed every compute after the first).
142
+ cdnOriginWarning("W1", `CDN '${cdnId}': cannot verify that the ECS origin's listener ` +
142
143
  `certificates cover '${hostname}' (imported certificate ARNs are ` +
143
144
  "opaque at synth). If the cluster uses a managed domain, redeploy " +
144
145
  "the domain stack with engine >= 14.2.0 so it publishes " +
@@ -150,13 +151,13 @@ export class CdnEcsOriginResolver {
150
151
  if (verdict.verdict === "none") {
151
152
  // E7 — the listener's default action is a fixed 404 and no rule
152
153
  // forwards the hostname: every origin fetch answers 404.
153
- throw new Error(`CDN '${cdnId}': the ECS origin's listener would answer 404 for ` +
154
+ throw cdnOriginRefusal("E7", `CDN '${cdnId}': the ECS origin's listener would answer 404 for ` +
154
155
  `'${hostname}' — its default action is a fixed 404 and no routing ` +
155
156
  `rule forwards that hostname. Cure: ${SHARED_CURE}`);
156
157
  }
157
158
  if (verdict.verdict === "partial") {
158
159
  // W2 — some paths forward, others hit the fixed-404 default.
159
- FjallLogger.warn(`CDN '${cdnId}': origin traffic for '${hostname}' forwards only for ` +
160
+ cdnOriginWarning("W2", `CDN '${cdnId}': origin traffic for '${hostname}' forwards only for ` +
160
161
  `some paths (matching rules: ${verdict.patterns.join("; ")}); ` +
161
162
  "requests outside those patterns answer the listener's fixed-404 " +
162
163
  "default. Verify the patterns against the distribution's " +
@@ -195,7 +196,7 @@ export class CdnEcsOriginResolver {
195
196
  // E10 — redirect hosts never serve traffic: the listener answers
196
197
  // them with a 301 to the cluster domain, so an origin pointed there
197
198
  // loops every viewer request through CloudFront → 301 → CloudFront.
198
- throw new Error(`CDN '${cdnId}': originHostname '${explicit}' is a redirectHosts ` +
199
+ throw cdnOriginRefusal("E10", `CDN '${cdnId}': originHostname '${explicit}' is a redirectHosts ` +
199
200
  "entry on the ECS origin — the listener 301s it to " +
200
201
  `'${profile.domainName ?? "the cluster domain"}' and it never ` +
201
202
  "serves traffic. Use a served hostname: a routing host, or the " +
@@ -204,7 +205,7 @@ export class CdnEcsOriginResolver {
204
205
  const computeOwnsRecord = profile.routedHosts.some((host) => normaliseDnsName(host) === explicitNormalised);
205
206
  if (computeOwnsRecord && originRecord !== undefined) {
206
207
  // E8 — the compute already owns that hostname's record.
207
- throw new Error(`CDN '${cdnId}': originRecord is set, but originHostname ` +
208
+ throw cdnOriginRefusal("E8", `CDN '${cdnId}': originRecord is set, but originHostname ` +
208
209
  `'${explicit}' is a routing host — the compute already owns its ` +
209
210
  "record (P1: the construct that declares a hostname owns its " +
210
211
  "record). Drop originRecord.");
@@ -218,7 +219,7 @@ export class CdnEcsOriginResolver {
218
219
  if (candidates.length === 1 && candidates[0] !== undefined) {
219
220
  if (originRecord !== undefined) {
220
221
  // E8 — same ownership rule as the explicit arm.
221
- throw new Error(`CDN '${cdnId}': originRecord is set, but the origin resolved to ` +
222
+ throw cdnOriginRefusal("E8", `CDN '${cdnId}': originRecord is set, but the origin resolved to ` +
222
223
  `routing host '${candidates[0]}' — the compute already owns its ` +
223
224
  "record. Drop originRecord.");
224
225
  }
@@ -226,18 +227,18 @@ export class CdnEcsOriginResolver {
226
227
  }
227
228
  if (candidates.length > 1) {
228
229
  // E3 — ambiguous: several served hostnames could be the origin.
229
- throw new Error(`CDN '${cdnId}': the ECS origin serves several hostnames ` +
230
+ throw cdnOriginRefusal("E3", `CDN '${cdnId}': the ECS origin serves several hostnames ` +
230
231
  `(${candidates.join(", ")}) and the origin is ambiguous. Set ` +
231
232
  "originHostname — on the distribution for its default origin, or " +
232
233
  "on the behaviour entry — to the one the distribution should " +
233
234
  "fetch from.");
234
235
  }
235
236
  if (profile.domainName === undefined) {
236
- // E4 — nothing to derive from (and nothing a viewer could reach over
237
- // TLS anyway; the no-domain listener is HTTP-only, so an explicit
238
- // originHostname would only trade this error for the port-80 one —
239
- // the catalogue cures are the honest ones).
240
- throw new Error(`CDN '${cdnId}': the ECS compute origin has no domain to derive an ` +
237
+ // E4 (no-domain arm) — nothing to derive from (and nothing a viewer
238
+ // could reach over TLS anyway; the no-domain listener is HTTP-only,
239
+ // so an explicit originHostname would only trade this error for the
240
+ // port-80 arm — the catalogue cures are the honest ones).
241
+ throw cdnOriginRefusal("E4", `CDN '${cdnId}': the ECS compute origin has no domain to derive an ` +
241
242
  "origin hostname from (and no certificate, so nothing serves " +
242
243
  "HTTPS). Give the cluster a domain (cluster.domainConfig), or " +
243
244
  'use originType "alb" with protocolPolicy "HTTP_ONLY" if ' +
@@ -76,7 +76,7 @@ export function composeDelegatedDomain(scope, props) {
76
76
  const nameServers = hostedZoneConstruct.nameServers ?? props.adoptedNameServers ?? [];
77
77
  const records = props.records ?? [];
78
78
  if (records.length > 0) {
79
- composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records);
79
+ composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records, props.recordIds);
80
80
  }
81
81
  // Step 2 of the R2 two-step gate: issuing the cert before the delegation
82
82
  // NS has propagated hangs ACM DNS-validation, so "zone" stops here.
@@ -11,8 +11,17 @@ import type { DnsRecord } from "./interfaces/domain.js";
11
11
  * runtime guard catches user-crafted `{ kind, ... }` literals that lack
12
12
  * `bind()`.
13
13
  *
14
- * Construct-id formula (`${safeZone}${safeName}${type}Record${index}`) is
15
- * byte-identical to the legacy composer this is an eject-contract
16
- * invariant (Phase 3 depends on stable IDs).
14
+ * Construct-id formulas (`recordIds` on `DomainCommonProps`):
15
+ * - `"indexed"` (default): `${safeZone}${safeName}${type}Record${index}`
16
+ * byte-identical to the legacy composer, an eject-contract invariant
17
+ * (Phase 3 depends on stable IDs). Position-coupled, so the records list
18
+ * is append-only for deployed zones.
19
+ * - `"stable"`: `${safeZone}${safeName}${type}Record` — position-free.
20
+ * {@link assertStableIdsDerivable} refuses the two shapes that would
21
+ * collide, with cures, before CDK's opaque duplicate-construct-id error
22
+ * can fire.
23
+ * A per-record `id` (PascalCase alphanumeric) replaces the `safeName`
24
+ * segment in either mode — the escape hatch the stable-mode collision
25
+ * refusal names.
17
26
  */
18
- export declare function composeTypedDnsRecords(scope: Construct, zone: IHostedZone, zoneName: string, records: DnsRecord[]): void;
27
+ export declare function composeTypedDnsRecords(scope: Construct, zone: IHostedZone, zoneName: string, records: DnsRecord[], recordIds?: "indexed" | "stable"): void;