@fjall/components-infrastructure 14.0.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.
@@ -15,17 +15,28 @@ export function defaultDnsComment(recordType, fqdn) {
15
15
  /**
16
16
  * Claim the (zone, name, type) triple in the app-scoped collision registry
17
17
  * (design D5, synth-side). Every wrapper in the dnsRecord family calls this
18
- * from its constructor so a second claimant — same stack or another stack in
19
- * the app — fails at synth instead of at CloudFormation deploy. Alias records
20
- * claim their underlying Route53 type ("A"). Observation only: no constructs
21
- * are created, so construct trees stay byte-identical.
18
+ * from its constructor so an illegal second claimant — same stack or another
19
+ * stack in the app — fails at synth instead of at CloudFormation deploy.
20
+ * Alias records claim their underlying Route53 type ("A") and pass their
21
+ * routing variant so legal policy siblings register cleanly. Observation
22
+ * only: no constructs are created, so construct trees stay byte-identical.
22
23
  */
23
- export function claimDnsRecord(construct, props, recordType, fqdn) {
24
+ export function claimDnsRecord(construct, props, recordType, fqdn, variant) {
24
25
  registerDnsRecordClaim(construct, {
25
26
  zone: props.zone,
26
27
  zoneName: props.zoneName,
27
28
  fqdn,
28
- recordType
29
+ recordType,
30
+ ...(variant?.setIdentifier !== undefined && {
31
+ setIdentifier: variant.setIdentifier
32
+ }),
33
+ ...(variant?.routingPolicy !== undefined && {
34
+ routingPolicy: variant.routingPolicy
35
+ }),
36
+ ...(variant?.region !== undefined && { region: variant.region }),
37
+ ...(variant?.geoLocationKey !== undefined && {
38
+ geoLocationKey: variant.geoLocationKey
39
+ })
29
40
  });
30
41
  }
31
42
  export function applyDnsRecordTags(construct, props) {
@@ -2,13 +2,23 @@ import type { IHostedZone } from "aws-cdk-lib/aws-route53";
2
2
  import type { IConstruct } from "constructs";
3
3
  /**
4
4
  * Synth-time DNS record-collision registry (design D5, synth-side half — the
5
- * deploy-time preflight against live zones is Phase 2).
5
+ * deploy-time preflight against live zones is the CLI/deploy-core domain
6
+ * gate).
6
7
  *
7
- * Route53 allows exactly one record set per (zone, name, type); a second
8
- * claimant today fails only at CloudFormation deploy with a raw "record set
9
- * already exists". This registry makes the collision loud at synth: every
10
- * construct in the dnsRecord wrapper family registers its claim here, and a
11
- * duplicate claim throws naming both claimant construct paths and the cure.
8
+ * Route53 allows exactly one record set per (zone, name, type) EXCEPT for
9
+ * routing-policy variants, where sibling record sets share the (name, type)
10
+ * pair and are discriminated by `setIdentifier`. Sibling variants must all
11
+ * carry the same policy type, and latency variants must each use a distinct
12
+ * region. A shape Route53 rejects fails only at CloudFormation deploy with a
13
+ * raw "record set already exists" / "conflicting RRSet"; this registry makes
14
+ * it loud at synth: every construct in the dnsRecord wrapper family registers
15
+ * its claim here, and an illegal combination throws naming both claimant
16
+ * construct paths and the cure.
17
+ *
18
+ * Legal coexistence (design 2026-08-17 cdn-domain-ownership C3): two claims
19
+ * on one (zone, name, type) with the SAME routing-policy type and DISTINCT
20
+ * `setIdentifier`s — e.g. the compute cluster's latency apex alias and a
21
+ * CDN's latency apex alias during an ingress migration — register cleanly.
12
22
  *
13
23
  * Scoping: claims are held per App (the construct-tree root), so multiple CDK
14
24
  * Apps in one process — the vitest convention of a fresh `new CdkApp()` per
@@ -22,6 +32,7 @@ import type { IConstruct } from "constructs";
22
32
  * (resetForTesting + resetManifestCollector in beforeEach/afterEach) keeps
23
33
  * working without a third per-test call.
24
34
  */
35
+ export type DnsRoutingPolicyType = "latency" | "weighted" | "geolocation";
25
36
  export interface DnsRecordClaim {
26
37
  /** Zone the record lands in — consulted for identity resolution. */
27
38
  readonly zone: IHostedZone;
@@ -34,14 +45,34 @@ export interface DnsRecordClaim {
34
45
  * — an alias and a plain A record on the same name are a real collision.
35
46
  */
36
47
  readonly recordType: string;
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.
54
+ */
55
+ readonly setIdentifier?: string;
56
+ /** Policy type of a variant claim — siblings must all match. */
57
+ readonly routingPolicy?: DnsRoutingPolicyType;
58
+ /**
59
+ * Latency region of a latency variant — Route53 allows one latency record
60
+ * per region per (name, type).
61
+ */
62
+ readonly region?: string;
63
+ /**
64
+ * Serialised location of a geolocation variant — Route53 allows one
65
+ * geolocation record per location value per (name, type).
66
+ */
67
+ readonly geoLocationKey?: string;
37
68
  }
38
69
  /**
39
70
  * Register a record-set claim for `scope`, throwing when a DIFFERENT
40
- * construct already claimed the same (zoneIdentity, recordName, recordType)
41
- * triple within the same App. Re-registration by the same construct path
42
- * (CDK aspects re-visiting, repeated synth) is idempotent. Pure observation:
43
- * no constructs are created or mutated, so construct trees — including the
44
- * eject-contract byte-identical composer IDs — are untouched.
71
+ * construct already claimed a shape Route53 cannot hold alongside it within
72
+ * the same App. Re-registration by the same construct path (CDK aspects
73
+ * re-visiting, repeated synth) is idempotent. Pure observation: no constructs
74
+ * are created or mutated, so construct trees — including the eject-contract
75
+ * byte-identical composer IDs — are untouched.
45
76
  */
46
77
  export declare function registerDnsRecordClaim(scope: IConstruct, claim: DnsRecordClaim): void;
47
78
  /**
@@ -1,5 +1,17 @@
1
1
  import { Token } from "aws-cdk-lib";
2
- /** Claims per App root: claim key → owning construct path. */
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.)
10
+ */
11
+ function isVariantEntry(entry) {
12
+ return entry.routingPolicy !== undefined || entry.setIdentifier !== undefined;
13
+ }
14
+ /** Claims per App root: claim key → claimant entries (variants share a key). */
3
15
  let claimsByRoot = new WeakMap();
4
16
  /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
5
17
  function normaliseDnsName(name) {
@@ -18,13 +30,20 @@ function resolveZoneIdentity(zone, zoneName) {
18
30
  }
19
31
  return normaliseDnsName(zoneName);
20
32
  }
33
+ function collisionError(claim, recordType, existing, claimantPath, detail) {
34
+ return new Error(`DNS record '${claim.fqdn}' (${recordType}) in zone '${claim.zoneName}': ` +
35
+ `already claimed by construct '${existing.path}' — duplicate claim by '${claimantPath}'. ` +
36
+ `${detail} ` +
37
+ `Keep one owner per record — satellites own their app records; the domain stack owns zone-level records. ` +
38
+ `Remove or rename one claimant, or run 'fjall domain records list ${claim.zoneName}' to inspect the zone.`);
39
+ }
21
40
  /**
22
41
  * Register a record-set claim for `scope`, throwing when a DIFFERENT
23
- * construct already claimed the same (zoneIdentity, recordName, recordType)
24
- * triple within the same App. Re-registration by the same construct path
25
- * (CDK aspects re-visiting, repeated synth) is idempotent. Pure observation:
26
- * no constructs are created or mutated, so construct trees — including the
27
- * eject-contract byte-identical composer IDs — are untouched.
42
+ * construct already claimed a shape Route53 cannot hold alongside it within
43
+ * the same App. Re-registration by the same construct path (CDK aspects
44
+ * re-visiting, repeated synth) is idempotent. Pure observation: no constructs
45
+ * are created or mutated, so construct trees — including the eject-contract
46
+ * byte-identical composer IDs — are untouched.
28
47
  */
29
48
  export function registerDnsRecordClaim(scope, claim) {
30
49
  const root = scope.node.root;
@@ -40,19 +59,54 @@ export function registerDnsRecordClaim(scope, claim) {
40
59
  recordType
41
60
  ].join("|");
42
61
  const claimantPath = scope.node.path;
62
+ const entry = {
63
+ path: claimantPath,
64
+ setIdentifier: claim.setIdentifier,
65
+ routingPolicy: claim.routingPolicy,
66
+ region: claim.region,
67
+ geoLocationKey: claim.geoLocationKey
68
+ };
43
69
  const existing = claims.get(key);
44
70
  if (existing === undefined) {
45
- claims.set(key, claimantPath);
71
+ claims.set(key, [entry]);
46
72
  return;
47
73
  }
48
- if (existing === claimantPath) {
49
- return;
74
+ for (const other of existing) {
75
+ if (other.path === claimantPath) {
76
+ return;
77
+ }
78
+ const bothVariants = isVariantEntry(other) && isVariantEntry(entry);
79
+ if (!bothVariants) {
80
+ // Simple vs simple, or simple vs variant — Route53 holds neither.
81
+ const detail = isVariantEntry(other) || isVariantEntry(entry)
82
+ ? "Route53 cannot mix a simple record set with routing-policy variants on one (zone, name, type) — give BOTH records the same routing-policy type with distinct setIdentifiers, or remove one."
83
+ : "Route53 allows one record set per (zone, name, type); CloudFormation would reject this at deploy.";
84
+ throw collisionError(claim, recordType, other, claimantPath, detail);
85
+ }
86
+ if (other.setIdentifier !== undefined &&
87
+ other.setIdentifier === entry.setIdentifier) {
88
+ throw collisionError(claim, recordType, other, claimantPath, `Both records carry setIdentifier '${String(entry.setIdentifier)}' — sibling routing-policy variants need distinct setIdentifiers.`);
89
+ }
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) {
96
+ 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
+ }
98
+ if (entry.routingPolicy === "latency" &&
99
+ other.region !== undefined &&
100
+ other.region === entry.region) {
101
+ throw collisionError(claim, recordType, other, claimantPath, `Both latency variants use region '${entry.region}' — Route53 allows one latency record per region; give the second variant a different region.`);
102
+ }
103
+ if (entry.routingPolicy === "geolocation" &&
104
+ other.geoLocationKey !== undefined &&
105
+ other.geoLocationKey === entry.geoLocationKey) {
106
+ throw collisionError(claim, recordType, other, claimantPath, `Both geolocation variants target location '${entry.geoLocationKey}' — Route53 allows one geolocation record per location value; give the second variant a different location.`);
107
+ }
50
108
  }
51
- throw new Error(`DNS record '${claim.fqdn}' (${recordType}) in zone '${claim.zoneName}': ` +
52
- `already claimed by construct '${existing}' — duplicate claim by '${claimantPath}'. ` +
53
- `Route53 allows one record set per (zone, name, type); CloudFormation would reject this at deploy. ` +
54
- `Keep one owner per record — satellites own their app records; the domain stack owns zone-level records. ` +
55
- `Remove or rename one claimant, or run 'fjall domain records list ${claim.zoneName}' to inspect the zone.`);
109
+ existing.push(entry);
56
110
  }
57
111
  /**
58
112
  * Reset all claims (for testing). Called by `App.resetForTesting()` alongside
@@ -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.0.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.0.0",
84
- "@fjall/util": "^14.0.0",
83
+ "@fjall/generator": "^14.2.0",
84
+ "@fjall/util": "^14.2.0",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {