@fjall/components-infrastructure 3.4.1 → 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.
Files changed (31) hide show
  1. package/dist/lib/patterns/aws/apexDomainPattern.js +12 -24
  2. package/dist/lib/patterns/aws/buildkite/buildkite.d.ts +54 -9
  3. package/dist/lib/patterns/aws/buildkite/buildkite.js +131 -23
  4. package/dist/lib/patterns/aws/buildkite/iam.d.ts +8 -1
  5. package/dist/lib/patterns/aws/buildkite/iam.js +11 -0
  6. package/dist/lib/patterns/aws/buildkite/userData.d.ts +13 -1
  7. package/dist/lib/patterns/aws/buildkite/userData.js +8 -3
  8. package/dist/lib/patterns/aws/buildkite.d.ts +1 -1
  9. package/dist/lib/patterns/aws/buildkite.js +1 -1
  10. package/dist/lib/patterns/aws/database.d.ts +6 -0
  11. package/dist/lib/patterns/aws/database.js +2 -1
  12. package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +6 -2
  13. package/dist/lib/patterns/aws/delegatedDomainPattern.js +28 -27
  14. package/dist/lib/patterns/aws/devSubstrate.js +15 -5
  15. package/dist/lib/patterns/aws/domainCertificateComposer.d.ts +48 -0
  16. package/dist/lib/patterns/aws/domainCertificateComposer.js +179 -0
  17. package/dist/lib/patterns/aws/domainValidation.js +95 -7
  18. package/dist/lib/patterns/aws/interfaces/domain.d.ts +33 -0
  19. package/dist/lib/resources/aws/compute/ecsRoles.d.ts +7 -0
  20. package/dist/lib/resources/aws/compute/ecsRoles.js +8 -1
  21. package/dist/lib/resources/aws/database/rdsAurora.d.ts +7 -0
  22. package/dist/lib/resources/aws/database/rdsAurora.js +2 -1
  23. package/dist/lib/resources/aws/database/rdsDefaults.d.ts +6 -3
  24. package/dist/lib/resources/aws/database/rdsDefaults.js +6 -3
  25. package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.d.ts +10 -0
  26. package/dist/lib/resources/aws/networking/crossAccountDelegationRecord.js +27 -1
  27. package/dist/lib/resources/aws/secrets/secret.d.ts +6 -0
  28. package/dist/lib/resources/aws/secrets/secret.js +1 -1
  29. package/dist/lib/utils/domainTypes.d.ts +5 -11
  30. package/dist/lib/utils/domainTypes.js +7 -4
  31. package/package.json +4 -4
