@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.
@@ -8,9 +8,9 @@ import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
8
8
  import { DomainCertificate } from "../networking/domainCertificate.js";
9
9
  import { AliasRecord } from "../networking/dnsRecord/aliasRecord.js";
10
10
  import { SecurityGroup } from "../networking/securityGroup.js";
11
- import { DNS_APEX, isManagedDomainBinding, isWithinZone } from "../../../utils/domainTypes.js";
12
- import { readInjectedManagedDomainBinding, readInjectedManagedDomainCoverage } from "../../../utils/managedDomainContext.js";
13
- import { computeListenerDefault404 } from "./ingressProfile.js";
11
+ import { isManagedDomainBinding, isWithinZone, recordLabelWithin } from "../../../utils/domainTypes.js";
12
+ import { readInjectedManagedDomainCoverage, resolveEffectiveManagedDomain } from "../../../utils/managedDomainContext.js";
13
+ import { computeListenerDefault404, planListenerRules } from "./ingressProfile.js";
14
14
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
15
15
  import { stackScopedExportName } from "../../../utils/exportNaming.js";
16
16
  import { registerAlbAliasTarget } from "../../../utils/albAliasTargetRegistry.js";
@@ -182,12 +182,6 @@ function preWrapperLogicalId(scope, id) {
182
182
  .slice(0, 240);
183
183
  return human + hash;
184
184
  }
185
- /** Record label for `fqdn` relative to `zoneName` (apex → `@`). */
186
- function recordLabelWithin(fqdn, zoneName) {
187
- return fqdn === zoneName
188
- ? DNS_APEX
189
- : fqdn.slice(0, fqdn.length - zoneName.length - 1);
190
- }
191
185
  export function addHostedZone(ctx, loadBalancer) {
192
186
  const props = ctx.props;
193
187
  const domainConfig = props.cluster?.domainConfig;
@@ -198,31 +192,25 @@ export function addHostedZone(ctx, loadBalancer) {
198
192
  let hostedZone;
199
193
  let zoneName;
200
194
  let certificate;
201
- // D2 — explicit props.managedDomain wins over the CLI-injected context
202
- // binding (explicit beats injected); the context read sits ahead of the
203
- // BYO domainConfig.hostedZone chain. Bare-CDK synth has no context entry.
204
- const managed = domainConfig?.managedDomain ??
205
- readInjectedManagedDomainBinding(ctx.scope.node, domainName, `Cluster '${props.clusterName}'`);
206
- if (managed) {
207
- zoneName = managed.zoneName;
208
- if (isManagedDomainBinding(managed)) {
209
- // D2 binding: concrete values injected by the CLI at deploy time —
210
- // literals cross accounts and regions where Fn.importValue cannot.
211
- hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
212
- hostedZoneId: managed.hostedZoneId,
213
- zoneName: managed.zoneName
214
- });
215
- if (managed.certificateArn !== undefined) {
216
- certificate = Certificate.fromCertificateArn(ctx.scope, `${props.clusterName}ManagedCertificate`, managed.certificateArn);
217
- }
218
- }
219
- else {
220
- // Export-name fallback (bare-CDK synth): same account, same region only.
221
- hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
222
- hostedZoneId: Fn.importValue(managed.hostedZoneIdExport),
223
- zoneName: managed.zoneName
224
- });
225
- certificate = Certificate.fromCertificateArn(ctx.scope, `${props.clusterName}ManagedCertificate`, Fn.importValue(managed.certificateArnExport));
195
+ // D2 — the shared precedence and zone-id branch live in
196
+ // resolveEffectiveManagedDomain (explicit beats injected; binding literal
197
+ // beats exports Fn.importValue). Bare-CDK synth has no context entry.
198
+ const effectiveManaged = resolveEffectiveManagedDomain(ctx.scope.node, domainConfig?.managedDomain, domainName, `Cluster '${props.clusterName}'`);
199
+ const managed = effectiveManaged?.managed;
200
+ if (effectiveManaged !== undefined && managed !== undefined) {
201
+ zoneName = effectiveManaged.zoneName;
202
+ hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
203
+ hostedZoneId: effectiveManaged.hostedZoneId,
204
+ zoneName: effectiveManaged.zoneName
205
+ });
206
+ // The regional-certificate lane stays per-form: a binding may omit the
207
+ // ARN (no regional certificate declared), the exports form always
208
+ // imports it.
209
+ const certificateArn = isManagedDomainBinding(managed)
210
+ ? managed.certificateArn
211
+ : Fn.importValue(managed.certificateArnExport);
212
+ if (certificateArn !== undefined) {
213
+ certificate = Certificate.fromCertificateArn(ctx.scope, `${props.clusterName}ManagedCertificate`, certificateArn);
226
214
  }
