@fjall/components-infrastructure 3.5.2 → 3.6.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.
@@ -1,5 +1,5 @@
1
1
  import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
2
- import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
2
+ import { composeDomainCertificates } from "./domainCertificateComposer.js";
3
3
  import { composeTypedDnsRecords } from "./dnsRecordComposer.js";
4
4
  import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
5
5
  /**
@@ -27,19 +27,17 @@ export function composeApexDomain(scope, props) {
27
27
  costAllocationEnvironment: props.costAllocationEnvironment,
28
28
  costAllocationDomain: props.zoneName
29
29
  });
30
- const certificates = new Map();
31
- (props.certificates ?? []).forEach((cert, index) => {
32
- const normalised = normaliseCertificate(cert);
33
- const safeCertName = toPascalCase(normalised.domainName.split(".").join(""));
34
- const dc = new DomainCertificate(scope, `${safeZone}${safeCertName}Cert${index}`, {
35
- domainName: normalised.domainName,
36
- subjectAlternativeNames: normalised.subjectAlternativeNames,
37
- transparencyLogging: normalised.transparencyLogging,
38
- hostedZone: hostedZoneConstruct.hostedZone,
39
- costAllocationEnvironment: props.costAllocationEnvironment,
40
- costAllocationDomain: props.zoneName
41
- });
42
- certificates.set(normalised.domainName, dc.certificate);
30
+ // Regional entries mint beside the zone as before; cloudFront entries
31
+ // mint in us-east-1 (in-stack here, or in the domain-paired
32
+ // UsEast1Certificates stack) and publish the zone-level export (D3).
33
+ const certificates = composeDomainCertificates(scope, {
34
+ certificates: props.certificates,
35
+ effectiveZoneName: props.zoneName,
36
+ safeZone,
37
+ hostedZone: hostedZoneConstruct.hostedZone,
38
+ hostedZoneId: props.hostedZoneId,
39
+ costAllocationEnvironment: props.costAllocationEnvironment,
40
+ costAllocationDomain: props.zoneName
43
41
  });
44
42
  if (props.records && props.records.length > 0) {
45
43
  composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, props.zoneName, props.records);
@@ -55,13 +53,3 @@ export function composeApexDomain(scope, props) {
55
53
  manualRecords: []
56
54
  };
57
55
  }
58
- function normaliseCertificate(cert) {
59
- if (typeof cert === "string") {
60
- return { domainName: cert };
61
- }
62
- return {
63
- domainName: cert.domainName,
64
- subjectAlternativeNames: cert.subjectAlternativeNames,
65
- transparencyLogging: cert.transparencyLogging
66
- };
67
- }
@@ -10,8 +10,12 @@ export interface DelegatedDomainPatternResult {
10
10
  }
11
11
  /**
12
12
  * Composition for `registrar: "external-delegated"` — the delegated-child
13
- * topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}` and,
14
- * when `parentDelegationRoleArn` is present, UPSERTs the child NS into the
13
+ * topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}`
14
+ * or ADOPTS an existing one when `hostedZoneId` + `adoptedNameServers` are
15
+ * set (both-or-neither, validated): the zone is imported instead of created
16
+ * and the delegation UPSERT carries the adopted NS literals, since an
17
+ * imported zone exposes no `hostedZoneNameServers` attribute. When
18
+ * `parentDelegationRoleArn` is present, the child NS are UPSERTed into the
15
19
  * parent zone via `CrossAccountZoneDelegationRecord` (D8 child-writes
16
20
  * delegation; same shape as devSubstrate Case 1). The role ARN MUST be a
17
21
  * literal — validated in `validateDomainProps` (VD2-a). When absent, the
@@ -2,14 +2,18 @@ import { Annotations } from "aws-cdk-lib";
2
2
  import { Role } from "../../resources/aws/iam/index.js";
3
3
  import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
4
4
  import { CrossAccountDelegationRecord } from "../../resources/aws/networking/crossAccountDelegationRecord.js";
5
- import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
5
+ import { composeDomainCertificates } from "./domainCertificateComposer.js";
6
6
  import { composeTypedDnsRecords } from "./dnsRecordComposer.js";
7
7
  import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
8
8
  import { DOMAIN_DEPLOY_DEFAULT_PHASE } from "../../utils/domainTypes.js";
9
9
  /**
10
10
  * Composition for `registrar: "external-delegated"` — the delegated-child
11
- * topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}` and,
12
- * when `parentDelegationRoleArn` is present, UPSERTs the child NS into the
11
+ * topology. Creates a hosted zone for `{delegatedSubdomain}.{zoneName}`
12
+ * or ADOPTS an existing one when `hostedZoneId` + `adoptedNameServers` are
13
+ * set (both-or-neither, validated): the zone is imported instead of created
14
+ * and the delegation UPSERT carries the adopted NS literals, since an
15
+ * imported zone exposes no `hostedZoneNameServers` attribute. When
16
+ * `parentDelegationRoleArn` is present, the child NS are UPSERTed into the
13
17
  * parent zone via `CrossAccountZoneDelegationRecord` (D8 child-writes
14
18
  * delegation; same shape as devSubstrate Case 1). The role ARN MUST be a
15
19
  * literal — validated in `validateDomainProps` (VD2-a). When absent, the
@@ -26,6 +30,9 @@ export function composeDelegatedDomain(scope, props) {
26
30
  const safeZone = toPascalCase(getSafeZoneName(effectiveZone));
27
31
  const hostedZoneConstruct = new HostedZone(scope, `${safeZone}HostedZone`, {
28
32
  zoneName: effectiveZone,
33
+ // Adoption path: an existing child zone is imported by id rather than
34
+ // created (both-or-neither with adoptedNameServers, validated upstream).
35
+ hostedZoneId: props.hostedZoneId,
29
36
  // The PARENT account owns the delegation role — this is the child.
30
37
  createDelegationRole: false,
31
38
  costAllocationEnvironment: props.costAllocationEnvironment,
@@ -44,6 +51,9 @@ export function composeDelegatedDomain(scope, props) {
44
51
  delegatedZone: hostedZoneConstruct.hostedZone,
45
52
  delegatedZoneName: effectiveZone,
46
53
  parentHostedZoneName: props.zoneName,
54
+ // Adoption: the imported zone has no NS attribute — the UPSERT uses
55
+ // the user-declared literals (undefined on the create path).
56
+ adoptedNameServers: props.adoptedNameServers,
47
57
  costAllocationEnvironment: props.costAllocationEnvironment,
48
58
  costAllocationDomain: props.zoneName
49
59
  });
@@ -55,7 +65,10 @@ export function composeDelegatedDomain(scope, props) {
55
65
  `the parent DelegationRole ARN from the managed-domain binding) or ` +
56
66
  `add the zone's NS records at the parent registrar manually.`);
57
67
  }
58
- const nameServers = hostedZoneConstruct.nameServers ?? [];
68
+ // Created zones surface their synth-time NS tokens; adopted zones surface
69
+ // the declared literals (authoritative — the same values the delegation
70
+ // record UPSERTs into the parent).
71
+ const nameServers = hostedZoneConstruct.nameServers ?? props.adoptedNameServers ?? [];
59
72
  const records = props.records ?? [];
60
73
  if (records.length > 0) {
61
74
  composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records);
@@ -70,19 +83,17 @@ export function composeDelegatedDomain(scope, props) {
70
83
  manualRecords: []
71
84
  };
72
85
  }
73
- const certificates = new Map();
74
- (props.certificates ?? []).forEach((cert, index) => {
75
- const normalised = normaliseCertificate(cert);
76
- const safeCertName = toPascalCase(normalised.domainName.split(".").join(""));
77
- const dc = new DomainCertificate(scope, `${safeZone}${safeCertName}Cert${index}`, {
78
- domainName: normalised.domainName,
79
- subjectAlternativeNames: normalised.subjectAlternativeNames,
80
- transparencyLogging: normalised.transparencyLogging,
81
- hostedZone: hostedZoneConstruct.hostedZone,
82
- costAllocationEnvironment: props.costAllocationEnvironment,
83
- costAllocationDomain: props.zoneName
84
- });
85
- certificates.set(normalised.domainName, dc.certificate);
86
+ // Regional entries mint beside the zone as before; cloudFront entries
87
+ // mint in us-east-1 (in-stack here, or in the domain-paired
88
+ // UsEast1Certificates stack) and publish the zone-level export (D3).
89
+ const certificates = composeDomainCertificates(scope, {
90
+ certificates: props.certificates,
91
+ effectiveZoneName: effectiveZone,
92
+ safeZone,
93
+ hostedZone: hostedZoneConstruct.hostedZone,
94
+ hostedZoneId: props.hostedZoneId,
95
+ costAllocationEnvironment: props.costAllocationEnvironment,
96
+ costAllocationDomain: props.zoneName
86
97
  });
87
98
  return {
88
99
  hostedZone: hostedZoneConstruct.hostedZone,
@@ -91,13 +102,3 @@ export function composeDelegatedDomain(scope, props) {
91
102
  manualRecords: []
92
103
  };
93
104
  }
94
- function normaliseCertificate(cert) {
95
- if (typeof cert === "string") {
96
- return { domainName: cert };
97
- }
98
- return {
99
- domainName: cert.domainName,
100
- subjectAlternativeNames: cert.subjectAlternativeNames,
101
- transparencyLogging: cert.transparencyLogging
102
- };
103
- }
@@ -0,0 +1,48 @@
1
+ import type { Construct } from "constructs";
2
+ import { type IHostedZone } from "aws-cdk-lib/aws-route53";
3
+ import type { ICertificate } from "aws-cdk-lib/aws-certificatemanager";
4
+ import type { Certificate } from "./interfaces/domain.js";
5
+ export interface DomainCertificateComposition {
6
+ readonly certificates: readonly Certificate[] | undefined;
7
+ /**
8
+ * Zone the export names derive from — the apex `zoneName`, or the
9
+ * delegated child's `${delegatedSubdomain}.${zoneName}` (mirrors the
10
+ * Domain construct's `resolveEffectiveZoneName`).
11
+ */
12
+ readonly effectiveZoneName: string;
13
+ /** PascalCase construct-ID prefix shared with the calling composer. */
14
+ readonly safeZone: string;
15
+ readonly hostedZone: IHostedZone;
16
+ /**
17
+ * Literal zone id when the zone is ADOPTED — the paired us-east-1 stack
18
+ * re-imports the zone by attributes instead of a runtime lookup (a created
19
+ * zone's id is a cross-stack token the paired stack must not consume).
20
+ */
21
+ readonly hostedZoneId: string | undefined;
22
+ readonly costAllocationEnvironment: string | undefined;
23
+ readonly costAllocationDomain: string;
24
+ }
25
+ /**
26
+ * Certificate composition shared by `composeApexDomain` and
27
+ * `composeDelegatedDomain`.
28
+ *
29
+ * Regional entries mint a `DomainCertificate` beside the zone exactly as
30
+ * before (byte-stable construct IDs `${safeZone}${safeCertName}Cert${index}`,
31
+ * per-domain `<domain>-certificate-arn` export). A `cloudFront: true` entry
32
+ * (D3) instead mints in us-east-1 — the only region CloudFront accepts
33
+ * viewer certificates from — and publishes the zone-level
34
+ * `<zone>-us-east-1-certificate-arn` export in place of the per-domain one:
35
+ *
36
+ * - domain stack already in us-east-1 (the fjall.io case): certificate and
37
+ * export stay IN-STACK, no extra stack;
38
+ * - any other resolved region: certificate and export live in a
39
+ * domain-paired `<StackName>UsEast1Certificates` stack. NO CDK
40
+ * cross-region references — the ARN is consumed as a LITERAL via D2
41
+ * DescribeStacks (`ManagedDomainBinding.usEast1CertificateArn`), so
42
+ * neither stack synthesises the export writer/reader machinery.
43
+ *
44
+ * An unresolved region or account is a synth-time error naming the cures:
45
+ * where the certificate must live is unknowable, and guessing would strand
46
+ * the export CloudFront consumers (patternDomain) fail without.
47
+ */
48
+ export declare function composeDomainCertificates(scope: Construct, composition: DomainCertificateComposition): Map<string, ICertificate>;
@@ -0,0 +1,179 @@
1
+ import { CfnOutput, Stack, Stage, Token } from "aws-cdk-lib";
2
+ import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
3
+ import { getDomainExportNames, getDomainUsEast1CertificatesStackName } from "@fjall/util";
4
+ import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
5
+ import { toPascalCase } from "../../utils/capitaliseString.js";
6
+ const US_EAST_1 = "us-east-1";
7
+ /**
8
+ * Certificate composition shared by `composeApexDomain` and
9
+ * `composeDelegatedDomain`.
10
+ *
11
+ * Regional entries mint a `DomainCertificate` beside the zone exactly as
12
+ * before (byte-stable construct IDs `${safeZone}${safeCertName}Cert${index}`,
13
+ * per-domain `<domain>-certificate-arn` export). A `cloudFront: true` entry
14
+ * (D3) instead mints in us-east-1 — the only region CloudFront accepts
15
+ * viewer certificates from — and publishes the zone-level
16
+ * `<zone>-us-east-1-certificate-arn` export in place of the per-domain one:
17
+ *
18
+ * - domain stack already in us-east-1 (the fjall.io case): certificate and
19
+ * export stay IN-STACK, no extra stack;
20
+ * - any other resolved region: certificate and export live in a
21
+ * domain-paired `<StackName>UsEast1Certificates` stack. NO CDK
22
+ * cross-region references — the ARN is consumed as a LITERAL via D2
23
+ * DescribeStacks (`ManagedDomainBinding.usEast1CertificateArn`), so
24
+ * neither stack synthesises the export writer/reader machinery.
25
+ *
26
+ * An unresolved region or account is a synth-time error naming the cures:
27
+ * where the certificate must live is unknowable, and guessing would strand
28
+ * the export CloudFront consumers (patternDomain) fail without.
29
+ */
30
+ export function composeDomainCertificates(scope, composition) {
31
+ const certificates = new Map();
32
+ (composition.certificates ?? []).forEach((cert, index) => {
33
+ const normalised = normaliseCertificate(cert);
34
+ const safeCertName = toPascalCase(normalised.domainName.split(".").join(""));
35
+ const certId = `${composition.safeZone}${safeCertName}Cert${index}`;
36
+ if (normalised.cloudFront !== true) {
37
+ const dc = new DomainCertificate(scope, certId, {
38
+ domainName: normalised.domainName,
39
+ subjectAlternativeNames: normalised.subjectAlternativeNames,
40
+ transparencyLogging: normalised.transparencyLogging,
41
+ hostedZone: composition.hostedZone,
42
+ costAllocationEnvironment: composition.costAllocationEnvironment,
43
+ costAllocationDomain: composition.costAllocationDomain
44
+ });
45
+ certificates.set(normalised.domainName, dc.certificate);
46
+ return;
47
+ }
48
+ certificates.set(normalised.domainName, mintCloudFrontCertificate(scope, composition, normalised, certId));
49
+ });
50
+ return certificates;
51
+ }
52
+ function mintCloudFrontCertificate(scope, composition, cert, certId) {
53
+ const domainStack = Stack.of(scope);
54
+ const region = Token.isUnresolved(domainStack.region)
55
+ ? undefined
56
+ : domainStack.region;
57
+ if (region === undefined) {
58
+ throw new Error(`Domain '${composition.effectiveZoneName}': cannot place the ` +
59
+ `CloudFront certificate for '${cert.domainName}' — the domain ` +
60
+ "stack's region is unresolved at synth (got a region token), so " +
61
+ "whether it already deploys to us-east-1 is unknowable. Deploy " +
62
+ "through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT and " +
63
+ "CDK_DEFAULT_REGION before synthesising.");
64
+ }
65
+ if (region === US_EAST_1) {
66
+ // In-stack placement (the fjall.io case): the domain stack's home region
67
+ // already satisfies CloudFront, so no paired stack is minted and the
68
+ // zone-level export publishes beside the zone.
69
+ const dc = new DomainCertificate(scope, certId, {
70
+ domainName: cert.domainName,
71
+ subjectAlternativeNames: cert.subjectAlternativeNames,
72
+ transparencyLogging: cert.transparencyLogging,
73
+ hostedZone: composition.hostedZone,
74
+ costAllocationEnvironment: composition.costAllocationEnvironment,
75
+ costAllocationDomain: composition.costAllocationDomain,
76
+ // The zone-level us-east-1 export below replaces the per-domain
77
+ // regional export: CloudFront consumers resolve the ARN through
78
+ // ManagedDomainBinding.usEast1CertificateArn, and a viewer
79
+ // certificate has no regional (ALB/ECS) consumers to export for.
80
+ exportCertificateArn: false
81
+ });
82
+ emitUsEast1Export(scope, composition, dc);
83
+ return dc.certificate;
84
+ }
85
+ const pairedStack = resolvePairedUsEast1Stack(domainStack, composition, cert);
86
+ // Re-create the zone reference INSIDE the paired stack: an adopted zone's
87
+ // literal id travels as-is, while a created zone's id is a cross-stack
88
+ // token the paired stack must not consume (that would demand
89
+ // crossRegionReferences) — it re-resolves by lookup on the declared zone
90
+ // name instead (same split as patternDomain's BYO path).
91
+ const certZone = composition.hostedZoneId !== undefined
92
+ ? AWSHostedZone.fromHostedZoneAttributes(pairedStack, `${composition.safeZone}UsEast1CertificateZone`, {
93
+ hostedZoneId: composition.hostedZoneId,
94
+ zoneName: composition.effectiveZoneName
95
+ })
96
+ : AWSHostedZone.fromLookup(pairedStack, `${composition.safeZone}UsEast1CertificateZone`, { domainName: composition.effectiveZoneName });
97
+ const dc = new DomainCertificate(pairedStack, certId, {
98
+ domainName: cert.domainName,
99
+ subjectAlternativeNames: cert.subjectAlternativeNames,
100
+ transparencyLogging: cert.transparencyLogging,
101
+ hostedZone: certZone,
102
+ costAllocationEnvironment: composition.costAllocationEnvironment,
103
+ costAllocationDomain: composition.costAllocationDomain,
104
+ exportCertificateArn: false
105
+ });
106
+ emitUsEast1Export(pairedStack, composition, dc);
107
+ return dc.certificate;
108
+ }
109
+ /**
110
+ * The zone-level `<zone>-us-east-1-certificate-arn` export
111
+ * (`getDomainExportNames(...).usEast1CertificateArn` — the SSOT
112
+ * `DomainService.resolveDomainForApp` reads into
113
+ * `ManagedDomainBinding.usEast1CertificateArn` for CloudFront consumers).
114
+ * Scope is the stack that owns the certificate: the domain stack in-region,
115
+ * the paired stack otherwise.
116
+ */
117
+ function emitUsEast1Export(scope, composition, dc) {
118
+ const exports = getDomainExportNames(composition.effectiveZoneName);
119
+ new CfnOutput(scope, `${composition.safeZone}UsEast1CertificateArn`, {
120
+ key: `${composition.safeZone}UsEast1CertificateArn`,
121
+ value: dc.certificateArn,
122
+ exportName: exports.usEast1CertificateArn
123
+ });
124
+ }
125
+ /**
126
+ * The domain-paired us-east-1 certificate stack,
127
+ * `<StackName>UsEast1Certificates` — the name contract
128
+ * `DomainService.resolveDomainForApp` derives when the domain stack itself
129
+ * is not us-east-1. Env-pinned to the domain stack's concrete account:
130
+ * without one the paired stack cannot be addressed, so an unresolved
131
+ * account fails synth with the cures.
132
+ */
133
+ function resolvePairedUsEast1Stack(domainStack, composition, cert) {
134
+ if (Token.isUnresolved(domainStack.account)) {
135
+ throw new Error(`Domain '${composition.effectiveZoneName}': the CloudFront ` +
136
+ `certificate for '${cert.domainName}' needs the paired us-east-1 ` +
137
+ "certificate stack, which needs a concrete account (got an " +
138
+ "unresolved account token). Deploy through the Fjall CLI, or " +
139
+ "export CDK_DEFAULT_ACCOUNT and CDK_DEFAULT_REGION before " +
140
+ "synthesising.");
141
+ }
142
+ const container = Stage.of(domainStack);
143
+ if (container === undefined) {
144
+ throw new Error(`Domain '${composition.effectiveZoneName}': the paired us-east-1 ` +
145
+ "certificate stack must be created beside the domain stack, but " +
146
+ "the domain stack has no enclosing App/Stage. Synthesise the " +
147
+ "domain under a CDK App (the Fjall CLI always does).");
148
+ }
149
+ // SSOT name contract with the read side (DomainService.resolveDomainForApp
150
+ // describes this stack with a us-east-1 CloudFormation client).
151
+ const pairedId = getDomainUsEast1CertificatesStackName(domainStack.stackName);
152
+ const existing = container.node.tryFindChild(pairedId);
153
+ if (existing !== undefined) {
154
+ if (!Stack.isStack(existing)) {
155
+ throw new Error(`Domain '${composition.effectiveZoneName}': construct '${pairedId}' ` +
156
+ "already exists beside the domain stack but is not a Stack — the " +
157
+ "paired us-east-1 certificate stack name is reserved by the D3 " +
158
+ "contract. Rename the colliding construct.");
159
+ }
160
+ return existing;
161
+ }
162
+ // Plain env-pinned stack, deliberately WITHOUT crossRegionReferences: the
163
+ // ARN leaves this stack as a literal through DescribeStacks, never as a
164
+ // CDK token, so no writer/reader machinery may synthesise.
165
+ return new Stack(container, pairedId, {
166
+ env: { account: domainStack.account, region: US_EAST_1 }
167
+ });
168
+ }
169
+ function normaliseCertificate(cert) {
170
+ if (typeof cert === "string") {
171
+ return { domainName: cert };
172
+ }
173
+ return {
174
+ domainName: cert.domainName,
175
+ subjectAlternativeNames: cert.subjectAlternativeNames,
176
+ transparencyLogging: cert.transparencyLogging,
177
+ cloudFront: cert.cloudFront
178
+ };
179
+ }
@@ -86,29 +86,69 @@ function validateRecords(scope, props, effectiveZone) {
86
86
  }
