@fjall/components-infrastructure 2.34.0 → 3.0.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 (62) hide show
  1. package/dist/lib/app.d.ts +19 -2
  2. package/dist/lib/app.js +46 -3
  3. package/dist/lib/patterns/aws/apexDomainPattern.d.ts +11 -12
  4. package/dist/lib/patterns/aws/apexDomainPattern.js +11 -31
  5. package/dist/lib/patterns/aws/cdn.js +25 -10
  6. package/dist/lib/patterns/aws/computeEcs.js +6 -1
  7. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +8 -3
  8. package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +13 -4
  9. package/dist/lib/patterns/aws/delegatedDomainPattern.js +57 -8
  10. package/dist/lib/patterns/aws/devSubstrate.d.ts +2 -1
  11. package/dist/lib/patterns/aws/devSubstrate.js +18 -8
  12. package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +2 -9
  13. package/dist/lib/patterns/aws/dnsRecordComposer.js +5 -97
  14. package/dist/lib/patterns/aws/domain.d.ts +3 -2
  15. package/dist/lib/patterns/aws/domain.js +3 -2
  16. package/dist/lib/patterns/aws/domainValidation.js +71 -46
  17. package/dist/lib/patterns/aws/externalRecordsPattern.js +2 -11
  18. package/dist/lib/patterns/aws/index.d.ts +1 -3
  19. package/dist/lib/patterns/aws/index.js +2 -3
  20. package/dist/lib/patterns/aws/interfaces/domain.d.ts +22 -8
  21. package/dist/lib/patterns/aws/interfaces/index.d.ts +0 -1
  22. package/dist/lib/patterns/aws/interfaces/index.js +3 -0
  23. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +55 -10
  24. package/dist/lib/patterns/aws/patternDomain.d.ts +77 -0
  25. package/dist/lib/patterns/aws/patternDomain.js +198 -0
  26. package/dist/lib/patterns/aws/payload.js +23 -30
  27. package/dist/lib/patterns/aws/staticSite.d.ts +1 -0
  28. package/dist/lib/patterns/aws/staticSite.js +28 -33
  29. package/dist/lib/resources/aws/base/awsStack.d.ts +8 -0
  30. package/dist/lib/resources/aws/base/awsStack.js +20 -17
  31. package/dist/lib/resources/aws/compute/ecs.d.ts +1 -1
  32. package/dist/lib/resources/aws/compute/ecs.js +1 -1
  33. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +1 -1
  34. package/dist/lib/resources/aws/compute/ecsNetworking.js +212 -47
  35. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +60 -9
  36. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +14 -1
  37. package/dist/lib/resources/aws/compute/ecsValidation.js +51 -0
  38. package/dist/lib/resources/aws/networking/dnsRecord/aRecord.js +3 -2
  39. package/dist/lib/resources/aws/networking/dnsRecord/aaaaRecord.js +3 -2
  40. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +23 -1
  41. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +14 -3
  42. package/dist/lib/resources/aws/networking/dnsRecord/caaRecord.js +3 -2
  43. package/dist/lib/resources/aws/networking/dnsRecord/cnameRecord.js +3 -2
  44. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.d.ts +10 -1
  45. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +21 -4
  46. package/dist/lib/resources/aws/networking/dnsRecord/mxRecord.js +3 -2
  47. package/dist/lib/resources/aws/networking/dnsRecord/nsRecord.js +3 -2
  48. package/dist/lib/resources/aws/networking/dnsRecord/srvRecord.js +3 -2
  49. package/dist/lib/resources/aws/networking/dnsRecord/txtRecord.js +3 -2
  50. package/dist/lib/resources/aws/networking/domainCertificate.d.ts +28 -4
  51. package/dist/lib/resources/aws/networking/domainCertificate.js +24 -10
  52. package/dist/lib/resources/aws/networking/hostedZone.d.ts +11 -0
  53. package/dist/lib/resources/aws/networking/hostedZone.js +6 -0
  54. package/dist/lib/utils/costAllocationTags.d.ts +9 -0
  55. package/dist/lib/utils/costAllocationTags.js +6 -1
  56. package/dist/lib/utils/dnsRecordRegistry.d.ts +52 -0
  57. package/dist/lib/utils/dnsRecordRegistry.js +64 -0
  58. package/dist/lib/utils/domainTypes.d.ts +43 -36
  59. package/dist/lib/utils/domainTypes.js +65 -2
  60. package/dist/lib/utils/managedDomainContext.d.ts +37 -0
  61. package/dist/lib/utils/managedDomainContext.js +111 -0
  62. package/package.json +4 -4
package/dist/lib/app.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type CloudAssembly } from "aws-cdk-lib/cx-api";
3
3
  import { type Construct } from "constructs";
4
4
  import { type IVpc } from "aws-cdk-lib/aws-ec2";
5
5
  import { type Role } from "aws-cdk-lib/aws-iam";
