@fjall/components-infrastructure 14.1.0 → 14.2.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.
@@ -0,0 +1,174 @@
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 mirrors `registerServiceWithALB`'s exact branching
13
+ * (including the single-service conditions-dropped branch), and the covered
14
+ * SAN set is the one `addHostedZone` mints. Never recompute these facts from
15
+ * cluster props elsewhere; extend the profile instead.
16
+ */
17
+ import type { IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
18
+ import type { IHostedZone } from "aws-cdk-lib/aws-route53";
19
+ import type { EcsServiceProps } from "./ecsTypes.js";
20
+ /**
21
+ * What the certificates ATTACHED to the cluster's listener are known to
22
+ * cover at synth:
23
+ *
24
+ * - `enumerated` — every attached certificate's host set is synth-known
25
+ * (cluster-minted certs, or managed certs described by the D5 coverage
26
+ * context). `hostnames` is complete: a non-matching name is provably
27
+ * uncovered.
28
+ * - `partial` — `hostnames` collects the known certs' hosts, but at least
29
+ * one attached certificate is opaque (imported ARN with no coverage
30
+ * context), so a non-matching name may still be covered.
31
+ * - `unknown` — only opaque certificates are attached.
32
+ */
33
+ export type CertificateCoverage = {
34
+ kind: "enumerated";
35
+ hostnames: string[];
36
+ } | {
37
+ kind: "partial";
38
+ hostnames: string[];
39
+ } | {
40
+ kind: "unknown";
41
+ };
42
+ export type CoverageAnswer = "covered" | "unknown" | "not-covered";
43
+ /**
44
+ * One listener rule as it will actually be emitted. `host` and `path` are
45
+ * ANDed when both present (`buildRoutingConditions`). Rules the emitter
46
+ * DROPS — a single ports-bearing service with ≤1 routing rule loses its
47
+ * conditions and becomes the listener default (`registerServiceWithALB`) —
48
+ * are deliberately absent here, exactly as they are absent from the ALB.
49
+ */
50
+ export interface EcsIngressRule {
51
+ host?: string;
52
+ path?: string;
53
+ kind: "forward" | "redirect";
54
+ }
55
+ export interface EcsIngressProfile {
56
+ loadBalancer: IApplicationLoadBalancer;
57
+ /** `cluster.loadBalancer === "internal"` — unreachable as a CloudFront origin. */
58
+ internal: boolean;
59
+ /** 443 iff a certificate resolved — the cluster ALB has exactly one listener. */
60
+ listenerPort: 443 | 80;
61
+ /**
62
+ * The cluster's resolved zone — the SAME `IHostedZone` instance the
63
+ * cluster's own records claim with, so a downstream record (the Cdn's
64
+ * origin record) claims under the identical zone identity. The DNS claim
65
+ * registry keys on the literal hosted-zone ID; a name-derived re-import
66
+ * would register under a different key and silently defeat same-name
67
+ * collision detection.
68
+ */
69
+ hostedZone?: IHostedZone;
70
+ zoneName?: string;
71
+ domainName?: string;
72
+ /** Every `services[].routing[].host`, deduped, in declaration order. */
73
+ routedHosts: string[];
74
+ redirectHosts: string[];
75
+ /**
76
+ * The listener's actual default-action predicate: `true` → fixed-404
77
+ * default; `false` → the sole target group IS the default action and the
78
+ * listener forwards EVERY hostname.
79
+ */
80
+ default404: boolean;
81
+ rules: EcsIngressRule[];
82
+ certificateCoverage: CertificateCoverage;
83
+ }
84
+ /**
85
+ * Zone-derived profile ingredients assembled by `addHostedZone`, where the
86
+ * zone, hosts, and certificate coverage are already computed for emission.
87
+ */
88
+ export interface EcsIngressZoneFacts {
89
+ hostedZone: IHostedZone;
90
+ zoneName: string;
91
+ domainName: string;
92
+ routedHosts: string[];
93
+ redirectHosts: string[];
94
+ /** False when `apexRecord: "none"` yielded the domainName record. */
95
+ apexRecordMinted: boolean;
96
+ certificateCoverage: CertificateCoverage;
97
+ }
98
+ /**
99
+ * The listener's default-action predicate — the SINGLE home of the
100
+ * computation `addLoadBalancerListener` passes to the listener factory as
101
+ * `default404`. `true` when ≥2 routes will exist (the emitter then attaches
102
+ * a fixed-404 default and conditions every rule) or when no service has a
103
+ * port (CDK rejects a listener with neither a default action nor targets).
104
+ */
105
+ export declare function computeListenerDefault404(services: EcsServiceProps[]): boolean;
106
+ /**
107
+ * The listener's rule structure as `registerServiceWithALB` and
108
+ * `addRedirectHostRules` will emit it. Mirrors the emitters' branching
109
+ * exactly: a single ports-bearing service with ≤1 rule contributes NO
110
+ * conditioned rule (its target group is the listener default — even a
111
+ * declared `routing.host` does not gate it), and every redirect host
112
+ * contributes a host-matched 301 rule.
113
+ */
114
+ export declare function enumerateListenerRules(services: EcsServiceProps[], redirectHosts: string[]): EcsIngressRule[];
115
+ /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
116
+ export declare function normaliseDnsName(name: string): string;
117
+ /**
118
+ * RFC 6125 certificate-name match: exact, or a single-label `*.` wildcard
119
+ * (matches exactly one additional label — `*.example.com` covers
120
+ * `a.example.com`, never `example.com` or `a.b.example.com`). DNS names
121
+ * compare ASCII case-insensitively (RFC 6125 §6.4.1 via RFC 4343).
122
+ */
123
+ export declare function certNameMatches(pattern: string, hostname: string): boolean;
124
+ /**
125
+ * Does the listener's attached-certificate set cover `hostname`?
126
+ * Fail closed where decidable, honest where opaque (P3): only `enumerated`
127
+ * coverage may answer `not-covered`; a miss under `partial`/`unknown` is
128
+ * `unknown` because an opaque certificate may still cover the name.
129
+ */
130
+ export declare function certificateCovers(coverage: CertificateCoverage, hostname: string): CoverageAnswer;
131
+ export type ForwardingVerdict = {
132
+ verdict: "forwards";
133
+ } | {
134
+ verdict: "partial";
135
+ patterns: string[];
136
+ } | {
137
+ verdict: "none";
138
+ };
139
+ /**
140
+ * Would the listener forward `Host: <hostname>` requests, for all paths?
141
+ * Computed on the real rule model, never a flattened hostname list:
142
+ *
143
+ * - no fixed-404 default → the default action forwards every hostname;
144
+ * - a host-only rule for the hostname, a host-matched catch-all `/*` path
145
+ * rule, or a host-less catch-all `/*` path rule, forwards all of its
146
+ * paths;
147
+ * - rules that involve the hostname only together with a narrower path
148
+ * condition (ANDed host+path, or host-less non-catch-all paths) forward
149
+ * SOME paths — `partial`, with the patterns named so the caller can
150
+ * surface them;
151
+ * - otherwise every request answers the fixed-404 default — `none`.
152
+ *
153
+ * Hostnames compare case-insensitively (DNS, RFC 4343) — the rule model
154
+ * carries the declared spelling, the caller's hostname may differ in case.
155
+ */
156
+ export declare function forwardingVerdict(profile: EcsIngressProfile, hostname: string): ForwardingVerdict;
157
+ export declare function buildIngressProfile(options: {
158
+ loadBalancer: IApplicationLoadBalancer;
159
+ internal: boolean;
160
+ services: EcsServiceProps[];
161
+ certificateAttached: boolean;
162
+ zoneFacts?: EcsIngressZoneFacts;
163
+ }): EcsIngressProfile;
164
+ /**
165
+ * W3 (design 2026-08-18 cdn-app-origin): a cluster whose listener default is
166
+ * fixed-404 while its `domainName` has a minted apex record and NO rule
167
+ * forwards it answers 404 on its own primary domain — usually the aftermath
168
+ * of adding a second routing rule (which flips the default action from
169
+ * forward to 404, `computeListenerDefault404`). A warning, not an error:
170
+ * pre-existing clusters can already be in this state, and narrowing them is
171
+ * not this check's mandate. Partial forwarding (path-split services) is the
172
+ * normal multi-route shape and does not warn.
173
+ */
174
+ export declare function warnWhenRecordedApexUnforwarded(profile: EcsIngressProfile, apexRecordMinted: boolean, clusterName: string): void;
@@ -0,0 +1,195 @@
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 mirrors `registerServiceWithALB`'s exact branching
13
+ * (including the single-service conditions-dropped branch), and the covered
14
+ * SAN set is the one `addHostedZone` mints. Never recompute these facts from
15
+ * cluster props elsewhere; extend the profile instead.
16
+ */
17
+ import { FjallLogger } from "../../../utils/validationLogger.js";
18
+ function normaliseRoutingRules(routing) {
19
+ return Array.isArray(routing) ? routing : routing ? [routing] : [];
20
+ }
21
+ function servicesWithPorts(services) {
22
+ return services.filter((s) => s.containers.some((c) => c.port !== undefined));
23
+ }
24
+ /**
25
+ * The listener's default-action predicate — the SINGLE home of the
26
+ * computation `addLoadBalancerListener` passes to the listener factory as
27
+ * `default404`. `true` when ≥2 routes will exist (the emitter then attaches
28
+ * a fixed-404 default and conditions every rule) or when no service has a
29
+ * port (CDK rejects a listener with neither a default action nor targets).
30
+ */
31
+ export function computeListenerDefault404(services) {
32
+ const withPorts = servicesWithPorts(services);
33
+ const willHaveMultipleRoutes = withPorts.length > 1 ||
34
+ withPorts.some((s) => normaliseRoutingRules(s.routing).length > 1);
35
+ return willHaveMultipleRoutes || withPorts.length === 0;
36
+ }
37
+ /**
38
+ * The listener's rule structure as `registerServiceWithALB` and
39
+ * `addRedirectHostRules` will emit it. Mirrors the emitters' branching
40
+ * exactly: a single ports-bearing service with ≤1 rule contributes NO
41
+ * conditioned rule (its target group is the listener default — even a
42
+ * declared `routing.host` does not gate it), and every redirect host
43
+ * contributes a host-matched 301 rule.
44
+ */
45
+ export function enumerateListenerRules(services, redirectHosts) {
46
+ const withPorts = servicesWithPorts(services);
47
+ const isSingleService = withPorts.length === 1;
48
+ const rules = [];
49
+ for (const service of withPorts) {
50
+ const routingRules = normaliseRoutingRules(service.routing);
51
+ if (isSingleService && routingRules.length <= 1)
52
+ continue;
53
+ for (const rule of routingRules) {
54
+ if (rule.host === undefined && rule.path === undefined)
55
+ continue;
56
+ rules.push({
57
+ ...(rule.host !== undefined && { host: rule.host }),
58
+ ...(rule.path !== undefined && { path: rule.path }),
59
+ kind: "forward"
60
+ });
61
+ }
62
+ }
63
+ for (const host of redirectHosts) {
64
+ rules.push({ host, kind: "redirect" });
65
+ }
66
+ return rules;
67
+ }
68
+ /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
69
+ export function normaliseDnsName(name) {
70
+ return name.toLowerCase().replace(/\.$/, "");
71
+ }
72
+ /**
73
+ * RFC 6125 certificate-name match: exact, or a single-label `*.` wildcard
74
+ * (matches exactly one additional label — `*.example.com` covers
75
+ * `a.example.com`, never `example.com` or `a.b.example.com`). DNS names
76
+ * compare ASCII case-insensitively (RFC 6125 §6.4.1 via RFC 4343).
77
+ */
78
+ export function certNameMatches(pattern, hostname) {
79
+ const p = pattern.toLowerCase();
80
+ const h = hostname.toLowerCase();
81
+ if (p === h)
82
+ return true;
83
+ if (!p.startsWith("*."))
84
+ return false;
85
+ const suffix = p.slice(1); // ".example.com"
86
+ if (!h.endsWith(suffix))
87
+ return false;
88
+ const label = h.slice(0, h.length - suffix.length);
89
+ return label.length > 0 && !label.includes(".");
90
+ }
91
+ /**
92
+ * Does the listener's attached-certificate set cover `hostname`?
93
+ * Fail closed where decidable, honest where opaque (P3): only `enumerated`
94
+ * coverage may answer `not-covered`; a miss under `partial`/`unknown` is
95
+ * `unknown` because an opaque certificate may still cover the name.
96
+ */
97
+ export function certificateCovers(coverage, hostname) {
98
+ if (coverage.kind === "unknown")
99
+ return "unknown";
100
+ if (coverage.hostnames.some((p) => certNameMatches(p, hostname))) {
101
+ return "covered";
102
+ }
103
+ return coverage.kind === "enumerated" ? "not-covered" : "unknown";
104
+ }
105
+ /**
106
+ * Would the listener forward `Host: <hostname>` requests, for all paths?
107
+ * Computed on the real rule model, never a flattened hostname list:
108
+ *
109
+ * - no fixed-404 default → the default action forwards every hostname;
110
+ * - a host-only rule for the hostname, a host-matched catch-all `/*` path
111
+ * rule, or a host-less catch-all `/*` path rule, forwards all of its
112
+ * paths;
113
+ * - rules that involve the hostname only together with a narrower path
114
+ * condition (ANDed host+path, or host-less non-catch-all paths) forward
115
+ * SOME paths — `partial`, with the patterns named so the caller can
116
+ * surface them;
117
+ * - otherwise every request answers the fixed-404 default — `none`.
118
+ *
119
+ * Hostnames compare case-insensitively (DNS, RFC 4343) — the rule model
120
+ * carries the declared spelling, the caller's hostname may differ in case.
121
+ */
122
+ export function forwardingVerdict(profile, hostname) {
123
+ if (!profile.default404)
124
+ return { verdict: "forwards" };
125
+ const wanted = normaliseDnsName(hostname);
126
+ const partial = [];
127
+ for (const rule of profile.rules) {
128
+ if (rule.kind !== "forward")
129
+ continue;
130
+ const hostMatches = rule.host !== undefined && normaliseDnsName(rule.host) === wanted;
131
+ if (hostMatches && (rule.path === undefined || rule.path === "/*")) {
132
+ return { verdict: "forwards" };
133
+ }
134
+ if (rule.host === undefined && rule.path === "/*") {
135
+ return { verdict: "forwards" };
136
+ }
137
+ if (hostMatches && rule.path !== undefined) {
138
+ partial.push(`Host=${rule.host} AND Path=${rule.path}`);
139
+ }
140
+ else if (rule.host === undefined && rule.path !== undefined) {
141
+ partial.push(`Path=${rule.path} (any host)`);
142
+ }
143
+ }
144
+ return partial.length > 0
145
+ ? { verdict: "partial", patterns: partial }
146
+ : { verdict: "none" };
147
+ }
148
+ export function buildIngressProfile(options) {
149
+ const { zoneFacts } = options;
150
+ const redirectHosts = zoneFacts?.redirectHosts ?? [];
151
+ return {
152
+ loadBalancer: options.loadBalancer,
153
+ internal: options.internal,
154
+ listenerPort: options.certificateAttached ? 443 : 80,
155
+ ...(zoneFacts !== undefined && {
156
+ hostedZone: zoneFacts.hostedZone,
157
+ zoneName: zoneFacts.zoneName,
158
+ domainName: zoneFacts.domainName
159
+ }),
160
+ routedHosts: zoneFacts?.routedHosts ?? [],
161
+ redirectHosts,
162
+ default404: computeListenerDefault404(options.services),
163
+ rules: enumerateListenerRules(options.services, redirectHosts),
164
+ certificateCoverage: zoneFacts?.certificateCoverage ?? {
165
+ // No domain → no certificates attached: the attached set is fully
166
+ // known (empty), which is the honest enumerated answer, not "unknown".
167
+ kind: "enumerated",
168
+ hostnames: []
169
+ }
170
+ };
171
+ }
172
+ /**
173
+ * W3 (design 2026-08-18 cdn-app-origin): a cluster whose listener default is
174
+ * fixed-404 while its `domainName` has a minted apex record and NO rule
175
+ * forwards it answers 404 on its own primary domain — usually the aftermath
176
+ * of adding a second routing rule (which flips the default action from
177
+ * forward to 404, `computeListenerDefault404`). A warning, not an error:
178
+ * pre-existing clusters can already be in this state, and narrowing them is
179
+ * not this check's mandate. Partial forwarding (path-split services) is the
180
+ * normal multi-route shape and does not warn.
181
+ */
182
+ export function warnWhenRecordedApexUnforwarded(profile, apexRecordMinted, clusterName) {
183
+ if (!profile.default404)
184
+ return;
185
+ if (profile.domainName === undefined || !apexRecordMinted)
186
+ return;
187
+ if (forwardingVerdict(profile, profile.domainName).verdict !== "none")
188
+ return;
189
+ FjallLogger.warn(`Cluster '${clusterName}': the listener's default action is a fixed 404 ` +
190
+ `and no routing rule forwards '${profile.domainName}', but its alias ` +
191
+ "record is minted — requests to the cluster's own domain will answer " +
192
+ "404 Not Found. Multiple routing rules flip the listener default from " +
193
+ "forward to 404; keep a forwarding path for the domain, e.g. a " +
194
+ 'routing rule { path: "/*" } on the service that should serve it.');
195
+ }
@@ -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, ManagedDomainCoverage } 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,19 @@ 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;
@@ -23,6 +23,17 @@ export const MANAGED_DOMAIN_CONTEXT_PREFIX = "fjall:managedDomain:";
23
23
  export function getManagedDomainContextKey(zoneName) {
24
24
  return `${MANAGED_DOMAIN_CONTEXT_PREFIX}${zoneName}`;
25
25
  }
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 const MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX = "fjall:managedDomainCoverage:";
34
+ export function getManagedDomainCoverageContextKey(zoneName) {
35
+ return `${MANAGED_DOMAIN_COVERAGE_CONTEXT_PREFIX}${zoneName}`;
36
+ }
26
37
  const REQUIRED_STRING_FIELDS = ["zoneName", "hostedZoneId"];
