@fjall/components-infrastructure 14.0.0 → 14.1.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.
@@ -6,9 +6,50 @@ import { type ICachePolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-
6
6
  import type App from "../../app.js";
7
7
  import { CloudFrontDistribution, type CachePolicyPreset, type AccessGateConfig } from "../../resources/aws/cdn/index.js";
8
8
  import { type ICdn } from "./interfaces/cdn.js";
9
- import type { StaticSiteRouting } from "@fjall/util";
9
+ import { type StaticSiteRouting } from "@fjall/util";
10
+ import { type ManagedDomainBinding, type ManagedDomainExports } from "../../utils/domainTypes.js";
10
11
  import { type Storage } from "./storage.js";
11
12
  import { type AnyCompute } from "./compute.js";
13
+ /**
14
+ * Managed-domain surface for a CDN — the distribution's counterpart to the
15
+ * ECS cluster's `domainConfig` (design 2026-08-17 cdn-domain-ownership C1).
16
+ * The CDN resolves its zone and us-east-1 viewer certificate exactly like
17
+ * the staticSite/payload patterns (explicit `managedDomain` beats the
18
+ * CLI-injected binding, which beats BYO `zoneName`/`hostedZoneId`), derives
19
+ * `domainNames` from `domainName`, and — unless `record: "none"` — owns the
20
+ * alias record for it in the CDN stack, satellite-style.
21
+ */
22
+ export interface CdnDomainConfig {
23
+ /** Public hostname the distribution serves, e.g. the zone apex. */
24
+ domainName: string;
25
+ /**
26
+ * "alias" (default) mints the Route53 alias record in the CDN stack.
27
+ * "none" claims the alternate domain name + certificate WITHOUT the DNS
28
+ * record — the pre-flip validation state: the distribution answers for
29
+ * `domainName` (testable via `curl --resolve`) while live DNS still points
30
+ * elsewhere.
31
+ */
32
+ record?: "alias" | "none";
33
+ /**
34
+ * Routing policy for the alias record. Use latency with a region label
35
+ * DIFFERENT from an existing compute-owned variant on the same name —
36
+ * Route53 allows one latency record per region, and sibling variants are
37
+ * exactly how an apex migrates between owners with no delete window (the
38
+ * claim registry enforces the legal shapes).
39
+ */
40
+ routingPolicy?: {
41
+ type: "latency";
42
+ region: string;
43
+ };
44
+ /** Defaults to `${id}${region}` when `routingPolicy` is set — the same
45
+ * derivation convention as the ECS cluster's apex record. */
46
+ setIdentifier?: string;
47
+ /** Explicit managed-domain identity — beats the CLI-injected binding. */
48
+ managedDomain?: ManagedDomainExports | ManagedDomainBinding;
49
+ /** BYO zone identity (with `hostedZoneId` to skip the runtime lookup). */
50
+ zoneName?: string;
51
+ hostedZoneId?: string;
52
+ }
12
53
  /**
13
54
  * Common CDN props shared across all origin types.
14
55
  */
@@ -19,6 +60,20 @@ interface BaseCdnProps {
19
60
  * is only set by hand for a directly-instantiated `Cdn`.
20
61
  */
21
62
  appName?: string;
63
+ /**
64
+ * Managed-domain surface: resolve the zone + us-east-1 viewer certificate
65
+ * through the domain helpers and own the domain's alias record in the CDN
66
+ * stack. Mutually exclusive with the literal
67
+ * `domainNames`/`certificate`/`certificateArn` surface, and requires the
68
+ * factory path (`app.addCdn(CdnFactory.build(...))`) so the `App` is known.
69
+ */
70
+ domainConfig?: CdnDomainConfig;
71
+ /**
72
+ * App instance backing `domainConfig` resolution (certificate placement,
73
+ * zone lookups). `CdnFactory.build` fills it — only set by hand for a
74
+ * directly-instantiated `Cdn`.
75
+ */
76
+ app?: App;
22
77
  cachePolicy?: CachePolicyPreset | ICachePolicy;
23
78
  defaultAllowedMethods?: "GET_HEAD" | "GET_HEAD_OPTIONS" | "ALL";
24
79
  behaviours?: SmartCdnBehaviour[];
@@ -97,6 +152,24 @@ export interface SmartCdnBehaviour {
97
152
  */
98
153
  export declare class Cdn extends CloudFrontDistribution implements ICdn {
99
154
  constructor(scope: Construct, id: string, props: ICdnProps);
155
+ /**
156
+ * Resolve a `domainConfig` through the shared pattern-domain helpers
157
+ * (design C1): zone identity via `resolvePatternZone` (explicit
158
+ * `managedDomain` → CLI-injected binding → BYO `zoneName`/`hostedZoneId`),
159
+ * viewer certificate via `resolvePatternCloudFrontCertificate` (binding's
160
+ * literal us-east-1 ARN, else app-owned provisioning). The exact resolution
161
+ * chain staticSite and payload already use — the CDN stops being the one
162
+ * CloudFront-fronted pattern without it.
163
+ */
164
+ private static resolveDomain;
165
+ /**
166
+ * Own the domain's DNS record in the CDN stack (satellite doctrine — the
167
+ * app owns its records, the domain stack owns zone-level records), exactly
168
+ * as the ECS cluster owns its apex alias. Latency routing gets the
169
+ * cluster's setIdentifier derivation (`${id}${region}`) unless overridden;
170
+ * `record: "none"` skips this entirely (pre-flip validation state).
171
+ */
172
+ private createDomainRecord;
100
173
  /**
101
174
  * Resolve ICdnProps to CloudFrontDistributionProps.
102
175
  */
@@ -148,6 +221,20 @@ export declare class Cdn extends CloudFrontDistribution implements ICdn {
148
221
  * domainNames: ["app.example.com"],
149
222
  * certificate: myCert
150
223
  * }));
224
+ *
225
+ * @example
226
+ * // Managed domain — the CDN resolves the zone + us-east-1 certificate and
227
+ * // owns the alias record in its own stack (satellite doctrine), like the
228
+ * // ECS cluster's domainConfig.
229
+ * app.addCdn(CdnFactory.build("AppCdn", {
230
+ * originType: "http",
231
+ * domainName: "origin.example.com",
232
+ * forwardHostHeader: true,
233
+ * domainConfig: {
234
+ * domainName: "example.com",
235
+ * routingPolicy: { type: "latency", region: "us-east-1" }
236
+ * }
237
+ * }));
151
238
  */
152
239
  export declare class CdnFactory {
153
240
  /**
@@ -1,14 +1,110 @@
1
1
  import { Fn, Token } from "aws-cdk-lib";
2
2
  import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
3
+ import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
3
4
  import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
5
+ import { AliasRecord } from "../../resources/aws/networking/index.js";
6
+ import { DNS_APEX } from "@fjall/util";
7
+ import { resolvePatternZone, resolvePatternCloudFrontCertificate } from "./patternDomain.js";
4
8
  import { isStorage } from "./storage.js";
5
9
  import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
10
+ /**
11
+ * Relative record label for `domain` within `zoneName` (the staticSite
12
+ * `recordLabelFor` convention): the zone apex maps to the canonical apex
13
+ * label, sub-names drop the zone suffix.
14
+ */
15
+ function recordLabelWithin(domain, zoneName) {
16
+ if (domain === zoneName) {
17
+ return DNS_APEX;
18
+ }
19
+ const suffix = `.${zoneName}`;
20
+ return domain.endsWith(suffix) ? domain.slice(0, -suffix.length) : domain;
21
+ }
22
+ /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
23
+ function normaliseDnsName(name) {
24
+ return name.toLowerCase().replace(/\.$/, "");
25
+ }
6
26
  /**
7
27
  * Validates CDN props — synth-time hard errors for shapes CloudFormation
8
28
  * would only reject at deploy.
9
29
  */
10
30
  function validateCdnProps(props) {
11
31
  const hasCertificate = !!(props.certificate || props.certificateArn);
32
+ // domainConfig is the managed-domain authority for the viewer config —
33
+ // mixing it with the literal surface would give the alias list and the
34
+ // certificate two owners with no defined precedence.
35
+ if (props.domainConfig !== undefined) {
36
+ if (props.domainNames !== undefined || hasCertificate) {
37
+ throw new Error(`CDN 'domainConfig' ('${props.domainConfig.domainName}') cannot be ` +
38
+ "combined with 'domainNames', 'certificate', or 'certificateArn'. " +
39
+ "'domainConfig' derives the alternate domain names and resolves " +
40
+ "the us-east-1 viewer certificate itself — drop the literal props, " +
41
+ "or drop 'domainConfig' and manage both by hand.");
42
+ }
43
+ if (props.app === undefined) {
44
+ throw new Error(`CDN 'domainConfig' ('${props.domainConfig.domainName}') requires ` +
45
+ "the App for certificate placement, but 'app' is unset. Create the " +
46
+ "CDN through the factory path — app.addCdn(CdnFactory.build(...)) " +
47
+ "— which fills it, or pass 'app' explicitly.");
48
+ }
49
+ // Latency region and setIdentifier are literals by contract — inspect
50
+ // them at synth so a config slip fails here with the cure, not at
51
+ // CloudFormation with a raw Route53 InvalidChangeBatch (or, for the
52
+ // empty-string cases, a CDK error about a prop the user never set).
53
+ const region = props.domainConfig.routingPolicy?.region;
54
+ if (region !== undefined && !/^[a-z]{2,4}(-[a-z]+)+-\d+$/.test(region)) {
55
+ throw new Error(`CDN 'domainConfig.routingPolicy.region' ('${region}') is not an ` +
56
+ "AWS region name (expected e.g. 'us-east-1'). Route53 latency " +
57
+ "records carry a literal region label — fix the region string.");
58
+ }
59
+ const setIdentifier = props.domainConfig.setIdentifier;
60
+ if (setIdentifier !== undefined &&
61
+ (setIdentifier.trim() === "" || setIdentifier.length > 128)) {
62
+ throw new Error(`CDN 'domainConfig.setIdentifier' (${JSON.stringify(setIdentifier)}) ` +
63
+ "must be 1-128 non-blank characters (Route53 SetIdentifier limit). " +
64
+ "Omit it to use the derived '<id><region>' value.");
65
+ }
66
+ }
67
+ // Origin-loop guard (design 2026-08-17 cdn-domain-ownership C4): an HTTP
68
+ // origin pointing at one of the distribution's own domain names sends
69
+ // every request CloudFront → DNS → CloudFront until the request-depth
70
+ // limit 508s. Synth can see this shape whenever the origin hostname is a
71
+ // literal, so refuse it with the cure.
72
+ const aliasNames = new Set();
73
+ if (props.domainConfig !== undefined) {
74
+ aliasNames.add(normaliseDnsName(props.domainConfig.domainName));
75
+ }
76
+ for (const name of props.domainNames ?? []) {
77
+ if (!Token.isUnresolved(name)) {
78
+ aliasNames.add(normaliseDnsName(name));
79
+ }
80
+ }
81
+ const defaultOriginHostname = props.originType === "http"
82
+ ? props.domainName
83
+ : props.originType === "auto" && typeof props.origin === "string"
84
+ ? props.origin
85
+ : undefined;
86
+ // Behaviour origins are the same literal-HTTP-origin surface: a string
87
+ // behaviour origin becomes an HTTP origin via detectOriginFromResource, so
88
+ // a self-referential one loops exactly like the default origin — but only
89
+ // for its path pattern, a PARTIAL outage that is even harder to spot.
90
+ const literalOriginHostnames = [
91
+ ...(defaultOriginHostname !== undefined ? [defaultOriginHostname] : []),
92
+ ...(props.behaviours ?? [])
93
+ .map((behaviour) => behaviour.origin)
94
+ .filter((origin) => typeof origin === "string")
95
+ ];
96
+ for (const originHostname of literalOriginHostnames) {
97
+ if (!Token.isUnresolved(originHostname) &&
98
+ aliasNames.has(normaliseDnsName(originHostname))) {
99
+ throw new Error(`CDN origin '${originHostname}' is also one of the distribution's own ` +
100
+ "domain names — once DNS points that name at the distribution, every " +
101
+ "request loops CloudFront → CloudFront. Point the origin at a " +
102
+ "dedicated origin hostname instead (e.g. an ECS service " +
103
+ "'routing[].host' such as 'origin.<zone>' resolving to the load " +
104
+ "balancer, or a domain-stack record targeting the origin resource) " +
105
+ "and keep the public name on the CDN.");
106
+ }
107
+ }
12
108
  // CloudFront accepts viewer certificates from us-east-1 only. A literal
13
109
  // ARN is inspectable at synth (design H3/D3 — the old advisory log is
14
110
  // replaced by real us-east-1 provisioning in the patterns; this assert
@@ -73,20 +169,80 @@ function validateCdnProps(props) {
73
169
  export class Cdn extends CloudFrontDistribution {
74
170
  constructor(scope, id, props) {
75
171
  validateCdnProps(props);
76
- const resolvedProps = Cdn.resolveProps(scope, id, props);
172
+ // Zone + certificate resolve BEFORE super() the viewer certificate is
173
+ // part of the distribution's props — so their constructs land on the CDN
174
+ // stack (`scope`) under `${id}`-prefixed IDs, the pattern convention.
175
+ const domain = props.domainConfig !== undefined && props.app !== undefined
176
+ ? Cdn.resolveDomain(scope, id, props.domainConfig, props.app)
177
+ : undefined;
178
+ const resolvedProps = Cdn.resolveProps(scope, id, props, domain);
77
179
  super(scope, id, resolvedProps);
180
+ if (domain !== undefined && props.domainConfig !== undefined) {
181
+ this.createDomainRecord(id, props.domainConfig, domain);
182
+ }
183
+ }
184
+ /**
185
+ * Resolve a `domainConfig` through the shared pattern-domain helpers
186
+ * (design C1): zone identity via `resolvePatternZone` (explicit
187
+ * `managedDomain` → CLI-injected binding → BYO `zoneName`/`hostedZoneId`),
188
+ * viewer certificate via `resolvePatternCloudFrontCertificate` (binding's
189
+ * literal us-east-1 ARN, else app-owned provisioning). The exact resolution
190
+ * chain staticSite and payload already use — the CDN stops being the one
191
+ * CloudFront-fronted pattern without it.
192
+ */
193
+ static resolveDomain(scope, id, domainConfig, app) {
194
+ const request = {
195
+ scope,
196
+ app,
197
+ idPrefix: id,
198
+ context: `CDN '${id}'`,
199
+ domain: domainConfig.domainName,
200
+ identity: domainConfig
201
+ };
202
+ const zone = resolvePatternZone(request);
203
+ const certificate = resolvePatternCloudFrontCertificate(request, zone);
204
+ return { zone, certificate };
205
+ }
206
+ /**
207
+ * Own the domain's DNS record in the CDN stack (satellite doctrine — the
208
+ * app owns its records, the domain stack owns zone-level records), exactly
209
+ * as the ECS cluster owns its apex alias. Latency routing gets the
210
+ * cluster's setIdentifier derivation (`${id}${region}`) unless overridden;
211
+ * `record: "none"` skips this entirely (pre-flip validation state).
212
+ */
213
+ createDomainRecord(id, domainConfig, domain) {
214
+ if (domainConfig.record === "none") {
215
+ return;
216
+ }
217
+ const region = domainConfig.routingPolicy?.region;
218
+ const setIdentifier = domainConfig.setIdentifier ??
219
+ (region !== undefined ? `${id}${region}` : undefined);
220
+ new AliasRecord(this, "AliasRecord", {
221
+ zone: domain.zone.hostedZone,
222
+ zoneName: domain.zone.zoneName,
223
+ recordName: recordLabelWithin(domainConfig.domainName, domain.zone.zoneName),
224
+ // CloudFrontTarget sets no EvaluateTargetHealth — Route53 requires it
225
+ // absent for CloudFront alias targets.
226
+ target: new CloudFrontTarget(this.getDistribution()),
227
+ ...(region !== undefined && { region }),
228
+ ...(setIdentifier !== undefined && { setIdentifier })
229
+ });
78
230
  }
79
231
  /**
80
232
  * Resolve ICdnProps to CloudFrontDistributionProps.
81
233
  */
82
- static resolveProps(scope, id, props) {
234
+ static resolveProps(scope, id, props, domain) {
83
235
  const defaultOrigin = Cdn.resolveDefaultOrigin(props);
84
236
  const behaviours = Cdn.resolveBehaviours(props.behaviours);
85
237
  const appName = props.appName;
238
+ const domainNames = props.domainNames ??
239
+ (props.domainConfig !== undefined
240
+ ? [props.domainConfig.domainName]
241
+ : undefined);
86
242
  const certificate = props.certificate ??
87
243
  (props.certificateArn
88
244
  ? Certificate.fromCertificateArn(scope, `${id}Certificate`, props.certificateArn)
89
- : undefined);
245
+ : domain?.certificate);
90
246
  const s3Routing = props.originType === "s3"
91
247
  ? {
92
248
  defaultRootObject: props.defaultRootObject,
@@ -102,7 +258,7 @@ export class Cdn extends CloudFrontDistribution {
102
258
  defaultCachePolicy: props.cachePolicy,
103
259
  defaultAllowedMethods: props.defaultAllowedMethods,
104
260
  behaviours,
105
- domainNames: props.domainNames,
261
+ domainNames,
106
262
  certificate,
107
263
  comment: props.comment,
108
264
  enableLogging: props.enableLogging,
@@ -265,6 +421,20 @@ export class Cdn extends CloudFrontDistribution {
265
421
  * domainNames: ["app.example.com"],
266
422
  * certificate: myCert
267
423
  * }));
424
+ *
425
+ * @example
426
+ * // Managed domain — the CDN resolves the zone + us-east-1 certificate and
427
+ * // owns the alias record in its own stack (satellite doctrine), like the
428
+ * // ECS cluster's domainConfig.
429
+ * app.addCdn(CdnFactory.build("AppCdn", {
430
+ * originType: "http",
431
+ * domainName: "origin.example.com",
432
+ * forwardHostHeader: true,
433
+ * domainConfig: {
434
+ * domainName: "example.com",
435
+ * routingPolicy: { type: "latency", region: "us-east-1" }
436
+ * }
437
+ * }));
268
438
  */
269
439
  export class CdnFactory {
270
440
  /**
@@ -278,9 +448,12 @@ export class CdnFactory {
278
448
  return (app, scope) => {
279
449
  // The App is the only place the app name is known here; without it the
280
450
  // export falls back to the construct id and the Domain import misses.
451
+ // The App instance itself rides along for domainConfig resolution
452
+ // (certificate placement needs app.getUsEast1CertificateStack()).
281
453
  return new Cdn(scope, id, {
282
454
  ...props,
283
- appName: props.appName ?? app.getName()
455
+ appName: props.appName ?? app.getName(),
456
+ app: props.app ?? app
284
457
  });
285
458
  };
286
459
  }
@@ -23,15 +23,19 @@ export declare function fjallApp(appName: string, computeName?: string): FjallAl
23
23
  /**
24
24
  * Target helper for a Fjall CloudFront-fronted app.
25
25
  *
26
- * Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`.
27
- * Phase 2 wires the emitter.
26
+ * Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`
27
+ * emitted by every `CloudFrontDistribution` given an `appName` (the
28
+ * `CdnFactory.build` path fills it from the App). Naming an app with no such
29
+ * export fails at CloudFormation execution, exactly as `fjallApp` documents.
28
30
  */
29
31
  export declare function fjallCdn(appName: string): FjallAliasTarget;
30
32
  /**
31
33
  * Target helper for a Fjall S3 static-site bucket.
32
34
  *
33
35
  * Requires the bucket stack to publish exports `${safeBucket}WebsiteEndpoint`
34
- * and `${safeBucket}WebsiteHostedZoneId`. Phase 2 wires these emitters.
36
+ * and `${safeBucket}WebsiteHostedZoneId` emitted by website-enabled `S3`
37
+ * storage. Naming a bucket with no such exports fails at CloudFormation
38
+ * execution, exactly as `fjallApp` documents.
35
39
  */
36
40
  export declare function fjallBucket(bucketName: string): FjallAliasTarget;
37
41
  /**
@@ -25,8 +25,10 @@ export function fjallApp(appName, computeName) {
25
25
  /**
26
26
  * Target helper for a Fjall CloudFront-fronted app.
27
27
  *
28
- * Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`.
29
- * Phase 2 wires the emitter.
28
+ * Requires the CDN stack to publish export `${safeApp}CdnDistributionDomainName`
29
+ * emitted by every `CloudFrontDistribution` given an `appName` (the
30
+ * `CdnFactory.build` path fills it from the App). Naming an app with no such
31
+ * export fails at CloudFormation execution, exactly as `fjallApp` documents.
30
32
  */
31
33
  export function fjallCdn(appName) {
32
34
  const resolved = resolveCdnTarget(appName);
@@ -42,7 +44,9 @@ export function fjallCdn(appName) {
42
44
  * Target helper for a Fjall S3 static-site bucket.
43
45
  *
44
46
  * Requires the bucket stack to publish exports `${safeBucket}WebsiteEndpoint`
45
- * and `${safeBucket}WebsiteHostedZoneId`. Phase 2 wires these emitters.
47
+ * and `${safeBucket}WebsiteHostedZoneId` emitted by website-enabled `S3`
48
+ * storage. Naming a bucket with no such exports fails at CloudFormation
49
+ * execution, exactly as `fjallApp` documents.
46
50
  */
47
51
  export function fjallBucket(bucketName) {
48
52
  const resolved = resolveBucketTarget(bucketName);
@@ -367,22 +367,28 @@ export function addHostedZone(ctx, loadBalancer) {
367
367
  ...(geoLocation !== undefined && { geoLocation }),
368
368
  ...(setIdentifier !== undefined && { setIdentifier })
369
369
  };
370
- const apex = new AliasRecord(ctx.scope, `${props.clusterName}ARecord`, {
371
- zone: hostedZone,
372
- zoneName,
373
- recordName: recordLabelWithin(domainName, zoneName),
374
- target: new LoadBalancerTarget(loadBalancer, {
375
- evaluateTargetHealth: hasRoutingPolicy
376
- }),
377
- // No-churn adoption: the pre-wrapper raw ARecord carried no comment, so
378
- // adding one now would be a property delta on the live production apex
379
- // record (flipless-retain-flip principle).
380
- omitComment: true,
381
- ...routingProps
382
- });
383
- // Preserve the pre-wrapper logical ID see preWrapperLogicalId.
384
- apex.record.node.defaultChild.overrideLogicalId(preWrapperLogicalId(ctx.scope, `${props.clusterName}ARecord`));
385
- aRecord = apex.record;
370
+ // apexRecord: "none" yields ONLY the domainName record itself (an
371
+ // ingress-migrated name now owned by a CDN's domainConfig) — routed-host,
372
+ // redirect-host records, listener, and certificates below are untouched
373
+ // (design 2026-08-17 cdn-domain-ownership C2).
374
+ if (domainConfig?.apexRecord !== "none") {
375
+ const apex = new AliasRecord(ctx.scope, `${props.clusterName}ARecord`, {
376
+ zone: hostedZone,
377
+ zoneName,
378
+ recordName: recordLabelWithin(domainName, zoneName),
379
+ target: new LoadBalancerTarget(loadBalancer, {
380
+ evaluateTargetHealth: hasRoutingPolicy
381
+ }),
382
+ // No-churn adoption: the pre-wrapper raw ARecord carried no comment,
383
+ // so adding one now would be a property delta on the live production
384
+ // apex record (flipless-retain-flip principle).
385
+ omitComment: true,
386
+ ...routingProps
387
+ });
388
+ // Preserve the pre-wrapper logical ID — see preWrapperLogicalId.
389
+ apex.record.node.defaultChild.overrideLogicalId(preWrapperLogicalId(ctx.scope, `${props.clusterName}ARecord`));
390
+ aRecord = apex.record;
391
+ }
386
392
  for (const host of routedHosts) {
387
393
  if (host === domainName)
388
394
  continue;
@@ -260,6 +260,16 @@ export interface DomainBaseConfig {
260
260
  * Omit for a plain alias record.
261
261
  */
262
262
  routingPolicy?: EcsDomainRoutingPolicy;
263
+ /**
264
+ * The cluster's own DNS record for `domainName`. "alias" (default) mints
265
+ * the alias A record targeting the ALB — the long-standing behaviour.
266
+ * "none" keeps EVERYTHING else (certificates, HTTPS listener, redirect
267
+ * hosts and their records, host-routed service records) but yields the
268
+ * `domainName` record itself — the final state of an ingress migration
269
+ * where a CDN's `domainConfig` has taken the name over
270
+ * (design 2026-08-17 cdn-domain-ownership C2).
271
+ */
272
+ apexRecord?: "alias" | "none";
263
273
  /**
264
274
  * Hostnames (FQDNs within the zone, e.g. `"www.example.com"`) that
265
275
  * permanently redirect to `domainName` at the ALB. Each host gets an alias
@@ -11,8 +11,35 @@ export class AliasRecord extends Construct {
11
11
  this.description =
12
12
  props.description ?? defaultDnsComment("alias", this.fqdn);
13
13
  // An alias record IS an A record set in Route53 — it claims type "A" so
14
- // an alias and a plain A record on the same name collide at synth.
15
- claimDnsRecord(this, props, "A", this.fqdn);
14
+ // an alias and a plain A record on the same name collide at synth. The
15
+ // routing variant rides along: policy siblings with distinct
16
+ // setIdentifiers (e.g. a compute apex alias and a CDN apex alias during
17
+ // an ingress migration) are legal and register cleanly.
18
+ const routingPolicy = props.region !== undefined
19
+ ? "latency"
20
+ : props.weight !== undefined
21
+ ? "weighted"
22
+ : props.geoLocation !== undefined
23
+ ? "geolocation"
24
+ : undefined;
25
+ // Route53 allows one geolocation record per location VALUE — serialise
26
+ // the location so the registry can compare siblings ("*" = the default
27
+ // wildcard record, which is equally one-per-(name, type)).
28
+ const geoLocationKey = props.geoLocation !== undefined
29
+ ? [
30
+ props.geoLocation.continentCode,
31
+ props.geoLocation.countryCode,
32
+ props.geoLocation.subdivisionCode
33
+ ]
34
+ .filter((part) => part !== undefined)
35
+ .join("/") || "*"
36
+ : undefined;
37
+ claimDnsRecord(this, props, "A", this.fqdn, {
38
+ setIdentifier: props.setIdentifier,
39
+ routingPolicy,
40
+ region: props.region,
41
+ ...(geoLocationKey !== undefined && { geoLocationKey })
42
+ });
16
43
  // Route53 ignores TTL on ALIAS records — the target dictates caching behaviour,
17
44
  // so we deliberately do not forward props.ttl to the underlying CDK resource.
18
45
  this.record = new CdkARecord(this, "Record", {
@@ -14,13 +14,27 @@ export interface DnsRecordCommonProps {
14
14
  export declare const DEFAULT_DNS_TTL_SECONDS = 300;
15
15
  export declare function resolveTtl(ttlSeconds: number | undefined): Duration;
16
16
  export declare function defaultDnsComment(recordType: string, fqdn: string): string;
17
+ /**
18
+ * Routing-variant half of a claim — filled in by wrappers whose records can
19
+ * carry a routing policy (today: AliasRecord). Sibling variants with the
20
+ * same policy type and distinct setIdentifiers legally share a (zone, name,
21
+ * type) triple; the registry encodes Route53's coexistence rules.
22
+ */
23
+ export interface DnsRecordClaimVariant {
24
+ readonly setIdentifier?: string;
25
+ readonly routingPolicy?: "latency" | "weighted" | "geolocation";
26
+ readonly region?: string;
27
+ /** Serialised geolocation value (continent/country/subdivision). */
28
+ readonly geoLocationKey?: string;
29
+ }
17
30
  /**
18
31
  * Claim the (zone, name, type) triple in the app-scoped collision registry
19
32
  * (design D5, synth-side). Every wrapper in the dnsRecord family calls this
20
- * from its constructor so a second claimant — same stack or another stack in
21
- * the app — fails at synth instead of at CloudFormation deploy. Alias records
22
- * claim their underlying Route53 type ("A"). Observation only: no constructs
23
- * are created, so construct trees stay byte-identical.
33
+ * from its constructor so an illegal second claimant — same stack or another
34
+ * stack in the app — fails at synth instead of at CloudFormation deploy.
35
+ * Alias records claim their underlying Route53 type ("A") and pass their
36
+ * routing variant so legal policy siblings register cleanly. Observation
37
+ * only: no constructs are created, so construct trees stay byte-identical.
24
38
  */
25
- export declare function claimDnsRecord(construct: Construct, props: Pick<DnsRecordCommonProps, "zone" | "zoneName">, recordType: string, fqdn: string): void;
39
+ export declare function claimDnsRecord(construct: Construct, props: Pick<DnsRecordCommonProps, "zone" | "zoneName">, recordType: string, fqdn: string, variant?: DnsRecordClaimVariant): void;
26
40
  export declare function applyDnsRecordTags(construct: Construct, props: Pick<DnsRecordCommonProps, "zoneName" | "costAllocationEnvironment" | "costAllocationDomain">): void;
@@ -15,17 +15,28 @@ export function defaultDnsComment(recordType, fqdn) {
15
15
  /**
16
16
  * Claim the (zone, name, type) triple in the app-scoped collision registry
17
17
  * (design D5, synth-side). Every wrapper in the dnsRecord family calls this
18
- * from its constructor so a second claimant — same stack or another stack in
19
- * the app — fails at synth instead of at CloudFormation deploy. Alias records
20
- * claim their underlying Route53 type ("A"). Observation only: no constructs
21
- * are created, so construct trees stay byte-identical.
18
+ * from its constructor so an illegal second claimant — same stack or another
19
+ * stack in the app — fails at synth instead of at CloudFormation deploy.
20
+ * Alias records claim their underlying Route53 type ("A") and pass their
21
+ * routing variant so legal policy siblings register cleanly. Observation
22
+ * only: no constructs are created, so construct trees stay byte-identical.
22
23
  */
23
- export function claimDnsRecord(construct, props, recordType, fqdn) {
24
+ export function claimDnsRecord(construct, props, recordType, fqdn, variant) {
24
25
  registerDnsRecordClaim(construct, {
25
26
  zone: props.zone,
26
27
  zoneName: props.zoneName,
27
28
  fqdn,
28
- recordType
29
+ recordType,
30
+ ...(variant?.setIdentifier !== undefined && {
31
+ setIdentifier: variant.setIdentifier
32
+ }),
33
+ ...(variant?.routingPolicy !== undefined && {
34
+ routingPolicy: variant.routingPolicy
35
+ }),
36
+ ...(variant?.region !== undefined && { region: variant.region }),
37
+ ...(variant?.geoLocationKey !== undefined && {
38
+ geoLocationKey: variant.geoLocationKey
39
+ })
29
40
  });
30
41
  }
31
42
  export function applyDnsRecordTags(construct, props) {
@@ -2,13 +2,23 @@ import type { IHostedZone } from "aws-cdk-lib/aws-route53";
2
2
  import type { IConstruct } from "constructs";
3
3
  /**
4
4
  * Synth-time DNS record-collision registry (design D5, synth-side half — the
5
- * deploy-time preflight against live zones is Phase 2).
5
+ * deploy-time preflight against live zones is the CLI/deploy-core domain
6
+ * gate).
6
7
  *
7
- * Route53 allows exactly one record set per (zone, name, type); a second
8
- * claimant today fails only at CloudFormation deploy with a raw "record set
9
- * already exists". This registry makes the collision loud at synth: every
10
- * construct in the dnsRecord wrapper family registers its claim here, and a
11
- * duplicate claim throws naming both claimant construct paths and the cure.
8
+ * Route53 allows exactly one record set per (zone, name, type) EXCEPT for
9
+ * routing-policy variants, where sibling record sets share the (name, type)
10
+ * pair and are discriminated by `setIdentifier`. Sibling variants must all
11
+ * carry the same policy type, and latency variants must each use a distinct
12
+ * region. A shape Route53 rejects fails only at CloudFormation deploy with a
13
+ * raw "record set already exists" / "conflicting RRSet"; this registry makes
14
+ * it loud at synth: every construct in the dnsRecord wrapper family registers
15
+ * its claim here, and an illegal combination throws naming both claimant
16
+ * construct paths and the cure.
17
+ *
18
+ * Legal coexistence (design 2026-08-17 cdn-domain-ownership C3): two claims
19
+ * on one (zone, name, type) with the SAME routing-policy type and DISTINCT
20
+ * `setIdentifier`s — e.g. the compute cluster's latency apex alias and a
21
+ * CDN's latency apex alias during an ingress migration — register cleanly.
12
22
  *
13
23
  * Scoping: claims are held per App (the construct-tree root), so multiple CDK
14
24
  * Apps in one process — the vitest convention of a fresh `new CdkApp()` per
@@ -22,6 +32,7 @@ import type { IConstruct } from "constructs";
22
32
  * (resetForTesting + resetManifestCollector in beforeEach/afterEach) keeps
23
33
  * working without a third per-test call.
24
34
  */
35
+ export type DnsRoutingPolicyType = "latency" | "weighted" | "geolocation";
25
36
  export interface DnsRecordClaim {
26
37
  /** Zone the record lands in — consulted for identity resolution. */
27
38
  readonly zone: IHostedZone;
@@ -34,14 +45,34 @@ export interface DnsRecordClaim {
34
45
  * — an alias and a plain A record on the same name are a real collision.
35
46
  */
36
47
  readonly recordType: string;
48
+ /**
49
+ * Routing-variant discriminator. A claim is a policy variant when it
50
+ * carries a `routingPolicy` OR a `setIdentifier` (CDK auto-generates a
51
+ * SetIdentifier for a policy record declared without one, so policy
52
+ * presence alone makes a legal variant); a claim with neither is a simple
53
+ * record that tolerates no siblings.
54
+ */
55
+ readonly setIdentifier?: string;
56
+ /** Policy type of a variant claim — siblings must all match. */
57
+ readonly routingPolicy?: DnsRoutingPolicyType;
58
+ /**
59
+ * Latency region of a latency variant — Route53 allows one latency record
60
+ * per region per (name, type).
61
+ */
62
+ readonly region?: string;
63
+ /**
64
+ * Serialised location of a geolocation variant — Route53 allows one
65
+ * geolocation record per location value per (name, type).
66
+ */
67
+ readonly geoLocationKey?: string;
37
68
  }
38
69
  /**
39
70
  * Register a record-set claim for `scope`, throwing when a DIFFERENT
40
- * construct already claimed the same (zoneIdentity, recordName, recordType)
41
- * triple within the same App. Re-registration by the same construct path
42
- * (CDK aspects re-visiting, repeated synth) is idempotent. Pure observation:
43
- * no constructs are created or mutated, so construct trees — including the
44
- * eject-contract byte-identical composer IDs — are untouched.
71
+ * construct already claimed a shape Route53 cannot hold alongside it within
72
+ * the same App. Re-registration by the same construct path (CDK aspects
73
+ * re-visiting, repeated synth) is idempotent. Pure observation: no constructs
74
+ * are created or mutated, so construct trees — including the eject-contract
75
+ * byte-identical composer IDs — are untouched.
45
76
  */
46
77
  export declare function registerDnsRecordClaim(scope: IConstruct, claim: DnsRecordClaim): void;
47
78
  /**
@@ -1,5 +1,17 @@
1
1
  import { Token } from "aws-cdk-lib";
2
- /** Claims per App root: claim key → owning construct path. */
2
+ /**
3
+ * A claim is a policy variant when it declares a routing policy OR carries a
4
+ * setIdentifier: CDK's RecordSet auto-generates a SetIdentifier whenever a
5
+ * policy (region/weight/geoLocation) is set without one, so a policy claim
6
+ * with no explicit setIdentifier still deploys as a legal sibling variant —
7
+ * classifying it as simple would refuse shapes Route53 holds happily.
8
+ * (A setIdentifier WITHOUT a policy cannot deploy — CDK rejects it at synth —
9
+ * so its classification here never decides a real outcome.)
10
+ */
11
+ function isVariantEntry(entry) {
12
+ return entry.routingPolicy !== undefined || entry.setIdentifier !== undefined;
13
+ }
14
+ /** Claims per App root: claim key → claimant entries (variants share a key). */
3
15
  let claimsByRoot = new WeakMap();
4
16
  /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
5
17
  function normaliseDnsName(name) {
@@ -18,13 +30,20 @@ function resolveZoneIdentity(zone, zoneName) {
18
30
  }
19
31
  return normaliseDnsName(zoneName);
20
32
  }
33
+ function collisionError(claim, recordType, existing, claimantPath, detail) {
34
+ return new Error(`DNS record '${claim.fqdn}' (${recordType}) in zone '${claim.zoneName}': ` +
35
+ `already claimed by construct '${existing.path}' — duplicate claim by '${claimantPath}'. ` +
36
+ `${detail} ` +
37
+ `Keep one owner per record — satellites own their app records; the domain stack owns zone-level records. ` +
38
+ `Remove or rename one claimant, or run 'fjall domain records list ${claim.zoneName}' to inspect the zone.`);
39
+ }
21
40
  /**
22
41
  * Register a record-set claim for `scope`, throwing when a DIFFERENT
23
- * construct already claimed the same (zoneIdentity, recordName, recordType)
24
- * triple within the same App. Re-registration by the same construct path
25
- * (CDK aspects re-visiting, repeated synth) is idempotent. Pure observation:
26
- * no constructs are created or mutated, so construct trees — including the
27
- * eject-contract byte-identical composer IDs — are untouched.
42
+ * construct already claimed a shape Route53 cannot hold alongside it within
43
+ * the same App. Re-registration by the same construct path (CDK aspects
44
+ * re-visiting, repeated synth) is idempotent. Pure observation: no constructs
45
+ * are created or mutated, so construct trees — including the eject-contract
46
+ * byte-identical composer IDs — are untouched.
28
47
  */
29
48
  export function registerDnsRecordClaim(scope, claim) {
30
49
  const root = scope.node.root;
@@ -40,19 +59,54 @@ export function registerDnsRecordClaim(scope, claim) {
40
59
  recordType
41
60
  ].join("|");
42
61
  const claimantPath = scope.node.path;
62
+ const entry = {
63
+ path: claimantPath,
64
+ setIdentifier: claim.setIdentifier,
65
+ routingPolicy: claim.routingPolicy,
66
+ region: claim.region,
67
+ geoLocationKey: claim.geoLocationKey
68
+ };
43
69
  const existing = claims.get(key);
44
70
  if (existing === undefined) {
45
- claims.set(key, claimantPath);
71
+ claims.set(key, [entry]);
46
72
  return;
47
73
  }
48
- if (existing === claimantPath) {
49
- return;
74
+ for (const other of existing) {
75
+ if (other.path === claimantPath) {
76
+ return;
77
+ }
78
+ const bothVariants = isVariantEntry(other) && isVariantEntry(entry);
79
+ if (!bothVariants) {
80
+ // Simple vs simple, or simple vs variant — Route53 holds neither.
81
+ const detail = isVariantEntry(other) || isVariantEntry(entry)
82
+ ? "Route53 cannot mix a simple record set with routing-policy variants on one (zone, name, type) — give BOTH records the same routing-policy type with distinct setIdentifiers, or remove one."
83
+ : "Route53 allows one record set per (zone, name, type); CloudFormation would reject this at deploy.";
84
+ throw collisionError(claim, recordType, other, claimantPath, detail);
85
+ }
86
+ if (other.setIdentifier !== undefined &&
87
+ other.setIdentifier === entry.setIdentifier) {
88
+ throw collisionError(claim, recordType, other, claimantPath, `Both records carry setIdentifier '${String(entry.setIdentifier)}' — sibling routing-policy variants need distinct setIdentifiers.`);
89
+ }
90
+ // Policy types compare only when both are declared: a setIdentifier-only
91
+ // claim carries no policy for Route53 to mismatch (and cannot deploy —
92
+ // CDK rejects setIdentifier on simple records at synth).
93
+ if (other.routingPolicy !== undefined &&
94
+ entry.routingPolicy !== undefined &&
95
+ other.routingPolicy !== entry.routingPolicy) {
96
+ throw collisionError(claim, recordType, other, claimantPath, `Sibling routing-policy variants must share one policy type (got '${String(other.routingPolicy)}' vs '${String(entry.routingPolicy)}') — Route53 rejects mixed-policy siblings.`);
97
+ }
98
+ if (entry.routingPolicy === "latency" &&
99
+ other.region !== undefined &&
100
+ other.region === entry.region) {
101
+ throw collisionError(claim, recordType, other, claimantPath, `Both latency variants use region '${entry.region}' — Route53 allows one latency record per region; give the second variant a different region.`);
102
+ }
103
+ if (entry.routingPolicy === "geolocation" &&
104
+ other.geoLocationKey !== undefined &&
105
+ other.geoLocationKey === entry.geoLocationKey) {
106
+ throw collisionError(claim, recordType, other, claimantPath, `Both geolocation variants target location '${entry.geoLocationKey}' — Route53 allows one geolocation record per location value; give the second variant a different location.`);
107
+ }
50
108
  }
51
- throw new Error(`DNS record '${claim.fqdn}' (${recordType}) in zone '${claim.zoneName}': ` +
52
- `already claimed by construct '${existing}' — duplicate claim by '${claimantPath}'. ` +
53
- `Route53 allows one record set per (zone, name, type); CloudFormation would reject this at deploy. ` +
54
- `Keep one owner per record — satellites own their app records; the domain stack owns zone-level records. ` +
55
- `Remove or rename one claimant, or run 'fjall domain records list ${claim.zoneName}' to inspect the zone.`);
109
+ existing.push(entry);
56
110
  }
57
111
  /**
58
112
  * Reset all claims (for testing). Called by `App.resetForTesting()` alongside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "14.0.0",
3
+ "version": "14.1.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -80,8 +80,8 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "@aws-sdk/client-organizations": "^3.1098.0",
83
- "@fjall/generator": "^14.0.0",
84
- "@fjall/util": "^14.0.0",
83
+ "@fjall/generator": "^14.1.0",
84
+ "@fjall/util": "^14.1.0",
85
85
  "constructs": "^10.7.2"
86
86
  },
87
87
  "overrides": {