@fjall/components-infrastructure 14.1.0 → 14.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/lib/patterns/aws/apexDomainPattern.js +1 -1
  2. package/dist/lib/patterns/aws/cdn.d.ts +83 -4
  3. package/dist/lib/patterns/aws/cdn.js +279 -69
  4. package/dist/lib/patterns/aws/cdnAppOrigin.d.ts +83 -0
  5. package/dist/lib/patterns/aws/cdnAppOrigin.js +252 -0
  6. package/dist/lib/patterns/aws/computeEcs.d.ts +9 -1
  7. package/dist/lib/patterns/aws/computeEcs.js +10 -0
  8. package/dist/lib/patterns/aws/delegatedDomainPattern.js +1 -1
  9. package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +13 -4
  10. package/dist/lib/patterns/aws/dnsRecordComposer.js +88 -6
  11. package/dist/lib/patterns/aws/domainCertificateComposer.js +66 -1
  12. package/dist/lib/patterns/aws/interfaces/compute.d.ts +7 -0
  13. package/dist/lib/patterns/aws/interfaces/domain.d.ts +32 -0
  14. package/dist/lib/patterns/aws/patternDomain.d.ts +9 -0
  15. package/dist/lib/patterns/aws/patternDomain.js +60 -27
  16. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +37 -0
  17. package/dist/lib/resources/aws/cdn/cloudFront.js +96 -4
  18. package/dist/lib/resources/aws/compute/ecs.d.ts +11 -0
  19. package/dist/lib/resources/aws/compute/ecs.js +27 -0
  20. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +2 -0
  21. package/dist/lib/resources/aws/compute/ecsNetworking.js +117 -61
  22. package/dist/lib/resources/aws/compute/ingressProfile.d.ts +203 -0
  23. package/dist/lib/resources/aws/compute/ingressProfile.js +215 -0
  24. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +10 -2
  25. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +18 -9
  26. package/dist/lib/utils/dnsRecordRegistry.d.ts +3 -5
  27. package/dist/lib/utils/dnsRecordRegistry.js +11 -14
  28. package/dist/lib/utils/domainTypes.d.ts +11 -0
  29. package/dist/lib/utils/domainTypes.js +17 -0
  30. package/dist/lib/utils/managedDomainContext.d.ts +58 -1
  31. package/dist/lib/utils/managedDomainContext.js +96 -0
  32. package/package.json +3 -3
@@ -8,8 +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 } from "../../../utils/managedDomainContext.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";
13
14
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
14
15
  import { stackScopedExportName } from "../../../utils/exportNaming.js";
15
16
  import { registerAlbAliasTarget } from "../../../utils/albAliasTargetRegistry.js";
@@ -99,24 +100,15 @@ export function addLoadBalancer(ctx, anyServiceUsesEc2, asgSecurityGroup) {
99
100
  }
100
101
  export function addLoadBalancerListener(ctx, loadBalancer, certificate, additionalCertificates) {
101
102
  const port = certificate ? 443 : 80;
102
- const servicesWithPorts = ctx.props.services.filter((s) => s.containers.some((c) => c.port !== undefined));
103
- const willHaveMultipleRoutes = servicesWithPorts.length > 1 ||
104
- servicesWithPorts.some((s) => {
105
- const rules = Array.isArray(s.routing)
106
- ? s.routing
107
- : s.routing
108
- ? [s.routing]
109
- : [];
110
- return rules.length > 1;
111
- });
112
- // CDK rejects listeners with neither a default action nor target groups.
113
- const noServicePorts = servicesWithPorts.length === 0;
114
103
  return addRoutingListener(loadBalancer, `${ctx.props.clusterName}Listener`, {
115
104
  port,
116
105
  ...(certificate && { certificate }),
117
106
  ...(additionalCertificates !== undefined &&
118
107
  additionalCertificates.length > 0 && { additionalCertificates }),
119
- default404: willHaveMultipleRoutes || noServicePorts
108
+ // Single home for the default-action predicate (ingressProfile.ts): the
109
+ // ingress profile exports the same value, so downstream verdicts model
110
+ // the listener the emitter actually built (design cdn-app-origin, P5).
111
+ default404: computeListenerDefault404(ctx.props.services)
120
112
  });
121
113
  }
