@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,215 @@
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 { FjallLogger } from "../../../utils/validationLogger.js";
19
+ function normaliseRoutingRules(routing) {
20
+ return Array.isArray(routing) ? routing : routing ? [routing] : [];
21
+ }
22
+ function servicesWithPorts(services) {
23
+ return services.filter((s) => s.containers.some((c) => c.port !== undefined));
24
+ }
25
+ /**
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.
31
+ */
32
+ export function planListenerRules(services, redirectHosts) {
33
+ const withPorts = servicesWithPorts(services);
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");
59
+ }
60
+ /**
61
+ * The listener's rule structure as `registerServiceWithALB` and
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.
66
+ */
67
+ export function enumerateListenerRules(services, redirectHosts) {
68
+ const plan = planListenerRules(services, redirectHosts);
69
+ const rules = [];
70
+ for (const servicePlan of plan.services) {
71
+ if (servicePlan.mode === "default")
72
+ continue;
73
+ for (const rule of servicePlan.routingRules) {
74
+ if (rule.host === undefined && rule.path === undefined)
75
+ continue;
76
+ rules.push({
77
+ ...(rule.host !== undefined && { host: rule.host }),
78
+ ...(rule.path !== undefined && { path: rule.path }),
79
+ kind: "forward"
80
+ });
81
+ }
82
+ }
83
+ for (const host of plan.redirectHosts) {
84
+ rules.push({ host, kind: "redirect" });
85
+ }
86
+ return rules;
87
+ }
88
+ /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
89
+ export function normaliseDnsName(name) {
90
+ return name.toLowerCase().replace(/\.$/, "");
91
+ }
92
+ /**
93
+ * RFC 6125 certificate-name match: exact, or a single-label `*.` wildcard
94
+ * (matches exactly one additional label — `*.example.com` covers
95
+ * `a.example.com`, never `example.com` or `a.b.example.com`). DNS names
96
+ * compare ASCII case-insensitively (RFC 6125 §6.4.1 via RFC 4343).
97
+ */
98
+ export function certNameMatches(pattern, hostname) {
99
+ const p = pattern.toLowerCase();
100
+ const h = hostname.toLowerCase();
101
+ if (p === h)
102
+ return true;
103
+ if (!p.startsWith("*."))
104
+ return false;
105
+ const suffix = p.slice(1); // ".example.com"
106
+ if (!h.endsWith(suffix))
107
+ return false;
108
+ const label = h.slice(0, h.length - suffix.length);
109
+ return label.length > 0 && !label.includes(".");
110
+ }
111
+ /**
112
+ * Does the listener's attached-certificate set cover `hostname`?
113
+ * Fail closed where decidable, honest where opaque (P3): only `enumerated`
114
+ * coverage may answer `not-covered`; a miss under `partial`/`unknown` is
115
+ * `unknown` because an opaque certificate may still cover the name.
116
+ */
117
+ export function certificateCovers(coverage, hostname) {
118
+ if (coverage.kind === "unknown")
119
+ return "unknown";
120
+ if (coverage.hostnames.some((p) => certNameMatches(p, hostname))) {
121
+ return "covered";
122
+ }
123
+ return coverage.kind === "enumerated" ? "not-covered" : "unknown";
124
+ }
125
+ /**
126
+ * Would the listener forward `Host: <hostname>` requests, for all paths?
127
+ * Computed on the real rule model, never a flattened hostname list:
128
+ *
129
+ * - no fixed-404 default → the default action forwards every hostname;
130
+ * - a host-only rule for the hostname, a host-matched catch-all `/*` path
131
+ * rule, or a host-less catch-all `/*` path rule, forwards all of its
132
+ * paths;
133
+ * - rules that involve the hostname only together with a narrower path
134
+ * condition (ANDed host+path, or host-less non-catch-all paths) forward
135
+ * SOME paths — `partial`, with the patterns named so the caller can
136
+ * surface them;
137
+ * - otherwise every request answers the fixed-404 default — `none`.
138
+ *
139
+ * Hostnames compare case-insensitively (DNS, RFC 4343) — the rule model
140
+ * carries the declared spelling, the caller's hostname may differ in case.
141
+ */
142
+ export function forwardingVerdict(profile, hostname) {
143
+ if (!profile.default404)
144
+ return { verdict: "forwards" };
145
+ const wanted = normaliseDnsName(hostname);
146
+ const partial = [];
147
+ for (const rule of profile.rules) {
148
+ if (rule.kind !== "forward")
149
+ continue;
150
+ const hostMatches = rule.host !== undefined && normaliseDnsName(rule.host) === wanted;
151
+ if (hostMatches && (rule.path === undefined || rule.path === "/*")) {
152
+ return { verdict: "forwards" };
153
+ }
154
+ if (rule.host === undefined && rule.path === "/*") {
155
+ return { verdict: "forwards" };
156
+ }
157
+ if (hostMatches && rule.path !== undefined) {
158
+ partial.push(`Host=${rule.host} AND Path=${rule.path}`);
159
+ }
160
+ else if (rule.host === undefined && rule.path !== undefined) {
161
+ partial.push(`Path=${rule.path} (any host)`);
162
+ }
163
+ }
164
+ return partial.length > 0
165
+ ? { verdict: "partial", patterns: partial }
166
+ : { verdict: "none" };
167
+ }
168
+ export function buildIngressProfile(options) {
169
+ const { zoneFacts } = options;
170
+ const redirectHosts = zoneFacts?.redirectHosts ?? [];
171
+ return {
172
+ loadBalancer: options.loadBalancer,
173
+ internal: options.internal,
174
+ listenerPort: options.certificateAttached ? 443 : 80,
175
+ ...(zoneFacts !== undefined && {
176
+ hostedZone: zoneFacts.hostedZone,
177
+ zoneName: zoneFacts.zoneName,
178
+ domainName: zoneFacts.domainName
179
+ }),
180
+ routedHosts: zoneFacts?.routedHosts ?? [],
181
+ redirectHosts,
182
+ default404: computeListenerDefault404(options.services),
183
+ rules: enumerateListenerRules(options.services, redirectHosts),
184
+ certificateCoverage: zoneFacts?.certificateCoverage ?? {
185
+ // No domain → no certificates attached: the attached set is fully
186
+ // known (empty), which is the honest enumerated answer, not "unknown".
187
+ kind: "enumerated",
188
+ hostnames: []
189
+ }
190
+ };
191
+ }
192
+ /**
193
+ * W3 (design 2026-08-18 cdn-app-origin): a cluster whose listener default is
194
+ * fixed-404 while its `domainName` has a minted apex record and NO rule
195
+ * forwards it answers 404 on its own primary domain — usually the aftermath
196
+ * of adding a second routing rule (which flips the default action from
197
+ * forward to 404, `computeListenerDefault404`). A warning, not an error:
198
+ * pre-existing clusters can already be in this state, and narrowing them is
199
+ * not this check's mandate. Partial forwarding (path-split services) is the
200
+ * normal multi-route shape and does not warn.
201
+ */
202
+ export function warnWhenRecordedApexUnforwarded(profile, apexRecordMinted, clusterName) {
203
+ if (!profile.default404)
204
+ return;
205
+ if (profile.domainName === undefined || !apexRecordMinted)
206
+ return;
207
+ if (forwardingVerdict(profile, profile.domainName).verdict !== "none")
208
+ return;
209
+ FjallLogger.warn(`Cluster '${clusterName}': the listener's default action is a fixed 404 ` +
210
+ `and no routing rule forwards '${profile.domainName}', but its alias ` +
211
+ "record is minted — requests to the cluster's own domain will answer " +
212
+ "404 Not Found. Multiple routing rules flip the listener default from " +
213
+ "forward to 404; keep a forwarding path for the domain, e.g. a " +
214
+ 'routing rule { path: "/*" } on the service that should serve it.');
215
+ }
@@ -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
@@ -20,9 +20,18 @@
20
20
  * layer (generator-standards § Infrastructure Layer Boundaries).
