@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
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ECS construct-reference origin resolution for the `Cdn` pattern (design
3
+ * 2026-08-18 cdn-app-origin, D3). Given a fronted ECS compute, resolve a
4
+ * TLS-valid ORIGIN HOSTNAME from the compute's own ingress profile, decide
5
+ * who owns its DNS record (P1: the construct that declares a hostname owns
6
+ * its record — a `routing[].host` hostname is the compute's; a derived or
7
+ * free hostname is the Cdn's), and validate certificate coverage and
8
+ * listener forwarding at synth — fail closed where decidable, warn where
9
+ * opaque or partial (P3).
10
+ *
11
+ * The resolver is memoized per compute within one `Cdn`: the default origin
12
+ * and every behaviour referencing the same compute share one resolution and
13
+ * at most one record.
14
+ */
15
+ import type { IHostedZone } from "aws-cdk-lib/aws-route53";
16
+ import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
17
+ import type { CdnOriginConfig } from "../../resources/aws/cdn/index.js";
18
+ import type { IEcsCompute } from "./interfaces/compute.js";
19
+ import { normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
20
+ export { normaliseDnsName };
21
+ /** A Cdn-owned origin alias record to mint after the distribution exists. */
22
+ export interface EcsOriginRecordPlan {
23
+ hostname: string;
24
+ hostedZone: IHostedZone;
25
+ zoneName: string;
26
+ recordName: string;
27
+ loadBalancer: IApplicationLoadBalancer;
28
+ }
29
+ export interface ResolvedEcsOrigin {
30
+ hostname: string;
31
+ originConfig: CdnOriginConfig;
32
+ /** Present when the Cdn owns the record and `originRecord` is not "none". */
33
+ recordPlan?: EcsOriginRecordPlan;
34
+ /** True when the hostname is a compute-declared routing host (P1). */
35
+ computeOwnsRecord: boolean;
36
+ }
37
+ /**
38
+ * Explicit per-compute origin overrides: the distribution-level
39
+ * `originHostname`/`originRecord` for the default origin's compute, and each
40
+ * behaviour's own `originHostname`/`originRecord` for its compute — so every
41
+ * lane that can throw E3 can also cure it (design D3: behaviour origins get
42
+ * the same targeted validation AND the same override surface).
43
+ */
44
+ export interface EcsOriginOverride {
45
+ originHostname?: string;
46
+ originRecord?: "alias" | "none";
47
+ }
48
+ export interface CdnEcsOriginResolverOptions {
49
+ cdnId: string;
50
+ /** The distribution's own alias names, normalised — the origin-loop guard. */
51
+ aliasNames: ReadonlySet<string>;
52
+ /** Per-compute explicit overrides — assembled by the Cdn from the
53
+ * distribution props (default origin) and each behaviour entry. */
54
+ overrides?: ReadonlyMap<IEcsCompute, EcsOriginOverride>;
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;
72
+ export declare class CdnEcsOriginResolver {
73
+ private readonly options;
74
+ private readonly resolutions;
75
+ constructor(options: CdnEcsOriginResolverOptions);
76
+ resolve(compute: IEcsCompute): ResolvedEcsOrigin;
77
+ /** Every Cdn-owned record the resolutions so far require, one per compute. */
78
+ getRecordPlans(): EcsOriginRecordPlan[];
79
+ private resolveFresh;
80
+ private explicitHostnameFor;
81
+ private effectiveOriginRecord;
82
+ private selectHostname;
83
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * ECS construct-reference origin resolution for the `Cdn` pattern (design
3
+ * 2026-08-18 cdn-app-origin, D3). Given a fronted ECS compute, resolve a
4
+ * TLS-valid ORIGIN HOSTNAME from the compute's own ingress profile, decide
5
+ * who owns its DNS record (P1: the construct that declares a hostname owns
6
+ * its record — a `routing[].host` hostname is the compute's; a derived or
7
+ * free hostname is the Cdn's), and validate certificate coverage and
8
+ * listener forwarding at synth — fail closed where decidable, warn where
9
+ * opaque or partial (P3).
10
+ *
11
+ * The resolver is memoized per compute within one `Cdn`: the default origin
12
+ * and every behaviour referencing the same compute share one resolution and
13
+ * at most one record.
14
+ */
15
+ import { Token } from "aws-cdk-lib";
16
+ import { certificateCovers, forwardingVerdict, normaliseDnsName } from "../../resources/aws/compute/ingressProfile.js";
17
+ export { normaliseDnsName };
18
+ import { isWithinZone, recordLabelWithin } from "../../utils/domainTypes.js";
19
+ import { FjallLogger } from "../../utils/validationLogger.js";
20
+ const SHARED_CURE = "declare the hostname as a routing host on the receiving service " +
21
+ "(services[].routing[].host) — one declaration adds the certificate SAN " +
22
+ "(cluster-minted certificates), the listener rule, and the DNS record. " +
23
+ "CAUTION: adding a SECOND routing rule to a service flips the listener " +
24
+ "default action from forward to fixed-404, so a cluster serving its " +
25
+ "domain through the default action must keep a forwarding path for it, " +
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
+ }
41
+ export class CdnEcsOriginResolver {
42
+ options;
43
+ resolutions = new Map();
44
+ constructor(options) {
45
+ this.options = options;
46
+ }
47
+ resolve(compute) {
48
+ const memoized = this.resolutions.get(compute);
49
+ if (memoized !== undefined) {
50
+ return memoized;
51
+ }
52
+ const resolution = this.resolveFresh(compute);
53
+ this.resolutions.set(compute, resolution);
54
+ return resolution;
55
+ }
56
+ /** Every Cdn-owned record the resolutions so far require, one per compute. */
57
+ getRecordPlans() {
58
+ const plans = [];
59
+ for (const resolution of this.resolutions.values()) {
60
+ if (resolution.recordPlan !== undefined) {
61
+ plans.push(resolution.recordPlan);
62
+ }
63
+ }
64
+ return plans;
65
+ }
66
+ resolveFresh(compute) {
67
+ const { cdnId } = this.options;
68
+ const profile = compute.getIngressProfile();
69
+ if (profile === undefined) {
70
+ // E1 — pre-existing shape, kept: a cluster with no ALB cannot origin.
71
+ throw cdnOriginRefusal("E1", `CDN '${cdnId}': the ECS compute origin has no load balancer. ` +
72
+ "Enable loadBalancer in the compute configuration, or front a " +
73
+ "different resource.");
74
+ }
75
+ if (profile.internal) {
76
+ // E2 — CloudFront has no path into a VPC-internal ALB.
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.'));
79
+ }
80
+ const { hostname, computeOwnsRecord } = this.selectHostname(compute, profile);
81
+ // Origin-loop guard (C4, extended): the resolved hostname must not be
82
+ // one of the distribution's own alias names.
83
+ if (this.options.aliasNames.has(normaliseDnsName(hostname))) {
84
+ throw cdnOriginRefusal("C4", `CDN '${cdnId}': resolved origin hostname '${hostname}' is also one ` +
85
+ "of the distribution's own domain names — once DNS points that " +
86
+ "name at the distribution, every request loops CloudFront → " +
87
+ "CloudFront. Pick a dedicated origin hostname (originHostname), " +
88
+ "or serve the alias from the compute directly.");
89
+ }
90
+ if (profile.listenerPort === 80) {
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 ` +
96
+ "(the cluster has no domain, so no certificate resolved). A " +
97
+ "CloudFront origin needs a TLS-valid HTTPS origin. Give the " +
98
+ "cluster a domain (cluster.domainConfig), or use originType " +
99
+ '"alb" with protocolPolicy "HTTP_ONLY" if plaintext origin ' +
100
+ "traffic is acceptable.");
101
+ }
102
+ // Record-ownership feasibility BEFORE coverage/forwarding (design D3
103
+ // step 3): an out-of-zone hostname's only real cures are an in-zone
104
+ // name or originRecord: "none" — deciding coverage first would hand the
105
+ // user E6's routing-host cure, which ECS itself refuses out-of-zone.
106
+ let recordPlan;
107
+ if (!computeOwnsRecord && this.effectiveOriginRecord(compute) !== "none") {
108
+ // The Cdn owns the record (P1). Zone facts are guaranteed here: a
109
+ // 443 listener implies a domain, which implies zone identity.
110
+ if (profile.hostedZone === undefined ||
111
+ profile.zoneName === undefined ||
112
+ !isWithinZone(hostname, profile.zoneName)) {
113
+ // E5 — a record we cannot mint: outside the cluster's zone.
114
+ throw cdnOriginRefusal("E5", `CDN '${cdnId}': originHostname '${hostname}' is outside the ECS ` +
115
+ `origin's hosted zone ('${profile.zoneName ?? "none"}'), so its ` +
116
+ "record cannot be minted here. Use a hostname inside the zone, " +
117
+ 'or set originRecord: "none" and manage the record where the ' +
118
+ "zone lives.");
119
+ }
120
+ recordPlan = {
121
+ hostname,
122
+ hostedZone: profile.hostedZone,
123
+ zoneName: profile.zoneName,
124
+ recordName: recordLabelWithin(hostname, profile.zoneName),
125
+ loadBalancer: profile.loadBalancer
126
+ };
127
+ }
128
+ const coverage = certificateCovers(profile.certificateCoverage, hostname);
129
+ if (coverage === "not-covered") {
130
+ // E6 — provably uncovered: every attached certificate is enumerated
131
+ // and none matches. TLS to the origin fails on every request.
132
+ throw cdnOriginRefusal("E6", `CDN '${cdnId}': no certificate on the ECS origin's listener covers ` +
133
+ `'${hostname}' (attached certificates cover: ` +
134
+ `${profile.certificateCoverage.kind === "unknown" ? "unknown" : profile.certificateCoverage.hostnames.join(", ") || "nothing"}). ` +
135
+ `Every origin fetch would fail TLS validation. Cure: ${SHARED_CURE}`);
136
+ }
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 ` +
143
+ `certificates cover '${hostname}' (imported certificate ARNs are ` +
144
+ "opaque at synth). If the cluster uses a managed domain, redeploy " +
145
+ "the domain stack with engine >= 14.2.0 so it publishes " +
146
+ "certificate-coverage outputs and this becomes checkable; for " +
147
+ "imported certificates, verify the SANs cover the hostname — TLS " +
148
+ "fails at runtime if not.");
149
+ }
150
+ const verdict = forwardingVerdict(profile, hostname);
151
+ if (verdict.verdict === "none") {
152
+ // E7 — the listener's default action is a fixed 404 and no rule
153
+ // forwards the hostname: every origin fetch answers 404.
154
+ throw cdnOriginRefusal("E7", `CDN '${cdnId}': the ECS origin's listener would answer 404 for ` +
155
+ `'${hostname}' — its default action is a fixed 404 and no routing ` +
156
+ `rule forwards that hostname. Cure: ${SHARED_CURE}`);
157
+ }
158
+ if (verdict.verdict === "partial") {
159
+ // W2 — some paths forward, others hit the fixed-404 default.
160
+ cdnOriginWarning("W2", `CDN '${cdnId}': origin traffic for '${hostname}' forwards only for ` +
161
+ `some paths (matching rules: ${verdict.patterns.join("; ")}); ` +
162
+ "requests outside those patterns answer the listener's fixed-404 " +
163
+ "default. Verify the patterns against the distribution's " +
164
+ "behaviours.");
165
+ }
166
+ return {
167
+ hostname,
168
+ // The hostname is certificate-covered, so the resource layer's
169
+ // HTTPS_ONLY default is exactly right — no protocol override.
170
+ originConfig: { type: "http", domainName: hostname },
171
+ ...(recordPlan !== undefined && { recordPlan }),
172
+ computeOwnsRecord
173
+ };
174
+ }
175
+ explicitHostnameFor(compute) {
176
+ return this.options.overrides?.get(compute)?.originHostname;
177
+ }
178
+ effectiveOriginRecord(compute) {
179
+ return this.options.overrides?.get(compute)?.originRecord;
180
+ }
181
+ selectHostname(compute, profile) {
182
+ const { cdnId } = this.options;
183
+ const explicit = this.explicitHostnameFor(compute);
184
+ const originRecord = this.effectiveOriginRecord(compute);
185
+ if (explicit !== undefined) {
186
+ if (Token.isUnresolved(explicit)) {
187
+ throw new Error(`CDN '${cdnId}': originHostname must be a literal hostname (got ` +
188
+ "an unresolved token). Coverage, forwarding, and the origin " +
189
+ "record are synth-time decisions — pass the concrete name.");
190
+ }
191
+ // DNS names are case-insensitive (RFC 4343): every membership test
192
+ // compares normalised, so a case-differing spelling cannot slip past
193
+ // the redirect/ownership guards the way it cannot slip past ACM.
194
+ const explicitNormalised = normaliseDnsName(explicit);
195
+ if (profile.redirectHosts.some((host) => normaliseDnsName(host) === explicitNormalised)) {
196
+ // E10 — redirect hosts never serve traffic: the listener answers
197
+ // them with a 301 to the cluster domain, so an origin pointed there
198
+ // loops every viewer request through CloudFront → 301 → CloudFront.
199
+ throw cdnOriginRefusal("E10", `CDN '${cdnId}': originHostname '${explicit}' is a redirectHosts ` +
200
+ "entry on the ECS origin — the listener 301s it to " +
201
+ `'${profile.domainName ?? "the cluster domain"}' and it never ` +
202
+ "serves traffic. Use a served hostname: a routing host, or the " +
203
+ "derived origin name (omit originHostname).");
204
+ }
205
+ const computeOwnsRecord = profile.routedHosts.some((host) => normaliseDnsName(host) === explicitNormalised);
206
+ if (computeOwnsRecord && originRecord !== undefined) {
207
+ // E8 — the compute already owns that hostname's record.
208
+ throw cdnOriginRefusal("E8", `CDN '${cdnId}': originRecord is set, but originHostname ` +
209
+ `'${explicit}' is a routing host — the compute already owns its ` +
210
+ "record (P1: the construct that declares a hostname owns its " +
211
+ "record). Drop originRecord.");
212
+ }
213
+ return { hostname: explicit, computeOwnsRecord };
214
+ }
215
+ // Auto-resolution: routed hosts that are not the distribution's own
216
+ // aliases. (Redirect hosts can never appear — ECS enforces
217
+ // serve-XOR-redirect at synth.)
218
+ const candidates = profile.routedHosts.filter((host) => !this.options.aliasNames.has(normaliseDnsName(host)));
219
+ if (candidates.length === 1 && candidates[0] !== undefined) {
220
+ if (originRecord !== undefined) {
221
+ // E8 — same ownership rule as the explicit arm.
222
+ throw cdnOriginRefusal("E8", `CDN '${cdnId}': originRecord is set, but the origin resolved to ` +
223
+ `routing host '${candidates[0]}' — the compute already owns its ` +
224
+ "record. Drop originRecord.");
225
+ }
226
+ return { hostname: candidates[0], computeOwnsRecord: true };
227
+ }
228
+ if (candidates.length > 1) {
229
+ // E3 — ambiguous: several served hostnames could be the origin.
230
+ throw cdnOriginRefusal("E3", `CDN '${cdnId}': the ECS origin serves several hostnames ` +
231
+ `(${candidates.join(", ")}) and the origin is ambiguous. Set ` +
232
+ "originHostname — on the distribution for its default origin, or " +
233
+ "on the behaviour entry — to the one the distribution should " +
234
+ "fetch from.");
235
+ }
236
+ if (profile.domainName === undefined) {
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 ` +
242
+ "origin hostname from (and no certificate, so nothing serves " +
243
+ "HTTPS). Give the cluster a domain (cluster.domainConfig), or " +
244
+ 'use originType "alb" with protocolPolicy "HTTP_ONLY" if ' +
245
+ "plaintext origin traffic is acceptable.");
246
+ }
247
+ return {
248
+ hostname: `origin.${profile.domainName}`,
249
+ computeOwnsRecord: false
250
+ };
251
+ }
252
+ }
@@ -5,7 +5,7 @@ import { type IApplicationLoadBalancer, type ApplicationListener } from "aws-cdk
5
5
  import { Construct } from "constructs";
6
6
  import { type IEcsCompute } from "./interfaces/compute.js";
7
7
  import { type SecretImport } from "../../resources/aws/secrets/index.js";
8
- import EcsCluster, { type EcsClusterProps } from "../../resources/aws/compute/ecs.js";
8
+ import EcsCluster, { type EcsClusterProps, type EcsIngressProfile } from "../../resources/aws/compute/ecs.js";
9
9
  export { ScalingType } from "./computeEcsTypes.js";
10
10
  export type { EcsCapacityProvider, Ec2CapacityConfig, RemoteConnectionSpec, EcsCapacityProviderConfig, EcsContainerConfig, ContainerDependency, ContainerVolume, EcsScheduledTaskConfig, EcsLifecycleHookMigrationsConfig, EcsPostDeployMigrationsConfig, EcsHookMigrationsConfig, EcsMigrationsConfig, EcsMigrationsMode, EcsCircuitBreakerConfig, EcsScalingConfig, EcsClusterConfig, EcsRoutingConfig, EcsServiceConfig, ServiceLogAlarm, EcsComputeProps } from "./computeEcsTypes.js";
11
11
  import { ScalingType, type EcsCapacityProviderConfig, type EcsCapacityProvider, type EcsContainerConfig, type EcsScalingConfig, type EcsServiceConfig, type EcsComputeProps, type QueueScalingConfig } from "./computeEcsTypes.js";
@@ -227,6 +227,14 @@ export declare class EcsCompute extends Construct implements IEcsCompute {
227
227
  getCluster(): ICluster;
228
228
  /** Get the Application Load Balancer if one was created. */
229
229
  getLoadBalancer(): IApplicationLoadBalancer | undefined;
230
+ /**
231
+ * Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
232
+ * listener facts, zone identity, and certificate coverage for constructs
233
+ * that front this compute — the `Cdn` construct-reference origin lane
234
+ * resolves its origin hostname against it. Undefined when the compute has
235
+ * no load balancer.
236
+ */
237
+ getIngressProfile(): EcsIngressProfile | undefined;
230
238
  /** Get a specific service by name. */
231
239
  getService(name: string): IBaseService | undefined;
232
240
  /** Get all services in the cluster. */
@@ -1653,6 +1653,16 @@ export class EcsCompute extends Construct {
1653
1653
  getLoadBalancer() {
1654
1654
  return this.ecsCluster.getLoadBalancer();
1655
1655
  }
1656
+ /**
1657
+ * Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
1658
+ * listener facts, zone identity, and certificate coverage for constructs
1659
+ * that front this compute — the `Cdn` construct-reference origin lane
1660
+ * resolves its origin hostname against it. Undefined when the compute has
1661
+ * no load balancer.
1662
+ */
1663
+ getIngressProfile() {
1664
+ return this.ecsCluster.getIngressProfile();
1665
+ }
1656
1666
  /** Get a specific service by name. */
1657
1667
  getService(name) {
1658
1668
  return this.ecsCluster.getService(name);
@@ -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;
@@ -16,15 +16,29 @@ import { DNS_APEX } from "@fjall/util";
16
16
  * runtime guard catches user-crafted `{ kind, ... }` literals that lack
17
17
  * `bind()`.
18
18
  *
19
- * Construct-id formula (`${safeZone}${safeName}${type}Record${index}`) is
20
- * byte-identical to the legacy composer this is an eject-contract
21
- * invariant (Phase 3 depends on stable IDs).
19
+ * Construct-id formulas (`recordIds` on `DomainCommonProps`):
20
+ * - `"indexed"` (default): `${safeZone}${safeName}${type}Record${index}`
21
+ * byte-identical to the legacy composer, an eject-contract invariant
22
+ * (Phase 3 depends on stable IDs). Position-coupled, so the records list
23
+ * is append-only for deployed zones.
24
+ * - `"stable"`: `${safeZone}${safeName}${type}Record` — position-free.
25
+ * {@link assertStableIdsDerivable} refuses the two shapes that would
26
+ * collide, with cures, before CDK's opaque duplicate-construct-id error
27
+ * can fire.
28
+ * A per-record `id` (PascalCase alphanumeric) replaces the `safeName`
29
+ * segment in either mode — the escape hatch the stable-mode collision
30
+ * refusal names.
22
31
  */
23
- export function composeTypedDnsRecords(scope, zone, zoneName, records) {
32
+ export function composeTypedDnsRecords(scope, zone, zoneName, records, recordIds = "indexed") {
24
33
  const safeZone = toPascalCase(getSafeZoneName(zoneName));
34
+ if (recordIds === "stable") {
35
+ assertStableIdsDerivable(zoneName, records);
36
+ }
25
37
  records.forEach((record, index) => {
26
- const safeName = toPascalCase(record.name === DNS_APEX ? "Apex" : record.name);
27
- const constructId = `${safeZone}${safeName}${record.type}Record${index}`;
38
+ const safeName = recordIdNameSegment(record);
39
+ const constructId = recordIds === "stable"
40
+ ? `${safeZone}${safeName}${record.type}Record`
41
+ : `${safeZone}${safeName}${record.type}Record${index}`;
28
42
  const common = {
29
43
  zone,
30
44
  zoneName,
@@ -40,6 +54,9 @@ export function composeTypedDnsRecords(scope, zone, zoneName, records) {
40
54
  }
41
55
  new AliasRecord(scope, constructId, {
42
56
  ...common,
57
+ // A declared AAAA alias must DEPLOY as AAAA — the resource defaults
58
+ // to "A" for its pattern-internal (IPv4) call sites only.
59
+ recordType: record.type,
43
60
  target: target
44
61
  });
45
62
  return;
@@ -102,6 +119,71 @@ export function composeTypedDnsRecords(scope, zone, zoneName, records) {
102
119
  }
103
120
  });
104
121
  }
122
+ /**
123
+ * Construct-id name segment: an explicit `id` verbatim (validated — it
124
+ * becomes a CloudFormation logical-ID segment), else the pascal-cased
125
+ * record name. The no-`id` derivation is byte-identical to the legacy
126
+ * inline formula (eject contract).
127
+ */
128
+ function recordIdNameSegment(record) {
129
+ if (record.id !== undefined) {
130
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(record.id)) {
131
+ throw new Error(`DNS record '${record.name}' (${record.type}): 'id' must be ` +
132
+ `alphanumeric starting with a letter (got '${record.id}') — it ` +
133
+ "becomes a CloudFormation logical-ID segment.");
134
+ }
135
+ return record.id;
136
+ }
137
+ return toPascalCase(record.name === DNS_APEX ? "Apex" : record.name);
138
+ }
139
+ /**
140
+ * Stable-mode pre-flight: refuse the two list shapes whose derived
141
+ * construct ids would collide, each with its own cure, BEFORE CDK's opaque
142
+ * "already a Construct with name" error can fire.
143
+ *
144
+ * - Duplicate (name, type) pairs are illegal Route53 regardless of id
145
+ * scheme (one record set per (zone, name, type) — the same doctrine the
146
+ * DNS claim registry enforces across constructs; this vocabulary cannot
147
+ * express routing-policy variants). Indexed mode surfaces them via the
148
+ * registry at claim time; stable mode must refuse before construction.
149
+ * - DISTINCT records whose derived construct ids concatenate to one string
150
+ * — same-type names that sanitise to one PascalCase segment (e.g.
151
+ * 'mail-eu' and 'mailEu'), or cross-type segment/type ambiguity (id
152
+ * 'ApiAAA' + type 'A' vs segment 'Api' + type 'AAAA' both derive
153
+ * 'ApiAAAARecord') — are legal Route53 but collide as construct ids; the
154
+ * per-record `id` escape hatch disambiguates. Keyed on the FULL derived
155
+ * id, never on (segment, type) pairs, precisely so the concatenation
156
+ * ambiguity cannot slip past to CDK.
157
+ */
158
+ function assertStableIdsDerivable(zoneName, records) {
159
+ const byRecordSet = new Map();
160
+ const byConstructId = new Map();
161
+ for (const record of records) {
162
+ const setKey = [
163
+ record.name.toLowerCase().replace(/\.$/, ""),
164
+ record.type
165
+ ].join("|");
166
+ const priorSet = byRecordSet.get(setKey);
167
+ if (priorSet !== undefined) {
168
+ throw new Error(`DNS record '${record.name}' (${record.type}) in zone '${zoneName}': ` +
169
+ "declared twice in this records list. Route53 allows one record " +
170
+ "set per (zone, name, type); CloudFormation would reject this at " +
171
+ "deploy. Merge the values into one entry (multi-value records " +
172
+ "take a string array), or remove one.");
173
+ }
174
+ byRecordSet.set(setKey, record);
175
+ const constructKey = `${recordIdNameSegment(record)}${record.type}Record`;
176
+ const prior = byConstructId.get(constructKey);
177
+ if (prior !== undefined) {
178
+ throw new Error(`DNS records '${prior.name}' (${prior.type}) and '${record.name}' ` +
179
+ `(${record.type}) in zone '${zoneName}': both derive record ` +
180
+ `construct id '${constructKey}' under recordIds: "stable". Give ` +
181
+ "one of them a distinct per-record 'id' (PascalCase " +
182
+ "alphanumeric) to disambiguate.");
183
+ }
184
+ byConstructId.set(constructKey, record);
185
+ }
186
+ }
105
187
  // Parse "10 mail.example.com" → { priority: 10, hostName: "mail.example.com" }.
106
188
  function parseMxValue(raw) {
107
189
  const parts = raw.trim().split(/\s+/);
@@ -1,6 +1,6 @@
1
1
  import { CfnOutput, Stack, Stage, Token } from "aws-cdk-lib";
2
2
  import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
3
- import { getDomainExportNames, getDomainUsEast1CertificatesStackName } from "@fjall/util";
3
+ import { getDomainExportNames, getDomainUsEast1CertificatesStackName, hostsExportPartName, serialiseHostsChunks } from "@fjall/util";
4
4
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
5
5
  import { toPascalCase } from "../../utils/capitaliseString.js";
6
6
  const US_EAST_1 = "us-east-1";
@@ -42,6 +42,13 @@ export function composeDomainCertificates(scope, composition) {
42
42
  costAllocationEnvironment: composition.costAllocationEnvironment,
43
43
  costAllocationDomain: composition.costAllocationDomain
44
44
  });
45
+ // D5 (design 2026-08-18 cdn-app-origin): publish the hostnames this
46
+ // certificate covers beside its ARN export — SAME per-certificate
47
+ // identity, so a consumer can bind coverage for exactly the
48
+ // certificates whose ARNs it binds, never a zone-level aggregate
49
+ // (which would list hostnames of certificates that are not on the
50
+ // consumer's listener and over-claim coverage).
51
+ emitHostsOutputs(scope, `${certId}Hosts`, getDomainExportNames(normalised.domainName).certificateHosts, certificateHosts(normalised));
45
52
  certificates.set(normalised.domainName, dc.certificate);
46
53
  return;
47
54
  }
@@ -80,6 +87,7 @@ function mintCloudFrontCertificate(scope, composition, cert, certId) {
80
87
  exportCertificateArn: false
81
88
  });
82
89
  emitUsEast1Export(scope, composition, dc);
90
+ emitUsEast1HostsOutput(scope, composition, cert);
83
91
  return dc.certificate;
84
92
  }
85
93
  const pairedStack = resolvePairedUsEast1Stack(domainStack, composition, cert);
@@ -104,8 +112,65 @@ function mintCloudFrontCertificate(scope, composition, cert, certId) {
104
112
  exportCertificateArn: false
105
113
  });
106
114
  emitUsEast1Export(pairedStack, composition, dc);
115
+ // The hosts output lands on the MAIN domain stack even though the
116
+ // certificate lives in the paired stack: the SAN list is a synth-time
117
+ // literal (no cross-region reference), and the CLI's existing main-stack
118
+ // DescribeStacks read must reach it without touching the paired-stack
119
+ // reader (design 2026-08-18 cdn-app-origin, D5).
120
+ emitUsEast1HostsOutput(domainStack, composition, cert);
107
121
  return dc.certificate;
108
122
  }
123
+ /** Hostnames a certificate covers: its domainName plus every SAN, deduped. */
124
+ function certificateHosts(cert) {
125
+ return [
126
+ ...new Set([cert.domainName, ...(cert.subjectAlternativeNames ?? [])])
127
+ ];
128
+ }
129
+ /**
130
+ * CloudFormation caps output values at 1024 bytes: a certificate with
131
+ * enough — or long enough — SANs would fail the WHOLE domain-stack deploy
132
+ * with an opaque CFN error if its hosts list rode one output. Hosts lists
133
+ * that fit keep the base export name and construct ID byte-identical to
134
+ * the single-output era (no logical-ID churn on deployed stacks);
135
+ * oversized lists are emitted as a part family (`<base>-1`, `<base>-2`, …,
136
+ * each a self-contained JSON array under the threshold) with the base name
137
+ * OMITTED — a consumer that only knows the base name reads absence as
138
+ * coverage-unknown, never a partial list it would misread as complete (see
139
+ * `hostsExportPartName`). Coverage is therefore always published; the old
140
+ * over-limit fail-open (skip the output, coverage permanently
141
+ * unverifiable) is gone. The chunking algorithm (`serialiseHostsChunks`)
142
+ * is homed in @fjall/util so the CLI's eject templates split identically.
143
+ */
144
+ function emitHostsOutputs(scope, keyPrefix, baseExportName, hosts) {
145
+ const chunks = serialiseHostsChunks(hosts);
146
+ const single = chunks.length === 1 ? chunks[0] : undefined;
147
+ if (single !== undefined) {
148
+ new CfnOutput(scope, keyPrefix, {
149
+ key: keyPrefix,
150
+ value: single,
151
+ exportName: baseExportName
152
+ });
153
+ return;
154
+ }
155
+ chunks.forEach((chunk, index) => {
156
+ const id = `${keyPrefix}${index + 1}`;
157
+ new CfnOutput(scope, id, {
158
+ key: id,
159
+ value: chunk,
160
+ exportName: hostsExportPartName(baseExportName, index + 1)
161
+ });
162
+ });
163
+ }
164
+ /**
165
+ * The zone-level `<zone>-us-east-1-certificate-hosts` output (D5 companion
166
+ * to `emitUsEast1Export`): the hostnames the viewer certificate covers,
167
+ * always minted on the MAIN domain stack so the D2 DescribeStacks read
168
+ * reaches it in one call regardless of where the certificate itself lives.
169
+ */
170
+ function emitUsEast1HostsOutput(scope, composition, cert) {
171
+ const exports = getDomainExportNames(composition.effectiveZoneName);
172
+ emitHostsOutputs(scope, `${composition.safeZone}UsEast1CertificateHosts`, exports.usEast1CertificateHosts, certificateHosts(cert));
173
+ }
109
174
  /**
110
175
  * The zone-level `<zone>-us-east-1-certificate-arn` export
111
176
  * (`getDomainExportNames(...).usEast1CertificateArn` — the SSOT
@@ -19,6 +19,7 @@ import { type IAutoScalingGroup } from "aws-cdk-lib/aws-autoscaling";
19
19
  import { type ISecurityGroup, type IConnectable } from "aws-cdk-lib/aws-ec2";
20
20
  import { type IGrantable, type Grant } from "aws-cdk-lib/aws-iam";
21
21
  import { type Construct } from "constructs";
22
+ import type { EcsIngressProfile } from "../../../resources/aws/compute/ingressProfile.js";
22
23
  /**
23
24
  * Compute type discriminator.
24
25
  * Used to determine which specific interface applies.
@@ -66,6 +67,12 @@ export interface IEcsCompute extends ICompute, IConnectable {
66
67
  * for HTTPS, 80 for HTTP); does not indicate protocol.
67
68
  */
68
69
  getPrimaryListenerPort(): number | undefined;
70
+ /**
71
+ * Synth-time ingress profile (design 2026-08-18 cdn-app-origin, D2):
72
+ * listener facts, zone identity, and certificate coverage for constructs
73
+ * that front this compute. Undefined when there is no load balancer.
74
+ */
75
+ getIngressProfile(): EcsIngressProfile | undefined;
69
76
  }
70
77
  /**
71
78
  * Lambda compute interface.