227
215
  }
228
216
  else if (domainConfig?.hostedZone) {
@@ -328,30 +316,31 @@ export function addHostedZone(ctx, loadBalancer) {
328
316
  exportCertificateArn: false
329
317
  }).certificate;
330
318
  }
331
- const additionalListenerCertificates = resolveAdditionalListenerCertificates(ctx.scope, props.clusterName, domainConfig?.additionalCertificates, managed);
332
- // D5 certificate coverage (design 2026-08-18 cdn-app-origin): fold the SAN
333
- // set of every certificate the listener will carry, where knowable at
334
- // synth. The cluster-minted certificate's set is the literal minted above;
335
- // managed certificates are described by the CLI-injected coverage context
336
- // (absent opaque, a domain stack predating the hosts outputs); BYO
337
- // imports are opaque by nature. Fail-open to "partial"/"unknown", never to
338
- // a false "covered" — consumers (the Cdn origin lane) error only on
339
- // enumerated non-coverage and warn on opacity.
340
- //
341
- // Provenance gate: the coverage context describes the zone's CURRENT
342
- // certificates — the same D2 resolution that produces an injected binding.
343
- // An EXPLICIT `domainConfig.managedDomain` BINDING may pin an OLDER
344
- // certificateArn (or usEast1CertificateArn) the current coverage does not
345
- // describe, so folding it would over-claim and invert P3 (TLS fails at
346
- // runtime after E6 passes the hostname as covered). Explicit bindings
347
- // therefore stay opaque, exactly as if no coverage context existed. The
319
+ // D5 provenance gate (design 2026-08-18 cdn-app-origin): the coverage
320
+ // context describes the zone's CURRENT certificates the same D2
321
+ // resolution that produces an injected binding. An EXPLICIT pinned BINDING
322
+ // may name OLDER ARNs the current coverage does not describe, so folding
323
+ // it would over-claim and invert P3 (TLS fails at runtime after E6 passes
324
+ // the hostname as covered); it stays opaque, exactly as if no coverage
325
+ // context existed (see EffectiveManagedDomain.explicitPinnedBinding). The
348
326
  // explicit EXPORTS form is unaffected: Fn.importValue resolves at deploy
349
327
  // to the zone's current certificate — the one the coverage describes.
350
- const explicitPinnedBinding = domainConfig?.managedDomain !== undefined &&
351
- isManagedDomainBinding(domainConfig.managedDomain);
352
- const injectedCoverage = managed && !explicitPinnedBinding
353
- ? readInjectedManagedDomainCoverage(ctx.scope.node, managed.zoneName, `Cluster '${props.clusterName}'`)
328
+ const injectedCoverage = effectiveManaged !== undefined && !effectiveManaged.explicitPinnedBinding
329
+ ? readInjectedManagedDomainCoverage(ctx.scope.node, effectiveManaged.zoneName, `Cluster '${props.clusterName}'`)
354
330
  : undefined;
331
+ // One walk over the additional-certificate sources yields both the
332
+ // listener attachments and their coverage facts — the fold below never
333
+ // re-discriminates the source list.
334
+ const additional = resolveAdditionalListenerCertificates(ctx.scope, props.clusterName, domainConfig?.additionalCertificates, managed, injectedCoverage?.usEast1CertificateHosts);
335
+ const additionalListenerCertificates = additional.certificates;
336
+ // D5 certificate coverage: fold the SAN set of every certificate the
337
+ // listener will carry, where knowable at synth. The cluster-minted
338
+ // certificate's set is the literal minted above; managed certificates are
339
+ // described by the CLI-injected coverage context (absent → opaque, a
340
+ // domain stack predating the hosts outputs); BYO imports are opaque by
341
+ // nature. Fail-open to "partial"/"unknown", never to a false "covered" —
342
+ // consumers (the Cdn origin lane) error only on enumerated non-coverage
343
+ // and warn on opacity.
355
344
  const knownCertificateHosts = [];
356
345
  let anyOpaqueCertificate = false;