21
21
  */
22
22
  import type { Node } from "constructs";
23
- import type { ManagedDomainBinding } from "@fjall/util";
23
+ import { type ManagedDomainBinding, type ManagedDomainCoverage, type ManagedDomainExports } from "@fjall/util";
24
24
  export declare const MANAGED_DOMAIN_CONTEXT_PREFIX: "fjall:managedDomain:";
25
25
  export declare function getManagedDomainContextKey(zoneName: string): string;
26
+ /**
27
+ * Companion channel to the binding (design 2026-08-18 cdn-app-origin, D5):
28
+ * the hostnames covered by exactly the certificates the same zone's binding
29
+ * names. A SEPARATE key on purpose — the binding parser above fails closed
30
+ * on unknown fields, so coverage could never ride the binding JSON without
31
+ * breaking older engines fed by a newer CLI.
32
+ */
33
+ export declare const MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX: "fjall:managedDomainCoverage:";
34
+ export declare function getManagedDomainCoverageContextKey(zoneName: string): string;
26
35
  /**
27
36
  * Read the CLI-injected {@link ManagedDomainBinding} for `domainName`,
28
37
  * walking exact → parent zones (mirroring the CLI's
@@ -35,3 +44,51 @@ export declare function getManagedDomainContextKey(zoneName: string): string;
35
44
  * `context` prefixes every error, e.g. `Static site 'marketing'`.
36
45
  */