87
87
  function validateCertificates(props, effectiveZone) {
88
88
  const certificates = props.certificates ?? [];
89
+ let cloudFrontCount = 0;
89
90
  for (const cert of certificates) {
90
91
  const resolved = normaliseCertificate(cert);
91
92
  assertWithinZone(resolved.domainName, effectiveZone);
92
93
  for (const san of resolved.subjectAlternativeNames ?? []) {
93
94
  assertWithinZone(san, effectiveZone);
94
95
  }
96
+ // D3 CloudFront designation consistency. TS already narrows the prop to
97
+ // boolean; the runtime check catches JS callers and stale generated code.
98
+ if (resolved.cloudFront !== undefined &&
99
+ typeof resolved.cloudFront !== "boolean") {
100
+ throw new Error(`Domain: certificate '${resolved.domainName}' has a non-boolean ` +
101
+ `'cloudFront' (received: ${JSON.stringify(resolved.cloudFront)}). ` +
102
+ `Cure: set cloudFront: true for a CloudFront viewer certificate, ` +
103
+ `or drop the prop.`);
104
+ }
105
+ if (resolved.cloudFront === true) {
106
+ cloudFrontCount += 1;
107
+ if (props.registrar === "external-records") {
108
+ throw new Error(`Domain: certificate '${resolved.domainName}' sets cloudFront: ` +
109
+ `true, which registrar 'external-records' does not support — ` +
110
+ `there is no Fjall-managed hosted zone to DNS-validate the ` +
111
+ `us-east-1 certificate against. Cure: manage the zone with ` +
112
+ `registrar 'route53' or 'external-delegated', or drop ` +
113
+ `cloudFront and provision an app-owned us-east-1 certificate.`);
114
+ }
115
+ }
116
+ }
117
+ if (cloudFrontCount > 1) {
118
+ throw new Error(`Domain: at most one certificate may set cloudFront: true (received ` +
119
+ `${cloudFrontCount}) — the zone-level ` +
120
+ `'<zone>-us-east-1-certificate-arn' export is singular. Cure: keep ` +
121
+ `one CloudFront certificate and move the other names into its ` +
122
+ `subjectAlternativeNames.`);
95
123
  }
96
124
  }