357
346
  if (domainConfig?.certificate) {
@@ -368,17 +357,8 @@ export function addHostedZone(ctx, loadBalancer) {
368
357
  else {
369
358
  knownCertificateHosts.push(domainName, ...subjectAlternativeNames);
370
359
  }
371
- for (const source of domainConfig?.additionalCertificates ?? []) {
372
- if ("certificateArn" in source) {
373
- anyOpaqueCertificate = true;
374
- }
375
- else if (injectedCoverage?.usEast1CertificateHosts !== undefined) {
376
- knownCertificateHosts.push(...injectedCoverage.usEast1CertificateHosts);
377
- }
378
- else {
379
- anyOpaqueCertificate = true;
380
- }
381
- }
360
+ knownCertificateHosts.push(...additional.knownHosts);
361
+ anyOpaqueCertificate = anyOpaqueCertificate || additional.anyOpaque;
382
362
  const dedupedCertificateHosts = [...new Set(knownCertificateHosts)];
383
363
  const certificateCoverage = anyOpaqueCertificate
384
364
  ? dedupedCertificateHosts.length > 0
@@ -503,13 +483,18 @@ const US_EAST_1 = "us-east-1";
503
483
  * anything else fails at synth rather than as CloudFormation's opaque
504
484
  * deploy-time rejection.
505
485
  */
506
- function resolveAdditionalListenerCertificates(scope, clusterName, sources, managed) {
507
- if (sources === undefined || sources.length === 0)
508
- return [];
486
+ function resolveAdditionalListenerCertificates(scope, clusterName, sources, managed, usEast1CertificateHosts) {
509
487
  const certificates = [];
488
+ const knownHosts = [];
489
+ let anyOpaque = false;
490
+ if (sources === undefined || sources.length === 0) {
491
+ return { certificates, knownHosts, anyOpaque };
492
+ }
510
493
  for (const source of sources) {
511
494
  if ("certificateArn" in source) {
495
+ // BYO import — opaque by nature.
512
496
  certificates.push(ListenerCertificate.fromArn(source.certificateArn));
497
+ anyOpaque = true;
513
498
  continue;
514
499
  }
515
500
  if (managed === undefined ||
@@ -543,8 +528,16 @@ function resolveAdditionalListenerCertificates(scope, clusterName, sources, mana
543
528
  "stack, or supply certificateArn for a same-region certificate.");
544
529
  }
545
530
  certificates.push(ListenerCertificate.fromArn(arn));
531
+ // The managed us-east-1 certificate's hosts, when the coverage context
532
+ // describes them (absent → a domain stack predating the hosts outputs).
533
+ if (usEast1CertificateHosts !== undefined) {
534
+ knownHosts.push(...usEast1CertificateHosts);
535
+ }
536
+ else {
537
+ anyOpaque = true;
538
+ }
546
539
  }
547
- return certificates;
540
+ return { certificates, knownHosts, anyOpaque };
548
541
  }
549
542
  export function addDirectAccessOutputs(ctx, autoScalingGroup) {
550
543
  if (!ctx.directAccessEnabled || !autoScalingGroup)
@@ -567,15 +560,17 @@ export function addDirectAccessOutputs(ctx, autoScalingGroup) {
567
560
  }
568
561
  export function registerServiceWithALB(ctx, listener, serviceName, serviceProps, service, primaryContainer, priorityState) {
569
562
  const containerPort = primaryContainer.containerPort;
570
- // Normalise routing to array
571
- const routingRules = Array.isArray(serviceProps.routing)
572
- ? serviceProps.routing
573
- : serviceProps.routing
574
- ? [serviceProps.routing]
575
- : [];
563
+ // The single home of the listener branching (P5, ingressProfile.ts): the
564
+ // ingress profile's rule model derives from this same plan, so what the
565
+ // profile reports is what this emitter builds.
566
+ const servicePlan = planListenerRules(ctx.props.services, []).services.find((plan) => plan.service === serviceProps);
567
+ if (servicePlan === undefined) {
568
+ throw new Error(`Service '${serviceName}' is not in the listener rule plan — ` +
569
+ "registerServiceWithALB was called for a service with no container " +
570
+ "ports, which can never receive listener traffic.");
571
+ }
572
+ const { routingRules } = servicePlan;
576
573
  const healthCheckPath = routingRules.find((r) => r.healthCheckPath)?.healthCheckPath ?? "/";
577
- const servicesWithPorts = ctx.props.services.filter((s) => s.containers.some((c) => c.port !== undefined));
578
- const isSingleService = servicesWithPorts.length === 1;
579
574
  const healthCheckConfig = isServiceEc2(serviceProps)
580
575
  ? {
581
576
  interval: Duration.seconds(30),
@@ -591,7 +586,7 @@ export function registerServiceWithALB(ctx, listener, serviceName, serviceProps,
591
586
  port: `${containerPort}`,
592
587
  timeout: Duration.seconds(10)
593
588
  };
594
- if (isSingleService && routingRules.length <= 1) {
589
+ if (servicePlan.mode === "default") {
595
590
  return listener.addTargets(`${serviceName}TargetGroup`, {
596
591
  targets: [
597
592
  service.loadBalancerTarget({
@@ -9,14 +9,15 @@
9
9
  * P5 (model the machinery, don't paraphrase it): every fact here is exported
10
10
  * from the SAME computation the emitting code uses — `default404` is the
11
11
  * predicate `addLoadBalancerListener` passes to the listener factory, the
12
- * rule structure mirrors `registerServiceWithALB`'s exact branching
13
- * (including the single-service conditions-dropped branch), and the covered
14
- * SAN set is the one `addHostedZone` mints. Never recompute these facts from
15
- * cluster props elsewhere; extend the profile instead.
12
+ * rule structure derives from the `planListenerRules` plan that
13
+ * `registerServiceWithALB` itself emits from (including the single-service
14
+ * conditions-dropped branch), and the covered SAN set is the one
15
+ * `addHostedZone` mints. Never recompute these facts from cluster props
16
+ * elsewhere; extend the plan or the profile instead.
16
17
  */
17
18
  import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
18
19
  import type { IHostedZone } from "aws-cdk-lib/aws-route53";
19
- import type { EcsServiceProps } from "./ecsTypes.js";
20
+ import type { EcsRoutingConfig, EcsServiceProps } from "./ecsTypes.js";
20
21
  /**
21
22
  * What the certificates ATTACHED to the cluster's listener are known to
22
23
  * cover at synth:
@@ -96,20 +97,48 @@ export interface EcsIngressZoneFacts {
96
97
  certificateCoverage: CertificateCoverage;
97
98
  }
98
99
  /**
99
- * The listener's default-action predicate the SINGLE home of the
100
- * computation `addLoadBalancerListener` passes to the listener factory as
101
- * `default404`. `true` when ≥2 routes will exist (the emitter then attaches
102
- * a fixed-404 default and conditions every rule) or when no service has a
103
- * port (CDK rejects a listener with neither a default action nor targets).
100
+ * One ports-bearing service's listener treatment. `mode: "default"` the
101
+ * sole ports-bearing service with ≤1 routing rule: its target group is
102
+ * added UNCONDITIONED and becomes the listener default action (even a
103
+ * declared `routing.host` does not gate it). `mode: "rules"` every
104
+ * routing rule becomes a conditioned, prioritised listener rule; the first
105
+ * carries the target group.
106
+ */
107
+ export interface ListenerServicePlan {
108
+ service: EcsServiceProps;
109
+ /** The service's `routing` config, normalised to an array. */
110
+ routingRules: EcsRoutingConfig[];
111
+ mode: "default" | "rules";
112
+ }
113
+ export interface ListenerRulePlan {
114
+ /** Ports-bearing services in declaration order (portless services never
115
+ * reach the listener). */
116
+ services: ListenerServicePlan[];
117
+ redirectHosts: string[];
118
+ }
119
+ /**
120
+ * The listener's rule structure, planned once (P5). This is the SINGLE home
121
+ * of the branching: `registerServiceWithALB` emits from a service's plan
122
+ * entry, and the ingress-profile facts below (`computeListenerDefault404`,
123
+ * `enumerateListenerRules`) derive from the same plan — the emitter and the
124
+ * model cannot drift because neither re-derives the decisions.
125
+ */
126
+ export declare function planListenerRules(services: EcsServiceProps[], redirectHosts: string[]): ListenerRulePlan;
127
+ /**
128
+ * The listener's default-action predicate — the value
129
+ * `addLoadBalancerListener` passes to the listener factory as `default404`.
130
+ * `true` exactly when no service plan supplies an unconditioned default
131
+ * target group: ≥2 routes will exist (the emitter then attaches a fixed-404
132
+ * default and conditions every rule), or no service has a port (CDK rejects
133
+ * a listener with neither a default action nor targets).
104
134
  */
105
135
  export declare function computeListenerDefault404(services: EcsServiceProps[]): boolean;
106
136
  /**
107
137
  * The listener's rule structure as `registerServiceWithALB` and
108
- * `addRedirectHostRules` will emit it. Mirrors the emitters' branching
109
- * exactly: a single ports-bearing service with ≤1 rule contributes NO
110
- * conditioned rule (its target group is the listener default — even a
111
- * declared `routing.host` does not gate it), and every redirect host
112
- * contributes a host-matched 301 rule.
138
+ * `addRedirectHostRules` will emit it derived from the same
139
+ * `planListenerRules` plan the emitters consume: a `mode: "default"`
140
+ * service contributes NO conditioned rule (its target group is the listener
141
+ * default), and every redirect host contributes a host-matched 301 rule.
113
142
  */
114
143
  export declare function enumerateListenerRules(services: EcsServiceProps[], redirectHosts: string[]): EcsIngressRule[];
115
144
  /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
@@ -9,10 +9,11 @@
9
9
  * P5 (model the machinery, don't paraphrase it): every fact here is exported
10
10
  * from the SAME computation the emitting code uses — `default404` is the
11
11
  * predicate `addLoadBalancerListener` passes to the listener factory, the
12
- * rule structure mirrors `registerServiceWithALB`'s exact branching
13
- * (including the single-service conditions-dropped branch), and the covered
14
- * SAN set is the one `addHostedZone` mints. Never recompute these facts from
15
- * cluster props elsewhere; extend the profile instead.
12
+ * rule structure derives from the `planListenerRules` plan that
13
+ * `registerServiceWithALB` itself emits from (including the single-service
14
+ * conditions-dropped branch), and the covered SAN set is the one
15
+ * `addHostedZone` mints. Never recompute these facts from cluster props
16
+ * elsewhere; extend the plan or the profile instead.
16
17
  */
17
18
  import { FjallLogger } from "../../../utils/validationLogger.js";
18
19
  function normaliseRoutingRules(routing) {
@@ -22,35 +23,54 @@ function servicesWithPorts(services) {
22
23
  return services.filter((s) => s.containers.some((c) => c.port !== undefined));
23
24
  }
24
25
  /**
25
- * The listener's default-action predicate the SINGLE home of the
26
- * computation `addLoadBalancerListener` passes to the listener factory as
27
- * `default404`. `true` when ≥2 routes will exist (the emitter then attaches
28
- * a fixed-404 default and conditions every rule) or when no service has a
29
- * port (CDK rejects a listener with neither a default action nor targets).
26
+ * The listener's rule structure, planned once (P5). This is the SINGLE home
27
+ * of the branching: `registerServiceWithALB` emits from a service's plan
28
+ * entry, and the ingress-profile facts below (`computeListenerDefault404`,
29
+ * `enumerateListenerRules`) derive from the same plan the emitter and the
30
+ * model cannot drift because neither re-derives the decisions.
30
31
  */
31
- export function computeListenerDefault404(services) {
32
+ export function planListenerRules(services, redirectHosts) {
32
33
  const withPorts = servicesWithPorts(services);
33
- const willHaveMultipleRoutes = withPorts.length > 1 ||
34
- withPorts.some((s) => normaliseRoutingRules(s.routing).length > 1);
35
- return willHaveMultipleRoutes || withPorts.length === 0;
34
+ const isSingleService = withPorts.length === 1;
35
+ return {
36
+ services: withPorts.map((service) => {
37
+ const routingRules = normaliseRoutingRules(service.routing);
38
+ return {
39
+ service,
40
+ routingRules,
41
+ mode: isSingleService && routingRules.length <= 1
42
+ ? "default"
43
+ : "rules"
44
+ };
45
+ }),
46
+ redirectHosts
47
+ };
48
+ }
49
+ /**
50
+ * The listener's default-action predicate — the value
51
+ * `addLoadBalancerListener` passes to the listener factory as `default404`.
52
+ * `true` exactly when no service plan supplies an unconditioned default
53
+ * target group: ≥2 routes will exist (the emitter then attaches a fixed-404
54
+ * default and conditions every rule), or no service has a port (CDK rejects
55
+ * a listener with neither a default action nor targets).
56
+ */
57
+ export function computeListenerDefault404(services) {
58
+ return !planListenerRules(services, []).services.some((plan) => plan.mode === "default");
36
59
  }
37
60
  /**
38
61
  * The listener's rule structure as `registerServiceWithALB` and
39
- * `addRedirectHostRules` will emit it. Mirrors the emitters' branching
40
- * exactly: a single ports-bearing service with ≤1 rule contributes NO
41
- * conditioned rule (its target group is the listener default — even a
42
- * declared `routing.host` does not gate it), and every redirect host
43
- * contributes a host-matched 301 rule.
62
+ * `addRedirectHostRules` will emit it derived from the same
63
+ * `planListenerRules` plan the emitters consume: a `mode: "default"`
64
+ * service contributes NO conditioned rule (its target group is the listener
65
+ * default), and every redirect host contributes a host-matched 301 rule.
44
66
  */
45
67
  export function enumerateListenerRules(services, redirectHosts) {
46
- const withPorts = servicesWithPorts(services);
47
- const isSingleService = withPorts.length === 1;
68
+ const plan = planListenerRules(services, redirectHosts);
48
69
  const rules = [];
49
- for (const service of withPorts) {
50
- const routingRules = normaliseRoutingRules(service.routing);
51
- if (isSingleService && routingRules.length <= 1)
70
+ for (const servicePlan of plan.services) {
71
+ if (servicePlan.mode === "default")
52
72
  continue;
53
- for (const rule of routingRules) {
73
+ for (const rule of servicePlan.routingRules) {
54
74
  if (rule.host === undefined && rule.path === undefined)
55
75
  continue;
56
76
  rules.push({
@@ -60,7 +80,7 @@ export function enumerateListenerRules(services, redirectHosts) {
60
80
  });
61
81
  }
62
82
  }
63
- for (const host of redirectHosts) {
83
+ for (const host of plan.redirectHosts) {
64
84
  rules.push({ host, kind: "redirect" });
65
85
  }
66
86
  return rules;
@@ -1,8 +1,16 @@
1
1
  import { Construct } from "constructs";
2
- import { ARecord as CdkARecord, type GeoLocation, type IAliasRecordTarget } from "aws-cdk-lib/aws-route53";
2
+ import { ARecord as CdkARecord, AaaaRecord as CdkAaaaRecord, type GeoLocation, type IAliasRecordTarget } from "aws-cdk-lib/aws-route53";
3
3
  import { type DnsRecordCommonProps } from "./dnsRecordBase.js";
4
4
  export interface AliasRecordProps extends DnsRecordCommonProps {
5
5
  readonly target: IAliasRecordTarget;
6
+ /**
7
+ * Record-set type the alias deploys as: `"A"` (IPv4, the default — every
8
+ * pattern-internal alias predates this knob and is a deployed A record)
9
+ * or `"AAAA"` (IPv6). A declared AAAA alias MUST deploy as AAAA — mapping
10
+ * it to A silently answers IPv4 for an IPv6 declaration and collides with
11
+ * a legal dual-stack sibling in the DNS claim registry.
12
+ */
13
+ readonly recordType?: "A" | "AAAA";
6
14
  /**
7
15
  * Latency routing region (CDK `RecordSetOptions.region`). Route53 requires
8
16
  * a `setIdentifier` alongside it — callers derive one when unset.
@@ -27,7 +35,7 @@ export interface AliasRecordProps extends DnsRecordCommonProps {
27
35
  readonly omitComment?: boolean;
28
36
  }
29
37
  export declare class AliasRecord extends Construct {
30
- readonly record: CdkARecord;
38
+ readonly record: CdkARecord | CdkAaaaRecord;
31
39
  readonly description: string;
32
40
  readonly fqdn: string;
33
41
  constructor(scope: Construct, id: string, props: AliasRecordProps);
@@ -1,5 +1,5 @@
1
1
  import { Construct } from "constructs";
2
- import { ARecord as CdkARecord, RecordTarget } from "aws-cdk-lib/aws-route53";
2
+ import { ARecord as CdkARecord, AaaaRecord as CdkAaaaRecord, RecordTarget } from "aws-cdk-lib/aws-route53";
3
3
  import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn } from "./dnsRecordBase.js";
4
4
  export class AliasRecord extends Construct {
5
5
  record;
@@ -7,14 +7,17 @@ export class AliasRecord extends Construct {
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
+ const recordType = props.recordType ?? "A";
10
11
  this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
12
  this.description =
12
13
  props.description ?? defaultDnsComment("alias", this.fqdn);
13
- // An alias record IS an A record set in Route53 — it claims type "A" so
14
- // an alias and a plain A record on the same name collide at synth. The
15
- // routing variant rides along: policy siblings with distinct
16
- // setIdentifiers (e.g. a compute apex alias and a CDN apex alias during
17
- // an ingress migration) are legal and register cleanly.
14
+ // An alias record IS an A (or AAAA) record set in Route53 — it claims
15
+ // its deployed type so an alias and a plain record of that type on the
16
+ // same name collide at synth, while dual-stack A + AAAA siblings
17
+ // register cleanly. The routing variant rides along: policy siblings
18
+ // with distinct setIdentifiers (e.g. a compute apex alias and a CDN
19
+ // apex alias during an ingress migration) are legal and register
20
+ // cleanly.
18
21
  const routingPolicy = props.region !== undefined
19
22
  ? "latency"
20
23
  : props.weight !== undefined
@@ -34,7 +37,7 @@ export class AliasRecord extends Construct {
34
37
  .filter((part) => part !== undefined)
35
38
  .join("/") || "*"
36
39
  : undefined;
37
- claimDnsRecord(this, props, "A", this.fqdn, {
40
+ claimDnsRecord(this, props, recordType, this.fqdn, {
38
41
  setIdentifier: props.setIdentifier,
39
42
  routingPolicy,
40
43
  region: props.region,
@@ -42,7 +45,9 @@ export class AliasRecord extends Construct {
42
45
  });
43
46
  // Route53 ignores TTL on ALIAS records — the target dictates caching behaviour,
44
47
  // so we deliberately do not forward props.ttl to the underlying CDK resource.
45
- this.record = new CdkARecord(this, "Record", {
48
+ // Both branches share the "Record" child id: an existing A alias's
49
+ // logical ID is byte-frozen, and a declared type is the ONLY delta.
50
+ const recordProps = {
46
51
  zone: props.zone,
47
52
  recordName: this.fqdn,
48
53
  target: RecordTarget.fromAlias(props.target),
@@ -55,7 +60,11 @@ export class AliasRecord extends Construct {
55
60
  ...(props.setIdentifier !== undefined && {
56
61
  setIdentifier: props.setIdentifier
57
62
  })
58
- });
63
+ };
64
+ this.record =
65
+ recordType === "AAAA"
66
+ ? new CdkAaaaRecord(this, "Record", recordProps)
67
+ : new CdkARecord(this, "Record", recordProps);
59
68
  applyDnsRecordTags(this, props);
60
69
  }
61
70
  }
@@ -46,11 +46,9 @@ export interface DnsRecordClaim {
46
46
  */
47
47
  readonly recordType: string;
48
48
  /**
49
- * Routing-variant discriminator. A claim is a policy variant when it
50
- * carries a `routingPolicy` OR a `setIdentifier` (CDK auto-generates a
51
- * SetIdentifier for a policy record declared without one, so policy
52
- * presence alone makes a legal variant); a claim with neither is a simple
53
- * record that tolerates no siblings.
49
+ * Routing-variant discriminator among sibling variants. Variant-ness
50
+ * itself is decided by `routingPolicy` alone (see {@link isVariantEntry});
51
+ * a claim without one is a simple record that tolerates no siblings.
54
52
  */
55
53
  readonly setIdentifier?: string;
56
54
  /** Policy type of a variant claim — siblings must all match. */
@@ -1,15 +1,16 @@
1
1
  import { Token } from "aws-cdk-lib";
2
2
  /**
3
- * A claim is a policy variant when it declares a routing policy OR carries a
4
- * setIdentifier: CDK's RecordSet auto-generates a SetIdentifier whenever a
5
- * policy (region/weight/geoLocation) is set without one, so a policy claim
6
- * with no explicit setIdentifier still deploys as a legal sibling variant —
7
- * classifying it as simple would refuse shapes Route53 holds happily.
8
- * (A setIdentifier WITHOUT a policy cannot deploy CDK rejects it at synth —
9
- * so its classification here never decides a real outcome.)
3
+ * A claim is a policy variant exactly when it declares a routing policy:
4
+ * CDK's RecordSet auto-generates a SetIdentifier whenever a policy
5
+ * (region/weight/geoLocation) is set without one, so a policy claim with no
6
+ * explicit setIdentifier still deploys as a legal sibling variant —
7
+ * classifying it as simple would refuse shapes Route53 holds happily. A
8
+ * setIdentifier WITHOUT a policy cannot deploy at all (CDK rejects it at
9
+ * synth), so it classifies as simple and the simple-vs-variant refusal —
10
+ * whose cure names the missing routing policy — fires first.
10
11
  */
11
12
  function isVariantEntry(entry) {
12
- return entry.routingPolicy !== undefined || entry.setIdentifier !== undefined;
13
+ return entry.routingPolicy !== undefined;
13
14
  }
14
15
  /** Claims per App root: claim key → claimant entries (variants share a key). */
15
16
  let claimsByRoot = new WeakMap();
@@ -87,12 +88,8 @@ export function registerDnsRecordClaim(scope, claim) {
87
88
  other.setIdentifier === entry.setIdentifier) {
88
89
  throw collisionError(claim, recordType, other, claimantPath, `Both records carry setIdentifier '${String(entry.setIdentifier)}' — sibling routing-policy variants need distinct setIdentifiers.`);
89
90
  }
90
- // Policy types compare only when both are declared: a setIdentifier-only
91
- // claim carries no policy for Route53 to mismatch (and cannot deploy
92
- // CDK rejects setIdentifier on simple records at synth).
93
- if (other.routingPolicy !== undefined &&
94
- entry.routingPolicy !== undefined &&
95
- other.routingPolicy !== entry.routingPolicy) {
91
+ // Both sides passed isVariantEntry, so both declare a policy.
92
+ if (other.routingPolicy !== entry.routingPolicy) {
96
93
  throw collisionError(claim, recordType, other, claimantPath, `Sibling routing-policy variants must share one policy type (got '${String(other.routingPolicy)}' vs '${String(entry.routingPolicy)}') — Route53 rejects mixed-policy siblings.`);
97
94
  }
98
95
  if (entry.routingPolicy === "latency" &&
@@ -8,6 +8,17 @@ export { DNS_APEX, getDomainExportNames, isManagedDomainBinding, type ManagedDom
8
8
  * § Infrastructure Layer Boundaries).
9
9
  */
10
10
  export declare function isWithinZone(candidate: string, zoneName: string): boolean;
11
+ /**
12
+ * Relative record label for `domain` within `zoneName` — the inverse of
13
+ * `resolveRecordFqdn` for names already known to sit inside the zone: the
14
+ * zone apex maps to the canonical apex label, sub-names drop the zone
15
+ * suffix. A name outside the zone passes through unchanged (callers
16
+ * validate zone membership with `isWithinZone` first). Single home shared
17
+ * by the patterns-layer CDN origin lane and the resources-layer ECS
18
+ * networking (lowest common layer per generator-standards § Infrastructure
19
+ * Layer Boundaries).
20
+ */
21
+ export declare function recordLabelWithin(domain: string, zoneName: string): string;
11
22
  /**
12
23
  * Canonical BIND-semantics resolver for DNS record names — the single
13
24
  * authority shared by the patterns-layer domain validation and the
@@ -14,6 +14,23 @@ export function isWithinZone(candidate, zoneName) {
14
14
  }
15
15
  return candidate.endsWith(`.${zoneName}`);
16
16
  }
17
+ /**
18
+ * Relative record label for `domain` within `zoneName` — the inverse of
19
+ * `resolveRecordFqdn` for names already known to sit inside the zone: the
20
+ * zone apex maps to the canonical apex label, sub-names drop the zone
21
+ * suffix. A name outside the zone passes through unchanged (callers
22
+ * validate zone membership with `isWithinZone` first). Single home shared
23
+ * by the patterns-layer CDN origin lane and the resources-layer ECS
24
+ * networking (lowest common layer per generator-standards § Infrastructure
25
+ * Layer Boundaries).
26
+ */
27
+ export function recordLabelWithin(domain, zoneName) {
28
+ if (domain === zoneName) {
29
+ return DNS_APEX;
30
+ }
31
+ const suffix = `.${zoneName}`;
32
+ return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
33
+ }
17
34
  /**
18
35
  * Canonical BIND-semantics resolver for DNS record names — the single
19
36
  * authority shared by the patterns-layer domain validation and the