122
114
  /**
@@ -190,12 +182,6 @@ function preWrapperLogicalId(scope, id) {
190
182
  .slice(0, 240);
191
183
  return human + hash;
192
184
  }
193
- /** Record label for `fqdn` relative to `zoneName` (apex → `@`). */
194
- function recordLabelWithin(fqdn, zoneName) {
195
- return fqdn === zoneName
196
- ? DNS_APEX
197
- : fqdn.slice(0, fqdn.length - zoneName.length - 1);
198
- }
199
185
  export function addHostedZone(ctx, loadBalancer) {
200
186
  const props = ctx.props;
201
187
  const domainConfig = props.cluster?.domainConfig;
@@ -206,31 +192,25 @@ export function addHostedZone(ctx, loadBalancer) {
206
192
  let hostedZone;
207
193
  let zoneName;
208
194
  let certificate;
209
- // D2 — explicit props.managedDomain wins over the CLI-injected context
210
- // binding (explicit beats injected); the context read sits ahead of the
211
- // BYO domainConfig.hostedZone chain. Bare-CDK synth has no context entry.
212
- const managed = domainConfig?.managedDomain ??
213
- readInjectedManagedDomainBinding(ctx.scope.node, domainName, `Cluster '${props.clusterName}'`);
214
- if (managed) {
215
- zoneName = managed.zoneName;
216
- if (isManagedDomainBinding(managed)) {
217
- // D2 binding: concrete values injected by the CLI at deploy time —
218
- // literals cross accounts and regions where Fn.importValue cannot.
219
- hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
220
- hostedZoneId: managed.hostedZoneId,
221
- zoneName: managed.zoneName
222
- });
223
- if (managed.certificateArn !== undefined) {
224
- certificate = Certificate.fromCertificateArn(ctx.scope, `${props.clusterName}ManagedCertificate`, managed.certificateArn);
225
- }
226
- }
227
- else {
228
- // Export-name fallback (bare-CDK synth): same account, same region only.
229
- hostedZone = AWSHostedZone.fromHostedZoneAttributes(ctx.scope, `${props.clusterName}ManagedHostedZone`, {
230
- hostedZoneId: Fn.importValue(managed.hostedZoneIdExport),
231
- zoneName: managed.zoneName
232
- });
233
- 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);
234
214
  }
235
215
  }
236
216
  else if (domainConfig?.hostedZone) {
@@ -302,6 +282,10 @@ export function addHostedZone(ctx, loadBalancer) {
302
282
  }
303
283
  }
304
284
  const subjectAlternativeNames = [...routedHosts, ...redirectHosts].filter((h) => h !== domainName);
285
+ // Captured BEFORE the BYO override below: whether the managed lane
286
+ // attached the binding's (or export fallback's) certificate — the D5
287
+ // coverage context describes exactly that certificate, never a BYO one.
288
+ const managedCertificateAttached = certificate !== undefined;
305
289
  if (domainConfig?.certificate) {
306
290
  certificate = domainConfig.certificate;
307
291
  }
@@ -332,7 +316,55 @@ export function addHostedZone(ctx, loadBalancer) {
332
316
  exportCertificateArn: false
333
317
  }).certificate;
334
318
  }
335
- const additionalListenerCertificates = resolveAdditionalListenerCertificates(ctx.scope, props.clusterName, domainConfig?.additionalCertificates, managed);
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
326
+ // explicit EXPORTS form is unaffected: Fn.importValue resolves at deploy
327
+ // to the zone's current certificate — the one the coverage describes.
328
+ const injectedCoverage = effectiveManaged !== undefined && !effectiveManaged.explicitPinnedBinding
329
+ ? readInjectedManagedDomainCoverage(ctx.scope.node, effectiveManaged.zoneName, `Cluster '${props.clusterName}'`)
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.
344
+ const knownCertificateHosts = [];
345
+ let anyOpaqueCertificate = false;
346
+ if (domainConfig?.certificate) {
347
+ anyOpaqueCertificate = true;
348
+ }
349
+ else if (managedCertificateAttached) {
350
+ if (injectedCoverage?.certificateHosts !== undefined) {
351
+ knownCertificateHosts.push(...injectedCoverage.certificateHosts);
352
+ }
353
+ else {
354
+ anyOpaqueCertificate = true;
355
+ }
356
+ }
357
+ else {
358
+ knownCertificateHosts.push(domainName, ...subjectAlternativeNames);
359
+ }
360
+ knownCertificateHosts.push(...additional.knownHosts);
361
+ anyOpaqueCertificate = anyOpaqueCertificate || additional.anyOpaque;
362
+ const dedupedCertificateHosts = [...new Set(knownCertificateHosts)];
363
+ const certificateCoverage = anyOpaqueCertificate
364
+ ? dedupedCertificateHosts.length > 0
365
+ ? { kind: "partial", hostnames: dedupedCertificateHosts }
366
+ : { kind: "unknown" }
367
+ : { kind: "enumerated", hostnames: dedupedCertificateHosts };
336
368
  let aRecord;
337
369
  if (loadBalancer) {
338
370
  const routingPolicy = domainConfig?.routingPolicy;
@@ -430,7 +462,16 @@ export function addHostedZone(ctx, loadBalancer) {
430
462
  }),
431
463
  ...(redirectHosts.length > 0 && {
432
464
  redirectRules: { hostFqdns: redirectHosts, targetHost: domainName }
433
- })
465
+ }),
466
+ ingress: {
467
+ hostedZone,
468
+ zoneName,
469
+ domainName,
470
+ routedHosts,
471
+ redirectHosts,
472
+ apexRecordMinted: loadBalancer !== undefined && domainConfig?.apexRecord !== "none",
473
+ certificateCoverage
474
+ }
434
475
  };
435
476
  }
436
477
  const US_EAST_1 = "us-east-1";
@@ -442,13 +483,18 @@ const US_EAST_1 = "us-east-1";
442
483
  * anything else fails at synth rather than as CloudFormation's opaque
443
484
  * deploy-time rejection.
444
485
  */
445
- function resolveAdditionalListenerCertificates(scope, clusterName, sources, managed) {
446
- if (sources === undefined || sources.length === 0)
447
- return [];
486
+ function resolveAdditionalListenerCertificates(scope, clusterName, sources, managed, usEast1CertificateHosts) {
448
487
  const certificates = [];
488
+ const knownHosts = [];
489
+ let anyOpaque = false;
490
+ if (sources === undefined || sources.length === 0) {
491
+ return { certificates, knownHosts, anyOpaque };
492
+ }
449
493
  for (const source of sources) {
450
494
  if ("certificateArn" in source) {
495
+ // BYO import — opaque by nature.
451
496
  certificates.push(ListenerCertificate.fromArn(source.certificateArn));
497
+ anyOpaque = true;
452
498
  continue;
453
499
  }
454
500
  if (managed === undefined ||
@@ -482,8 +528,16 @@ function resolveAdditionalListenerCertificates(scope, clusterName, sources, mana
482
528
  "stack, or supply certificateArn for a same-region certificate.");
483
529
  }
484
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
+ }
485
539
  }
486
- return certificates;
540
+ return { certificates, knownHosts, anyOpaque };
487
541
  }