37
46
  export declare function readInjectedManagedDomainBinding(node: Node, domainName: string, context: string): ManagedDomainBinding | undefined;
47
+ /**
48
+ * Read the CLI-injected {@link ManagedDomainCoverage} for the zone a binding
49
+ * already resolved to. Exact-key lookup on purpose (no parent-zone walk):
50
+ * the CLI injects coverage for precisely the zones it injects bindings for,
51
+ * and the caller passes the binding's own `zoneName`.
52
+ *
53
+ * Absent → undefined (a domain stack that predates the hosts outputs, or a
54
+ * bare-CDK synth) — consumers treat that as coverage-unknown and warn, never
55
+ * as not-covered. A present-but-corrupt value still throws (repo posture:
56
+ * corrupt context fails the synth rather than falling through), but UNKNOWN
57
+ * FIELDS ARE IGNORED, unlike the binding parser: coverage is advisory
58
+ * validation input, and forward tolerance here is what lets a future CLI add
59
+ * coverage fields without breaking older engines — the exact trap that
60
+ * forced this channel off the binding JSON in the first place.
61
+ */
62
+ export declare function readInjectedManagedDomainCoverage(node: Node, zoneName: string, context: string): ManagedDomainCoverage | undefined;
63
+ /**
64
+ * The one managed-domain identity a consumer acts on, resolved by the D2
65
+ * precedence every lane shares: an EXPLICIT `managedDomain` prop wins over
66
+ * the CLI-injected context binding (explicit beats injected); bare-CDK
67
+ * synth carries no context entry and yields undefined.
68
+ */
69
+ export interface EffectiveManagedDomain {
70
+ managed: ManagedDomainBinding | ManagedDomainExports;
71
+ zoneName: string;
72
+ /**
73
+ * Zone id ready for `HostedZone.fromHostedZoneAttributes`: the binding's
74
+ * literal (crosses accounts and regions), or an `Fn.importValue` token on
75
+ * the exports form (same-account, same-region only).
76
+ */
77
+ hostedZoneId: string;
78
+ /**
79
+ * True when the identity is an EXPLICIT binding prop. An explicit binding
80
+ * may pin certificate ARNs older than the zone's current ones, so the
81
+ * injected coverage context — which describes the CURRENT certificates —
82
+ * must not be applied to it (the D5 provenance gate): folding it would
83
+ * over-claim coverage and invert P3. Injected bindings and the exports
84
+ * form (which resolves at deploy to the current certificate) are safe.
85
+ */
86
+ explicitPinnedBinding: boolean;
87
+ }
88
+ /**
89
+ * Resolve the effective managed-domain identity for `domainName` — the
90
+ * SINGLE home of the explicit-beats-injected precedence and of the
91
+ * binding-vs-exports zone-id branch, shared by the resources layer
92
+ * (`ecsNetworking.addHostedZone`) and the patterns layer (`patternDomain`).
93
+ */
94
+ export declare function resolveEffectiveManagedDomain(node: Node, explicit: ManagedDomainBinding | ManagedDomainExports | undefined, domainName: string, context: string): EffectiveManagedDomain | undefined;
@@ -19,10 +19,23 @@
19
19
  * (`patternDomain.resolvePatternZone`), and utils is their lowest common
20
20
  * layer (generator-standards § Infrastructure Layer Boundaries).
21
21
  */
22
+ import { Fn } from "aws-cdk-lib";
23
+ import { isManagedDomainBinding } from "@fjall/util";
22
24
  export const MANAGED_DOMAIN_CONTEXT_PREFIX = "fjall:managedDomain:";
23
25
  export function getManagedDomainContextKey(zoneName) {
24
26
  return `${MANAGED_DOMAIN_CONTEXT_PREFIX}${zoneName}`;
25
27
  }
28
+ /**
29
+ * Companion channel to the binding (design 2026-08-18 cdn-app-origin, D5):
30
+ * the hostnames covered by exactly the certificates the same zone's binding
31
+ * names. A SEPARATE key on purpose — the binding parser above fails closed
32
+ * on unknown fields, so coverage could never ride the binding JSON without
33
+ * breaking older engines fed by a newer CLI.
34
+ */
35
+ export const MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX = "fjall:managedDomainCoverage:";
36
+ export function getManagedDomainCoverageContextKey(zoneName) {
37
+ return `${MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX}${zoneName}`;
38
+ }
26
39
  const REQUIRED_STRING_FIELDS = ["zoneName", "hostedZoneId"];
27
40
  const OPTIONAL_STRING_FIELDS = [
28
41
  "certificateArn",
@@ -56,6 +69,89 @@ export function readInjectedManagedDomainBinding(node, domainName, context) {
56
69
  }
57
70
  return undefined;
58
71
  }
