@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
@@ -1,15 +1,16 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CnameRecord as CdkCnameRecord } from "aws-cdk-lib/aws-route53";
3
- import { applyDnsRecordTags, defaultDnsComment, resolveFqdn, resolveTtl } from "./dnsRecordBase.js";
3
+ import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn, resolveTtl } from "./dnsRecordBase.js";
4
4
  export class CnameRecord extends Construct {
5
5
  record;
6
6
  description;
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
- this.fqdn = resolveFqdn(props.zoneName, props.recordName);
10
+ this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
11
  this.description =
12
12
  props.description ?? defaultDnsComment("CNAME", this.fqdn);
13
+ claimDnsRecord(this, props, "CNAME", this.fqdn);
13
14
  this.record = new CdkCnameRecord(this, "Record", {
14
15
  zone: props.zone,
15
16
  recordName: this.fqdn,
@@ -1,6 +1,7 @@
1
1
  import { Duration } from "aws-cdk-lib";
2
2
  import { type Construct } from "constructs";
3
3
  import type { IHostedZone } from "aws-cdk-lib/aws-route53";
4
+ export { resolveRecordFqdn } from "../../../../utils/domainTypes.js";
4
5
  export interface DnsRecordCommonProps {
5
6
  readonly zone: IHostedZone;
6
7
  readonly zoneName: string;
@@ -11,7 +12,15 @@ export interface DnsRecordCommonProps {
11
12
  readonly costAllocationDomain?: string;
12
13
  }
13
14
  export declare const DEFAULT_DNS_TTL_SECONDS = 300;
14
- export declare function resolveFqdn(zoneName: string, recordName: string): string;
15
15
  export declare function resolveTtl(ttlSeconds: number | undefined): Duration;
16
16
  export declare function defaultDnsComment(recordType: string, fqdn: string): string;
17
+ /**
18
+ * Claim the (zone, name, type) triple in the app-scoped collision registry
19
+ * (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.
24
+ */
25
+ export declare function claimDnsRecord(construct: Construct, props: Pick<DnsRecordCommonProps, "zone" | "zoneName">, recordType: string, fqdn: string): void;
17
26
  export declare function applyDnsRecordTags(construct: Construct, props: Pick<DnsRecordCommonProps, "zoneName" | "costAllocationEnvironment" | "costAllocationDomain">): void;
@@ -1,16 +1,33 @@
1
1
  import { Duration } from "aws-cdk-lib";
2
- import { DNS_APEX } from "@fjall/util";
3
2
  import { applyCostAllocationTags } from "../../../../utils/costAllocationTags.js";
3
+ import { registerDnsRecordClaim } from "../../../../utils/dnsRecordRegistry.js";
4
+ // The record wrappers resolve their FQDN via the canonical BIND-semantics
5
+ // resolver — the same function the patterns-layer domain validation calls —
6
+ // re-exported here so the family keeps a single helper surface.
7
+ export { resolveRecordFqdn } from "../../../../utils/domainTypes.js";
4
8
  export const DEFAULT_DNS_TTL_SECONDS = 300;
5
- export function resolveFqdn(zoneName, recordName) {
6
- return recordName === DNS_APEX ? zoneName : `${recordName}.${zoneName}`;
7
- }
8
9
  export function resolveTtl(ttlSeconds) {
9
10
  return Duration.seconds(ttlSeconds ?? DEFAULT_DNS_TTL_SECONDS);
10
11
  }
11
12
  export function defaultDnsComment(recordType, fqdn) {
12
13
  return `Fjall-managed ${recordType} record for ${fqdn}`;
13
14
  }
15
+ /**
16
+ * Claim the (zone, name, type) triple in the app-scoped collision registry
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.
22
+ */
23
+ export function claimDnsRecord(construct, props, recordType, fqdn) {
24
+ registerDnsRecordClaim(construct, {
25
+ zone: props.zone,
26
+ zoneName: props.zoneName,
27
+ fqdn,
28
+ recordType
29
+ });
30
+ }
14
31
  export function applyDnsRecordTags(construct, props) {
15
32
  applyCostAllocationTags(construct, {
16
33
  service: "dnsRecord",
@@ -1,14 +1,15 @@
1
1
  import { Construct } from "constructs";
2
2
  import { MxRecord as CdkMxRecord } from "aws-cdk-lib/aws-route53";
3
- import { applyDnsRecordTags, defaultDnsComment, resolveFqdn, resolveTtl } from "./dnsRecordBase.js";
3
+ import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn, resolveTtl } from "./dnsRecordBase.js";
4
4
  export class MxRecord extends Construct {
5
5
  record;
6
6
  description;
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
- this.fqdn = resolveFqdn(props.zoneName, props.recordName);
10
+ this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
11
  this.description = props.description ?? defaultDnsComment("MX", this.fqdn);
12
+ claimDnsRecord(this, props, "MX", this.fqdn);
12
13
  this.record = new CdkMxRecord(this, "Record", {
13
14
  zone: props.zone,
14
15
  recordName: this.fqdn,
@@ -1,14 +1,15 @@
1
1
  import { Construct } from "constructs";
2
2
  import { NsRecord as CdkNsRecord } from "aws-cdk-lib/aws-route53";
3
- import { applyDnsRecordTags, defaultDnsComment, resolveFqdn, resolveTtl } from "./dnsRecordBase.js";
3
+ import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn, resolveTtl } from "./dnsRecordBase.js";
4
4
  export class NsRecord extends Construct {
5
5
  record;
6
6
  description;
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
- this.fqdn = resolveFqdn(props.zoneName, props.recordName);
10
+ this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
11
  this.description = props.description ?? defaultDnsComment("NS", this.fqdn);
12
+ claimDnsRecord(this, props, "NS", this.fqdn);
12
13
  this.record = new CdkNsRecord(this, "Record", {
13
14
  zone: props.zone,
14
15
  recordName: this.fqdn,
@@ -1,14 +1,15 @@
1
1
  import { Construct } from "constructs";
2
2
  import { SrvRecord as CdkSrvRecord } from "aws-cdk-lib/aws-route53";
3
- import { applyDnsRecordTags, defaultDnsComment, resolveFqdn, resolveTtl } from "./dnsRecordBase.js";
3
+ import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn, resolveTtl } from "./dnsRecordBase.js";
4
4
  export class SrvRecord extends Construct {
5
5
  record;
6
6
  description;
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
- this.fqdn = resolveFqdn(props.zoneName, props.recordName);
10
+ this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
11
  this.description = props.description ?? defaultDnsComment("SRV", this.fqdn);
12
+ claimDnsRecord(this, props, "SRV", this.fqdn);
12
13
  this.record = new CdkSrvRecord(this, "Record", {
13
14
  zone: props.zone,
14
15
  recordName: this.fqdn,
@@ -1,14 +1,15 @@
1
1
  import { Construct } from "constructs";
2
2
  import { TxtRecord as CdkTxtRecord } from "aws-cdk-lib/aws-route53";
3
- import { applyDnsRecordTags, defaultDnsComment, resolveFqdn, resolveTtl } from "./dnsRecordBase.js";
3
+ import { applyDnsRecordTags, claimDnsRecord, defaultDnsComment, resolveRecordFqdn, resolveTtl } from "./dnsRecordBase.js";
4
4
  export class TxtRecord extends Construct {
5
5
  record;
6
6
  description;
7
7
  fqdn;
8
8
  constructor(scope, id, props) {
9
9
  super(scope, id);
10
- this.fqdn = resolveFqdn(props.zoneName, props.recordName);
10
+ this.fqdn = resolveRecordFqdn(props.recordName, props.zoneName);
11
11
  this.description = props.description ?? defaultDnsComment("TXT", this.fqdn);
12
+ claimDnsRecord(this, props, "TXT", this.fqdn);
12
13
  this.record = new CdkTxtRecord(this, "Record", {
13
14
  zone: props.zone,
14
15
  recordName: this.fqdn,
@@ -1,5 +1,5 @@
1
- import { Construct } from "constructs";
2
- import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
1
+ import { type Construct } from "constructs";
2
+ import { Certificate, type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
3
3
  import { type IHostedZone } from "aws-cdk-lib/aws-route53";
4
4
  export interface DomainCertificateProps {
5
5
  readonly domainName: string;
@@ -8,6 +8,15 @@ export interface DomainCertificateProps {
8
8
  readonly description?: string;
9
9
  readonly transparencyLogging?: boolean;
10
10
  readonly costAllocationEnvironment?: string;
11
+ /**
12
+ * Inherit the environment tag from an outer cascade (the fjall App's
13
+ * global tags) instead of stamping the "management" default when
14
+ * `costAllocationEnvironment` is not given. In-stack certificates minted
15
+ * inside an application (the ECS fallthrough mint) must set this: the
16
+ * cluster's app already tags the resolved environment tree-wide, and the
17
+ * resource-level default would out-tag it (deeper scope wins).
18
+ */
19
+ readonly inheritCostAllocationEnvironment?: boolean;
11
20
  readonly costAllocationDomain?: string;
12
21
  /**
13
22
  * Publish the `${domain}-certificate-arn` CloudFormation export. Default true.
@@ -17,9 +26,24 @@ export interface DomainCertificateProps {
17
26
  */
18
27
  readonly exportCertificateArn?: boolean;
19
28
  }
20
- export declare class DomainCertificate extends Construct {
29
+ /**
30
+ * EXTENDS `Certificate` (subclass, not wrapper) so the underlying
31
+ * `AWS::CertificateManager::Certificate` synthesises at `<id>/Resource` — the
32
+ * byte-identical construct path, and therefore CFN logical ID, of a raw
33
+ * `new Certificate(scope, id)`. Wrapping a `Construct` around an inner
34
+ * `new Certificate(this, "Certificate")` would nest the resource one level
35
+ * deeper (`<id>/Certificate/Resource`), re-hash the logical ID, and make
36
+ * CloudFormation REPLACE a live in-use certificate on adoption (create new +
37
+ * flip listener + delete old) — exactly the churn the adoption design rules
38
+ * out ("same construct ID → zero cert churn"). Pinned by
39
+ * ecsDomainBaseline.test.ts and ecsNetworkingDomain.test.ts.
40
+ */
41
+ export declare class DomainCertificate extends Certificate {
42
+ /**
43
+ * Self-alias kept for API compatibility with the wrapper era — consumers
44
+ * that read `.certificate` compile and behave unchanged.
45
+ */
21
46
  readonly certificate: ICertificate;
22
- readonly certificateArn: string;
23
47
  readonly description: string;
24
48
  constructor(scope: Construct, id: string, props: DomainCertificateProps);
25
49
  }
@@ -1,30 +1,44 @@
1
- import { Construct } from "constructs";
2
1
  import { CfnOutput, Tags } from "aws-cdk-lib";
3
2
  import { Certificate, CertificateValidation } from "aws-cdk-lib/aws-certificatemanager";
4
3
  import { getDomainExportNames } from "@fjall/util";
5
4
  import { toPascalCase } from "../../../utils/capitaliseString.js";
6
5
  import { applyCostAllocationTags } from "../../../utils/costAllocationTags.js";
7
- export class DomainCertificate extends Construct {
6
+ /**
7
+ * EXTENDS `Certificate` (subclass, not wrapper) so the underlying
8
+ * `AWS::CertificateManager::Certificate` synthesises at `<id>/Resource` — the
9
+ * byte-identical construct path, and therefore CFN logical ID, of a raw
10
+ * `new Certificate(scope, id)`. Wrapping a `Construct` around an inner
11
+ * `new Certificate(this, "Certificate")` would nest the resource one level
12
+ * deeper (`<id>/Certificate/Resource`), re-hash the logical ID, and make
13
+ * CloudFormation REPLACE a live in-use certificate on adoption (create new +
14
+ * flip listener + delete old) — exactly the churn the adoption design rules
15
+ * out ("same construct ID → zero cert churn"). Pinned by
16
+ * ecsDomainBaseline.test.ts and ecsNetworkingDomain.test.ts.
17
+ */
18
+ export class DomainCertificate extends Certificate {
19
+ /**
20
+ * Self-alias kept for API compatibility with the wrapper era — consumers
21
+ * that read `.certificate` compile and behave unchanged.
22
+ */
8
23
  certificate;
9
- certificateArn;
10
24
  description;
11
25
  constructor(scope, id, props) {
12
- super(scope, id);
13
- this.description =
14
- props.description ??
15
- `Fjall-managed ACM certificate for ${props.domainName}`;
16
- this.certificate = new Certificate(this, "Certificate", {
26
+ super(scope, id, {
17
27
  domainName: props.domainName,
18
28
  subjectAlternativeNames: props.subjectAlternativeNames,
19
29
  validation: CertificateValidation.fromDns(props.hostedZone),
20
30
  transparencyLoggingEnabled: props.transparencyLogging ?? true
21
31
  });
22
- this.certificateArn = this.certificate.certificateArn;
32
+ this.certificate = this;
33
+ this.description =
34
+ props.description ??
35
+ `Fjall-managed ACM certificate for ${props.domainName}`;
23
36
  Tags.of(this).add("fjall:description", this.description);
24
37
  applyCostAllocationTags(this, {
25
38
  service: "certificate",
26
39
  domain: props.costAllocationDomain ?? props.domainName,
27
- environment: props.costAllocationEnvironment
40
+ environment: props.costAllocationEnvironment,
41
+ inheritEnvironment: props.inheritCostAllocationEnvironment
28
42
  });
29
43
  if (props.exportCertificateArn !== false) {
30
44
  const safeKey = toPascalCase(props.domainName.split(".").join(""));
@@ -1,5 +1,6 @@
1
1
  import { Construct } from "constructs";
2
2
  import { type IHostedZone } from "aws-cdk-lib/aws-route53";
3
+ import { type RemovalPolicyString } from "../messaging/utils.js";
3
4
  import { DelegationRole } from "../iam/delegationRole.js";
4
5
  import { type AwsStack } from "../base/awsStack.js";
5
6
  export interface HostedZoneProps {
@@ -10,6 +11,16 @@ export interface HostedZoneProps {
10
11
  readonly organisationIdExportName?: string;
11
12
  readonly costAllocationEnvironment?: string;
12
13
  readonly costAllocationDomain?: string;
14
+ /**
15
+ * Removal policy for the created zone (D4: zone safety). Default resolves
16
+ * via `envAwareRemovalPolicyDefault()` (production → RETAIN; other
17
+ * recognised stages → DESTROY; unrecognised values fail synth) — deleting
18
+ * a live zone dangles every parent/registrar delegation, so production
19
+ * zones survive stack destruction by default. The flip is metadata-only:
20
+ * the zone's logical ID is unaffected (flipless-retain-flip ADR). Ignored
21
+ * on the import path (`hostedZoneId` set), which manages no zone resource.
22
+ */
23
+ readonly removalPolicy?: RemovalPolicyString;
13
24
  }
14
25
  export interface HostedZoneFactoryImportOptions {
15
26
  readonly costAllocationEnvironment?: string;
@@ -3,6 +3,7 @@ import { CfnOutput, Fn, Tags } from "aws-cdk-lib";
3
3
  import { HostedZone as AWSHostedZone } from "aws-cdk-lib/aws-route53";
4
4
  import { getDomainExportNames } from "@fjall/util";
5
5
  import { toPascalCase, getSafeZoneName } from "../../../utils/capitaliseString.js";
6
+ import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
6
7
  import { DelegationRole } from "../iam/delegationRole.js";
7
8
  import { applyCostAllocationTags } from "../../../utils/costAllocationTags.js";
8
9
  import { resolveOrgId } from "../../../utils/cdkContext.js";
@@ -39,6 +40,11 @@ export class HostedZone extends Construct {
39
40
  zoneName: props.zoneName,
40
41
  comment: this.description
41
42
  });
43
+ // D4 — resolved inside the create branch only: the env-aware default
44
+ // throws on unrecognised environments, and import-only synths manage
45
+ // no zone resource to police.
46
+ const removalPolicyValue = props.removalPolicy ?? envAwareRemovalPolicyDefault();
47
+ created.applyRemovalPolicy(toRemovalPolicy(removalPolicyValue));
42
48
  this.hostedZone = created;
43
49
  this.hostedZoneId = created.hostedZoneId;
44
50
  this.nameServers = created.hostedZoneNameServers
@@ -10,6 +10,15 @@ export interface CostAllocationTagsArgs {
10
10
  readonly service: string;
11
11
  readonly domain: string;
12
12
  readonly environment?: string;
13
+ /**
14
+ * When `environment` is not given, skip the environment tag at this scope
15
+ * entirely instead of stamping the "management" default, so an outer
16
+ * cascade (the fjall App's global tags carry the account-stage-resolved
17
+ * environment) provides it. Without this, the deeper-scope default wins
18
+ * CDK tag resolution and a production stack's resource is mis-tagged
19
+ * "management". An explicit `environment` always wins over inheritance.
20
+ */
21
+ readonly inheritEnvironment?: boolean;
13
22
  readonly owner?: string;
14
23
  }
15
24
  export declare function applyCostAllocationTags(scope: IConstruct, args: CostAllocationTagsArgs): void;
@@ -7,7 +7,12 @@ export const COST_ALLOCATION_TAGS = {
7
7
  };
8
8
  export const DEFAULT_COST_ALLOCATION_ENVIRONMENT = "management";
9
9
  export function applyCostAllocationTags(scope, args) {
10
- Tags.of(scope).add(COST_ALLOCATION_TAGS.ENVIRONMENT, args.environment ?? DEFAULT_COST_ALLOCATION_ENVIRONMENT);
10
+ if (args.environment !== undefined) {
11
+ Tags.of(scope).add(COST_ALLOCATION_TAGS.ENVIRONMENT, args.environment);
12
+ }
13
+ else if (args.inheritEnvironment !== true) {
14
+ Tags.of(scope).add(COST_ALLOCATION_TAGS.ENVIRONMENT, DEFAULT_COST_ALLOCATION_ENVIRONMENT);
15
+ }
11
16
  Tags.of(scope).add(COST_ALLOCATION_TAGS.SERVICE, args.service);
12
17
  Tags.of(scope).add(COST_ALLOCATION_TAGS.DOMAIN, args.domain);
13
18
  if (args.owner !== undefined) {
@@ -0,0 +1,52 @@
1
+ import type { IHostedZone } from "aws-cdk-lib/aws-route53";
2
+ import type { IConstruct } from "constructs";
3
+ /**
4
+ * Synth-time DNS record-collision registry (design D5, synth-side half — the
5
+ * deploy-time preflight against live zones is Phase 2).
6
+ *
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.
12
+ *
13
+ * Scoping: claims are held per App (the construct-tree root), so multiple CDK
14
+ * Apps in one process — the vitest convention of a fresh `new CdkApp()` per
15
+ * test — never cross-talk, while stacks within one App share a claim table.
16
+ * Cross-stack duplicates inside one App are exactly the surface nothing
17
+ * catches before the change set executes.
18
+ *
19
+ * Lifecycle follows the manifest-collector precedent (manifestWriter.ts):
20
+ * module-level synth state with a reset hook, wired into
21
+ * `App.resetForTesting()` so the existing test-hygiene convention
22
+ * (resetForTesting + resetManifestCollector in beforeEach/afterEach) keeps
23
+ * working without a third per-test call.
24
+ */
25
+ export interface DnsRecordClaim {
26
+ /** Zone the record lands in — consulted for identity resolution. */
27
+ readonly zone: IHostedZone;
28
+ /** Zone name as passed to the wrapper (identity fallback and error copy). */
29
+ readonly zoneName: string;
30
+ /** Fully-qualified record name the wrapper resolved. */
31
+ readonly fqdn: string;
32
+ /**
33
+ * Route53 record-set type. Alias records claim their underlying type ("A")
34
+ * — an alias and a plain A record on the same name are a real collision.
35
+ */
36
+ readonly recordType: string;
37
+ }
38
+ /**
39
+ * 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.
45
+ */
46
+ export declare function registerDnsRecordClaim(scope: IConstruct, claim: DnsRecordClaim): void;
47
+ /**
48
+ * Reset all claims (for testing). Called by `App.resetForTesting()` alongside
49
+ * the App singleton reset so per-test App recycling starts clean — the
50
+ * manifest-collector lifecycle precedent.
51
+ */
52
+ export declare function resetDnsRecordRegistry(): void;
@@ -0,0 +1,64 @@
1
+ import { Token } from "aws-cdk-lib";
2
+ /** Claims per App root: claim key → owning construct path. */
3
+ let claimsByRoot = new WeakMap();
4
+ /** Lowercase and strip the trailing dot — DNS names are case-insensitive. */
5
+ function normaliseDnsName(name) {
6
+ return name.toLowerCase().replace(/\.$/, "");
7
+ }
8
+ /**
9
+ * Zone identity tolerates both literal zone IDs and zone names: prefer
10
+ * `hostedZoneId` when it is a known literal (imported or looked-up zones),
11
+ * else fall back to the lowercased zone name — zones created in-app carry an
12
+ * unresolved token ID that is meaningless as an identity.
13
+ */
14
+ function resolveZoneIdentity(zone, zoneName) {
15
+ const hostedZoneId = zone.hostedZoneId;
16
+ if (hostedZoneId && !Token.isUnresolved(hostedZoneId)) {
17
+ return hostedZoneId;
18
+ }
19
+ return normaliseDnsName(zoneName);
20
+ }
21
+ /**
22
+ * 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.
28
+ */
29
+ export function registerDnsRecordClaim(scope, claim) {
30
+ const root = scope.node.root;
31
+ let claims = claimsByRoot.get(root);
32
+ if (!claims) {
33
+ claims = new Map();
34
+ claimsByRoot.set(root, claims);
35
+ }
36
+ const recordType = claim.recordType.toUpperCase();
37
+ const key = [
38
+ resolveZoneIdentity(claim.zone, claim.zoneName),
39
+ normaliseDnsName(claim.fqdn),
40
+ recordType
41
+ ].join("|");
42
+ const claimantPath = scope.node.path;
43
+ const existing = claims.get(key);
44
+ if (existing === undefined) {
45
+ claims.set(key, claimantPath);
46
+ return;
47
+ }
48
+ if (existing === claimantPath) {
49
+ return;
50
+ }
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.`);
56
+ }
57
+ /**
58
+ * Reset all claims (for testing). Called by `App.resetForTesting()` alongside
59
+ * the App singleton reset so per-test App recycling starts clean — the
60
+ * manifest-collector lifecycle precedent.
61
+ */
62
+ export function resetDnsRecordRegistry() {
63
+ claimsByRoot = new WeakMap();
64
+ }
@@ -1,36 +1,43 @@
1
- export { DNS_APEX, getDomainExportNames, type ManagedDomainExports } from "@fjall/util";
2
- export interface DnsRecordInput {
3
- type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS" | "SRV" | "CAA";
4
- name: string;
5
- value: string;
6
- ttl?: number;
7
- priority?: number;
8
- weight?: number;
9
- port?: number;
10
- }
11
- export interface DelegationInput {
12
- subdomain: string;
13
- targetAccount: string;
14
- }
15
- export interface CertificateInput {
16
- domainName: string;
17
- subjectAlternativeNames?: string[];
18
- }
19
- export interface DomainApexProps {
20
- type: "domain";
21
- zoneName: string;
22
- records?: DnsRecordInput[];
23
- delegations?: DelegationInput[];
24
- certificates?: CertificateInput[];
25
- hostedZoneId?: string;
26
- }
27
- export interface DomainDelegatedProps {
28
- type: "delegated";
29
- zoneName: string;
30
- parentZoneName: string;
31
- parentAccountName: string;
32
- records?: DnsRecordInput[];
33
- certificates?: CertificateInput[];
34
- }
35
- export type IDomainProps = DomainApexProps | DomainDelegatedProps;
36
- export declare const ALIAS_CDK_PREFIX: "ALIAS:";
1
+ export { DNS_APEX, getDomainExportNames, isManagedDomainBinding, type ManagedDomainBinding, type ManagedDomainExports } from "@fjall/util";
2
+ /**
3
+ * Dot-boundary suffix matching. Rejects pathological cases where a user-supplied
4
+ * zone appears as a suffix of another zone (e.g. 'evilexample.com' against
5
+ * zone 'example.com' — `endsWith('example.com')` returns true without the
6
+ * leading dot). Shared by the patterns-layer domain validation and the
7
+ * resources-layer ECS networking (lowest common layer per generator-standards
8
+ * § Infrastructure Layer Boundaries).
9
+ */
10
+ export declare function isWithinZone(candidate: string, zoneName: string): boolean;
11
+ /**
12
+ * Canonical BIND-semantics resolver for DNS record names — the single
13
+ * authority shared by the patterns-layer domain validation and the
14
+ * resources-layer dnsRecord family (lowest common layer per
15
+ * generator-standards § Infrastructure Layer Boundaries). Contract:
16
+ *
17
+ * - `'@'` — the zone apex; resolves to the zone name.
18
+ * - Trailing dot — an absolute FQDN; must equal the zone or end with
19
+ * `.<zone>.`, otherwise the name belongs to another zone and is rejected.
20
+ * The conventional trailing dot is shed on return because CloudFormation
21
+ * RecordSet names carry none at this layer (CDK re-appends it).
22
+ * - No trailing dot — relative to the zone regardless of label count
23
+ * (`www` and `s1._domainkey` alike); the zone is appended exactly once.
24
+ * - A dotless name that already ends with the bare zone is ambiguous between
25
+ * the relative and absolute readings and is rejected, naming both honest
26
+ * spellings.
27
+ */
28
+ export declare function resolveRecordFqdn(recordName: string, zoneName: string): string;
29
+ /**
30
+ * Two-step deploy phase for constructs whose certificates must not be issued
31
+ * before their zone's NS delegation has propagated (design R2 cert-hang
32
+ * guard). `"zone"` synthesises the hosted zone (+ delegation record) only;
33
+ * `"full"` additionally issues certificates. Shared by the delegated `Domain`
34
+ * topology and `DevSubstrate` — lowest common layer per generator-standards
35
+ * § Infrastructure Layer Boundaries.
36
+ */
37
+ export type DomainDeployPhase = "zone" | "full";
38
+ /**
39
+ * Default deploy phase when `phase` is omitted — the complete build. The
40
+ * two-step guard (R2) sets `"zone"` explicitly for step 1, so the safe
41
+ * default for a single-shot deploy is everything.
42
+ */
43
+ export declare const DOMAIN_DEPLOY_DEFAULT_PHASE: DomainDeployPhase;
@@ -1,2 +1,65 @@
1
- export { DNS_APEX, getDomainExportNames } from "@fjall/util";
2
- export const ALIAS_CDK_PREFIX = "ALIAS:";
1
+ import { DNS_APEX } from "@fjall/util";
2
+ export { DNS_APEX, getDomainExportNames, isManagedDomainBinding } from "@fjall/util";
3
+ /**
4
+ * Dot-boundary suffix matching. Rejects pathological cases where a user-supplied
5
+ * zone appears as a suffix of another zone (e.g. 'evilexample.com' against
6
+ * zone 'example.com' — `endsWith('example.com')` returns true without the
7
+ * leading dot). Shared by the patterns-layer domain validation and the
8
+ * resources-layer ECS networking (lowest common layer per generator-standards
9
+ * § Infrastructure Layer Boundaries).
10
+ */
11
+ export function isWithinZone(candidate, zoneName) {
12
+ if (candidate === zoneName) {
13
+ return true;
14
+ }
15
+ return candidate.endsWith(`.${zoneName}`);
16
+ }
17
+ /**
18
+ * Canonical BIND-semantics resolver for DNS record names — the single
19
+ * authority shared by the patterns-layer domain validation and the
20
+ * resources-layer dnsRecord family (lowest common layer per
21
+ * generator-standards § Infrastructure Layer Boundaries). Contract:
22
+ *
23
+ * - `'@'` — the zone apex; resolves to the zone name.
24
+ * - Trailing dot — an absolute FQDN; must equal the zone or end with
25
+ * `.<zone>.`, otherwise the name belongs to another zone and is rejected.
26
+ * The conventional trailing dot is shed on return because CloudFormation
27
+ * RecordSet names carry none at this layer (CDK re-appends it).
28
+ * - No trailing dot — relative to the zone regardless of label count
29
+ * (`www` and `s1._domainkey` alike); the zone is appended exactly once.
30
+ * - A dotless name that already ends with the bare zone is ambiguous between
31
+ * the relative and absolute readings and is rejected, naming both honest
32
+ * spellings.
33
+ */
34
+ export function resolveRecordFqdn(recordName, zoneName) {
35
+ if (recordName === DNS_APEX) {
36
+ return zoneName;
37
+ }
38
+ if (recordName.endsWith(".")) {
39
+ const absolute = recordName.slice(0, -1);
40
+ if (absolute !== zoneName && !absolute.endsWith(`.${zoneName}`)) {
41
+ throw new Error(`DNS record name '${recordName}' is absolute (trailing dot) but ` +
42
+ `lies outside zone '${zoneName}'. Cure: an absolute name must be ` +
43
+ `'${zoneName}.' or end with '.${zoneName}.'; records for another ` +
44
+ `zone belong on that zone's own Domain construct.`);
45
+ }
46
+ return absolute;
47
+ }
48
+ if (recordName === zoneName || recordName.endsWith(`.${zoneName}`)) {
49
+ const relativeSpelling = recordName === zoneName
50
+ ? DNS_APEX
51
+ : recordName.slice(0, -(zoneName.length + 1));
52
+ throw new Error(`DNS record name '${recordName}' ends with zone '${zoneName}' but has ` +
53
+ `no trailing dot, so it is ambiguous between relative ` +
54
+ `('${recordName}.${zoneName}') and absolute ('${recordName}'). ` +
55
+ `Cure: spell it '${relativeSpelling}' (relative to the zone) or ` +
56
+ `'${recordName}.' (absolute).`);
57
+ }
58
+ return `${recordName}.${zoneName}`;
59
+ }
60
+ /**
61
+ * Default deploy phase when `phase` is omitted — the complete build. The
62
+ * two-step guard (R2) sets `"zone"` explicitly for step 1, so the safe
63
+ * default for a single-shot deploy is everything.
64
+ */
65
+ export const DOMAIN_DEPLOY_DEFAULT_PHASE = "full";
@@ -0,0 +1,37 @@
1
+ /**
2
+ * CDK-context channel for CLI-injected managed-domain bindings (design
3
+ * 2026-07-17 domain-management gold-plating, D2 — resolution by injection).
4
+ *
5
+ * At deploy time the Fjall CLI resolves each app-consumed domain against the
6
+ * declared `fjall/domains/` components (DescribeStacks on the domain stack)
7
+ * and injects one context entry per zone:
8
+ *
9
+ * -c fjall:managedDomain:<zoneName>=<JSON ManagedDomainBinding>
10
+ *
11
+ * (emitter: deploy-core `CdkArgumentBuilder.buildContextArgs` — keep the
12
+ * prefix literal in sync). Constructs read the entry ahead of their BYO
13
+ * `zoneName`/`hostedZone` props; an EXPLICIT `managedDomain` prop still wins
14
+ * over the injected value. Bare-CDK synth simply lacks the context and falls
15
+ * back to explicit props.
16
+ *
17
+ * Utils layer on purpose: consumed by both the resources layer
18
+ * (`ecsNetworking.addHostedZone`) and the patterns layer
19
+ * (`patternDomain.resolvePatternZone`), and utils is their lowest common
20
+ * layer (generator-standards § Infrastructure Layer Boundaries).
21
+ */
22
+ import type { Node } from "constructs";
23
+ import type { ManagedDomainBinding } from "@fjall/util";
24
+ export declare const MANAGED_DOMAIN_CONTEXT_PREFIX: "fjall:managedDomain:";
25
+ export declare function getManagedDomainContextKey(zoneName: string): string;
26
+ /**
27
+ * Read the CLI-injected {@link ManagedDomainBinding} for `domainName`,
28
+ * walking exact → parent zones (mirroring the CLI's
29
+ * `DomainService.matchManagedDomain` semantics: `app.example.com` matches a
30
+ * binding injected for `example.com`). Returns undefined when no entry is
31
+ * present (bare-CDK synth); throws a context-prefixed, value-echoing,
32
+ * cure-naming error when an entry exists but is malformed — a corrupt
33
+ * binding must fail the synth, never fall through to a guessed zone.
34
+ *
35
+ * `context` prefixes every error, e.g. `Static site 'marketing'`.
36
+ */
37
+ export declare function readInjectedManagedDomainBinding(node: Node, domainName: string, context: string): ManagedDomainBinding | undefined;