97
125
  /**
98
126
  * D8 delegation-consistency checks for the child-writes shape.
99
127
  *
100
- * `parentDelegationRoleArn` and `phase` belong to the delegated topology
101
- * (`external-delegated`) only; on any other registrar their presence is a
102
- * hard error rather than a silent no-op. On the delegated topology the role
103
- * ARN must be a LITERAL string (VD2-a): an unresolved token means the caller
104
- * reached for `Fn.importValue`, which resolves same-account only and is
105
- * exactly the trap that sank the legacy `DomainDelegation` pattern.
128
+ * `parentDelegationRoleArn`, `phase` and `adoptedNameServers` belong to the
129
+ * delegated topology (`external-delegated`) only; on any other registrar
130
+ * their presence is a hard error rather than a silent no-op. On the
131
+ * delegated topology the role ARN must be a LITERAL string (VD2-a): an
132
+ * unresolved token means the caller reached for `Fn.importValue`, which
133
+ * resolves same-account only and is exactly the trap that sank the legacy
134
+ * `DomainDelegation` pattern. Adoption (`hostedZoneId` +
135
+ * `adoptedNameServers`) is both-or-neither: an imported zone exposes no
136
+ * NS attribute, so a one-sided declaration either cannot delegate or
137
+ * cannot name the zone it delegates to.
106
138
  */