72
+ /**
73
+ * Read the CLI-injected {@link ManagedDomainCoverage} for the zone a binding
74
+ * already resolved to. Exact-key lookup on purpose (no parent-zone walk):
75
+ * the CLI injects coverage for precisely the zones it injects bindings for,
76
+ * and the caller passes the binding's own `zoneName`.
77
+ *
78
+ * Absent → undefined (a domain stack that predates the hosts outputs, or a
79
+ * bare-CDK synth) — consumers treat that as coverage-unknown and warn, never
80
+ * as not-covered. A present-but-corrupt value still throws (repo posture:
81
+ * corrupt context fails the synth rather than falling through), but UNKNOWN
82
+ * FIELDS ARE IGNORED, unlike the binding parser: coverage is advisory
83
+ * validation input, and forward tolerance here is what lets a future CLI add
84
+ * coverage fields without breaking older engines — the exact trap that
85
+ * forced this channel off the binding JSON in the first place.
86
+ */
87
+ export function readInjectedManagedDomainCoverage(node, zoneName, context) {
88
+ const key = getManagedDomainCoverageContextKey(zoneName);
89
+ const raw = node.tryGetContext(key);
90
+ if (raw === undefined)
91
+ return undefined;
92
+ let value = raw;
93
+ if (typeof raw === "string") {
94
+ try {
95
+ value = JSON.parse(raw);
96
+ }
97
+ catch {
98
+ throw new Error(`${context}: CDK context '${key}' is not valid JSON (got '${raw}'). ` +
99
+ "The value must be a JSON ManagedDomainCoverage " +
100
+ "({ certificateHosts?, usEast1CertificateHosts? }) — re-run the " +
101
+ "deploy through the Fjall CLI, or correct the hand-set context " +
102
+ "entry.");
103
+ }
104
+ }
105
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
106
+ throw new Error(`${context}: CDK context '${key}' must be a JSON object ` +
107
+ `ManagedDomainCoverage (got ${JSON.stringify(value)}). Re-run the ` +
108
+ "deploy through the Fjall CLI, or correct the hand-set context entry.");
109
+ }
110
+ const record = value;
111
+ const coverage = {};
112
+ for (const field of [
113
+ "certificateHosts",
114
+ "usEast1CertificateHosts"
115
+ ]) {
116
+ const fieldValue = record[field];
117
+ if (fieldValue === undefined)
118
+ continue;
119
+ // An empty array is corruption, never data: a real certificate always
120
+ // covers at least its domain name, and the CLI never emits []. Letting
121
+ // it through would read as an enumerated "covers nothing" fact and turn
122
+ // an UNKNOWN coverage state into a definite (false) not-covered verdict.
123
+ if (!Array.isArray(fieldValue) ||
124
+ fieldValue.length === 0 ||
125
+ fieldValue.some((h) => typeof h !== "string" || h === "")) {
126
+ throw new Error(`${context}: CDK context '${key}' field '${field}' must be a ` +
127
+ `non-empty array of non-empty strings when present (got ` +
128
+ `${JSON.stringify(fieldValue)}). Re-run the deploy through the ` +
129
+ "Fjall CLI, or correct the hand-set context entry.");
130
+ }
131
+ coverage[field] = fieldValue;
132
+ }
133
+ return coverage;
134
+ }
135
+ /**
136
+ * Resolve the effective managed-domain identity for `domainName` — the
137
+ * SINGLE home of the explicit-beats-injected precedence and of the
138
+ * binding-vs-exports zone-id branch, shared by the resources layer
139
+ * (`ecsNetworking.addHostedZone`) and the patterns layer (`patternDomain`).
140
+ */
141
+ export function resolveEffectiveManagedDomain(node, explicit, domainName, context) {
142
+ const managed = explicit ?? readInjectedManagedDomainBinding(node, domainName, context);
143
+ if (managed === undefined)
144
+ return undefined;
145
+ const isBinding = isManagedDomainBinding(managed);
146
+ return {
147
+ managed,
148
+ zoneName: managed.zoneName,
149
+ hostedZoneId: isBinding
150
+ ? managed.hostedZoneId
151
+ : Fn.importValue(managed.hostedZoneIdExport),
152
+ explicitPinnedBinding: explicit !== undefined && isBinding
153
+ };
154
+ }
59
155
  function parseManagedDomainBinding(raw, zoneName, key, context) {
60
156
  let value = raw;
61
157
  if (typeof raw === "string") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "14.1.0",
3
+ "version": "14.3.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -80,8 +80,8 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^14.1.0",
84
- "@fjall/util": "^14.1.0",
83
+ "@fjall/generator": "^14.3.0",
84
+ "@fjall/util": "^14.3.0",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {