@fjall/components-infrastructure 3.5.2 → 3.6.1

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 (24) hide show
  1. package/dist/lib/patterns/aws/apexDomainPattern.js +12 -24
  2. package/dist/lib/patterns/aws/clickhouseDatabase.js +5 -2
  3. package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +6 -2
  4. package/dist/lib/patterns/aws/delegatedDomainPattern.js +28 -27
  5. package/dist/lib/patterns/aws/devSubstrate.js +28 -10
  6. package/dist/lib/patterns/aws/domainCertificateComposer.d.ts +48 -0
  7. package/dist/lib/patterns/aws/domainCertificateComposer.js +179 -0
  8. package/dist/lib/patterns/aws/domainValidation.js +95 -7
  9. package/dist/lib/patterns/aws/interfaces/domain.d.ts +33 -0
  10. package/dist/lib/resources/aws/compute/ecs.js +7 -3
  11. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +24 -2
  12. package/dist/lib/resources/aws/compute/ecsNetworking.js +136 -10
  13. package/dist/lib/resources/aws/compute/ecsServiceFactory.js +7 -1
  14. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +37 -0
  15. package/dist/lib/resources/aws/compute/ecsValidation.js +45 -0
  16. package/dist/lib/resources/aws/compute/listenerRouting.d.ts +10 -1
  17. package/dist/lib/resources/aws/compute/listenerRouting.js +13 -1
  18. package/dist/lib/resources/aws/compute/persistentDataVolume.d.ts +7 -0
  19. package/dist/lib/resources/aws/compute/persistentDataVolume.js +6 -0
  20. package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.d.ts +10 -0
  21. package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +27 -1
  22. package/dist/lib/utils/domainTypes.d.ts +5 -11
  23. package/dist/lib/utils/domainTypes.js +7 -4
  24. package/package.json +4 -4
@@ -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
- }
@@ -361,6 +361,7 @@ export class ClickHouseDatabase extends Construct {
361
361
  clickHouseContainerSecrets[userPasswordEnvName(name)] =
362
362
  userSecret.getImport("password");
363
363
  }
364
+ const resolvedAlertsTopic = resolveAlertsTopic(this, "AlertsTopic", props.alertsTopic);
364
365
  const ecsCompute = new EcsCompute(this, "Compute", {
365
366
  type: "ecs",
366
367
  vpc,
@@ -393,7 +394,10 @@ export class ClickHouseDatabase extends Construct {
393
394
  deviceName: CLICKHOUSE_EBS_DEVICE_NAME,
394
395
  availabilityZone: dataAz,
395
396
  iops: CLICKHOUSE_EBS_IOPS,
396
- throughputMbps: CLICKHOUSE_EBS_THROUGHPUT_MBPS
397
+ throughputMbps: CLICKHOUSE_EBS_THROUGHPUT_MBPS,
398
+ ...(resolvedAlertsTopic !== undefined && {
399
+ alarmTopic: resolvedAlertsTopic
400
+ })
397
401
  },
398
402
  userData,
399
403
  desiredCapacity: 1,
@@ -492,7 +496,6 @@ export class ClickHouseDatabase extends Construct {
492
496
  // asgName is the CloudWatch dimension the host metrics key off, so without
493
497
  // it the alarms cannot be built; no topic is the default, so skip silently
494
498
  // rather than throw.
495
- const resolvedAlertsTopic = resolveAlertsTopic(this, "AlertsTopic", props.alertsTopic);
496
499
  const asgName = ecsCompute.getAutoScalingGroupName();
497
500
  if (resolvedAlertsTopic !== undefined && asgName !== undefined) {
498
501
  createClickHouseAlarms({
@@ -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
- }
@@ -5,6 +5,7 @@ import { Repository } from "aws-cdk-lib/aws-ecr";
5
5
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
6
6
  import { Code, Runtime } from "aws-cdk-lib/aws-lambda";
7
7
  import { PublicHostedZone } from "aws-cdk-lib/aws-route53";
8
+ import { LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
8
9
  import { SqsDestination } from "aws-cdk-lib/aws-s3-notifications";
9
10
  import { Construct } from "constructs";
10
11
  import { readFileSync } from "node:fs";
@@ -20,6 +21,7 @@ import { createLambdaTargetGroup } from "../../resources/aws/compute/lambdaAlbTa
20
21
  import { LambdaFunction } from "../../resources/aws/compute/lambda.js";
21
22
  import { Ecr } from "../../resources/aws/storage/ecr.js";
22
23
  import { SecurityGroup } from "../../resources/aws/networking/securityGroup.js";
24
+ import { AliasRecord } from "../../resources/aws/networking/dnsRecord/aliasRecord.js";
23
25
  import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
24
26
  import { CrossAccountDelegationRecord } from "../../resources/aws/networking/crossAccountDelegationRecord.js";
25
27
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
@@ -56,6 +58,7 @@ const DEV_SUBSTRATE_PARENT_ROLE_ID = "DevSubstrateParentDelegationRole";
56
58
  /** Construct ids for the substrate's wildcard cert + :443 HTTPS listener (A6). */
57
59
  const DEV_SUBSTRATE_CERT_ID = "DevSubstrateCertificate";
58
60
  const DEV_SUBSTRATE_HTTPS_LISTENER_ID = "DevSubstrateHttpsListener";
61
+ const DEV_SUBSTRATE_WILDCARD_ALIAS_ID = "DevSubstrateWildcardAlias";
59
62
  // Default deploy phase when `phase` is omitted — the cert + :443 listener
60
63
  // build. Shared with the delegated `Domain` topology via
61
64
  // `DOMAIN_DEPLOY_DEFAULT_PHASE` (utils/domainTypes.ts): the R2 two-step guard
@@ -68,13 +71,6 @@ const DEV_SUBSTRATE_HTTPS_LISTENER_ID = "DevSubstrateHttpsListener";
68
71
  * `point-at-existing` (GAP-7) is deferred, so this is a fixed value for now.
69
72
  */
70
73
  const DEV_SUBSTRATE_DATABASE_MODE = "aurora";
71
- /**
72
- * The container port slots serve HTTP on. Fjall owns the slot image, so this is a
73
- * fixed contract: the ALB → slot-SG ingress opens exactly this port and the
74
- * host-header rules a slot registers (§5.4) forward to it. The public :80 ALB
75
- * front listener is separate.
76
- */
77
- const DEV_SUBSTRATE_SLOT_PORT = 8080;
78
74
  /**
79
75
  * Physical-name prefix pinning every substrate resource inside the `fjall-dev-*`
80
76
  * fence. Load-bearing: the dev-tier IAM grants (`@fjall/generator devRolePolicies`)
@@ -210,17 +206,24 @@ export class DevSubstrate extends Construct {
210
206
  // explicitly. Path + name pin the role inside the /fjall/dev/ fence: the dev
211
207
  // deploy role's only iam:PassRole grant is on role/fjall/dev/*, so the CDK
212
208
  // defaults (path "/", CFN-generated name) would AccessDeny slot task-def
213
- // registration.
209
+ // registration. ssmSecretsPath grants the slot parameter tree — the worker
210
+ // writes DATABASE_URL + FJALL_* under /fjall/dev/<app>/<slot>/ and slot
211
+ // task-defs inject them via this role; without it the task dies at
212
+ // ResourceInitializationError on ssm:GetParameters.
214
213
  const slotExecutionRole = createBaseExecutionRole(this, DEV_SUBSTRATE_SLOT_EXEC_ROLE_ID, {
214
+ ssmSecretsPath: `/fjall/dev/${props.appKebab}`,
215
215
  path: "/fjall/dev/",
216
216
  roleName: `${devFenceName(props.appKebab)}-slot-exec`
217
217
  });
218
218
  const { partition, region, account } = Stack.of(this);
219
+ // Whole app dev-fence subtree, not /slots/* — the worker names each slot's
220
+ // log group /fjall/dev/<app>/<slot> (the slot base path), so a /slots/*
221
+ // scope would deny CreateLogStream at task start.
219
222
  slotExecutionRole.addToPolicy(new PolicyStatement({
220
223
  effect: Effect.ALLOW,
221
224
  actions: ["logs:CreateLogStream", "logs:PutLogEvents"],
222
225
  resources: [
223
- `arn:${partition}:logs:${region}:${account}:log-group:/fjall/dev/${props.appKebab}/slots/*:*`
226
+ `arn:${partition}:logs:${region}:${account}:log-group:/fjall/dev/${props.appKebab}/*:*`
224
227
  ]
225
228
  }));
226
229
  // Slot-SG → Aurora ingress. Dormant in Data-API mode (Data API reaches the
@@ -258,7 +261,11 @@ export class DevSubstrate extends Construct {
258
261
  // fence-pinned name — the reaper finds it by the subtree fence tags. A pinned
259
262
  // name would only reintroduce the 32-char ALB-name truncation dance.
260
263
  const loadBalancer = createApplicationLoadBalancer(this, DEV_SUBSTRATE_ALB_ID, { vpc, internetFacing: true, securityGroup: albSecurityGroup });
261
- slotSecurityGroup.connections.allowFrom(albSecurityGroup, Port.tcp(DEV_SUBSTRATE_SLOT_PORT),
264
+ // All-TCP, not a pinned port: the worker registers each slot's target group
265
+ // on the per-slot `buildSpec.containerPort` (default 3000, `--port`
266
+ // overridable), chosen at provision time — synth cannot know it. Scope stays
267
+ // tight because the source is the substrate's own ALB SG.
268
+ slotSecurityGroup.connections.allowFrom(albSecurityGroup, Port.allTcp(),
262
269
  // EC2 SG-rule descriptions reject non-ASCII (allowed set is
263
270
  // `a-zA-Z0-9. _-:/()#,@[]+=&;{}!$*` — no arrow, em-dash, or even `>`).
264
271
  "Dev substrate ALB to slot tasks");
@@ -376,6 +383,17 @@ export class DevSubstrate extends Construct {
376
383
  // Unconditional 404 as on :80 (per-slot rules attach out of band, §5.4). open
377
384
  // (CDK default) adds the :443 ingress to the ALB SG.
378
385
  const httpsListener = addRoutingListener(loadBalancer, DEV_SUBSTRATE_HTTPS_LISTENER_ID, { port: 443, certificate: certificate.certificate, default404: true });
386
+ // GAP-4: one wildcard ALIAS resolves every slot host to the ALB — routing
387
+ // happens per-slot via host-header rules, so no per-slot DNS write is ever
388
+ // needed. Phase-gated with the cert (the record is useless before :443
389
+ // exists) and created in adopt mode too: the adopted zone predates the
390
+ // substrate, but the record targets the substrate's own ALB.
391
+ new AliasRecord(this, DEV_SUBSTRATE_WILDCARD_ALIAS_ID, {
392
+ zone: childZone,
393
+ zoneName: childZoneName,
394
+ recordName: "*",
395
+ target: new LoadBalancerTarget(loadBalancer)
396
+ });
379
397
  return { devDomain: childZoneName, httpsListener };
380
398
  }
381
399
  /**
@@ -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
+ }