27
38
  const OPTIONAL_STRING_FIELDS = [
28
39
  "certificateArn",
@@ -56,6 +67,64 @@ export function readInjectedManagedDomainBinding(node, domainName, context) {
56
67
  }
57
68
  return undefined;
58
69
  }
70
+ /**
71
+ * Read the CLI-injected {@link ManagedDomainCoverage} for the zone a binding
72
+ * already resolved to. Exact-key lookup on purpose (no parent-zone walk):
73
+ * the CLI injects coverage for precisely the zones it injects bindings for,
74
+ * and the caller passes the binding's own `zoneName`.
75
+ *
76
+ * Absent → undefined (a domain stack that predates the hosts outputs, or a
77
+ * bare-CDK synth) — consumers treat that as coverage-unknown and warn, never
78
+ * as not-covered. A present-but-corrupt value still throws (repo posture:
79
+ * corrupt context fails the synth rather than falling through), but UNKNOWN
80
+ * FIELDS ARE IGNORED, unlike the binding parser: coverage is advisory
81
+ * validation input, and forward tolerance here is what lets a future CLI add
82
+ * coverage fields without breaking older engines — the exact trap that
83
+ * forced this channel off the binding JSON in the first place.
84
+ */
85
+ export function readInjectedManagedDomainCoverage(node, zoneName, context) {
86
+ const key = getManagedDomainCoverageContextKey(zoneName);
87
+ const raw = node.tryGetContext(key);
88
+ if (raw === undefined)
89
+ return undefined;
90
+ let value = raw;
91
+ if (typeof raw === "string") {
92
+ try {
93
+ value = JSON.parse(raw);
94
+ }
95
+ catch {
96
+ throw new Error(`${context}: CDK context '${key}' is not valid JSON (got '${raw}'). ` +
97
+ "The value must be a JSON ManagedDomainCoverage " +
98
+ "({ certificateHosts?, usEast1CertificateHosts? }) — re-run the " +
99
+ "deploy through the Fjall CLI, or correct the hand-set context " +
100
+ "entry.");
101
+ }
102
+ }
103
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
104
+ throw new Error(`${context}: CDK context '${key}' must be a JSON object ` +
105
+ `ManagedDomainCoverage (got ${JSON.stringify(value)}). Re-run the ` +
106
+ "deploy through the Fjall CLI, or correct the hand-set context entry.");
107
+ }
108
+ const record = value;
109
+ const coverage = {};
110
+ for (const field of [
111
+ "certificateHosts",
112
+ "usEast1CertificateHosts"
113
+ ]) {
114
+ const fieldValue = record[field];
115
+ if (fieldValue === undefined)
116
+ continue;
117
+ if (!Array.isArray(fieldValue) ||
118
+ fieldValue.some((h) => typeof h !== "string" || h === "")) {
119
+ throw new Error(`${context}: CDK context '${key}' field '${field}' must be an array ` +
120
+ `of non-empty strings when present (got ` +
121
+ `${JSON.stringify(fieldValue)}). Re-run the deploy through the ` +
122
+ "Fjall CLI, or correct the hand-set context entry.");
123
+ }
124
+ coverage[field] = fieldValue;
125
+ }
126
+ return coverage;
127
+ }
59
128
  function parseManagedDomainBinding(raw, zoneName, key, context) {
60
129
  let value = raw;
61
130
  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.2.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.2.0",
84
+ "@fjall/util": "^14.2.0",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {