@fjall/components-infrastructure 2.33.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 (66) hide show
  1. package/dist/lib/app.d.ts +19 -2
  2. package/dist/lib/app.js +61 -4
  3. package/dist/lib/patterns/aws/account.js +6 -1
  4. package/dist/lib/patterns/aws/apexDomainPattern.d.ts +11 -12
  5. package/dist/lib/patterns/aws/apexDomainPattern.js +11 -31
  6. package/dist/lib/patterns/aws/cdn.js +25 -10
  7. package/dist/lib/patterns/aws/computeEcs.js +15 -6
  8. package/dist/lib/patterns/aws/computeEcsTypes.d.ts +13 -4
  9. package/dist/lib/patterns/aws/delegatedDomainPattern.d.ts +13 -4
  10. package/dist/lib/patterns/aws/delegatedDomainPattern.js +57 -8
  11. package/dist/lib/patterns/aws/devSubstrate.d.ts +2 -1
  12. package/dist/lib/patterns/aws/devSubstrate.js +18 -8
  13. package/dist/lib/patterns/aws/dnsRecordComposer.d.ts +2 -9
  14. package/dist/lib/patterns/aws/dnsRecordComposer.js +5 -97
  15. package/dist/lib/patterns/aws/domain.d.ts +3 -2
  16. package/dist/lib/patterns/aws/domain.js +3 -2
  17. package/dist/lib/patterns/aws/domainValidation.js +71 -46
  18. package/dist/lib/patterns/aws/externalRecordsPattern.js +2 -11
  19. package/dist/lib/patterns/aws/index.d.ts +1 -3
  20. package/dist/lib/patterns/aws/index.js +2 -3
  21. package/dist/lib/patterns/aws/interfaces/domain.d.ts +22 -8
  22. package/dist/lib/patterns/aws/interfaces/index.d.ts +0 -1
  23. package/dist/lib/patterns/aws/interfaces/index.js +3 -0
  24. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +55 -10
  25. package/dist/lib/patterns/aws/patternDomain.d.ts +77 -0
  26. package/dist/lib/patterns/aws/patternDomain.js +198 -0
  27. package/dist/lib/patterns/aws/payload.js +23 -30
  28. package/dist/lib/patterns/aws/staticSite.d.ts +1 -0
  29. package/dist/lib/patterns/aws/staticSite.js +28 -33
  30. package/dist/lib/resources/aws/base/awsStack.d.ts +8 -0
  31. package/dist/lib/resources/aws/base/awsStack.js +20 -17
  32. package/dist/lib/resources/aws/compute/ecs.d.ts +1 -1
  33. package/dist/lib/resources/aws/compute/ecs.js +1 -1
  34. package/dist/lib/resources/aws/compute/ecsNetworking.d.ts +1 -1
  35. package/dist/lib/resources/aws/compute/ecsNetworking.js +212 -47
  36. package/dist/lib/resources/aws/compute/ecsTaskDefinition.js +4 -4
  37. package/dist/lib/resources/aws/compute/ecsTypes.d.ts +60 -9
  38. package/dist/lib/resources/aws/compute/ecsValidation.d.ts +14 -1
  39. package/dist/lib/resources/aws/compute/ecsValidation.js +51 -0
  40. package/dist/lib/resources/aws/logging/logGroup.d.ts +6 -0
  41. package/dist/lib/resources/aws/logging/logGroup.js +11 -1
  42. package/dist/lib/resources/aws/networking/dnsRecord/aRecord.js +3 -2
  43. package/dist/lib/resources/aws/networking/dnsRecord/aaaaRecord.js +3 -2
  44. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.d.ts +23 -1
  45. package/dist/lib/resources/aws/networking/dnsRecord/aliasRecord.js +14 -3
  46. package/dist/lib/resources/aws/networking/dnsRecord/caaRecord.js +3 -2
  47. package/dist/lib/resources/aws/networking/dnsRecord/cnameRecord.js +3 -2
  48. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.d.ts +10 -1
  49. package/dist/lib/resources/aws/networking/dnsRecord/dnsRecordBase.js +21 -4
  50. package/dist/lib/resources/aws/networking/dnsRecord/mxRecord.js +3 -2
  51. package/dist/lib/resources/aws/networking/dnsRecord/nsRecord.js +3 -2
  52. package/dist/lib/resources/aws/networking/dnsRecord/srvRecord.js +3 -2
  53. package/dist/lib/resources/aws/networking/dnsRecord/txtRecord.js +3 -2
  54. package/dist/lib/resources/aws/networking/domainCertificate.d.ts +28 -4
  55. package/dist/lib/resources/aws/networking/domainCertificate.js +24 -10
  56. package/dist/lib/resources/aws/networking/hostedZone.d.ts +11 -0
  57. package/dist/lib/resources/aws/networking/hostedZone.js +6 -0
  58. package/dist/lib/utils/costAllocationTags.d.ts +9 -0
  59. package/dist/lib/utils/costAllocationTags.js +6 -1
  60. package/dist/lib/utils/dnsRecordRegistry.d.ts +52 -0
  61. package/dist/lib/utils/dnsRecordRegistry.js +64 -0
  62. package/dist/lib/utils/domainTypes.d.ts +43 -36
  63. package/dist/lib/utils/domainTypes.js +65 -2
  64. package/dist/lib/utils/managedDomainContext.d.ts +37 -0
  65. package/dist/lib/utils/managedDomainContext.js +111 -0
  66. package/package.json +4 -4
@@ -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()`,
@@ -1,105 +1,13 @@
1
1
  import { CaaTag } from "aws-cdk-lib/aws-route53";
2
2
  import { ARecord, AaaaRecord, AliasRecord, CnameRecord, MxRecord, TxtRecord, NsRecord, SrvRecord, CaaRecord } from "../../resources/aws/networking/dnsRecord/index.js";
3
3
  import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
4
- import { ALIAS_CDK_PREFIX } from "../../utils/domainTypes.js";
5
4
  import { DNS_APEX } from "@fjall/util";
5
+ // H13: the legacy BIND-era `composeDnsRecords` (untyped `DnsRecordInput` with
6
+ // ALIAS sentinels in `value`) was deleted in Phase 2. The typed composer below
7
+ // deliberately preserves its construct-id formula — see the eject-contract
8
+ // note on `composeTypedDnsRecords`.
6
9
  /**
7
- * Legacy composer consumed by `DomainFactory` (deleted in Phase 2). Accepts
8
- * the `DnsRecordInput` shape from the BIND-primary era where ALIAS sentinels
9
- * live in `value`. Typed targets are not supported here; use
10
- * `composeTypedDnsRecords` instead.
11
- */
12
- export function composeDnsRecords(scope, zone, zoneName, records) {
13
- const safeZone = toPascalCase(getSafeZoneName(zoneName));
14
- records.forEach((record, index) => {
15
- // Skip alias sentinels — they are resolved via the typed path in Phase 1+.
16
- if (record.value.startsWith(ALIAS_CDK_PREFIX)) {
17
- return;
18
- }
19
- const safeName = toPascalCase(record.name === DNS_APEX ? "Apex" : record.name);
20
- const constructId = `${safeZone}${safeName}${record.type}Record${index}`;
21
- const common = {
22
- zone,
23
- zoneName,
24
- recordName: record.name,
25
- ttl: record.ttl
26
- };
27
- switch (record.type) {
28
- case "A":
29
- new ARecord(scope, constructId, {
30
- ...common,
31
- values: [record.value]
32
- });
33
- break;
34
- case "AAAA":
35
- new AaaaRecord(scope, constructId, {
36
- ...common,
37
- values: [record.value]
38
- });
39
- break;
40
- case "CNAME":
41
- new CnameRecord(scope, constructId, {
42
- ...common,
43
- domainName: record.value
44
- });
45
- break;
46
- case "MX":
47
- new MxRecord(scope, constructId, {
48
- ...common,
49
- values: [
50
- {
51
- hostName: record.value,
52
- priority: record.priority ?? 10
53
- }
54
- ]
55
- });
56
- break;
57
- case "TXT":
58
- new TxtRecord(scope, constructId, {
59
- ...common,
60
- values: [record.value]
61
- });
62
- break;
63
- case "NS":
64
- new NsRecord(scope, constructId, {
65
- ...common,
66
- values: [record.value]
67
- });
68
- break;
69
- case "SRV":
70
- new SrvRecord(scope, constructId, {
71
- ...common,
72
- values: [
73
- {
74
- hostName: record.value,
75
- priority: record.priority ?? 10,
76
- weight: record.weight ?? 0,
77
- port: record.port ?? 443
78
- }
79
- ]
80
- });
81
- break;
82
- case "CAA":
83
- new CaaRecord(scope, constructId, {
84
- ...common,
85
- values: [
86
- {
87
- flag: 0,
88
- tag: record.value.includes("issuewild")
89
- ? CaaTag.ISSUEWILD
90
- : record.value.includes("iodef")
91
- ? CaaTag.IODEF
92
- : CaaTag.ISSUE,
93
- value: record.value
94
- }
95
- ]
96
- });
97
- break;
98
- }
99
- });
100
- }
101
- /**
102
- * Typed composer consumed by the new `Domain` construct. Accepts the
10
+ * Typed composer consumed by the `Domain` construct. Accepts the
103
11
  * discriminated `DnsRecord` union (`StandardRecord | AliasRecord`).
104
12
  *
105
13
  * `AliasRecord` entries MUST carry a `target` produced by `fjallApp()`,
@@ -7,8 +7,9 @@ import type { DomainProps, ManualRecord } from "./interfaces/domain.js";
7
7
  * User-facing Route53-oriented domain construct. Dispatches on
8
8
  * `props.registrar` to one of three per-registrar patterns:
9
9
  *
10
- * - `route53` → `composeApexDomain` (apex HZ + delegations)
11
- * - `external-delegated` → `composeDelegatedDomain` (sub-HZ only)
10
+ * - `route53` → `composeApexDomain` (apex HZ + org-gated DelegationRole)
11
+ * - `external-delegated` → `composeDelegatedDomain` (sub-HZ; D8
12
+ * child-writes NS delegation via `parentDelegationRoleArn`)
12
13
  * - `external-records` → `composeExternalRecords` (no HZ, manual records)
13
14
  *
14
15
  * Applies gold-plating tags (`fjall:description`, three
@@ -11,8 +11,9 @@ import { applyCostAllocationTags } from "../../utils/costAllocationTags.js";
11
11
  * User-facing Route53-oriented domain construct. Dispatches on
12
12
  * `props.registrar` to one of three per-registrar patterns:
13
13
  *
14
- * - `route53` → `composeApexDomain` (apex HZ + delegations)
15
- * - `external-delegated` → `composeDelegatedDomain` (sub-HZ only)
14
+ * - `route53` → `composeApexDomain` (apex HZ + org-gated DelegationRole)
15
+ * - `external-delegated` → `composeDelegatedDomain` (sub-HZ; D8
16
+ * child-writes NS delegation via `parentDelegationRoleArn`)
16
17
  * - `external-records` → `composeExternalRecords` (no HZ, manual records)
17
18
  *
18
19
  * Applies gold-plating tags (`fjall:description`, three
@@ -1,10 +1,12 @@
1
- import { Annotations } from "aws-cdk-lib";
1
+ import { Annotations, Token } from "aws-cdk-lib";
2
2
  import { DNS_APEX } from "@fjall/util";
3
+ import { isWithinZone, resolveRecordFqdn } from "../../utils/domainTypes.js";
3
4
  const VALID_REGISTRARS = [
4
5
  "route53",
5
6
  "external-delegated",
6
7
  "external-records"
7
8
  ];
9
+ const VALID_PHASES = ["zone", "full"];
8
10
  /**
9
11
  * Synchronous synth-time validator for `DomainProps`. Throws on hard errors
10
12
  * (CDK catches and surfaces the stack trace pointing at the `new Domain(...)`
@@ -18,7 +20,7 @@ export function validateDomainProps(scope, props) {
18
20
  const effectiveZone = resolveEffectiveZoneName(props);
19
21
  validateRecords(scope, props, effectiveZone);
20
22
  validateCertificates(props, effectiveZone);
21
- validateDelegations(props);
23
+ validateDelegationConfig(props);
22
24
  }
23
25
  function validateRegistrar(props) {
24
26
  const registrar = props.registrar;
@@ -26,11 +28,15 @@ function validateRegistrar(props) {
26
28
  !VALID_REGISTRARS.includes(registrar)) {
27
29
  throw new Error(`Domain registrar must be one of ${VALID_REGISTRARS.join(", ")}; received: ${String(registrar)}`);
28
30
  }
29
- // Runtime defence — TS forbids `delegations` on external-records, but a JS
30
- // caller could still pass it. Surface the mistake loudly.
31
- if (registrar === "external-records" &&
32
- props.delegations !== undefined) {
33
- throw new Error("Domain: 'delegations' is not supported with registrar: 'external-records' there is no Fjall-managed hosted zone to cascade from.");
31
+ // D8/D12 removed-surface defence — the parent-writes `delegations[]` prop
32
+ // no longer exists on any registrar. TS already rejects it; this catches JS
33
+ // callers and stale generated code, naming the replacement.
34
+ if (props.delegations !== undefined) {
35
+ throw new Error("Domain: 'delegations' was removed (D8one delegation direction, child " +
36
+ "writes NS). Cure: declare the delegated child as its own Domain with " +
37
+ "registrar: 'external-delegated' and set 'parentDelegationRoleArn' " +
38
+ "(the CLI injects it via ManagedDomainBinding.delegationRoleArn); the " +
39
+ "child then UPSERTs its NS records into this zone.");
34
40
  }
35
41
  }
36
42
  function resolveEffectiveZoneName(props) {
@@ -47,10 +53,9 @@ function validateRecords(scope, props, effectiveZone) {
47
53
  const records = props.records ?? [];
48
54
  const seen = new Map();
49
55
  for (const record of records) {
50
- const fqdn = recordFqdn(record.name, effectiveZone);
51
- if (!isWithinZone(fqdn, effectiveZone)) {
52
- throw new Error(`Record '${fqdn}' falls outside managed zone '${effectiveZone}'. Use a separate Domain construct or a SubdomainDelegation.`);
53
- }
56
+ // Rejects wrong-zone absolute names and ambiguous bare-zone-suffixed
57
+ // names itself; a resolved name is therefore always within the zone.
58
+ const fqdn = resolveRecordFqdn(record.name, effectiveZone);
54
59
  if ("target" in record) {
55
60
  if (record.name === DNS_APEX && props.registrar === "external-records") {
56
61
  throw new Error("Apex A records with Fjall targets require registrar: 'route53' or 'external-delegated' because RFC 1035 forbids CNAME at apex. Use a subdomain or change registrar.");
@@ -89,19 +94,61 @@ function validateCertificates(props, effectiveZone) {
89
94
  }
90
95
  }
91
96
  }
92
- function validateDelegations(props) {
93
- if (props.registrar === "external-records") {
97
+ /**
98
+ * D8 delegation-consistency checks for the child-writes shape.
99
+ *
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.
106
+ */
107
+ function validateDelegationConfig(props) {
108
+ const arn = props
109
+ .parentDelegationRoleArn;
110
+ const phase = props.phase;
111
+ if (props.registrar !== "external-delegated") {
112
+ if (arn !== undefined) {
113
+ throw new Error(`Domain: 'parentDelegationRoleArn' is only supported with registrar: ` +
114
+ `'external-delegated' (received registrar: '${props.registrar}'). ` +
115
+ `Cure: the delegated child zone declares it — move the prop onto ` +
116
+ `the child Domain, or drop it.`);
117
+ }
118
+ if (phase !== undefined) {
119
+ throw new Error(`Domain: 'phase' is only supported with registrar: ` +
120
+ `'external-delegated' (received registrar: '${props.registrar}'). ` +
121
+ `Cure: drop the prop — only delegated child zones need the ` +
122
+ `two-step zone→full deploy gate.`);
123
+ }
94
124
  return;
95
125
  }
96
- const delegations = props
97
- .delegations;
98
- if (!delegations) {
126
+ if (phase !== undefined &&
127
+ !VALID_PHASES.includes(phase)) {
128
+ throw new Error(`Domain: 'phase' must be one of ${VALID_PHASES.map((p) => `'${p}'`).join(", ")}; ` +
129
+ `received: '${String(phase)}'. Cure: use 'zone' for step 1 of the ` +
130
+ `two-step delegated deploy, 'full' (or omit) for the complete build.`);
131
+ }
132
+ if (arn === undefined) {
99
133
  return;
100
134
  }
101
- for (const delegation of delegations) {
102
- if (delegation.auto === true && !delegation.toAccount) {
103
- throw new Error(`Delegation '${delegation.subdomain}' has auto: true but no 'toAccount' set. Provide the child Fjall account name.`);
104
- }
135
+ if (typeof arn !== "string") {
136
+ throw new Error(`Domain: 'parentDelegationRoleArn' must be a string; received: ` +
137
+ `${typeof arn}. Cure: pass the parent DelegationRole ARN as a ` +
138
+ `literal string.`);
139
+ }
140
+ if (Token.isUnresolved(arn)) {
141
+ throw new Error("Domain: 'parentDelegationRoleArn' is an unresolved token (e.g. " +
142
+ "Fn.importValue) — cross-account delegation requires a literal role " +
143
+ "ARN because Fn::ImportValue resolves same-account only (VD2-a). " +
144
+ "Cure: deploy via the Fjall CLI, which injects the literal ARN from " +
145
+ "ManagedDomainBinding.delegationRoleArn, or paste the parent zone's " +
146
+ "DelegationRoleArn output value directly.");
147
+ }
148
+ if (!/^arn:[^:]+:iam::\d{12}:role\/.+$/.test(arn)) {
149
+ throw new Error(`Domain: 'parentDelegationRoleArn' does not look like an IAM role ARN; ` +
150
+ `received: '${arn}'. Cure: pass the parent zone's DelegationRoleArn ` +
151
+ `output (arn:<partition>:iam::<account>:role/<name>).`);
105
152
  }
106
153
  }
107
154
  function normaliseCertificate(cert) {
@@ -113,33 +160,11 @@ function normaliseCertificate(cert) {
113
160
  subjectAlternativeNames: cert.subjectAlternativeNames
114
161
  };
115
162
  }
116
- function recordFqdn(recordName, zoneName) {
117
- if (recordName === DNS_APEX) {
118
- return zoneName;
119
- }
120
- // A user may already provide a FQDN (e.g. "api.example.com") or a single
121
- // label ("api"). If the name contains a dot, treat it as an FQDN — the
122
- // zone-boundary check then decides whether it is within the managed zone.
123
- // A bare label is always appended to the zone.
124
- if (recordName.includes(".")) {
125
- return recordName;
126
- }
127
- return `${recordName}.${zoneName}`;
128
- }
129
163
  function assertWithinZone(candidate, zoneName) {
130
164
  if (!isWithinZone(candidate, zoneName)) {
131
- throw new Error(`'${candidate}' falls outside managed zone '${zoneName}'. Use a separate Domain construct or a SubdomainDelegation.`);
132
- }
133
- }
134
- /**
135
- * Dot-boundary suffix matching. Rejects pathological cases where a user-supplied
136
- * zone appears as a suffix of another zone (e.g. 'evilexample.com' against
137
- * zone 'example.com' — `endsWith('example.com')` returns true without the
138
- * leading dot).
139
- */
140
- function isWithinZone(candidate, zoneName) {
141
- if (candidate === zoneName) {
142
- return true;
165
+ throw new Error(`'${candidate}' falls outside managed zone '${zoneName}'. Use a separate Domain construct or a delegated child Domain (registrar: 'external-delegated').`);
143
166
  }
144
- return candidate.endsWith(`.${zoneName}`);
145
167
  }
168
+ // isWithinZone (dot-boundary suffix matching) and resolveRecordFqdn (the
169
+ // canonical BIND-semantics record-name resolver) live in
170
+ // lib/utils/domainTypes.ts — shared with the resources-layer dnsRecord family.
@@ -2,7 +2,7 @@ import { CfnOutput } from "aws-cdk-lib";
2
2
  import { Certificate as AcmCertificate, CertificateValidation } from "aws-cdk-lib/aws-certificatemanager";
3
3
  import { resolveTargetToDnsName } from "./targets/targetResolution.js";
4
4
  import { toPascalCase, getSafeZoneName } from "../../utils/capitaliseString.js";
5
- import { DNS_APEX } from "@fjall/util";
5
+ import { resolveRecordFqdn } from "../../utils/domainTypes.js";
6
6
  const MANUAL_RECORD_TTL_SECONDS = 300;
7
7
  /**
8
8
  * Composition for `registrar: "external-records"`. No Route53 hosted zone is
@@ -60,7 +60,7 @@ export function composeExternalRecords(scope, props) {
60
60
  // entries.
61
61
  const records = props.records ?? [];
62
62
  records.forEach((record, index) => {
63
- const fqdn = toFqdn(record.name, props.zoneName);
63
+ const fqdn = resolveRecordFqdn(record.name, props.zoneName);
64
64
  if ("target" in record) {
65
65
  const value = resolveTargetToDnsName(record.target);
66
66
  const manual = {
@@ -98,15 +98,6 @@ function normaliseCertificate(cert) {
98
98
  transparencyLogging: cert.transparencyLogging
99
99
  };
100
100
  }
101
- function toFqdn(recordName, zoneName) {
102
- if (recordName === DNS_APEX) {
103
- return zoneName;
104
- }
105
- if (recordName === zoneName || recordName.endsWith(`.${zoneName}`)) {
106
- return recordName;
107
- }
108
- return `${recordName}.${zoneName}`;
109
- }
110
101
  function narrowManualType(type) {
111
102
  switch (type) {
112
103
  case "A":
@@ -7,11 +7,9 @@ export * from "./fivetranProxy.js";
7
7
  export * from "./database.js";
8
8
  export * from "../../resources/aws/compute/ec2.js";
9
9
  export * from "../../resources/aws/networking/hostedZone.js";
10
- export * from "./domainFactory.js";
11
- export * from "./domainDelegation.js";
12
10
  export { Domain } from "./domain.js";
13
11
  export * from "./targets/index.js";
14
- export { getDomainExportNames, type DnsRecordInput, type DelegationInput, type CertificateInput, type DomainApexProps, type DomainDelegatedProps, type IDomainProps, type ManagedDomainExports, type FjallTarget, type StandardRecord, type DnsRecord, type Certificate, type SubdomainDelegation, type DomainCommonProps, type Route53ApexProps, type ExternalDelegatedProps, type ExternalRecordsProps, type DomainProps, type ManualRecord } from "./interfaces/domain.js";
12
+ export { getDomainExportNames, type ManagedDomainExports, type DomainDeployPhase, type FjallTarget, type StandardRecord, type DnsRecord, type Certificate, type DomainCommonProps, type Route53ApexProps, type ExternalDelegatedProps, type ExternalRecordsProps, type DomainProps, type ManualRecord } from "./interfaces/domain.js";
15
13
  export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV } from "./interfaces/database.js";
16
14
  export * from "./compute.js";
17
15
  export * from "./storage.js";
@@ -9,9 +9,8 @@ export * from "./database.js";
9
9
  // Resource-layer re-exports — consumed by patterns that compose these primitives
10
10
  export * from "../../resources/aws/compute/ec2.js";
11
11
  export * from "../../resources/aws/networking/hostedZone.js";
12
- // Domain patterns
13
- export * from "./domainFactory.js";
14
- export * from "./domainDelegation.js";
12
+ // Domain patterns (H13: the legacy DomainFactory/ApexDomain/DomainDelegation
13
+ // layer was deleted in Phase 2 — `Domain` is the only construct surface).
15
14
  export { Domain } from "./domain.js";
16
15
  export * from "./targets/index.js";
17
16
  // Explicit exports from the interfaces barrel — avoids collision between the
@@ -1,4 +1,5 @@
1
- export type { DnsRecordInput, DelegationInput, CertificateInput, DomainApexProps, DomainDelegatedProps, IDomainProps, ManagedDomainExports } from "../../../utils/domainTypes.js";
1
+ import type { DomainDeployPhase } from "../../../utils/domainTypes.js";
2
+ export type { ManagedDomainExports, DomainDeployPhase } from "../../../utils/domainTypes.js";
2
3
  export { getDomainExportNames } from "../../../utils/domainTypes.js";
3
4
  /**
4
5
  * Typed marker for the target of an `AliasRecord`. The helpers in
@@ -39,16 +40,10 @@ export type Certificate = string | {
39
40
  readonly subjectAlternativeNames?: string[];
40
41
  readonly transparencyLogging?: boolean;
41
42
  };
42
- export interface SubdomainDelegation {
43
- readonly subdomain: string;
44
- readonly toAccount: string;
45
- readonly auto?: boolean;
46
- }
47
43
  export interface DomainCommonProps {
48
44
  readonly zoneName: string;
49
45
  readonly records?: DnsRecord[];
50
46
  readonly certificates?: Certificate[];
51
- readonly delegations?: SubdomainDelegation[];
52
47
  readonly tags?: Record<string, string>;
53
48
  readonly description?: string;
54
49
  readonly costAllocationEnvironment?: string;
@@ -60,8 +55,27 @@ export interface Route53ApexProps extends DomainCommonProps {
60
55
  export interface ExternalDelegatedProps extends DomainCommonProps {
61
56
  readonly registrar: "external-delegated";
62
57
  readonly delegatedSubdomain: string;
58
+ /**
59
+ * D8 — child-writes delegation. LITERAL ARN of the parent zone's
60
+ * DelegationRole (VD2-a: never `Fn.importValue`, which resolves
61
+ * same-account only). When present, the child zone UPSERTs its NS records
62
+ * into the parent zone via `CrossAccountZoneDelegationRecord`. The CLI
63
+ * injects this from `ManagedDomainBinding.delegationRoleArn` at deploy
64
+ * time; omit it for an externally-owned parent and add the NS records at
65
+ * the registrar instead (a synth warning reminds you the zone will not
66
+ * resolve until delegated).
67
+ */
68
+ readonly parentDelegationRoleArn?: string;
69
+ /**
70
+ * Two-step deploy gate (R2 cert-hang guard, generalised from
71
+ * `DevSubstrate.phase`). `"zone"` synthesises the child zone, delegation
72
+ * record and user records only; `"full"` (default) additionally issues the
73
+ * certificates — deploy that only after the delegated NS has propagated,
74
+ * else ACM DNS-validation hangs until CloudFormation rolls back.
75
+ */
76
+ readonly phase?: DomainDeployPhase;
63
77
  }
64
- export interface ExternalRecordsProps extends Omit<DomainCommonProps, "delegations"> {
78
+ export interface ExternalRecordsProps extends DomainCommonProps {
65
79
  readonly registrar: "external-records";
66
80
  }
67
81
  export type DomainProps = Route53ApexProps | ExternalDelegatedProps | ExternalRecordsProps;
@@ -14,4 +14,3 @@ export { type ConnectorType, type ConnectionAccess, type MessagingAccess, type C
14
14
  export { type PatternType, type IPattern, type IPayload, type IPayloadProps, type IPatternProps, type AnyPattern, type PayloadDatabaseConfig, type PayloadComputeConfig, type PayloadCdnConfig, isPayloadPattern, isPattern } from "./pattern.js";
15
15
  export { type OrganisationType, type IOrganisation, type IPlatform, type IAccount, type AnyOrganisation, isOrganisation, isPlatform, isAccount, isOrganisationResource } from "./organisation.js";
16
16
  export { type IVpcPeer } from "./vpcPeer.js";
17
- export { type DnsRecordInput, type DelegationInput, type CertificateInput, type DomainApexProps, type DomainDelegatedProps, type IDomainProps } from "./domain.js";
@@ -22,3 +22,6 @@ export { CONNECTION_TYPE, isConnector, isConnectable, isConnectionConfig, isConn
22
22
  export { isPayloadPattern, isPattern } from "./pattern.js";
23
23
  // Organisation interfaces
24
24
  export { isOrganisation, isPlatform, isAccount, isOrganisationResource } from "./organisation.js";
25
+ // Domain interfaces — the legacy `IDomainProps` family was deleted with the
26
+ // H13 BIND-era layer; consumers use the `DomainProps` union exported from
27
+ // the patterns barrel.
@@ -23,7 +23,7 @@ import { type Storage } from "../storage.js";
23
23
  import { type QueueMessaging } from "../messaging.js";
24
24
  import { type Cdn, type SmartCdnBehaviour, type StaticSiteRouting } from "../cdn.js";
25
25
  import { type LambdaFunction } from "../../../resources/aws/compute/index.js";
26
- import type { ManagedDomainExports } from "../../../utils/domainTypes.js";
26
+ import type { ManagedDomainBinding, ManagedDomainExports } from "../../../utils/domainTypes.js";
27
27
  import type { PatternType } from "@fjall/util";
28
28
  export type { ProxyConfig, ReadReplicaConfig, CredentialsConfig, EncryptionConfig, AuroraEncryptionConfig, AuroraWriterConfig, AuroraReadersConfig, DatabaseInsightsConfig };
29
29
  /**
@@ -287,11 +287,13 @@ export interface PayloadCdnConfig {
287
287
  * }));
288
288
  *
289
289
  * @example
290
- * // With custom domain (auto-creates certificate + DNS)
290
+ * // With custom domain (auto-creates certificate + DNS; the zone identity
291
+ * // is explicit — a CLI-injected managedDomain binding, or zoneName)
291
292
  * app.addPattern(PatternFactory.build("PayloadApp", {
292
293
  * type: "payload",
293
294
  * name: "my-cms",
294
- * domain: "cms.example.com"
295
+ * domain: "cms.example.com",
296
+ * zoneName: "example.com"
295
297
  * }));
296
298
  *
297
299
  * @example
@@ -316,18 +318,42 @@ export interface IPayloadProps {
316
318
  /**
317
319
  * Custom domain for the Payload application.
318
320
  * When provided, automatically:
319
- * - Looks up the Route53 hosted zone
320
- * - Creates an ACM certificate with DNS validation (in us-east-1)
321
+ * - Resolves the Route53 hosted zone from the explicit identity
322
+ * (`managedDomain` or `zoneName`/`hostedZoneId`) never guessed from
323
+ * the domain's labels
324
+ * - Provides a us-east-1 ACM certificate for CloudFront: the managed
325
+ * binding's literal ARN, or an app-owned DNS-validated certificate
326
+ * (in-stack for us-east-1 apps, otherwise in the app's dedicated
327
+ * us-east-1 certificate stack consumed via cross-region references)
321
328
  * - Configures CloudFront with the certificate
322
329
  * - Creates an A record (alias) pointing to CloudFront
323
330
  *
324
- * Requires: A Route53 hosted zone for the domain must exist.
331
+ * Requires: `managedDomain` or `zoneName` naming the zone that contains
332
+ * the domain.
325
333
  *
326
334
  * @example "cms.example.com"
327
335
  */
328
336
  domain?: string;
329
- /** Import zone and cert from a managed domain stack instead of creating inline */
330
- managedDomain?: ManagedDomainExports;
337
+ /**
338
+ * Zone and certificate identity from a managed domain stack. The
339
+ * CLI-injected `ManagedDomainBinding` carries literal values (hosted-zone
340
+ * ID + us-east-1 certificate ARN) and crosses accounts and regions; the
341
+ * legacy `ManagedDomainExports` form imports by export name (same account
342
+ * and region only). Takes priority over `zoneName`.
343
+ */
344
+ managedDomain?: ManagedDomainExports | ManagedDomainBinding;
345
+ /**
346
+ * Explicit hosted-zone name containing `domain` (BYO zones). Fjall never
347
+ * derives the zone from the domain's labels — set this when not using a
348
+ * managed domain. `domain` must sit within this zone (validated at synth).
349
+ * @example "example.com.au"
350
+ */
351
+ zoneName?: string;
352
+ /**
353
+ * Literal hosted-zone ID for `zoneName`. Optional — when set, the zone is
354
+ * referenced directly instead of via a runtime `HostedZone.fromLookup`.
355
+ */
356
+ hostedZoneId?: string;
331
357
  /** Database configuration */
332
358
  database?: PayloadDatabaseConfig;
333
359
  /** Compute (Lambda) configuration */
@@ -413,6 +439,7 @@ export interface StaticSiteCdnConfig {
413
439
  * routing: "multipage",
414
440
  * security: { headers: true },
415
441
  * domain: "lbexc.com",
442
+ * zoneName: "lbexc.com", // or a CLI-injected `managedDomain` binding
416
443
  * forms: { to: "hello@lbexc.com", corsOrigin: "https://lbexc.com" }
417
444
  * }));
418
445
  */
@@ -431,8 +458,26 @@ export interface IStaticSiteProps {
431
458
  security?: StaticSiteSecurityConfig;
432
459
  /** Custom domain (same machinery as payload). Required when `forms` is set. */
433
460
  domain?: string;
434
- /** Import zone and cert from a managed domain stack instead of creating inline */
435
- managedDomain?: ManagedDomainExports;
461
+ /**
462
+ * Zone and certificate identity from a managed domain stack. The
463
+ * CLI-injected `ManagedDomainBinding` carries literal values (hosted-zone
464
+ * ID + us-east-1 certificate ARN) and crosses accounts and regions; the
465
+ * legacy `ManagedDomainExports` form imports by export name (same account
466
+ * and region only). Takes priority over `zoneName`.
467
+ */
468
+ managedDomain?: ManagedDomainExports | ManagedDomainBinding;
469
+ /**
470
+ * Explicit hosted-zone name containing `domain` (BYO zones). Fjall never
471
+ * derives the zone from the domain's labels — set this when not using a
472
+ * managed domain. `domain` must sit within this zone (validated at synth).
473
+ * @example "example.com.au"
474
+ */
475
+ zoneName?: string;
476
+ /**
477
+ * Literal hosted-zone ID for `zoneName`. Optional — when set, the zone is
478
+ * referenced directly instead of via a runtime `HostedZone.fromLookup`.
479
+ */
480
+ hostedZoneId?: string;
436
481
  /** Optional contact-form endpoint (Lambda → SES). Requires `domain`. */
437
482
  forms?: StaticSiteFormsConfig;
438
483
  /** CDN configuration - for advanced per-path overrides. */