6
- import { AwsStack } from "./resources/index.js";
6
+ import { AwsStack, type AwsStackProps } from "./resources/index.js";
7
7
  import { type Ecr } from "./resources/aws/storage/ecr.js";
8
8
  import { type AnyDatabase } from "./patterns/aws/database.js";
9
9
  import { type INetworkProps, type Network } from "./patterns/aws/network.js";
@@ -123,8 +123,10 @@ export declare class App extends CdkApp {
123
123
  *
124
124
  * @param key - The key of the stack
125
125
  * @param dependencies - The stack(s) that this stack depends on
126
+ * @param props - Stack props applied on first creation only (cached
127
+ * stacks are returned as-is)
126
128
  */
127
- getStack(key: string, dependencies?: AwsStack | AwsStack[]): AwsStack;
129
+ getStack(key: string, dependencies?: AwsStack | AwsStack[], props?: AwsStackProps): AwsStack;
128
130
  /**
129
131
  * Retrieve default compute stack - named as `${this.name}Compute`
130
132
  *
@@ -162,6 +164,21 @@ export declare class App extends CdkApp {
162
164
  * require VPC, but we maintain consistent stack dependency patterns.
163
165
  */
164
166
  getDefaultMessagingStack(): AwsStack;
167
+ /**
168
+ * Retrieve the app-owned us-east-1 certificate stack — named
169
+ * `${this.name}UsEast1Certificates` (domain gold-plating D3, BYO path).
170
+ *
171
+ * CloudFront accepts viewer certificates from us-east-1 only, so
172
+ * CloudFront-consuming patterns (staticsite, payload) whose app deploys to
173
+ * another region provision their DomainCertificate here; the distribution
174
+ * in the Cdn stack consumes the ARN through CDK cross-region references.
175
+ *
176
+ * `crossRegionReferences: true` must be set on BOTH the producing and the
177
+ * consuming stack; both get it at construction (the Cdn stack in
178
+ * getDefaultCdnStack, this stack here) and the emitted
179
+ * Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
180
+ */
181
+ getUsEast1CertificateStack(): AwsStack;
165
182
  /**
166
183
  * Get a VPC by name. If no name is provided, returns the default VPC.
167
184
  *
package/dist/lib/app.js CHANGED
@@ -18,6 +18,7 @@ import AuditRoleFactory from "./resources/aws/audit/auditRole.js";
18
18
  import { FJALL_AUDIT_CONFIG } from "./config/audit.js";
19
19
  import { FjallLogger } from "./utils/validationLogger.js";
20
20
  import { getManifestCollector, writeManifest } from "./utils/manifestWriter.js";
21
+ import { resetDnsRecordRegistry } from "./utils/dnsRecordRegistry.js";
21
22
  import { toPascalCase, toKebab } from "./utils/capitaliseString.js";
22
23
  import { COST_ALLOCATION_TAGS } from "./utils/costAllocationTags.js";
23
24
  import { DEFAULT_ORG_ID, resolveOrgId } from "./utils/cdkContext.js";
@@ -168,6 +169,10 @@ export class App extends CdkApp {
168
169
  }
169
170
  static resetForTesting() {
170
171
  App.instance = null;
172
+ // App-scoped synth state resets with the App singleton — the DNS
173
+ // record-collision registry (D5) follows the manifest-collector lifecycle
174
+ // without requiring a third per-test reset call.
175
+ resetDnsRecordRegistry();
171
176
  }
172
177
  /**
173
178
  * Retrieve a stack by key. If the stack does not exist, it will be created.
@@ -175,8 +180,10 @@ export class App extends CdkApp {
175
180
  *
176
181
  * @param key - The key of the stack
177
182
  * @param dependencies - The stack(s) that this stack depends on
183
+ * @param props - Stack props applied on first creation only (cached
184
+ * stacks are returned as-is)
178
185
  */
179
- getStack(key, dependencies) {
186
+ getStack(key, dependencies, props) {
180
187
  // Apply the aspect once before creating the first stack
181
188
  if (!this.aspectApplied &&
182
189
  Object.keys(this.stacks).length === 0 &&
@@ -184,7 +191,7 @@ export class App extends CdkApp {
184
191
  this.applyTagsAspect();
185
192
  }
186
193
  if (!this.stacks[key]) {
187
- this.stacks[key] = new AwsStack(key, dependencies);
194
+ this.stacks[key] = new AwsStack(key, dependencies, props);
188
195
  }
189
196
  return this.stacks[key];
190
197
  }
@@ -225,7 +232,10 @@ export class App extends CdkApp {
225
232
  * stack is synthesised.
226
233
  */
227
234
  getDefaultCdnStack() {
228
- return this.getStack(`${this.stackPrefix}Cdn`, this.networkDisabled ? undefined : this.getDefaultNetworkStack());
235
+ // crossRegionReferences is enabled at creation so the Cdn stack can
236
+ // consume a us-east-1 certificate (domain gold-plating D3, BYO path).
237
+ // The flag is inert unless a cross-region reference is synthesised.
238
+ return this.getStack(`${this.stackPrefix}Cdn`, this.networkDisabled ? undefined : this.getDefaultNetworkStack(), { crossRegionReferences: true });
229
239
  }
230
240
  /**
231
241
  * Retrieve default messaging stack - named as `${this.name}Messaging`
@@ -237,6 +247,39 @@ export class App extends CdkApp {
237
247
  getDefaultMessagingStack() {
238
248
  return this.getStack(`${this.stackPrefix}Messaging`, this.getDefaultNetworkStack());
239
249
  }
250
+ /**
251
+ * Retrieve the app-owned us-east-1 certificate stack — named
252
+ * `${this.name}UsEast1Certificates` (domain gold-plating D3, BYO path).
253
+ *
254
+ * CloudFront accepts viewer certificates from us-east-1 only, so
255
+ * CloudFront-consuming patterns (staticsite, payload) whose app deploys to
256
+ * another region provision their DomainCertificate here; the distribution
257
+ * in the Cdn stack consumes the ARN through CDK cross-region references.
258
+ *
259
+ * `crossRegionReferences: true` must be set on BOTH the producing and the
260
+ * consuming stack; both get it at construction (the Cdn stack in
261
+ * getDefaultCdnStack, this stack here) and the emitted
262
+ * Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
263
+ */
264
+ getUsEast1CertificateStack() {
265
+ const key = `${this.stackPrefix}UsEast1Certificates`;
266
+ if (!this.stacks[key]) {
267
+ const account = process.env.CDK_DEFAULT_ACCOUNT;
268
+ if (!account) {
269
+ throw new Error(`App '${this.name}': the us-east-1 certificate stack needs a ` +
270
+ "concrete account (got CDK_DEFAULT_ACCOUNT undefined). " +
271
+ "Cross-region references resolve only between env-pinned stacks " +
272
+ "— deploy through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT " +
273
+ "and CDK_DEFAULT_REGION before synthesising.");
274
+ }
275
+ this.stacks[key] = new AwsStack(key, undefined, {
276
+ accountId: account,
277
+ region: "us-east-1",
278
+ crossRegionReferences: true
279
+ });
280
+ }
281
+ return this.stacks[key];
282
+ }
240
283
  /**
241
284
  * Get a VPC by name. If no name is provided, returns the default VPC.
242
285
  *
@@ -1,26 +1,25 @@
1
1
  import type { Construct } from "constructs";
2
2
  import type { IHostedZone } from "aws-cdk-lib/aws-route53";
3
3
  import type { ICertificate } from "aws-cdk-lib/aws-certificatemanager";
4
- import type { ManualRecord, Route53ApexProps, SubdomainDelegation } from "./interfaces/domain.js";
4
+ import type { ManualRecord, Route53ApexProps } from "./interfaces/domain.js";
5
5
  export interface ApexDomainPatternResult {
6
6
  readonly hostedZone: IHostedZone;
7
7
  readonly certificates: Map<string, ICertificate>;
8
8
  readonly nameServers: string[] | undefined;
9
9
  readonly manualRecords: ManualRecord[];
10
- readonly delegations: SubdomainDelegation[];
11
10
  }
12
11
  /**
13
12
  * Composition for `registrar: "route53"`. Creates (or imports) the apex
14
- * `HostedZone`, wires child-account delegations via `NsRecord` (pointing at
15
- * each child's nameservers published as exports), and composes all user
16
- * records + certificates.
13
+ * `HostedZone` and composes all user records + certificates.
17
14
  *
18
- * Delegation mechanism: the child Fjall account scaffolds its own
19
- * `HostedZone`, and the parent simply writes NS records pointing at the
20
- * child's nameservers (Fn.importValue of the child stack's nameservers
21
- * output). This differs from the legacy `DomainDelegation` pattern, which
22
- * uses `CrossAccountZoneDelegationRecord` to create the child HZ from the
23
- * parent via IAM assume-role. Both patterns are valid for their respective
24
- * semantics see R10 in the Phase 1 plan.
15
+ * Delegation direction (D8): child writes NS. The apex zone's contribution
16
+ * to delegation is the org-gated `DelegationRole` the `HostedZone` wrapper
17
+ * creates on the create path — the delegated child `Domain` assumes that
18
+ * role by LITERAL ARN (`parentDelegationRoleArn`) and UPSERTs its own NS
19
+ * records into this zone via `CrossAccountZoneDelegationRecord`. The old
20
+ * parent-writes `delegations[]` path (NS records from `Fn.importValue` of
21
+ * child nameserver exports) was removed with the H13 legacy layer: it was
22
+ * export-locked, child-first-deploy-ordered, and removal required a parent
23
+ * redeploy.
25
24
  */
26
25
  export declare function composeApexDomain(scope: Construct, props: Route53ApexProps): ApexDomainPatternResult;
@@ -1,24 +1,21 @@
1
- import { Fn } from "aws-cdk-lib";
2
- import { getDomainExportNames } from "@fjall/util";
3
1
  import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
4
2
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
5
- import { NsRecord } from "../../resources/aws/networking/dnsRecord/index.js";
6
3
  import { composeTypedDnsRecords } from "./dnsRecordComposer.js";
7
4
  import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
8
5
  import { resolveOrgId } from "../../utils/cdkContext.js";
9
6
  /**
10
7
  * Composition for `registrar: "route53"`. Creates (or imports) the apex
11
- * `HostedZone`, wires child-account delegations via `NsRecord` (pointing at
12
- * each child's nameservers published as exports), and composes all user
13
- * records + certificates.
8
+ * `HostedZone` and composes all user records + certificates.
14
9
  *
15
- * Delegation mechanism: the child Fjall account scaffolds its own
16
- * `HostedZone`, and the parent simply writes NS records pointing at the
17
- * child's nameservers (Fn.importValue of the child stack's nameservers
18
- * output). This differs from the legacy `DomainDelegation` pattern, which
19
- * uses `CrossAccountZoneDelegationRecord` to create the child HZ from the
20
- * parent via IAM assume-role. Both patterns are valid for their respective
21
- * semantics see R10 in the Phase 1 plan.
10
+ * Delegation direction (D8): child writes NS. The apex zone's contribution
11
+ * to delegation is the org-gated `DelegationRole` the `HostedZone` wrapper
12
+ * creates on the create path — the delegated child `Domain` assumes that
13
+ * role by LITERAL ARN (`parentDelegationRoleArn`) and UPSERTs its own NS
14
+ * records into this zone via `CrossAccountZoneDelegationRecord`. The old
15
+ * parent-writes `delegations[]` path (NS records from `Fn.importValue` of
16
+ * child nameserver exports) was removed with the H13 legacy layer: it was
17
+ * export-locked, child-first-deploy-ordered, and removal required a parent
18
+ * redeploy.
22
19
  */
23
20
  export function composeApexDomain(scope, props) {
24
21
  const safeZone = toPascalCase(getSafeZoneName(props.zoneName));
@@ -51,22 +48,6 @@ export function composeApexDomain(scope, props) {
51
48
  if (props.records && props.records.length > 0) {
52
49
  composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, props.zoneName, props.records);
53
50
  }
54
- const delegations = props.delegations ?? [];
55
- delegations.forEach((delegation, index) => {
56
- const childZoneName = `${delegation.subdomain}.${props.zoneName}`;
57
- const safeChild = toPascalCase(getSafeZoneName(childZoneName));
58
- const childExports = getDomainExportNames(childZoneName);
59
- // Cross-phase dependency: child stack must deploy first; CFN fails here
60
- // at deploy if the nameservers export does not yet exist.
61
- const nameserversToken = Fn.importValue(childExports.nameservers);
62
- new NsRecord(scope, `${safeZone}Delegation${safeChild}${index}`, {
63
- zone: hostedZoneConstruct.hostedZone,
64
- zoneName: props.zoneName,
65
- recordName: delegation.subdomain,
66
- // Fn.split returns a list token suitable for a multi-value NS record.
67
- values: Fn.split(",", nameserversToken)
68
- });
69
- });
70
51
  return {
71
52
  hostedZone: hostedZoneConstruct.hostedZone,
72
53
  certificates,
@@ -75,8 +56,7 @@ export function composeApexDomain(scope, props) {
75
56
  nameServers: hostedZoneConstruct.isImported
76
57
  ? undefined
77
58
  : (hostedZoneConstruct.nameServers ?? undefined),
78
- manualRecords: [],
79
- delegations
59
+ manualRecords: []
80
60
  };
81
61
  }
82
62
  function normaliseCertificate(cert) {
@@ -1,23 +1,38 @@
1
- import { Fn } from "aws-cdk-lib";
1
+ import { Fn, Token } from "aws-cdk-lib";
2
2
  import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
3
3
  import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
4
4
  import { isStorage } from "./storage.js";
5
5
  import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
6
- import { FjallLogger } from "../../utils/validationLogger.js";
7
6
  /**
8
- * Validates CDN props and logs warnings for misconfigured options.
7
+ * Validates CDN props synth-time hard errors for shapes CloudFormation
8
+ * would only reject at deploy.
9
9
  */
10
10
  function validateCdnProps(props) {
11
11
  const hasCertificate = !!(props.certificate || props.certificateArn);
12
- // Validate certificate region warning
13
- if (hasCertificate && props.domainNames && props.domainNames.length > 0) {
14
- FjallLogger.info("CloudFront certificates must be in us-east-1 region. " +
15
- "Ensure your certificate was created in us-east-1.");
12
+ // CloudFront accepts viewer certificates from us-east-1 only. A literal
13
+ // ARN is inspectable at synth (design H3/D3 the old advisory log is
14
+ // replaced by real us-east-1 provisioning in the patterns; this assert
15
+ // covers the BYO-ARN escape hatch), so fail here with the cure rather
16
+ // than at CloudFormation with InvalidViewerCertificate.
17
+ if (props.certificateArn !== undefined &&
18
+ !Token.isUnresolved(props.certificateArn)) {
19
+ const certificateRegion = props.certificateArn.split(":")[3];
20
+ if (certificateRegion !== "us-east-1") {
21
+ throw new Error(`CDN 'certificateArn' is in region '${certificateRegion}' ` +
22
+ `(got '${props.certificateArn}'). CloudFront accepts viewer ` +
23
+ "certificates from us-east-1 only — pass the ARN of a us-east-1 " +
24
+ "certificate, or set the pattern's domain zone identity so it " +
25
+ "provisions one.");
26
+ }
16
27
  }
17
- // Validate domain names require certificate
28
+ // Aliases without a viewer certificate fail at CloudFormation deploy; fail
29
+ // at synth with the cure instead.
18
30
  if (props.domainNames && props.domainNames.length > 0 && !hasCertificate) {
19
- FjallLogger.warn("'domainNames' provided but no 'certificate' specified. " +
20
- "Custom domain names require an ACM certificate in us-east-1.");
31
+ throw new Error(`CDN 'domainNames' [${props.domainNames.join(", ")}] were provided ` +
32
+ "without a certificate. CloudFront serves custom domain names only " +
33
+ "with a us-east-1 ACM certificate — pass 'certificate' or " +
34
+ "'certificateArn', or configure the pattern's 'domain' with a zone " +
35
+ "identity so it provisions one.");
21
36
  }
22
37
  // Validate ALB origin
23
38
  if (props.originType === "alb") {
@@ -25,7 +25,7 @@ import { FjallLogger } from "../../utils/validationLogger.js";
25
25
  import { VALIDATION_PATTERNS } from "@fjall/generator";
26
26
  import { evaluateBakeGuard } from "@fjall/util/docker";
27
27
  import { toKebab, buildParameterPath } from "@fjall/util";
28
- import { validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
28
+ import { validateEcsDomainConfig, validateSecretName } from "../../resources/aws/compute/ecsValidation.js";
29
29
  import { COMPUTE_DEFAULTS, collectImportedSecretNames } from "./compute.js";
30
30
  import { isHookMigrations } from "./computeEcsTypes.js";
31
31
  export { ScalingType } from "./computeEcsTypes.js";
@@ -81,6 +81,11 @@ function validateSeparateTaskDef(serviceName, separateTaskDef) {
81
81
  * Extracted for clarity and detail parity with database/network patterns.
82
82
  */
83
83
  export function validateEcsProps(props) {
84
+ // Mirror of the resources-layer domainConfig checks (H8 legacy `region`
85
+ // rejection, routingPolicy shape) — defence-in-depth per generator-standards
86
+ // § "Validate at the Lowest Layer the Field Belongs To". The cluster name is
87
+ // the construct id, unavailable here, so errors prefix with the domain name.
88
+ validateEcsDomainConfig(props.cluster?.domainConfig);
84
89
  // Validate services array exists and is not empty
85
90
  if (!props.services || props.services.length === 0) {
86
91
  throw new Error("At least one service must be specified in 'services' array.");
@@ -522,6 +522,10 @@ export interface EcsClusterConfig {
522
522
  * Domain for HTTPS access.
523
523
  * - Omit: ALB created with default DNS (*.elb.amazonaws.com)
524
524
  * - Specified: Creates ACM certificate + Route53 DNS A record
525
+ *
526
+ * Requires `domainConfig` to name a zone source (`managedDomain` or
527
+ * `hostedZone`) — Fjall never creates a hosted zone inside an app stack
528
+ * (design D1/H1), so a bare `domain` with no zone source throws at synth.
525
529
  */
526
530
  domain?: string;
527
531
  /**
@@ -538,9 +542,10 @@ export interface EcsClusterConfig {
538
542
  */
539
543
  directAccess?: boolean;
540
544
  /**
541
- * Advanced domain configuration for routing policies (latency, weighted, geo).
542
- * Only used when domain is specified.
543
- * Allows for multi-region deployments with advanced DNS routing.
545
+ * Domain configuration: zone source (managed domain or imported zone),
546
+ * certificate, and routing policy (latency via `routingPolicy`, weighted,
547
+ * geo). Required for any custom domain; enables multi-region deployments
548
+ * with advanced DNS routing.
544
549
  */
545
550
  domainConfig?: DomainConfig;
546
551
  /**
@@ -9,9 +9,18 @@ export interface DelegatedDomainPatternResult {
9
9
  readonly manualRecords: ManualRecord[];
10
10
  }
11
11
  /**
12
- * Composition for `registrar: "external-delegated"`. Creates a hosted zone
13
- * for `{delegatedSubdomain}.{zoneName}` — the user adds NS records at their
14
- * external registrar to delegate the subdomain to Fjall. No cross-account
15
- * delegation role is required because Fjall is not creating the parent zone.
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
15
+ * parent zone via `CrossAccountZoneDelegationRecord` (D8 child-writes
16
+ * delegation; same shape as devSubstrate Case 1). The role ARN MUST be a
17
+ * literal — validated in `validateDomainProps` (VD2-a). When absent, the
18
+ * user delegates out of band (external registrar / manual NS) and a synth
19
+ * warning flags that the zone will not resolve until they do.
20
+ *
21
+ * `phase: "zone"` early-returns before certificate issuance (R2 cert-hang
22
+ * guard, shared `DomainDeployPhase` contract with `DevSubstrate`): issuing a
23
+ * DNS-validated certificate before the delegation has propagated hangs ACM
24
+ * until CloudFormation rolls back (~1 h).
16
25
  */
17
26
  export declare function composeDelegatedDomain(scope: Construct, props: ExternalDelegatedProps): DelegatedDomainPatternResult;
@@ -1,22 +1,75 @@
1
+ import { Annotations } from "aws-cdk-lib";
2
+ import { Role } from "../../resources/aws/iam/index.js";
1
3
  import { HostedZone } from "../../resources/aws/networking/hostedZone.js";
4
+ import { CrossAccountDelegationRecord } from "../../resources/aws/networking/crossAccountDelegationRecord.js";
2
5
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
3
6
  import { composeTypedDnsRecords } from "./dnsRecordComposer.js";
4
7
  import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
8
+ import { DOMAIN_DEPLOY_DEFAULT_PHASE } from "../../utils/domainTypes.js";
5
9
  /**
6
- * Composition for `registrar: "external-delegated"`. Creates a hosted zone
7
- * for `{delegatedSubdomain}.{zoneName}` — the user adds NS records at their
8
- * external registrar to delegate the subdomain to Fjall. No cross-account
9
- * delegation role is required because Fjall is not creating the parent zone.
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
13
+ * parent zone via `CrossAccountZoneDelegationRecord` (D8 child-writes
14
+ * delegation; same shape as devSubstrate Case 1). The role ARN MUST be a
15
+ * literal — validated in `validateDomainProps` (VD2-a). When absent, the
16
+ * user delegates out of band (external registrar / manual NS) and a synth
17
+ * warning flags that the zone will not resolve until they do.
18
+ *
19
+ * `phase: "zone"` early-returns before certificate issuance (R2 cert-hang
20
+ * guard, shared `DomainDeployPhase` contract with `DevSubstrate`): issuing a
21
+ * DNS-validated certificate before the delegation has propagated hangs ACM
22
+ * until CloudFormation rolls back (~1 h).
10
23
  */
11
24
  export function composeDelegatedDomain(scope, props) {
12
25
  const effectiveZone = `${props.delegatedSubdomain}.${props.zoneName}`;
13
26
  const safeZone = toPascalCase(getSafeZoneName(effectiveZone));
14
27
  const hostedZoneConstruct = new HostedZone(scope, `${safeZone}HostedZone`, {
15
28
  zoneName: effectiveZone,
29
+ // The PARENT account owns the delegation role — this is the child.
16
30
  createDelegationRole: false,
17
31
  costAllocationEnvironment: props.costAllocationEnvironment,
18
32
  costAllocationDomain: props.zoneName
19
33
  });
34
+ // D8 child-writes delegation. The in-stack custom resource assumes the
35
+ // parent account's DelegationRole by LITERAL ARN (Role.fromRoleArn) — NOT
36
+ // Fn.importValue, which resolves same-account only (VD2-a). The parent
37
+ // DelegationRole trusts the whole org (OrganizationPrincipal), so a child
38
+ // account can assume it. The CLI injects the ARN via
39
+ // ManagedDomainBinding.delegationRoleArn at deploy time.
40
+ if (props.parentDelegationRoleArn !== undefined) {
41
+ const parentRole = Role.fromRoleArn(scope, `${safeZone}ParentDelegationRole`, props.parentDelegationRoleArn);
42
+ new CrossAccountDelegationRecord(scope, `${safeZone}DelegationRecord`, {
43
+ delegationRole: parentRole,
44
+ delegatedZone: hostedZoneConstruct.hostedZone,
45
+ delegatedZoneName: effectiveZone,
46
+ parentHostedZoneName: props.zoneName,
47
+ costAllocationEnvironment: props.costAllocationEnvironment,
48
+ costAllocationDomain: props.zoneName
49
+ });
50
+ }
51
+ else {
52
+ Annotations.of(scope).addWarning(`Zone '${effectiveZone}' will not resolve until it is delegated: no ` +
53
+ `parentDelegationRoleArn was provided, so no NS records are written ` +
54
+ `into '${props.zoneName}'. Deploy via the Fjall CLI (which injects ` +
55
+ `the parent DelegationRole ARN from the managed-domain binding) or ` +
56
+ `add the zone's NS records at the parent registrar manually.`);
57
+ }
58
+ const nameServers = hostedZoneConstruct.nameServers ?? [];
59
+ const records = props.records ?? [];
60
+ if (records.length > 0) {
61
+ composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, records);
62
+ }
63
+ // Step 2 of the R2 two-step gate: issuing the cert before the delegation
64
+ // NS has propagated hangs ACM DNS-validation, so "zone" stops here.
65
+ if ((props.phase ?? DOMAIN_DEPLOY_DEFAULT_PHASE) !== "full") {
66
+ return {
67
+ hostedZone: hostedZoneConstruct.hostedZone,
68
+ certificates: new Map(),
69
+ nameServers,
70
+ manualRecords: []
71
+ };
72
+ }
20
73
  const certificates = new Map();
21
74
  (props.certificates ?? []).forEach((cert, index) => {
22
75
  const normalised = normaliseCertificate(cert);
@@ -31,10 +84,6 @@ export function composeDelegatedDomain(scope, props) {
31
84
  });
32
85
  certificates.set(normalised.domainName, dc.certificate);
33
86
  });
34
- if (props.records && props.records.length > 0) {
35
- composeTypedDnsRecords(scope, hostedZoneConstruct.hostedZone, effectiveZone, props.records);
36
- }
37
- const nameServers = hostedZoneConstruct.nameServers ?? [];
38
87
  return {
39
88
  hostedZone: hostedZoneConstruct.hostedZone,
40
89
  certificates,
@@ -1,5 +1,6 @@
1
1
  import { Construct } from "constructs";
2
2
  import type App from "../../app.js";
3
+ import { type DomainDeployPhase } from "../../utils/domainTypes.js";
3
4
  /**
4
5
  * Props for the internal `DevSubstrate` construct.
5
6
  *
@@ -44,7 +45,7 @@ export interface IDevSubstrateProps {
44
45
  * only after the delegated NS has propagated, else ACM DNS-validation hangs.
45
46
  * Defaults to `"full"`. Inert unless `domain` is set.
46
47
  */
47
- readonly phase?: "zone" | "full";
48
+ readonly phase?: DomainDeployPhase;
48
49
  /**
49
50
  * Public domain the substrate hangs a `dev.<appDomain>` child zone under. When
50
51
  * omitted, the substrate stands up no zone/cert (early milestone — G2 reaches
@@ -24,6 +24,7 @@ import { CrossAccountDelegationRecord } from "../../resources/aws/networking/cro
24
24
  import { DomainCertificate } from "../../resources/aws/networking/domainCertificate.js";
25
25
  import { Role } from "../../resources/aws/iam/role.js";
26
26
  import { toPascalCase } from "../../utils/capitaliseString.js";
27
+ import { DOMAIN_DEPLOY_DEFAULT_PHASE } from "../../utils/domainTypes.js";
27
28
  import { DEV_AURORA_DEFAULTS } from "../../resources/aws/database/rdsDefaults.js";
28
29
  import { DEV_APP_TAG_KEY, DEV_TAG_KEY, DEV_TAG_VALUE } from "../../utils/devSubstrateTags.js";
29
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -54,12 +55,11 @@ const DEV_SUBSTRATE_PARENT_ROLE_ID = "DevSubstrateParentDelegationRole";
54
55
  /** Construct ids for the substrate's wildcard cert + :443 HTTPS listener (A6). */
55
56
  const DEV_SUBSTRATE_CERT_ID = "DevSubstrateCertificate";
56
57
  const DEV_SUBSTRATE_HTTPS_LISTENER_ID = "DevSubstrateHttpsListener";
57
- /**
58
- * Default deploy phase when `phase` is omitted — the cert + :443 listener build.
59
- * The two-step guard (design R2) sets `"zone"` explicitly for step 1, so the
60
- * safe default for a single-shot deploy is the complete substrate.
61
- */
62
- const DEV_SUBSTRATE_DEFAULT_PHASE = "full";
58
+ // Default deploy phase when `phase` is omitted — the cert + :443 listener
59
+ // build. Shared with the delegated `Domain` topology via
60
+ // `DOMAIN_DEPLOY_DEFAULT_PHASE` (utils/domainTypes.ts): the R2 two-step guard
61
+ // sets `"zone"` explicitly for step 1, so the safe default for a single-shot
62
+ // deploy is the complete substrate.
63
63
  /**
64
64
  * The substrate's database mode, surfaced to slot-ops as an export so the
65
65
  * hand-created G2 slot knows whether to wire Aurora Data-API creds or a
@@ -142,6 +142,11 @@ export class DevSubstrate extends Construct {
142
142
  // exposes for the cluster ARN; it returns databaseCluster.clusterArn.
143
143
  dbClusterArn: database.getSnapshotTarget().arn,
144
144
  dbSecretArn: database.getCredentials().secret.secretArn,
145
+ // Proxy-less substrate ⇒ getHostEndpoint() is the cluster writer endpoint;
146
+ // it + the port ride the exports because the dev role cannot rds:Describe*
147
+ // at runtime to resolve them.
148
+ dbWriterEndpoint: database.getHostEndpoint(),
149
+ dbPort: database.getHostPort(),
145
150
  slotExecutionRoleArn: slotExecutionRole.roleArn,
146
151
  slotRepositoryUri: slotRepository.repositoryUri,
147
152
  wakerTargetGroupArn: wakerTargetGroup.targetGroupArn,
@@ -244,7 +249,10 @@ export class DevSubstrate extends Construct {
244
249
  // fence-pinned name — the reaper finds it by the subtree fence tags. A pinned
245
250
  // name would only reintroduce the 32-char ALB-name truncation dance.
246
251
  const loadBalancer = createApplicationLoadBalancer(this, DEV_SUBSTRATE_ALB_ID, { vpc, internetFacing: true, securityGroup: albSecurityGroup });
247
- slotSecurityGroup.connections.allowFrom(albSecurityGroup, Port.tcp(DEV_SUBSTRATE_SLOT_PORT), "Dev substrate ALB → slot tasks");
252
+ slotSecurityGroup.connections.allowFrom(albSecurityGroup, Port.tcp(DEV_SUBSTRATE_SLOT_PORT),
253
+ // EC2 SG-rule descriptions reject non-ASCII (allowed set is
254
+ // `a-zA-Z0-9. _-:/()#,@[]+=&;{}!$*` — no arrow, em-dash, or even `>`).
255
+ "Dev substrate ALB to slot tasks");
248
256
  // Unconditional 404 default — the substrate's only routes are per-slot
249
257
  // host-header rules, so an unmatched host must 404, never fall through (the ECS
250
258
  // path's 404 is conditional on >=2 routes). The :443 listener + wildcard cert
@@ -327,7 +335,7 @@ export class DevSubstrate extends Construct {
327
335
  }
328
336
  // Step 2 of the R2 two-step gate: synthesising the cert before the step-1 NS
329
337
  // has propagated hangs ACM DNS-validation for hours, so "zone" stops here.
330
- if ((props.phase ?? DEV_SUBSTRATE_DEFAULT_PHASE) !== "full")
338
+ if ((props.phase ?? DOMAIN_DEPLOY_DEFAULT_PHASE) !== "full")
331
339
  return { devDomain: childZoneName };
332
340
  // exportCertificateArn:false — consumed in-stack by the :443 listener (slots
333
341
  // reference the exported LISTENER arn, not the cert), and the wildcard `*.`
@@ -375,6 +383,8 @@ export class DevSubstrate extends Construct {
375
383
  emit(this, "AlbCloudWatchName", values.albCloudWatchName);
376
384
  emit(databaseScope, "DbClusterArn", values.dbClusterArn);
377
385
  emit(databaseScope, "DbSecretArn", values.dbSecretArn);
386
+ emit(databaseScope, "DbWriterEndpoint", values.dbWriterEndpoint);
387
+ emit(databaseScope, "DbPort", values.dbPort);
378
388
  emit(this, "SlotExecutionRoleArn", values.slotExecutionRoleArn);
379
389
  emit(this, "SlotRepositoryUri", values.slotRepositoryUri);
380
390
  emit(this, "WakerTargetGroupArn", values.wakerTargetGroupArn);
@@ -1,15 +1,8 @@
1
1
  import type { Construct } from "constructs";
2
2
  import type { IHostedZone } from "aws-cdk-lib/aws-route53";
3
- import type { DnsRecord, DnsRecordInput } from "./interfaces/domain.js";
3
+ import type { DnsRecord } from "./interfaces/domain.js";
4
4
  /**
5
- * Legacy composer consumed by `DomainFactory` (deleted in Phase 2). Accepts
6
- * the `DnsRecordInput` shape from the BIND-primary era where ALIAS sentinels
7
- * live in `value`. Typed targets are not supported here; use
8
- * `composeTypedDnsRecords` instead.
9
- */
10
- export declare function composeDnsRecords(scope: Construct, zone: IHostedZone, zoneName: string, records: DnsRecordInput[]): void;
11
- /**
12
- * Typed composer consumed by the new `Domain` construct. Accepts the
5
+ * Typed composer consumed by the `Domain` construct. Accepts the
13
6
  * discriminated `DnsRecord` union (`StandardRecord | AliasRecord`).
14
7
  *
15
8
  * `AliasRecord` entries MUST carry a `target` produced by `fjallApp()`,