@@ -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
- }
@@ -161,6 +161,8 @@ export class DevSubstrate extends Construct {
161
161
  databaseName: DEV_SUBSTRATE_DATABASE_NAME,
162
162
  clusterIdentifier: devFenceName(props.appKebab),
163
163
  credentialsSecretName: `${devFenceName(props.appKebab)}-master`,
164
+ // Default alias derives from the fixed databaseName — collides account-wide.
165
+ credentialsKmsAliasName: `cmk/${devFenceName(props.appKebab)}-master`,
164
166
  engineVersion: props.engineVersion ?? DEV_AURORA_DEFAULTS.ENGINE_VERSION,
165
167
  serverlessV2MinCapacity: DEV_AURORA_DEFAULTS.SERVERLESS_V2_MIN_CAPACITY,
166
168
  serverlessV2MaxCapacity: DEV_AURORA_DEFAULTS.SERVERLESS_V2_MAX_CAPACITY,
@@ -205,8 +207,14 @@ export class DevSubstrate extends Construct {
205
207
  // createBaseExecutionRole DELIBERATELY omits logs (auto-granted only on the
206
208
  // ECS-pattern path via AwsLogDriver.bind()). Slot task-defs reference this
207
209
  // shared role by ARN, so that auto-grant never fires — add the log-write grant
208
- // explicitly.
209
- const slotExecutionRole = createBaseExecutionRole(this, DEV_SUBSTRATE_SLOT_EXEC_ROLE_ID);
210
+ // explicitly. Path + name pin the role inside the /fjall/dev/ fence: the dev
211
+ // deploy role's only iam:PassRole grant is on role/fjall/dev/*, so the CDK
212
+ // defaults (path "/", CFN-generated name) would AccessDeny slot task-def
213
+ // registration.
214
+ const slotExecutionRole = createBaseExecutionRole(this, DEV_SUBSTRATE_SLOT_EXEC_ROLE_ID, {
215
+ path: "/fjall/dev/",
216
+ roleName: `${devFenceName(props.appKebab)}-slot-exec`
217
+ });
210
218
  const { partition, region, account } = Stack.of(this);
211
219
  slotExecutionRole.addToPolicy(new PolicyStatement({
212
220
  effect: Effect.ALLOW,
@@ -376,9 +384,11 @@ export class DevSubstrate extends Construct {
376
384
  * the hand-created G2 slot service consumes these to place tasks, register a
377
385
  * host-header rule, inject DB creds and point a sleeping slot at the waker.
378
386
  *
379
- * No KMS-alias export: the substrate's Aurora + waker use the AWS-managed default
380
- * key (design VF4 the waker's kms:Decrypt is ViaService-scoped precisely
381
- * because there is no per-app CMK), so there is no alias to export.
387
+ * No KMS-alias export: cluster storage and SSM SecureStrings ride AWS-managed
388
+ * default keys, and while the master-credentials secret IS CMK-encrypted (the
389
+ * Secret wrapper mints `cmk/fjall-dev-<app>-master`), no consumer addresses
390
+ * that key by alias — the provisioner's and waker's kms:Decrypt are
391
+ * ViaService-scoped (Secrets Manager / SSM), so there is no alias to export.
382
392
  *
383
393
  * The Aurora slot-SG ingress (A2.5) makes the Database stack depend on the
384
394
  * Compute stack. So the two DB exports are scoped under `databaseScope` (the
@@ -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
@@ -16,9 +16,16 @@ import type { EcsServiceProps } from "./ecsTypes.js";
16
16
  * SHARED or IMPORTED role those auto-grants cannot reach — e.g. the dev
17
17
  * substrate's slot exec role, referenced by slot task-defs via ARN — MUST add
18
18
  * the explicit `logs` / `secretsmanager` statements itself.
19
+ *
20
+ * `path`/`roleName` pin the IAM path and physical name — needed when an IAM
21
+ * grant fences on the role's path (the dev-role `iam:PassRole` grant on
22
+ * `role/fjall/dev/*`), since the CDK default is path `/` + a CFN-generated
23
+ * name no path- or prefix-scoped grant can match.
19
24
  */
20
25
  export declare function createBaseExecutionRole(scope: Construct, id: string, opts?: {
21
26
  ssmSecretsPath?: string;
27
+ path?: string;
28
+ roleName?: string;
22
29
  }): Role;
23
30
  /**
24
31
  * Creates the execution role for ECS infrastructure operations.
@@ -15,10 +15,17 @@ import { deriveSsmSecretsPath } from "./ecsTaskDefinition.js";
15
15
  * SHARED or IMPORTED role those auto-grants cannot reach — e.g. the dev
16
16
  * substrate's slot exec role, referenced by slot task-defs via ARN — MUST add
17
17
  * the explicit `logs` / `secretsmanager` statements itself.
18
+ *
19
+ * `path`/`roleName` pin the IAM path and physical name — needed when an IAM
20
+ * grant fences on the role's path (the dev-role `iam:PassRole` grant on
21
+ * `role/fjall/dev/*`), since the CDK default is path `/` + a CFN-generated
22
+ * name no path- or prefix-scoped grant can match.
18
23
  */
19
24
  export function createBaseExecutionRole(scope, id, opts = {}) {
20
25
  const executionRole = new Role(scope, id, {
21
- assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com")
26
+ assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com"),
27
+ path: opts.path,
28
+ roleName: opts.roleName
22
29
  });
23
30
  // GetAuthorizationToken is an account-level API that requires resources: ["*"].
24
31
  // The image-pull actions also use "*" because ecrRepository can be a string URI
@@ -20,6 +20,13 @@ interface RdsProps {
20
20
  clusterIdentifier?: string;
21
21
  /** Physical name of the generated master-credentials secret. */
22
22
  credentialsSecretName?: string;
23
+ /**
24
+ * KMS alias for the CMK encrypting the master-credentials secret. Aliases are
25
+ * account+region-unique, so a caller with a fixed `databaseName` deployed once
26
+ * per app (e.g. the dev substrate) must pass an app-scoped alias or the second
27
+ * app's deploy fails on the alias collision.
28
+ */
29
+ credentialsKmsAliasName?: string;
23
30
  /** Serverless-v2 floor in ACU. 0 enables scale-to-zero (auto-pause). */
24
31
  serverlessV2MinCapacity?: number;
25
32
  /** Serverless-v2 ceiling in ACU. */
@@ -100,6 +100,7 @@ export class RdsAurora extends Construct {
100
100
  this.databaseCredentials = new Secret(this, `${this.databaseNameValue}Credentials`, {
101
101
  secretName: props.credentialsSecretName ??
102
102
  ResourceNaming.credentialsSecretName(id),
103
+ aliasName: props.credentialsKmsAliasName,
103
104
  generateSecretString: {
104
105
  secretStringTemplate: JSON.stringify({ username }),
105
106
  excludePunctuation: true,
@@ -151,7 +152,7 @@ export class RdsAurora extends Construct {
151
152
  const readers = this.buildReaders(props, piEnabled, performanceInsightsKey, performanceInsightsRetention);
152
153
  const engine = props.engine ||
153
154
  DatabaseClusterEngine.auroraPostgres({
154
- version: AuroraPostgresEngineVersion.of("16.6", "16")
155
+ version: AuroraPostgresEngineVersion.of("16.10", "16")
155
156
  });
156
157
  const parameterGroup = new ParameterGroup(this, `${this.databaseNameValue}ParameterGroup`, {
157
158
  engine,
@@ -16,15 +16,18 @@ export declare const RDS_DEFAULTS: Readonly<{
16
16
  * (fast dev-envs Phase 3). `min 0` ACU scales the shared cluster to zero when
17
17
  * idle; `enableDataApi` lets slot DDL run over HTTPS with no VPC connection, and
18
18
  * is the only mode under which min-0 actually saves (a live pooled connection
19
- * pins the cluster above 0 ACU). Engine 16.6 satisfies the auto-pause-capable
20
- * floor (see `MIN_AUTO_PAUSE_POSTGRES_VERSION`).
19
+ * pins the cluster above 0 ACU). Engine 16.10 satisfies the auto-pause-capable
20
+ * floor (see `MIN_AUTO_PAUSE_POSTGRES_VERSION`). AWS removes old minors from
21
+ * the creatable list over time (16.6 rejected with "Cannot find version" by
22
+ * 2026-07) — pin a mid-window minor, not the oldest available, and expect this
23
+ * to need a bump roughly yearly.
21
24
  */
22
25
  export declare const DEV_AURORA_DEFAULTS: Readonly<{
23
26
  readonly SERVERLESS_V2_MIN_CAPACITY: 0;
24
27
  readonly SERVERLESS_V2_MAX_CAPACITY: 4;
25
28
  readonly AUTO_PAUSE_SECONDS: 1200;
26
29
  readonly ENABLE_DATA_API: true;
27
- readonly ENGINE_VERSION: "16.6";
30
+ readonly ENGINE_VERSION: "16.10";
28
31
  }>;
29
32
  /**
30
33
  * Bounds AWS enforces on `ServerlessV2ScalingConfiguration.SecondsUntilAutoPause`