107
139
  function validateDelegationConfig(props) {
108
140
  const arn = props
109
141
  .parentDelegationRoleArn;
110
142
  const phase = props.phase;
111
143
  if (props.registrar !== "external-delegated") {
144
+ const adoptedNameServers = props
145
+ .adoptedNameServers;
146
+ if (adoptedNameServers !== undefined) {
147
+ throw new Error(`Domain: 'adoptedNameServers' is only supported with registrar: ` +
148
+ `'external-delegated' (received registrar: '${props.registrar}'). ` +
149
+ `Cure: adoption of an existing child zone is declared on the ` +
150
+ `delegated child Domain — move the prop there, or drop it.`);
151
+ }
112
152
  if (arn !== undefined) {
113
153
  throw new Error(`Domain: 'parentDelegationRoleArn' is only supported with registrar: ` +
114
154
  `'external-delegated' (received registrar: '${props.registrar}'). ` +
@@ -129,6 +169,7 @@ function validateDelegationConfig(props) {
129
169
  `received: '${String(phase)}'. Cure: use 'zone' for step 1 of the ` +
130
170
  `two-step delegated deploy, 'full' (or omit) for the complete build.`);
131
171
  }
172
+ validateAdoptionConfig(props);
132
173
  if (arn === undefined) {
133
174
  return;
134
175
  }
@@ -151,13 +192,60 @@ function validateDelegationConfig(props) {
151
192
  `output (arn:<partition>:iam::<account>:role/<name>).`);
152
193
  }
153
194
  }
195
+ /**
196
+ * Child-zone ADOPTION consistency (external-delegated only). `hostedZoneId`
197
+ * and `adoptedNameServers` are both-or-neither, and the name servers must be
198
+ * a non-empty array of literal hostnames: an imported zone exposes no
199
+ * `hostedZoneNameServers` attribute, so these literals are the ONLY source
200
+ * the `Custom::CrossAccountZoneDelegation` UPSERT has for the parent NS row
201
+ * — a token or empty set would delegate the zone to nowhere.
202
+ */
203
+ function validateAdoptionConfig(props) {
204
+ const hostedZoneId = props.hostedZoneId;
205
+ const adoptedNameServers = props
206
+ .adoptedNameServers;
207
+ if (hostedZoneId === undefined && adoptedNameServers === undefined) {
208
+ return;
209
+ }
210
+ if (adoptedNameServers === undefined) {
211
+ throw new Error(`Domain: adopting an existing child zone requires 'adoptedNameServers' ` +
212
+ `alongside 'hostedZoneId' (both-or-neither). An imported zone ` +
213
+ `exposes no name-server attribute at synth, so the parent NS UPSERT ` +
214
+ `needs the live values. Cure: read them once ` +
215
+ `(aws route53 get-hosted-zone --id ${String(hostedZoneId)}) and ` +
216
+ `paste the DelegationSet.NameServers literals.`);
217
+ }
218
+ if (hostedZoneId === undefined) {
219
+ throw new Error(`Domain: 'adoptedNameServers' requires 'hostedZoneId' (both-or-neither) ` +
220
+ `— the literals describe an EXISTING zone to adopt. Cure: set the ` +
221
+ `adopted zone's hostedZoneId, or drop 'adoptedNameServers' to create ` +
222
+ `a fresh child zone.`);
223
+ }
224
+ if (typeof hostedZoneId !== "string" || hostedZoneId.length === 0) {
225
+ throw new Error(`Domain: 'hostedZoneId' must be a non-empty string; received: ` +
226
+ `${JSON.stringify(hostedZoneId)}.`);
227
+ }
228
+ if (!Array.isArray(adoptedNameServers) || adoptedNameServers.length === 0) {
229
+ throw new Error(`Domain: 'adoptedNameServers' must be a non-empty array of NS ` +
230
+ `hostnames; received: ${JSON.stringify(adoptedNameServers)}. Cure: ` +
231
+ `paste the adopted zone's DelegationSet.NameServers.`);
232
+ }
233
+ for (const ns of adoptedNameServers) {
234
+ if (typeof ns !== "string" || ns.length === 0 || Token.isUnresolved(ns)) {
235
+ throw new Error(`Domain: every 'adoptedNameServers' entry must be a literal NS ` +
236
+ `hostname (no CDK tokens) — the delegation UPSERT writes them ` +
237
+ `verbatim into the parent zone. Received: ${JSON.stringify(ns)}.`);
238
+ }
239
+ }
240
+ }
154
241
  function normaliseCertificate(cert) {
155
242
  if (typeof cert === "string") {
156
243
  return { domainName: cert };
157
244
  }
158
245
  return {
159
246
  domainName: cert.domainName,
160
- subjectAlternativeNames: cert.subjectAlternativeNames
247
+ subjectAlternativeNames: cert.subjectAlternativeNames,
248
+ cloudFront: cert.cloudFront
161
249
  };
162
250
  }
163
251
  function assertWithinZone(candidate, zoneName) {
@@ -39,6 +39,22 @@ export type Certificate = string | {
39
39
  readonly domainName: string;
40
40
  readonly subjectAlternativeNames?: string[];
41
41
  readonly transparencyLogging?: boolean;
42
+ /**
43
+ * D3 — CloudFront viewer certificate. CloudFront accepts certificates
44
+ * from us-east-1 only, so a `cloudFront: true` entry mints its
45
+ * `DomainCertificate` there — in-stack when the domain stack itself
46
+ * resolves to us-east-1, otherwise in a domain-paired
47
+ * `<StackName>UsEast1Certificates` stack — and publishes the
48
+ * zone-level `<zone>-us-east-1-certificate-arn` export in place of
49
+ * the per-domain regional export. The ARN reaches CloudFront
50
+ * consumers as a LITERAL through
51
+ * `ManagedDomainBinding.usEast1CertificateArn` (D2 DescribeStacks),
52
+ * never via CDK cross-region references. At most one certificate per
53
+ * Domain may set it (the zone-level export is singular — put extra
54
+ * names in `subjectAlternativeNames`); unsupported on registrar
55
+ * `"external-records"`, which has no hosted zone to validate against.
56
+ */
57
+ readonly cloudFront?: boolean;
42
58
  };
43
59
  export interface DomainCommonProps {
44
60
  readonly zoneName: string;
@@ -55,6 +71,23 @@ export interface Route53ApexProps extends DomainCommonProps {
55
71
  export interface ExternalDelegatedProps extends DomainCommonProps {
56
72
  readonly registrar: "external-delegated";
57
73
  readonly delegatedSubdomain: string;
74
+ /**
75
+ * Child-zone ADOPTION: hosted zone id of an EXISTING zone for
76
+ * `{delegatedSubdomain}.{zoneName}`. When present the pattern IMPORTS the
77
+ * zone instead of creating one. Requires `adoptedNameServers`
78
+ * (both-or-neither — validated): an imported zone exposes no
79
+ * `hostedZoneNameServers` attribute at synth, so the parent NS UPSERT
80
+ * needs the live values as literals.
81
+ */
82
+ readonly hostedZoneId?: string;
83
+ /**
84
+ * Literal NS hostnames of the adopted zone (from a live
85
+ * `get-hosted-zone` read). Carried verbatim into the
86
+ * `Custom::CrossAccountZoneDelegation` UPSERT — the delegation record in
87
+ * the parent zone must keep pointing at the adopted zone's real name
88
+ * servers. Only valid together with `hostedZoneId`.
89
+ */
90
+ readonly adoptedNameServers?: string[];
58
91
  /**
59
92
  * D8 — child-writes delegation. LITERAL ARN of the parent zone's
60
93
  * DelegationRole (VD2-a: never `Fn.importValue`, which resolves
@@ -6,6 +6,16 @@ export interface CrossAccountDelegationRecordProps {
6
6
  readonly delegatedZone: IHostedZone;
7
7
  readonly delegatedZoneName: string;
8
8
  readonly parentHostedZoneName: string;
9
+ /**
10
+ * Literal NS hostnames for an ADOPTED (imported) delegated zone. An
11
+ * imported `IHostedZone` exposes no `hostedZoneNameServers` attribute —
12
+ * CDK cannot know a live zone's NS at synth — so the custom resource
13
+ * would UPSERT an empty parent NS row. When set, a facade over the
14
+ * imported zone supplies these literals instead (see
15
+ * `withLiteralNameServers`). Omit for created zones, whose NS arrive as
16
+ * a CloudFormation attribute token.
17
+ */
18
+ readonly adoptedNameServers?: string[];
9
19
  readonly description?: string;
10
20
  readonly costAllocationEnvironment?: string;
11
21
  readonly costAllocationDomain?: string;
@@ -2,6 +2,29 @@ import { Construct } from "constructs";
2
2
  import { Tags } from "aws-cdk-lib";
3
3
  import { CrossAccountZoneDelegationRecord as CdkCrossAccountZoneDelegationRecord } from "aws-cdk-lib/aws-route53";
4
4
  import { applyCostAllocationTags } from "../../../utils/costAllocationTags.js";
5
+ /**
6
+ * CDK facade seam for zone adoption. `CrossAccountZoneDelegationRecord`
7
+ * reads exactly `zoneName` and `hostedZoneNameServers` off its
8
+ * `delegatedZone`; the facade delegates every access to the imported zone
9
+ * and overrides only `hostedZoneNameServers` with the user-declared
10
+ * literals. Methods are bound to the underlying zone so `this`-dependent
11
+ * internals behave identically. Pinned by the adopted-case synth snapshot
12
+ * in crossAccountDelegationRecord.test.ts — a CDK upgrade that starts
13
+ * reading more through this seam trips the pin.
14
+ */
15
+ function withLiteralNameServers(zone, nameServers) {
16
+ return new Proxy(zone, {
17
+ get(target, property) {
18
+ if (property === "hostedZoneNameServers") {
19
+ return [...nameServers];
20
+ }
21
+ const value = Reflect.get(target, property);
22
+ return typeof value === "function"
23
+ ? value.bind(target)
24
+ : value;
25
+ }
26
+ });
27
+ }
5
28
  // CDK's CrossAccountZoneDelegationRecord is a custom-resource Lambda: user-level tags
6
29
  // on the record itself may not reach AWS. We tag the wrapping Fjall Construct for
7
30
  // assertion purposes; create-path tags still propagate to child resources (Lambda,
@@ -14,9 +37,12 @@ export class CrossAccountDelegationRecord extends Construct {
14
37
  this.description =
15
38
  props.description ??
16
39
  `Fjall-managed cross-account delegation for ${props.delegatedZoneName} in parent ${props.parentHostedZoneName}`;
40
+ const delegatedZone = props.adoptedNameServers !== undefined
41
+ ? withLiteralNameServers(props.delegatedZone, props.adoptedNameServers)
42
+ : props.delegatedZone;
17
43
  this.record = new CdkCrossAccountZoneDelegationRecord(this, "Record", {
18
44
  delegationRole: props.delegationRole,
19
- delegatedZone: props.delegatedZone,
45
+ delegatedZone,
20
46
  parentHostedZoneName: props.parentHostedZoneName
21
47
  });
22
48
  Tags.of(this).add("fjall:description", this.description);
@@ -29,15 +29,9 @@ export declare function resolveRecordFqdn(recordName: string, zoneName: string):
29
29
  /**
30
30
  * Two-step deploy phase for constructs whose certificates must not be issued
31
31
  * before their zone's NS delegation has propagated (design R2 cert-hang
32
- * guard). `"zone"` synthesises the hosted zone (+ delegation record) only;
33
- * `"full"` additionally issues certificates. Shared by the delegated `Domain`
34
- * topology and `DevSubstrate` lowest common layer per generator-standards
35
- * § Infrastructure Layer Boundaries.
32
+ * guard) plus its omitted-phase default homed in `@fjall/util`
33
+ * (`infra/domainExports.ts`, the lowest common layer) and re-exported here
34
+ * so construct-side consumers keep their import path. deploy-core's
35
+ * `orchestration/domain/types.ts` re-exports the same declaration.
36
36
  */
37
- export type DomainDeployPhase = "zone" | "full";
38
- /**
39
- * Default deploy phase when `phase` is omitted — the complete build. The
40
- * two-step guard (R2) sets `"zone"` explicitly for step 1, so the safe
41
- * default for a single-shot deploy is everything.
42
- */
43
- export declare const DOMAIN_DEPLOY_DEFAULT_PHASE: DomainDeployPhase;
37
+ export { DOMAIN_DEPLOY_DEFAULT_PHASE, type DomainDeployPhase } from "@fjall/util";
@@ -58,8 +58,11 @@ export function resolveRecordFqdn(recordName, zoneName) {
58
58
  return `${recordName}.${zoneName}`;
59
59
  }
60
60
  /**
61
- * Default deploy phase when `phase` is omitted the complete build. The
62
- * two-step guard (R2) sets `"zone"` explicitly for step 1, so the safe
63
- * default for a single-shot deploy is everything.
61
+ * Two-step deploy phase for constructs whose certificates must not be issued
62
+ * before their zone's NS delegation has propagated (design R2 cert-hang
63
+ * guard) plus its omitted-phase default homed in `@fjall/util`
64
+ * (`infra/domainExports.ts`, the lowest common layer) and re-exported here
65
+ * so construct-side consumers keep their import path. deploy-core's
66
+ * `orchestration/domain/types.ts` re-exports the same declaration.
64
67
  */
65
- export const DOMAIN_DEPLOY_DEFAULT_PHASE = "full";
68
+ export { DOMAIN_DEPLOY_DEFAULT_PHASE } from "@fjall/util";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "3.5.2",
3
+ "version": "3.6.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,8 +67,8 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@aws-sdk/client-organizations": "^3.1038.0",
70
- "@fjall/generator": "^3.5.2",
71
- "@fjall/util": "^3.5.2",
70
+ "@fjall/generator": "^3.6.0",
71
+ "@fjall/util": "^3.6.0",
72
72
  "constructs": "^10.6.0"
73
73
  },
74
74
  "overrides": {
@@ -82,5 +82,5 @@
82
82
  "engines": {
83
83
  "node": ">=18.0.0"
84
84
  },
85
- "gitHead": "f59755647b2fd81ff692a3545e8e199d93c3bdf8"
85
+ "gitHead": "10366db602d7e80669e22825a52071a47e05c3b7"
86
86
  }