488
542
  export function addDirectAccessOutputs(ctx, autoScalingGroup) {
489
543
  if (!ctx.directAccessEnabled || !autoScalingGroup)
@@ -506,15 +560,17 @@ export function addDirectAccessOutputs(ctx, autoScalingGroup) {
506
560
  }
507
561
  export function registerServiceWithALB(ctx, listener, serviceName, serviceProps, service, primaryContainer, priorityState) {
508
562
  const containerPort = primaryContainer.containerPort;
509
- // Normalise routing to array
510
- const routingRules = Array.isArray(serviceProps.routing)
511
- ? serviceProps.routing
512
- : serviceProps.routing
513
- ? [serviceProps.routing]
514
- : [];
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;
515
573
  const healthCheckPath = routingRules.find((r) => r.healthCheckPath)?.healthCheckPath ?? "/";
516
- const servicesWithPorts = ctx.props.services.filter((s) => s.containers.some((c) => c.port !== undefined));
517
- const isSingleService = servicesWithPorts.length === 1;
518
574
  const healthCheckConfig = isServiceEc2(serviceProps)
519
575
  ? {
520
576
  interval: Duration.seconds(30),
@@ -530,7 +586,7 @@ export function registerServiceWithALB(ctx, listener, serviceName, serviceProps,
530
586
  port: `${containerPort}`,
531
587
  timeout: Duration.seconds(10)
532
588
  };
533
- if (isSingleService && routingRules.length <= 1) {
589
+ if (servicePlan.mode === "default") {
534
590
  return listener.addTargets(`${serviceName}TargetGroup`, {
535
591
  targets: [
536
592
  service.loadBalancerTarget({
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Synth-time ingress profile for an ECS cluster's ALB (design 2026-08-18
3
+ * cdn-app-origin, D2). The profile is the one sanctioned answer to "what
4
+ * does this cluster's listener actually do?" for downstream constructs —
5
+ * today the `Cdn` construct-reference origin lane, which needs a TLS-valid
6
+ * origin hostname, certificate coverage, and a forwarding verdict without
7
+ * re-deriving any of it from props.
8
+ *
9
+ * P5 (model the machinery, don't paraphrase it): every fact here is exported
10
+ * from the SAME computation the emitting code uses — `default404` is the
11
+ * predicate `addLoadBalancerListener` passes to the listener factory, the
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.
17
+ */
18
+ import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
19
+ import type { IHostedZone } from "aws-cdk-lib/aws-route53";
20
+ import type { EcsRoutingConfig, EcsServiceProps } from "./ecsTypes.js";
21
+ /**
22
+ * What the certificates ATTACHED to the cluster's listener are known to
23
+ * cover at synth:
24
+ *
25
+ * - `enumerated` — every attached certificate's host set is synth-known
26
+ * (cluster-minted certs, or managed certs described by the D5 coverage
27
+ * context). `hostnames` is complete: a non-matching name is provably
28
+ * uncovered.
29
+ * - `partial` — `hostnames` collects the known certs' hosts, but at least
30
+ * one attached certificate is opaque (imported ARN with no coverage
31
+ * context), so a non-matching name may still be covered.
32
+ * - `unknown` — only opaque certificates are attached.
33
+ */
34
+ export type CertificateCoverage = {
35
+ kind: "enumerated";
36
+ hostnames: string[];
37
+ } | {
38
+ kind: "partial";
39
+ hostnames: string[];
40
+ } | {
41
+ kind: "unknown";
42
+ };
43
+ export type CoverageAnswer = "covered" | "unknown" | "not-covered";
44
+ /**
45
+ * One listener rule as it will actually be emitted. `host` and `path` are
46
+ * ANDed when both present (`buildRoutingConditions`). Rules the emitter
47
+ * DROPS — a single ports-bearing service with ≤1 routing rule loses its
48
+ * conditions and becomes the listener default (`registerServiceWithALB`) —
49
+ * are deliberately absent here, exactly as they are absent from the ALB.
50
+ */
51
+ export interface EcsIngressRule {
52
+ host?: string;
53
+ path?: string;
54
+ kind: "forward" | "redirect";
55
+ }
56
+ export interface EcsIngressProfile {
57
+ loadBalancer: IApplicationLoadBalancer;
58
+ /** `cluster.loadBalancer === "internal"` — unreachable as a CloudFront origin. */
59
+ internal: boolean;
60
+ /** 443 iff a certificate resolved — the cluster ALB has exactly one listener. */
61
+ listenerPort: 443 | 80;
62
+ /**
63
+ * The cluster's resolved zone — the SAME `IHostedZone` instance the
64
+ * cluster's own records claim with, so a downstream record (the Cdn's
65
+ * origin record) claims under the identical zone identity. The DNS claim
66
+ * registry keys on the literal hosted-zone ID; a name-derived re-import
67
+ * would register under a different key and silently defeat same-name
68
+ * collision detection.
69
+ */
70
+ hostedZone?: IHostedZone;
71
+ zoneName?: string;
72
+ domainName?: string;
73
+ /** Every `services[].routing[].host`, deduped, in declaration order. */
74
+ routedHosts: string[];
75
+ redirectHosts: string[];
76
+ /**
77
+ * The listener's actual default-action predicate: `true` → fixed-404
78
+ * default; `false` → the sole target group IS the default action and the
79
+ * listener forwards EVERY hostname.
80
+ */
81
+ default404: boolean;
82
+ rules: EcsIngressRule[];
83
+ certificateCoverage: CertificateCoverage;
84
+ }
85
+ /**
86
+ * Zone-derived profile ingredients assembled by `addHostedZone`, where the
87
+ * zone, hosts, and certificate coverage are already computed for emission.
88
+ */
89
+ export interface EcsIngressZoneFacts {
90
+ hostedZone: IHostedZone;
91
+ zoneName: string;
92
+ domainName: string;
93
+ routedHosts: string[];
94
+ redirectHosts: string[];
95
+ /** False when `apexRecord: "none"` yielded the domainName record. */
96
+ apexRecordMinted: boolean;
97
+ certificateCoverage: CertificateCoverage;
98
+ }
99
+ /**
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).
134
+ */
135
+ export declare function computeListenerDefault404(services: EcsServiceProps[]): boolean;
136
+ /**
137
+ * The listener's rule structure as `registerServiceWithALB` and
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.
142
+ */
143
+ export declare function enumerateListenerRules(services: EcsServiceProps[], redirectHosts: string[]): EcsIngressRule[];
144
+ /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
145
+ export declare function normaliseDnsName(name: string): string;
146
+ /**
147
+ * RFC 6125 certificate-name match: exact, or a single-label `*.` wildcard
148
+ * (matches exactly one additional label — `*.example.com` covers
149
+ * `a.example.com`, never `example.com` or `a.b.example.com`). DNS names
150
+ * compare ASCII case-insensitively (RFC 6125 §6.4.1 via RFC 4343).
151
+ */
152
+ export declare function certNameMatches(pattern: string, hostname: string): boolean;
153
+ /**
154
+ * Does the listener's attached-certificate set cover `hostname`?
155
+ * Fail closed where decidable, honest where opaque (P3): only `enumerated`
156
+ * coverage may answer `not-covered`; a miss under `partial`/`unknown` is
157
+ * `unknown` because an opaque certificate may still cover the name.
158
+ */
159
+ export declare function certificateCovers(coverage: CertificateCoverage, hostname: string): CoverageAnswer;
160
+ export type ForwardingVerdict = {
161
+ verdict: "forwards";
162
+ } | {
163
+ verdict: "partial";
164
+ patterns: string[];
165
+ } | {
166
+ verdict: "none";
167
+ };
168
+ /**
169
+ * Would the listener forward `Host: <hostname>` requests, for all paths?
170
+ * Computed on the real rule model, never a flattened hostname list:
171
+ *
172
+ * - no fixed-404 default → the default action forwards every hostname;
173
+ * - a host-only rule for the hostname, a host-matched catch-all `/*` path
174
+ * rule, or a host-less catch-all `/*` path rule, forwards all of its
175
+ * paths;
176
+ * - rules that involve the hostname only together with a narrower path
177
+ * condition (ANDed host+path, or host-less non-catch-all paths) forward
178
+ * SOME paths — `partial`, with the patterns named so the caller can
179
+ * surface them;
180
+ * - otherwise every request answers the fixed-404 default — `none`.
181
+ *
182
+ * Hostnames compare case-insensitively (DNS, RFC 4343) — the rule model
183
+ * carries the declared spelling, the caller's hostname may differ in case.
184
+ */
185
+ export declare function forwardingVerdict(profile: EcsIngressProfile, hostname: string): ForwardingVerdict;
186
+ export declare function buildIngressProfile(options: {
187
+ loadBalancer: IApplicationLoadBalancer;
188
+ internal: boolean;
189
+ services: EcsServiceProps[];
190
+ certificateAttached: boolean;
191
+ zoneFacts?: EcsIngressZoneFacts;
192
+ }): EcsIngressProfile;
193
+ /**
194
+ * W3 (design 2026-08-18 cdn-app-origin): a cluster whose listener default is
195
+ * fixed-404 while its `domainName` has a minted apex record and NO rule
196
+ * forwards it answers 404 on its own primary domain — usually the aftermath
197
+ * of adding a second routing rule (which flips the default action from
198
+ * forward to 404, `computeListenerDefault404`). A warning, not an error:
199
+ * pre-existing clusters can already be in this state, and narrowing them is
200
+ * not this check's mandate. Partial forwarding (path-split services) is the
201
+ * normal multi-route shape and does not warn.
202
+ */
203
+ export declare function warnWhenRecordedApexUnforwarded(profile: EcsIngressProfile, apexRecordMinted: boolean, clusterName: string): void;