@fjall/components-infrastructure 36.0.0 → 38.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 (44) hide show
  1. package/dist/lib/app.d.ts +28 -0
  2. package/dist/lib/app.js +34 -2
  3. package/dist/lib/config/aws/disasterRecovery.d.ts +1 -2
  4. package/dist/lib/config/aws/disasterRecovery.js +52 -32
  5. package/dist/lib/patterns/aws/cdn.d.ts +19 -11
  6. package/dist/lib/patterns/aws/cdn.js +53 -25
  7. package/dist/lib/patterns/aws/domain.js +3 -3
  8. package/dist/lib/patterns/aws/externalRecordsPattern.d.ts +9 -6
  9. package/dist/lib/patterns/aws/externalRecordsPattern.js +22 -46
  10. package/dist/lib/patterns/aws/interfaces/domain.d.ts +8 -5
  11. package/dist/lib/patterns/aws/interfaces/pattern.d.ts +9 -10
  12. package/dist/lib/patterns/aws/payload.d.ts +5 -0
  13. package/dist/lib/patterns/aws/payload.js +23 -11
  14. package/dist/lib/patterns/aws/targets/targetResolution.d.ts +4 -4
  15. package/dist/lib/patterns/aws/targets/targetResolution.js +4 -4
  16. package/dist/lib/resources/aws/backup/backupVault.d.ts +17 -2
  17. package/dist/lib/resources/aws/backup/backupVault.js +37 -6
  18. package/dist/lib/resources/aws/cdn/cloudFront.d.ts +42 -18
  19. package/dist/lib/resources/aws/cdn/cloudFront.js +39 -51
  20. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.d.ts +90 -0
  21. package/dist/lib/resources/aws/cdn/cloudFrontLogDelivery.js +213 -0
  22. package/dist/lib/resources/aws/cdn/index.d.ts +1 -0
  23. package/dist/lib/resources/aws/cdn/index.js +1 -0
  24. package/dist/lib/resources/aws/logging/accessLogBucket.d.ts +15 -8
  25. package/dist/lib/resources/aws/logging/accessLogBucket.js +16 -9
  26. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.d.ts +14 -0
  27. package/dist/lib/resources/aws/messaging/eventBridgeQueueGrants.js +90 -0
  28. package/dist/lib/resources/aws/messaging/eventTargets.d.ts +4 -3
  29. package/dist/lib/resources/aws/messaging/eventTargets.js +71 -31
  30. package/dist/lib/resources/aws/messaging/queuePolicyQuota.d.ts +25 -0
  31. package/dist/lib/resources/aws/messaging/queuePolicyQuota.js +190 -0
  32. package/dist/lib/resources/aws/messaging/schedule.d.ts +5 -5
  33. package/dist/lib/resources/aws/messaging/sqs.js +4 -0
  34. package/dist/lib/resources/aws/messaging/subscription.d.ts +4 -3
  35. package/dist/lib/resources/aws/secrets/kms.d.ts +8 -0
  36. package/dist/lib/resources/aws/secrets/kms.js +12 -2
  37. package/dist/lib/resources/aws/secrets/stackEncryptionKey.js +6 -4
  38. package/dist/lib/utils/backupTierMapping.d.ts +11 -16
  39. package/dist/lib/utils/backupTierMapping.js +2 -15
  40. package/dist/lib/utils/cdkContext.d.ts +14 -0
  41. package/dist/lib/utils/cdkContext.js +38 -1
  42. package/dist/lib/utils/resourceNaming.d.ts +22 -0
  43. package/dist/lib/utils/resourceNaming.js +32 -0
  44. package/package.json +3 -3
package/dist/lib/app.d.ts CHANGED
@@ -211,6 +211,34 @@ export declare class App extends CdkApp {
211
211
  * Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
212
212
  */
213
213
  getUsEast1CertificateStack(): AwsStack;
214
+ /**
215
+ * Retrieve the app-owned us-east-1 CloudFront logging stack — named
216
+ * `${this.name}UsEast1CdnLogging`.
217
+ *
218
+ * CloudWatch Logs accepts CloudFront standard-logging (v2) delivery
219
+ * sources, destinations and deliveries in us-east-1 only, so a logging
220
+ * CDN whose app deploys to another region creates them here; the
221
+ * distribution ARN and the log bucket ARN reach this stack through CDK
222
+ * cross-region references, which makes it a CONSUMER of the Cdn stack —
223
+ * it deploys after the Cdn stack and is destroyed before it, the mirror
224
+ * of the certificate stack. A CDN in a us-east-1 app never creates it.
225
+ *
226
+ * Everything crossing the boundary is a value the export writer requires
227
+ * stable under an unchanged logical id: the distribution ARN and the
228
+ * bucket ARN both are. Never route a per-synth value (a timestamp, an
229
+ * asset hash) through this stack's references.
230
+ */
231
+ getUsEast1CdnLoggingStack(): AwsStack;
232
+ /**
233
+ * The two us-east-1 stacks share a shape: no stack dependencies of their
234
+ * own (the CDK cross-region reference adds the consumer→producer edge),
235
+ * env-pinned to the deploying account (cross-region references resolve
236
+ * only between env-pinned stacks) and `crossRegionReferences: true` — the
237
+ * flag must be on BOTH the producing and the consuming stack, and the
238
+ * emitted Custom::CrossRegionExportWriter/Reader pair is pinned by
239
+ * template test.
240
+ */
241
+ private getUsEast1Stack;
214
242
  /**
215
243
  * Get a VPC by name. If no name is provided, returns the default VPC.
216
244
  *
package/dist/lib/app.js CHANGED
@@ -312,11 +312,43 @@ export class App extends CdkApp {
312
312
  * Custom::CrossRegionExportWriter/Reader pair is pinned by template test.
313
313
  */
314
314
  getUsEast1CertificateStack() {
315
- const key = `${this.stackPrefix}UsEast1Certificates`;
315
+ return this.getUsEast1Stack("UsEast1Certificates", "certificate");
316
+ }
317
+ /**
318
+ * Retrieve the app-owned us-east-1 CloudFront logging stack — named
319
+ * `${this.name}UsEast1CdnLogging`.
320
+ *
321
+ * CloudWatch Logs accepts CloudFront standard-logging (v2) delivery
322
+ * sources, destinations and deliveries in us-east-1 only, so a logging
323
+ * CDN whose app deploys to another region creates them here; the
324
+ * distribution ARN and the log bucket ARN reach this stack through CDK
325
+ * cross-region references, which makes it a CONSUMER of the Cdn stack —
326
+ * it deploys after the Cdn stack and is destroyed before it, the mirror
327
+ * of the certificate stack. A CDN in a us-east-1 app never creates it.
328
+ *
329
+ * Everything crossing the boundary is a value the export writer requires
330
+ * stable under an unchanged logical id: the distribution ARN and the
331
+ * bucket ARN both are. Never route a per-synth value (a timestamp, an
332
+ * asset hash) through this stack's references.
333
+ */
334
+ getUsEast1CdnLoggingStack() {
335
+ return this.getUsEast1Stack("UsEast1CdnLogging", "CloudFront logging");
336
+ }
337
+ /**
338
+ * The two us-east-1 stacks share a shape: no stack dependencies of their
339
+ * own (the CDK cross-region reference adds the consumer→producer edge),
340
+ * env-pinned to the deploying account (cross-region references resolve
341
+ * only between env-pinned stacks) and `crossRegionReferences: true` — the
342
+ * flag must be on BOTH the producing and the consuming stack, and the
343
+ * emitted Custom::CrossRegionExportWriter/Reader pair is pinned by
344
+ * template test.
345
+ */
346
+ getUsEast1Stack(suffix, purpose) {
347
+ const key = `${this.stackPrefix}${suffix}`;
316
348
  if (!this.stacks[key]) {
317
349
  const account = process.env.CDK_DEFAULT_ACCOUNT;
318
350
  if (!account) {
319
- throw new Error(`App '${this.name}': the us-east-1 certificate stack needs a ` +
351
+ throw new Error(`App '${this.name}': the us-east-1 ${purpose} stack needs a ` +
320
352
  "concrete account (got CDK_DEFAULT_ACCOUNT undefined). " +
321
353
  "Cross-region references resolve only between env-pinned stacks " +
322
354
  "— deploy through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT " +
@@ -12,8 +12,7 @@ export interface DisasterRecoveryProps {
12
12
  customBackupPlans?: CustomBackupPlanConfig[];
13
13
  }
14
14
  export declare class DisasterRecovery extends Construct {
15
- readonly backupVault?: BackupVault;
16
- private readonly vaultRef;
15
+ readonly backupVault: BackupVault;
17
16
  readonly backupPlans: {
18
17
  standard: BackupPlan;
19
18
  resilient: BackupPlan;
@@ -1,12 +1,14 @@
1
- import { CfnOutput, Duration, RemovalPolicy } from "aws-cdk-lib";
1
+ import { Annotations, CfnOutput, Duration, RemovalPolicy } from "aws-cdk-lib";
2
2
  import { Construct } from "constructs";
3
3
  import { BackupVault, BackupPlan } from "../../resources/aws/backup/index.js";
4
4
  import { BackupPlanRule, BackupVault as Vault } from "aws-cdk-lib/aws-backup";
5
- import { cronSchedule } from "../../resources/aws/messaging/index.js";
6
5
  import { AccountPrincipal, Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
6
+ import { Key } from "aws-cdk-lib/aws-kms";
7
+ import { cronSchedule } from "../../resources/aws/messaging/index.js";
7
8
  import { getConfig } from "../../utils/getConfig.js";
8
9
  import { toPascalCase } from "../../utils/capitaliseString.js";
9
- import { BACKUP_VAULT_NAME } from "@fjall/util";
10
+ import { readBackupVaultVerdict } from "../../utils/cdkContext.js";
11
+ import { BACKUP_VAULT_NAME, isReownedBackupVault } from "@fjall/util";
10
12
  // Backup retention constants (in days)
11
13
  const RETENTION_PERIODS = {
12
14
  STANDARD: 90,
@@ -28,11 +30,13 @@ const VAULT_LOCK = {
28
30
  MAX_RETENTION: Duration.days(36500),
29
31
  GRACE_PERIOD: Duration.days(3)
30
32
  };
33
+ function liveLockMode(lock) {
34
+ if (lock === null)
35
+ return "none";
36
+ return lock.lockDate !== undefined ? "compliance" : "governance";
37
+ }
31
38
  export class DisasterRecovery extends Construct {
32
- // Set in create-mode only; in adopt-mode the existing vault is referenced, not created.
33
39
  backupVault;
34
- // Vault the plans write to: created vault (create-mode) or imported handle (adopt-mode).
35
- vaultRef;
36
40
  backupPlans;
37
41
  customBackupPlans;
38
42
  constructor(scope, id, props) {
@@ -41,9 +45,10 @@ export class DisasterRecovery extends Construct {
41
45
  const providerAccounts = config.providerAccounts;
42
46
  const account = providerAccounts.find((pa) => pa.id === props.accountId);
43
47
  const isComplianceAccount = account?.environment === "compliance";
44
- // Set by the deploy orchestration after probing DescribeBackupVault: adopt the
45
- // retained fixed-name vault by reference rather than re-CREATE it (which collides).
46
- const adoptExistingVault = this.node.tryGetContext("fjallAdoptBackupVault") === "true";
48
+ // Injected by the deploy engine after probing the live vault and the account
49
+ // stack; unset (a bare synth) is the create template.
50
+ const verdict = readBackupVaultVerdict(this.node);
51
+ const reOwned = isReownedBackupVault(verdict);
47
52
  const disasterRecoveryRegion = config.disasterRecoveryRegion;
48
53
  // Look up compliance account for cross-account replication
49
54
  // Skip if the compliance account (prevent self-replication)
@@ -58,9 +63,11 @@ export class DisasterRecovery extends Construct {
58
63
  // permanently-immutable compliance lock. See
59
64
  // decisions/2026-06-03-backup-vault-lock-mode-by-intent.md.
60
65
  const vaultLock = account?.vaultLock ?? (isComplianceAccount ? "governance" : "none");
61
- // Adopt-mode references an already-locked vault and applies no lock config,
62
- // so it is exempt from the acknowledgement gate.
63
- if (!adoptExistingVault &&
66
+ // Fjall never issues a lock call against a vault it did not create: a
67
+ // re-owned vault declares no lock and the intent only warns when it
68
+ // differs, so the acknowledgement gate guards only a lock this template
69
+ // creates.
70
+ if (!reOwned &&
64
71
  vaultLock === "compliance" &&
65
72
  account?.acknowledgeImmutableVaultLock !== true) {
66
73
  throw new Error(`Account "${account?.name ?? props.accountId}" requests vaultLock: ` +
@@ -69,7 +76,7 @@ export class DisasterRecovery extends Construct {
69
76
  `compliance lock cannot be removed by anyone, including AWS or the ` +
70
77
  `root user. Set the acknowledgement explicitly to proceed.`);
71
78
  }
72
- const lockConfiguration = vaultLock === "compliance"
79
+ const intendedLockConfiguration = vaultLock === "compliance"
73
80
  ? {
74
81
  minRetention: VAULT_LOCK.MIN_RETENTION,
75
82
  maxRetention: VAULT_LOCK.MAX_RETENTION,
@@ -81,20 +88,33 @@ export class DisasterRecovery extends Construct {
81
88
  maxRetention: VAULT_LOCK.MAX_RETENTION
82
89
  }
83
90
  : undefined;
84
- if (adoptExistingVault) {
85
- this.vaultRef = Vault.fromBackupVaultName(this, "BackupVault", BACKUP_VAULT_NAME);
86
- }
87
- else {
88
- this.backupVault = new BackupVault(this, "BackupVault", {
89
- vaultName: BACKUP_VAULT_NAME,
90
- removalPolicy: RemovalPolicy.RETAIN,
91
- lockConfiguration: lockConfiguration
92
- });
93
- this.vaultRef = this.backupVault.vault;
91
+ const lockConfiguration = reOwned ? undefined : intendedLockConfiguration;
92
+ const liveLocked = verdict.state !== "absent" && verdict.lock !== null;
93
+ if (verdict.state !== "absent" && reOwned) {
94
+ const liveMode = liveLockMode(verdict.lock);
95
+ if (liveMode !== vaultLock) {
96
+ Annotations.of(this).addWarningV2("fjall:backupVault:lockIntentNotApplied", `Backup vault "${BACKUP_VAULT_NAME}" is re-owned: Fjall does not ` +
97
+ `manage the lock of a vault it did not create. Its live lock ` +
98
+ `(${liveMode}) stays as it is and the configured vaultLock ` +
99
+ `(${vaultLock}) is not applied — align the configured vaultLock ` +
100
+ `with the live lock, or change the live lock with the AWS Backup ` +
101
+ `API where it is still changeable.`);
102
+ }
94
103
  }
95
- // Cross-account access policy — create-mode only. addToAccessPolicy is unavailable
96
- // on an imported vault, and a live (locked) vault retains the grant written at creation.
97
- if (this.backupVault && isComplianceAccount) {
104
+ this.backupVault = new BackupVault(this, "BackupVault", {
105
+ vaultName: BACKUP_VAULT_NAME,
106
+ removalPolicy: RemovalPolicy.RETAIN,
107
+ lockConfiguration,
108
+ ...(verdict.state !== "absent" &&
109
+ reOwned && {
110
+ encryptionKey: Key.fromKeyArn(this, "BackupVaultLiveKey", verdict.keyArn)
111
+ }),
112
+ importIdentityOnly: verdict.state === "unowned"
113
+ });
114
+ const vault = this.backupVault.vault;
115
+ // Cross-account copy grant. Declared in every mode; the wrapper strips it
116
+ // from the import pass, and the converge deploy applies it.
117
+ if (isComplianceAccount) {
98
118
  const allAccounts = providerAccounts;
99
119
  const productionAccounts = allAccounts.filter((acc) => acc.environment === "production");
100
120
  if (productionAccounts.length > 0) {
@@ -104,7 +124,7 @@ export class DisasterRecovery extends Construct {
104
124
  actions: ["backup:CopyIntoBackupVault"],
105
125
  resources: ["*"]
106
126
  });
107
- this.backupVault.vault.addToAccessPolicy(copyStatement);
127
+ vault.addToAccessPolicy(copyStatement);
108
128
  }
109
129
  }
110
130
  // Construct cross-region replication vault ARN (same account, DR region)
@@ -118,19 +138,19 @@ export class DisasterRecovery extends Construct {
118
138
  planName: "standard",
119
139
  rules: this.createStandardBackupRules("standard"),
120
140
  tagValue: "default",
121
- backupVault: this.vaultRef
141
+ backupVault: vault
122
142
  }),
123
143
  resilient: new BackupPlan(this, "ResilientBackupPlan", {
124
144
  planName: "resilient",
125
145
  rules: this.createResilientBackupRules("resilient", replicationVaultArn),
126
146
  tagValue: "resilient",
127
- backupVault: this.vaultRef
147
+ backupVault: vault
128
148
  }),
129
149
  enterprise: new BackupPlan(this, "EnterpriseBackupPlan", {
130
150
  planName: "enterprise",
131
151
  rules: this.createEnterpriseBackupRules("enterprise", replicationVaultArn, disasterRecoveryVaultArn),
132
152
  tagValue: "enterprise",
133
- backupVault: this.vaultRef
153
+ backupVault: vault
134
154
  })
135
155
  };
136
156
  // Create custom backup plans if provided
@@ -140,7 +160,7 @@ export class DisasterRecovery extends Construct {
140
160
  planName: config.planName,
141
161
  rules: config.rules,
142
162
  tagValue: config.tagValue,
143
- backupVault: this.vaultRef
163
+ backupVault: vault
144
164
  });
145
165
  });
146
166
  }
@@ -157,7 +177,7 @@ export class DisasterRecovery extends Construct {
157
177
  exportName: `${id}ReplicationRegion`
158
178
  });
159
179
  }
160
- if (vaultLock !== "none") {
180
+ if (reOwned ? liveLocked : lockConfiguration !== undefined) {
161
181
  new CfnOutput(this, "VaultLockEnabled", {
162
182
  key: "VaultLockEnabled",
163
183
  value: "true",
@@ -4,7 +4,7 @@ import { type IApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalanc
4
4
  import { type ICertificate } from "aws-cdk-lib/aws-certificatemanager";
5
5
  import { type ICachePolicy, type IResponseHeadersPolicy } from "aws-cdk-lib/aws-cloudfront";
6
6
  import type App from "../../app.js";
7
- import { CloudFrontDistribution, type CachePolicyPreset, type AccessGateConfig } from "../../resources/aws/cdn/index.js";
7
+ import { CloudFrontDistribution, type CloudFrontLogFormat, type CachePolicyPreset, type AccessGateConfig } from "../../resources/aws/cdn/index.js";
8
8
  import { type ICdn } from "./interfaces/cdn.js";
9
9
  import { type StaticSiteRouting, type CloudFrontPriceClass } from "@fjall/util";
10
10
  import { type BackupTier } from "../../utils/backupTierMapping.js";
@@ -83,17 +83,17 @@ interface BaseCdnProps {
83
83
  certificateArn?: string;
84
84
  comment?: string;
85
85
  /**
86
- * Standard (S3) access logging. Default: the app backup tier's
87
- * `cloudFrontLogging` column — enterprise logs to the stack's shared,
88
- * lifecycle-expired access-log bucket under `cloudfront/<id>/`. Set either
89
- * knob to take over: `enableLogging: false` opts out at any tier,
90
- * `logBucket` delivers to a bucket of your own. Standard logging cannot
91
- * deliver into an opt-in region, so a tier- or explicitly-enabled CDN whose
92
- * delivery region is opt-in is refused at synth with those two cures — see
93
- * {@link CloudFrontDistributionProps.enableLogging}.
86
+ * Standard logging (v2): access logs delivered through CloudWatch Logs.
87
+ * Default: the app backup tier's `cloudFrontLogging` column — enterprise
88
+ * logs to the stack's shared, lifecycle-expired access-log bucket under
89
+ * `cloudfront/<id>`, as JSON. An object takes over at any tier (its
90
+ * `bucket`, `prefix` and `format` each default as above); `false` opts
91
+ * out at any tier. The delivery resources live in the CDN stack when the
92
+ * app deploys to us-east-1 and in the app's `UsEast1CdnLogging` stack
93
+ * otherwise (CloudWatch Logs accepts them there only), so a logging CDN
94
+ * outside us-east-1 needs the `App` — the factory path fills it.
94
95
  */
95
- enableLogging?: boolean;
96
- logBucket?: IBucket;
96
+ logging?: CdnLoggingConfig | false;
97
97
  priceClass?: CloudFrontPriceClass;
98
98
  forwardHostHeader?: boolean;
99
99
  accessGate?: false | AccessGateConfig;
@@ -104,6 +104,14 @@ interface BaseCdnProps {
104
104
  */
105
105
  appBackupTier?: BackupTier;
106
106
  }
107
+ export interface CdnLoggingConfig {
108
+ /** Bucket the logs land in; defaults to the stack's access-log bucket. */
109
+ bucket?: IBucket;
110
+ /** Key prefix (no leading or trailing slash); defaults to `cloudfront/<id>`. */
111
+ prefix?: string;
112
+ /** CloudWatch Logs output format; defaults to `json`. */
113
+ format?: CloudFrontLogFormat;
114
+ }
107
115
  /**
108
116
  * S3 origin CDN props - use with S3 bucket
109
117
  */
@@ -1,7 +1,7 @@
1
- import { Fn, Token } from "aws-cdk-lib";
1
+ import { Fn, Stack, Token } from "aws-cdk-lib";
2
2
  import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
3
3
  import { CloudFrontTarget, LoadBalancerTarget } from "aws-cdk-lib/aws-route53-targets";
4
- import { CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
4
+ import { assertNoLegacyLoggingProps, CLOUDFRONT_LOG_DELIVERY_REGION, CloudFrontDistribution } from "../../resources/aws/cdn/index.js";
5
5
  import { AliasRecord } from "../../resources/aws/networking/index.js";
6
6
  import { tierDefault } from "../../utils/backupTierMapping.js";
7
7
  import { stackAccessLogBucket } from "../../resources/aws/logging/index.js";
@@ -12,31 +12,54 @@ import { isStorage } from "./storage.js";
12
12
  import { CdnEcsOriginResolver, cdnOriginRefusal, internalAlbMessage, normaliseDnsName } from "./cdnAppOrigin.js";
13
13
  import { isCompute, isEcsCompute, isLambdaCompute } from "./compute.js";
14
14
  /**
15
- * An explicit `enableLogging` or `logBucket` is the override and passes
16
- * through untouched; otherwise the tier decides, and a tier-enabled CDN logs
17
- * to the stack's shared access-log bucket under its own prefix.
15
+ * `false` is off at any tier; an object is on at any tier with its own
16
+ * defaults filled; `undefined` lets the tier decide, and a tier-enabled CDN
17
+ * logs to the stack's shared access-log bucket under its own prefix.
18
18
  */
19
19
  function resolveCdnLogging(scope, id, props) {
20
- if (props.enableLogging !== undefined || props.logBucket !== undefined) {
21
- return {
22
- enableLogging: props.enableLogging,
23
- logBucket: props.logBucket,
24
- logFilePrefix: undefined
25
- };
26
- }
27
- if (!tierDefault(props.appBackupTier, "cloudFrontLogging")) {
28
- return {
29
- enableLogging: undefined,
30
- logBucket: undefined,
31
- logFilePrefix: undefined
32
- };
20
+ if (props.logging === false)
21
+ return undefined;
22
+ if (props.logging === undefined &&
23
+ !tierDefault(props.appBackupTier, "cloudFrontLogging")) {
24
+ return undefined;
33
25
  }
26
+ const config = props.logging ?? {};
34
27
  return {
35
- enableLogging: true,
36
- logBucket: stackAccessLogBucket(scope),
37
- logFilePrefix: `cloudfront/${id}/`
28
+ bucket: config.bucket ?? stackAccessLogBucket(scope),
29
+ ...(config.prefix !== undefined && { prefix: config.prefix }),
30
+ ...(config.format !== undefined && { format: config.format }),
31
+ ...resolveLogDeliveryScope(scope, id, props.app)
38
32
  };
39
33
  }
34
+ /**
35
+ * CloudWatch Logs accepts CloudFront delivery resources in us-east-1 only.
36
+ * A CDN stack already there hosts them itself; any other region hands them
37
+ * to the app's `UsEast1CdnLogging` stack, whose distribution and bucket
38
+ * ARNs arrive through CDK cross-region references — which need both stacks
39
+ * env-pinned, so a token region is refused here with the cure rather than
40
+ * by CloudFormation with a region error after the CDN stack has deployed.
41
+ */
42
+ function resolveLogDeliveryScope(scope, id, app) {
43
+ const region = Stack.of(scope).region;
44
+ if (Token.isUnresolved(region)) {
45
+ throw new Error(`CDN '${id}': standard logging (v2) needs a concrete region at synth ` +
46
+ "— the delivery resources go to us-east-1 and reach the " +
47
+ "distribution through cross-region references, which resolve only " +
48
+ "between env-pinned stacks — but this stack's region is a token. " +
49
+ "Deploy through the Fjall CLI, or export CDK_DEFAULT_ACCOUNT and " +
50
+ "CDK_DEFAULT_REGION before synthesising; or set logging: false.");
51
+ }
52
+ if (region === CLOUDFRONT_LOG_DELIVERY_REGION)
53
+ return {};
54
+ if (app === undefined) {
55
+ throw new Error(`CDN '${id}': standard logging (v2) from region ${region} places its ` +
56
+ "delivery resources in the app's us-east-1 logging stack, but 'app' " +
57
+ "is unset. Create the CDN through the factory path — " +
58
+ "app.addCdn(CdnFactory.build(...)) — which fills it, pass 'app' " +
59
+ "explicitly, or set logging: false.");
60
+ }
61
+ return { deliveryScope: app.getUsEast1CdnLoggingStack().getStack() };
62
+ }
40
63
  /**
41
64
  * The distribution's own alias names, normalised — consumed by the
42
65
  * origin-loop guard and by the ECS origin resolver (auto-resolution excludes
@@ -59,7 +82,10 @@ function collectAliasNames(props) {
59
82
  * Validates CDN props — synth-time hard errors for shapes CloudFormation
60
83
  * would only reject at deploy.
61
84
  */
62
- function validateCdnProps(props) {
85
+ function validateCdnProps(id, props) {
86
+ // The pattern rebuilds the distribution props field by field, so the
87
+ // resource-layer refusal never sees a legacy key given here.
88
+ assertNoLegacyLoggingProps(`CDN '${id}'`, props, "logging: { bucket?, prefix?, format? } | false");
63
89
  const hasCertificate = !!(props.certificate || props.certificateArn);
64
90
  // domainConfig is the managed-domain authority for the viewer config —
65
91
  // mixing it with the literal surface would give the alias list and the
@@ -251,7 +277,7 @@ const ROUTING_CDN_BEHAVIOUR = {
251
277
  };
252
278
  export class Cdn extends CloudFrontDistribution {
253
279
  constructor(scope, id, props) {
254
- validateCdnProps(props);
280
+ validateCdnProps(id, props);
255
281
  // Zone + certificate resolve BEFORE super() — the viewer certificate is
256
282
  // part of the distribution's props — so their constructs land on the CDN
257
283
  // stack (`scope`) under `${id}`-prefixed IDs, the pattern convention.
@@ -476,6 +502,7 @@ export class Cdn extends CloudFrontDistribution {
476
502
  originRequestPolicy: props.originRequestPolicy
477
503
  }
478
504
  : {};
505
+ const logging = resolveCdnLogging(scope, id, props);
479
506
  return {
480
507
  distributionProps: {
481
508
  appName,
@@ -486,7 +513,7 @@ export class Cdn extends CloudFrontDistribution {
486
513
  domainNames,
487
514
  certificate,
488
515
  comment: props.comment,
489
- ...resolveCdnLogging(scope, id, props),
516
+ ...(logging !== undefined && { logging }),
490
517
  priceClass: props.priceClass,
491
518
  forwardHostHeader: props.forwardHostHeader ?? impliedForwardHostHeader,
492
519
  accessGate: props.accessGate,
@@ -699,7 +726,8 @@ export class CdnFactory {
699
726
  // The App is the only place the app name is known here; without it the
700
727
  // export falls back to the construct id and the Domain import misses.
701
728
  // The App instance itself rides along for domainConfig resolution
702
- // (certificate placement needs app.getUsEast1CertificateStack()).
729
+ // (certificate placement needs app.getUsEast1CertificateStack()) and
730
+ // for log-delivery placement (app.getUsEast1CdnLoggingStack()).
703
731
  return new Cdn(scope, id, {
704
732
  ...props,
705
733
  appName: props.appName ?? app.getName(),
@@ -99,9 +99,9 @@ export class Domain extends Construct {
99
99
  }
100
100
  }
101
101
  /**
102
- * Re-publish the domain's effective zone name under a predictable export
103
- * key so downstream consumers (Phase 2, Phase 3 eject) can resolve the
104
- * managed zone without knowing the registrar discriminant. The `Zone` and
102
+ * Re-publish the domain's effective zone name under a predictable output
103
+ * key (mirrored byte-for-byte by the eject template) so a stack reader can
104
+ * name the zone without knowing the registrar discriminant. The `Zone` and
105
105
  * `HostedZoneId` outputs on the inner `HostedZone` construct cover the
106
106
  * Route53-backed paths; this output covers external-records mode where no
107
107
  * hosted zone exists.
@@ -7,12 +7,15 @@ export interface ExternalRecordsPatternResult {
7
7
  }
8
8
  /**
9
9
  * Composition for `registrar: "external-records"`. No Route53 hosted zone is
10
- * created; certificates still use ACM DNS validation (the user must paste the
11
- * validation records at their external registrar) and any user-declared
12
- * records are emitted as `ManualRecord` entries with per-record `CfnOutput`s
13
- * so Phase 2 can resolve token values after deploy.
10
+ * created. Certificates use ACM DNS validation with no zone to write to, so
11
+ * their validation CNAMEs must be created at the external registrar — and
12
+ * those records do not exist at synth: ACM chooses them when CloudFormation
13
+ * creates the certificate, and `AWS::CertificateManager::Certificate`
14
+ * returns only its ARN. They are therefore never `ManualRecord` entries:
15
+ * they are read from ACM (`DescribeCertificate`) once the certificate is
16
+ * pending, while CloudFormation waits for the records to resolve.
14
17
  *
15
- * Ordering invariant: certificate-validation entries come first so the user
16
- * adds them before the app records (ACM can't issue until validation resolves).
18
+ * User-declared records become `ManualRecord` entries, each with a
19
+ * `CfnOutput` triple keyed by its position in `records`.
17
20
  */
18
21
  export declare function composeExternalRecords(scope: Construct, props: ExternalRecordsProps): ExternalRecordsPatternResult;
@@ -6,58 +6,35 @@ 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
9
- * created; certificates still use ACM DNS validation (the user must paste the
10
- * validation records at their external registrar) and any user-declared
11
- * records are emitted as `ManualRecord` entries with per-record `CfnOutput`s
12
- * so Phase 2 can resolve token values after deploy.
9
+ * created. Certificates use ACM DNS validation with no zone to write to, so
10
+ * their validation CNAMEs must be created at the external registrar — and
11
+ * those records do not exist at synth: ACM chooses them when CloudFormation
12
+ * creates the certificate, and `AWS::CertificateManager::Certificate`
13
+ * returns only its ARN. They are therefore never `ManualRecord` entries:
14
+ * they are read from ACM (`DescribeCertificate`) once the certificate is
15
+ * pending, while CloudFormation waits for the records to resolve.
13
16
  *
14
- * Ordering invariant: certificate-validation entries come first so the user
15
- * adds them before the app records (ACM can't issue until validation resolves).
17
+ * User-declared records become `ManualRecord` entries, each with a
18
+ * `CfnOutput` triple keyed by its position in `records`.
16
19
  */
17
20
  export function composeExternalRecords(scope, props) {
18
21
  const safeZone = toPascalCase(getSafeZoneName(props.zoneName));
19
22
  const certificates = new Map();
20
23
  const manualRecords = [];
21
- // 1. Certificates — ACM DNS validation emits CNAMEs the user must paste at
22
- // their registrar. Capture the validation records as ManualRecord
23
- // entries (cert-validation first per ordering invariant).
24
24
  const normalisedCerts = (props.certificates ?? []).map(normaliseCertificate);
25
25
  normalisedCerts.forEach((cert, index) => {
26
26
  const safeCertName = toPascalCase(cert.domainName.split(".").join(""));
27
27
  const acm = new AcmCertificate(scope, `${safeZone}${safeCertName}Cert${index}`, {
28
28
  domainName: cert.domainName,
29
29
  subjectAlternativeNames: cert.subjectAlternativeNames,
30
- // No hosted zone to validate against — ACM emits tokens for the user
31
- // to resolve via `describeStackResources` or deploy output.
32
30
  validation: CertificateValidation.fromDns(),
33
31
  transparencyLoggingEnabled: cert.transparencyLogging ?? true
34
32
  });
35
33
  certificates.set(cert.domainName, acm);
36
- const certNames = [
37
- cert.domainName,
38
- ...(cert.subjectAlternativeNames ?? [])
39
- ];
40
- certNames.forEach((name, nameIndex) => {
41
- // `domainValidationOption` returns a token CNAME name/value pair. Using
42
- // `attrDomainValidationOptions` returns an array token; per-name lookup
43
- // uses index. CDK exposes the first validation option via
44
- // `certificate.certificateArn` only — we fall back to emitting a
45
- // predictable CfnOutput pointing to the attribute for Phase 2.
46
- const record = {
47
- name: `_acme-challenge.${name}`,
48
- type: "CNAME",
49
- value: `<ACM-validation-token-for-${name}>`,
50
- ttl: MANUAL_RECORD_TTL_SECONDS,
51
- reason: "certificate-validation"
52
- };
53
- manualRecords.push(record);
54
- emitManualRecordOutputs(scope, safeZone, manualRecords.length - 1, record, `CertValidation${safeCertName}${nameIndex}`);
55
- });
56
34
  });
57
- // 2. User-declared records. Typed targets resolve to token strings via
58
- // `resolveTargetToDnsName`. Apex ALIAS was already rejected by
59
- // `validateDomainProps`, so all records here are safe to emit as manual
60
- // entries.
35
+ // Typed targets resolve to token strings via `resolveTargetToDnsName`.
36
+ // Apex ALIAS was already rejected by `validateDomainProps`, so all records
37
+ // here are safe to emit as manual entries.
61
38
  const records = props.records ?? [];
62
39
  records.forEach((record, index) => {
63
40
  const fqdn = resolveRecordFqdn(record.name, props.zoneName);
@@ -67,11 +44,10 @@ export function composeExternalRecords(scope, props) {
67
44
  name: fqdn,
68
45
  type: "CNAME",
69
46
  value,
70
- ttl: MANUAL_RECORD_TTL_SECONDS,
71
- reason: "user-declared"
47
+ ttl: MANUAL_RECORD_TTL_SECONDS
72
48
  };
73
49
  manualRecords.push(manual);
74
- emitManualRecordOutputs(scope, safeZone, manualRecords.length - 1, manual, `UserRecord${index}`);
50
+ emitManualRecordOutputs(scope, safeZone, index, manual);
75
51
  return;
76
52
  }
77
53
  const rawValue = record.value;
@@ -80,11 +56,10 @@ export function composeExternalRecords(scope, props) {
80
56
  name: fqdn,
81
57
  type: narrowManualType(record.type),
82
58
  value: joined,
83
- ttl: record.ttl ?? MANUAL_RECORD_TTL_SECONDS,
84
- reason: "user-declared"
59
+ ttl: record.ttl ?? MANUAL_RECORD_TTL_SECONDS
85
60
  };
86
61
  manualRecords.push(manual);
87
- emitManualRecordOutputs(scope, safeZone, manualRecords.length - 1, manual, `UserRecord${index}`);
62
+ emitManualRecordOutputs(scope, safeZone, index, manual);
88
63
  });
89
64
  return { certificates, manualRecords };
90
65
  }
@@ -115,17 +90,18 @@ function narrowManualType(type) {
115
90
  return "TXT";
116
91
  }
117
92
  }
118
- function emitManualRecordOutputs(scope, safeZone, index, record, discriminator) {
119
- new CfnOutput(scope, `${safeZone}ManualRecord${discriminator}Name`, {
93
+ function emitManualRecordOutputs(scope, safeZone, index, record) {
94
+ const idPrefix = `${safeZone}ManualRecordUserRecord${index}`;
95
+ new CfnOutput(scope, `${idPrefix}Name`, {
120
96
  key: `${safeZone}ManualRecord${index}Name`,
121
97
  value: record.name,
122
- description: `External-records manual DNS entry (${record.reason})`
98
+ description: "External-records manual DNS entry (user-declared)"
123
99
  });
124
- new CfnOutput(scope, `${safeZone}ManualRecord${discriminator}Type`, {
100
+ new CfnOutput(scope, `${idPrefix}Type`, {
125
101
  key: `${safeZone}ManualRecord${index}Type`,
126
102
  value: record.type
127
103
  });
128
- new CfnOutput(scope, `${safeZone}ManualRecord${discriminator}Value`, {
104
+ new CfnOutput(scope, `${idPrefix}Value`, {
129
105
  key: `${safeZone}ManualRecord${index}Value`,
130
106
  value: record.value
131
107
  });
@@ -158,15 +158,18 @@ export interface ExternalRecordsProps extends DomainCommonProps {
158
158
  }
159
159
  export type DomainProps = Route53ApexProps | ExternalDelegatedProps | ExternalRecordsProps;
160
160
  /**
161
- * Manual record emitted by `registrar: "external-records"` mode for the user
162
- * to paste at their external registrar. For typed targets the `value` is a
163
- * CloudFormation token (`Fn::ImportValue`) at synth time; Phase 2 resolves the
164
- * concrete value via `describeStackResources` after deploy.
161
+ * A user-declared record that `registrar: "external-records"` mode reports
162
+ * for the user to create at their external registrar, one per `records`
163
+ * entry, in declaration order. For typed targets the `value` is a
164
+ * CloudFormation token (`Fn::ImportValue`) at synth time; the concrete host
165
+ * is the deployed stack's `ManualRecord<index>Value` output.
166
+ *
167
+ * Certificate-validation records are never manual records: ACM chooses them
168
+ * at deploy time, so no synth-time value exists (see `composeExternalRecords`).
165
169
  */
166
170
  export interface ManualRecord {
167
171
  readonly name: string;
168
172
  readonly type: "A" | "AAAA" | "CNAME" | "TXT";
169
173
  readonly value: string;
170
174
  readonly ttl: number;
171
- readonly reason: "certificate-validation" | "user-declared";
172
175
  }