@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
@@ -0,0 +1,190 @@
1
+ import { CfnResource, Stack } from "aws-cdk-lib";
2
+ import { CfnEventBus, CfnRule } from "aws-cdk-lib/aws-events";
3
+ import { CfnQueue, QueuePolicy } from "aws-cdk-lib/aws-sqs";
4
+ /**
5
+ * SQS's queue-policy quotas.
6
+ * @see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-policies.html
7
+ */
8
+ export const QUEUE_POLICY_LIMITS = {
9
+ MAX_BYTES: 8192,
10
+ MAX_STATEMENTS: 20
11
+ };
12
+ /** CDK's own ARN estimate, and the context key that tunes it, for a reference this module cannot size. */
13
+ const ARN_SIZE_ESTIMATE_CONTEXT_KEY = "@aws-cdk/aws-iam.arnSizeEstimate";
14
+ const DEFAULT_ARN_SIZE_ESTIMATE = 150;
15
+ const PSEUDO_PARAMETER_MAX_LENGTH = {
16
+ "AWS::AccountId": 12,
17
+ "AWS::Partition": 10,
18
+ "AWS::Region": 16,
19
+ "AWS::StackName": 128,
20
+ "AWS::URLSuffix": 16
21
+ };
22
+ const RULE_NAME_MAX_LENGTH = 64;
23
+ const QUEUE_NAME_MAX_LENGTH = 80;
24
+ const EVENT_BUS_NAME_MAX_LENGTH = 256;
25
+ /** `arn:<partition>:<service>:<region>:<account>:` at its longest, without the service. */
26
+ const ARN_PREFIX_MAX_LENGTH = "arn:::::".length +
27
+ PSEUDO_PARAMETER_MAX_LENGTH["AWS::Partition"] +
28
+ PSEUDO_PARAMETER_MAX_LENGTH["AWS::Region"] +
29
+ PSEUDO_PARAMETER_MAX_LENGTH["AWS::AccountId"];
30
+ /**
31
+ * Refuse, at synthesis, a queue policy SQS would refuse at deploy.
32
+ *
33
+ * Every grant to a queue — an EventBridge rule, an SNS subscription, an S3
34
+ * notification — lands in the queue's one policy, and SQS rejects the whole
35
+ * policy past its byte or statement quota. CloudFormation only finds out
36
+ * mid-deploy and rolls the stack back, so the check runs where the growth is
37
+ * authored.
38
+ *
39
+ * The byte count is an upper bound: an ARN CloudFormation has not minted yet
40
+ * is sized at the longest its partition, region, account and name can be, so
41
+ * the refusal can arrive a few grants before SQS's would. A reference whose
42
+ * shape is not known here takes CDK's ARN estimate, tunable through the
43
+ * `@aws-cdk/aws-iam.arnSizeEstimate` context key.
44
+ */
45
+ export function validateQueuePolicyQuota(queue) {
46
+ const policy = queue.node.children.find((child) => child instanceof QueuePolicy);
47
+ if (policy === undefined)
48
+ return [];
49
+ const stack = Stack.of(queue);
50
+ const document = stack.resolve(policy.document);
51
+ const statements = isRecord(document) ? document.Statement : undefined;
52
+ const statementCount = Array.isArray(statements) ? statements.length : 0;
53
+ const estimator = new PolicySizeEstimator(stack);
54
+ const bytes = estimator.jsonLength(document);
55
+ const errors = [];
56
+ if (statementCount > QUEUE_POLICY_LIMITS.MAX_STATEMENTS) {
57
+ errors.push(`The resource policy on queue ${queue.node.path} has ${statementCount} statements; SQS allows at most ${QUEUE_POLICY_LIMITS.MAX_STATEMENTS}. Split the grants across queues.`);
58
+ }
59
+ if (bytes > QUEUE_POLICY_LIMITS.MAX_BYTES) {
60
+ const unsized = estimator.unsizedReferences === 0
61
+ ? ""
62
+ : ` ${estimator.unsizedReferences} reference(s) this check cannot size are counted at ${estimator.unsizedReferenceBytes} bytes each; if those ARNs are shorter, lower the "${ARN_SIZE_ESTIMATE_CONTEXT_KEY}" context.`;
63
+ errors.push(`The resource policy on queue ${queue.node.path} may reach ${bytes} bytes; SQS refuses a policy over ${QUEUE_POLICY_LIMITS.MAX_BYTES}. Every EventBridge rule, SNS topic or other source granted this queue adds to the one policy — split them across queues.${unsized}`);
64
+ }
65
+ return errors;
66
+ }
67
+ class PolicySizeEstimator {
68
+ #stack;
69
+ #resources;
70
+ #unsizedReferences = 0;
71
+ constructor(stack) {
72
+ this.#stack = stack;
73
+ }
74
+ /** How many references this estimator has counted at the ARN estimate. */
75
+ get unsizedReferences() {
76
+ return this.#unsizedReferences;
77
+ }
78
+ get unsizedReferenceBytes() {
79
+ const configured = this.#stack.node.tryGetContext(ARN_SIZE_ESTIMATE_CONTEXT_KEY);
80
+ return typeof configured === "number" && configured > 0
81
+ ? configured
82
+ : DEFAULT_ARN_SIZE_ESTIMATE;
83
+ }
84
+ /** Bytes of `value` serialised as compact JSON, with each intrinsic sized as the string it deploys as. */
85
+ jsonLength(value) {
86
+ if (Array.isArray(value)) {
87
+ return (2 +
88
+ Math.max(value.length - 1, 0) +
89
+ value.reduce((sum, item) => sum + this.jsonLength(item), 0));
90
+ }
91
+ if (isRecord(value)) {
92
+ const intrinsic = this.#intrinsicLength(value);
93
+ if (intrinsic !== undefined)
94
+ return intrinsic + 2;
95
+ const entries = Object.entries(value);
96
+ return (2 +
97
+ Math.max(entries.length - 1, 0) +
98
+ entries.reduce((sum, [key, item]) => sum + utf8Length(JSON.stringify(key)) + 1 + this.jsonLength(item), 0));
99
+ }
100
+ return utf8Length(JSON.stringify(value) ?? "null");
101
+ }
102
+ #intrinsicLength(value) {
103
+ const keys = Object.keys(value);
104
+ if (keys.length !== 1)
105
+ return undefined;
106
+ const [name] = keys;
107
+ const argument = value[name];
108
+ if (name === "Ref" && typeof argument === "string") {
109
+ return Object.hasOwn(PSEUDO_PARAMETER_MAX_LENGTH, argument)
110
+ ? PSEUDO_PARAMETER_MAX_LENGTH[argument]
111
+ : this.#refLength(argument);
112
+ }
113
+ if (name === "Fn::Join" && Array.isArray(argument)) {
114
+ const [separator, parts] = argument;
115
+ if (typeof separator === "string" && Array.isArray(parts)) {
116
+ return (separator.length * Math.max(parts.length - 1, 0) +
117
+ parts.reduce((sum, part) => sum + this.#stringLength(part), 0));
118
+ }
119
+ }
120
+ if (name === "Fn::GetAtt" && Array.isArray(argument)) {
121
+ const [logicalId, attribute] = argument;
122
+ if (typeof logicalId === "string" && attribute === "Arn") {
123
+ return this.#arnLength(logicalId);
124
+ }
125
+ }
126
+ return name.startsWith("Fn::") ? this.#unknownLength() : undefined;
127
+ }
128
+ #stringLength(value) {
129
+ if (typeof value === "string")
130
+ return utf8Length(value);
131
+ const intrinsic = isRecord(value)
132
+ ? this.#intrinsicLength(value)
133
+ : undefined;
134
+ return intrinsic ?? this.#unknownLength();
135
+ }
136
+ #arnLength(logicalId) {
137
+ const resource = this.#resourceByLogicalId(logicalId);
138
+ if (resource instanceof CfnRule) {
139
+ const busName = resource.eventBusName;
140
+ const bus = busName === undefined
141
+ ? 0
142
+ : this.#stringLength(this.#stack.resolve(busName)) + 1;
143
+ return (ARN_PREFIX_MAX_LENGTH +
144
+ "events".length +
145
+ "rule/".length +
146
+ bus +
147
+ this.#nameLength(resource.name, RULE_NAME_MAX_LENGTH));
148
+ }
149
+ if (resource instanceof CfnQueue) {
150
+ return (ARN_PREFIX_MAX_LENGTH +
151
+ "sqs".length +
152
+ this.#nameLength(resource.queueName, QUEUE_NAME_MAX_LENGTH));
153
+ }
154
+ return this.#unknownLength();
155
+ }
156
+ /** `Ref` on an event bus returns its name; any other resource's `Ref` is not sized here. */
157
+ #refLength(logicalId) {
158
+ const resource = this.#resourceByLogicalId(logicalId);
159
+ return resource instanceof CfnEventBus
160
+ ? this.#nameLength(resource.name, EVENT_BUS_NAME_MAX_LENGTH)
161
+ : this.#unknownLength();
162
+ }
163
+ /** A name as deployed, or `bound` when CloudFormation will generate it or it resolves to an intrinsic. */
164
+ #nameLength(value, bound) {
165
+ if (value === undefined)
166
+ return bound;
167
+ const resolved = this.#stack.resolve(value);
168
+ return typeof resolved === "string" ? utf8Length(resolved) : bound;
169
+ }
170
+ #resourceByLogicalId(logicalId) {
171
+ if (this.#resources === undefined) {
172
+ const stack = this.#stack;
173
+ this.#resources = new Map(stack.node
174
+ .findAll()
175
+ .filter((construct) => construct instanceof CfnResource && Stack.of(construct) === stack)
176
+ .map((resource) => [stack.resolve(resource.logicalId), resource]));
177
+ }
178
+ return this.#resources.get(logicalId);
179
+ }
180
+ #unknownLength() {
181
+ this.#unsizedReferences++;
182
+ return this.unsizedReferenceBytes;
183
+ }
184
+ }
185
+ function utf8Length(value) {
186
+ return Buffer.byteLength(value, "utf8");
187
+ }
188
+ function isRecord(value) {
189
+ return typeof value === "object" && value !== null && !Array.isArray(value);
190
+ }
@@ -31,14 +31,14 @@ export interface ScheduleProps {
31
31
  /**
32
32
  * JSON payload sent to the target on every tick (D5 — typed as
33
33
  * `Record<string, unknown>`). Translates to `message` (SQS) or `event`
34
- * (Lambda / EventBus) on the underlying adapter. Values may be
35
- * `EventField.*` tokens for payload-template substitution.
36
- * `scheduledAt: EventField.time` is auto-injected unless the caller
37
- * already provides a `scheduledAt` key.
34
+ * (Lambda) on the underlying adapter. Values may be `EventField.*` tokens
35
+ * for payload-template substitution. `scheduledAt: EventField.time` is
36
+ * auto-injected unless the caller already provides a `scheduledAt` key.
38
37
  *
39
38
  * Not supported on ECS targets — CDK's `EcsTask` adapter ignores arbitrary
40
39
  * input; use container command/environment overrides on the task definition
41
- * instead. Passing `payload` against an ECS target throws.
40
+ * instead. Nor on event-bus targets, which forward the scheduled event
41
+ * unchanged. Passing `payload` against either throws.
42
42
  *
43
43
  * @example
44
44
  * app.addSchedule("MetricsCollection", {
@@ -4,6 +4,7 @@ import { toPascalCase } from "../../../utils/capitaliseString.js";
4
4
  import { Queue, QueueEncryption, FifoThroughputLimit, DeduplicationScope } from "aws-cdk-lib/aws-sqs";
5
5
  import { toRemovalPolicy } from "./utils.js";
6
6
  import { createSqsDlqAlarms } from "../monitoring/index.js";
7
+ import { validateQueuePolicyQuota } from "./queuePolicyQuota.js";
7
8
  /**
8
9
  * AWS SQS service limits and defaults.
9
10
  * @see https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-quotas.html
@@ -169,6 +170,9 @@ export class SQSQueue extends Construct {
169
170
  : undefined,
170
171
  removalPolicy: resolvedRemovalPolicy
171
172
  });
173
+ this.node.addValidation({
174
+ validate: () => [this.queue, this.dlq].flatMap((queue) => queue === undefined ? [] : validateQueuePolicyQuota(queue))
175
+ });
172
176
  new CfnOutput(this, `${outputName}QueueUrl`, {
173
177
  key: `${outputName}QueueUrl`,
174
178
  value: this.queue.queueUrl,
@@ -30,15 +30,16 @@ export interface SubscriptionProps {
30
30
  /**
31
31
  * Optional JSON payload that overrides the matched event detail before
32
32
  * delivery to the target (D5 — typed as `Record<string, unknown>`).
33
- * Translates to `message` (SQS) or `event` (Lambda / EventBus / CodeBuild)
34
- * on the underlying adapter. Values may be `EventField.*` tokens for
33
+ * Translates to `message` (SQS) or `event` (Lambda / CodeBuild) on the
34
+ * underlying adapter. Values may be `EventField.*` tokens for
35
35
  * payload-template substitution (e.g.
36
36
  * `EventField.fromPath("$.detail.repository.name")`). When omitted, the
37
37
  * full matched event is forwarded verbatim — the common pub/sub case.
38
38
  *
39
39
  * Not supported on ECS targets — CDK's `EcsTask` adapter ignores arbitrary
40
40
  * input; use container command/environment overrides on the task definition
41
- * instead. Passing `payload` against an ECS target throws.
41
+ * instead. Nor on log-group targets, whose metric filters read the matched
42
+ * event untransformed. Passing `payload` against either throws.
42
43
  */
43
44
  payload?: Record<string, unknown>;
44
45
  /**
@@ -10,6 +10,14 @@ export type KeyProtectionScope = (typeof KEY_PROTECTION_SCOPES)[number];
10
10
  * forgotten (tier-aware alert policy design §8 K4, `KMS_KEY_ORPHAN`).
11
11
  */
12
12
  export { KMS_RETENTION_TAG_KEY } from "@fjall/util/aws";
13
+ /**
14
+ * Stamped beside the retention marker, valued with the alias name as `kms
15
+ * list-aliases` reports it. The alias resource goes with the stack and
16
+ * CloudFormation tags nothing on a KMS key, so after a stack delete this tag
17
+ * is the only thing that still says which stack's key a retained key was —
18
+ * the deploy-test teardown releases retained stack keys by it.
19
+ */
20
+ export { KMS_ALIAS_TAG_KEY } from "@fjall/util/aws";
13
21
  interface CustomerManagedKeyProps {
14
22
  description?: string;
15
23
  aliasName?: string;
@@ -1,4 +1,4 @@
1
- import { KMS_RETENTION_TAG_KEY } from "@fjall/util/aws";
1
+ import { KMS_ALIAS_TAG_KEY, KMS_RETENTION_TAG_KEY } from "@fjall/util/aws";
2
2
  import { CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
3
3
  import { Alias, Key } from "aws-cdk-lib/aws-kms";
4
4
  import { Construct } from "constructs";
@@ -17,6 +17,14 @@ export const KEY_PROTECTION_SCOPES = [
17
17
  * forgotten (tier-aware alert policy design §8 K4, `KMS_KEY_ORPHAN`).
18
18
  */
19
19
  export { KMS_RETENTION_TAG_KEY } from "@fjall/util/aws";
20
+ /**
21
+ * Stamped beside the retention marker, valued with the alias name as `kms
22
+ * list-aliases` reports it. The alias resource goes with the stack and
23
+ * CloudFormation tags nothing on a KMS key, so after a stack delete this tag
24
+ * is the only thing that still says which stack's key a retained key was —
25
+ * the deploy-test teardown releases retained stack keys by it.
26
+ */
27
+ export { KMS_ALIAS_TAG_KEY } from "@fjall/util/aws";
20
28
  /**
21
29
  * How long a scheduled-for-deletion key stays recoverable.
22
30
  *
@@ -52,8 +60,10 @@ export class CustomerManagedKey extends Construct {
52
60
  ? PENDING_DELETION_WINDOW
53
61
  : undefined
54
62
  });
63
+ const aliasName = props.aliasName || ResourceNaming.defaultKeyAlias(id);
55
64
  if (removalPolicy === RemovalPolicy.RETAIN) {
56
65
  Tags.of(this.key).add(KMS_RETENTION_TAG_KEY, props.protects);
66
+ Tags.of(this.key).add(KMS_ALIAS_TAG_KEY, ResourceNaming.qualifiedKeyAlias(aliasName));
57
67
  }
58
68
  new CfnOutput(this, `${outputName}KeyArn`, {
59
69
  key: `${outputName}Arn`,
@@ -61,7 +71,7 @@ export class CustomerManagedKey extends Construct {
61
71
  exportName: stackScopedExportName(this, `${outputName}KeyArn`)
62
72
  });
63
73
  this.alias = new Alias(this, `${id}KeyAlias`, {
64
- aliasName: props.aliasName || ResourceNaming.defaultKeyAlias(id),
74
+ aliasName,
65
75
  targetKey: this.key
66
76
  });
67
77
  new CfnOutput(this, `${outputName}KeyAliasArn`, {
@@ -1,8 +1,9 @@
1
1
  import { Stack } from "aws-cdk-lib";
2
2
  import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
3
3
  import { tierDefault } from "../../../utils/backupTierMapping.js";
4
+ import { ResourceNaming } from "../../../utils/resourceNaming.js";
4
5
  import { CustomerManagedKey } from "./kms.js";
5
- const STACK_ENCRYPTION_KEY_ID = "FjallStackEncryptionKey";
6
+ const STACK_ENCRYPTION_KEY_ID = ResourceNaming.stackEncryptionKeyConstructId();
6
7
  /**
7
8
  * AWS services that publish to an application's SNS topics on the app's
8
9
  * behalf and therefore encrypt messages through the topic's key: EventBridge
@@ -48,9 +49,10 @@ export class StackEncryptionKey extends CustomerManagedKey {
48
49
  constructor(stack) {
49
50
  super(stack, STACK_ENCRYPTION_KEY_ID, {
50
51
  description: `Encrypts the enterprise-tier resources of ${stack.stackName} (DynamoDB tables, SNS topics, Lambda environment, ECS Exec sessions, log groups)`,
51
- // KMS alias names are unique per account-region, so the stack name is
52
- // the namespace — every stack in the account carries its own key.
53
- aliasName: `cmk/${stack.stackName}/${STACK_ENCRYPTION_KEY_ID}`,
52
+ // Derived through ResourceNaming so the deploy-test teardown, which
53
+ // finds the retained key by this alias after the stack is gone, cannot
54
+ // spell it differently.
55
+ aliasName: ResourceNaming.stackEncryptionKeyAlias(stack.stackName),
54
56
  protects: "outlives-stack"
55
57
  });
56
58
  }
@@ -1,22 +1,17 @@
1
- import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, type BackupTier, type BackupTierDefaults, tierDefault } from "@fjall/util";
2
- /** Tag key used by AWS Backup plan selection rules */
3
- export declare const BACKUP_TIER_TAG_KEY = "fjall:disasterRecovery:tier";
1
+ import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, type BackupTier, type BackupTierDefaults, tierDefault } from "@fjall/util";
4
2
  /**
5
- * The application backup tier vocabulary and its defaults table are owned by
6
- * `@fjall/util` (`BACKUP_TIERS`, `BACKUP_TIER_DEFAULTS`, `tierDefault`) and
7
- * shared with the generator (`BACKUP_VAULT_TIERS`) and the CLI's synth
8
- * expectations, so every system derives from one declaration. Re-exported
9
- * here so construct code keeps one import path for tier concerns.
3
+ * The application backup tier vocabulary, its defaults table, the DR tag key
4
+ * and the tier → tag-value map are owned by `@fjall/util` (`BACKUP_TIERS`,
5
+ * `BACKUP_TIER_DEFAULTS`, `tierDefault`, `BACKUP_TIER_TAG_KEY`,
6
+ * `BACKUP_TIER_TAG_MAP`) and shared with the generator (`BACKUP_VAULT_TIERS`),
7
+ * the CLI's synth expectations and the webapp's posture rules (which read the
8
+ * tag back from discovered resources), so every system derives from one
9
+ * declaration. Re-exported here so construct code keeps one import path for
10
+ * tier concerns: `App.applyBackupTier()` (app-level tag), `StandardTagsAspect`
11
+ * (per-resource tag) and `BackupPlan` (selection key) all read the same pair.
10
12
  */
11
13
  export type { BackupTier, BackupTierDefaults };
12
- export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, tierDefault };
13
- /**
14
- * Shared mapping from application backup tier names to AWS Backup plan tag values.
15
- *
16
- * Used by both App.applyBackupTier() (app-level tag) and StandardTagsAspect
17
- * (per-resource tag) to ensure consistent tier → tag translation.
18
- */
19
- export declare const BACKUP_TIER_TAG_MAP: Readonly<Record<BackupTier, string>>;
14
+ export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault };
20
15
  /**
21
16
  * The tier a resource actually runs under: its own override, else the
22
17
  * app-level tier `App.getApp({ backup: { tier } })` declared. The single
@@ -1,18 +1,5 @@
1
- import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, tierDefault } from "@fjall/util";
2
- /** Tag key used by AWS Backup plan selection rules */
3
- export const BACKUP_TIER_TAG_KEY = "fjall:disasterRecovery:tier";
4
- export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, tierDefault };
5
- /**
6
- * Shared mapping from application backup tier names to AWS Backup plan tag values.
7
- *
8
- * Used by both App.applyBackupTier() (app-level tag) and StandardTagsAspect
9
- * (per-resource tag) to ensure consistent tier → tag translation.
10
- */
11
- export const BACKUP_TIER_TAG_MAP = Object.freeze({
12
- standard: "default",
13
- resilient: "resilient",
14
- enterprise: "enterprise"
15
- });
1
+ import { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault } from "@fjall/util";
2
+ export { BACKUP_TIERS, BACKUP_TIER_DEFAULTS, BACKUP_TIER_TAG_KEY, BACKUP_TIER_TAG_MAP, tierDefault };
16
3
  /**
17
4
  * The tier a resource actually runs under: its own override, else the
18
5
  * app-level tier `App.getApp({ backup: { tier } })` declared. The single
@@ -1,5 +1,6 @@
1
1
  import type { Node } from "constructs";
2
2
  import { ACCOUNT_TRAIL_STATES, type AccountTrailState } from "@fjall/util/config";
3
+ import { type BackupVaultVerdict } from "@fjall/util";
3
4
  export declare const CDK_CONTEXT_KEYS: {
4
5
  readonly ORG_ID: "orgId";
5
6
  readonly ROOT_ID: "rootId";
@@ -7,6 +8,7 @@ export declare const CDK_CONTEXT_KEYS: {
7
8
  readonly ORG_CONFIG: "orgConfig";
8
9
  readonly ACCOUNT_TRAIL_STATE: "fjallAccountTrailState";
9
10
  readonly DEV_OU_ID: "fjallDevOuId";
11
+ readonly BACKUP_VAULT: "fjallBackupVault";
10
12
  };
11
13
  export { ACCOUNT_TRAIL_STATES, type AccountTrailState };
12
14
  /**
@@ -16,6 +18,18 @@ export { ACCOUNT_TRAIL_STATES, type AccountTrailState };
16
18
  * mid-decommission must fail the synth, not silently re-create a trail.
17
19
  */
18
20
  export declare function resolveAccountTrailState(node: Node): AccountTrailState;
21
+ /**
22
+ * Reads the backup-vault ownership verdict the deploy engine injects before
23
+ * synth (`-c fjallBackupVault=<json>`; the wire contract is
24
+ * `BackupVaultVerdictSchema` in @fjall/util). Unset, or the `-c key=`
25
+ * empty-string boundary, is `absent` — the create template, which is what a
26
+ * synth with no engine in front of it (a local `cdk synth`, a construct test)
27
+ * has always produced. Malformed context is a LOUD synth error, never a
28
+ * default: a mis-read verdict would either re-CREATE a colliding fixed-name
29
+ * vault or declare a re-owned vault against the wrong key, and
30
+ * `EncryptionKeyArn` is create-only, so that second mistake replaces the vault.
31
+ */
32
+ export declare function readBackupVaultVerdict(node: Node): BackupVaultVerdict;
19
33
  export declare const DEFAULT_ORG_ID: "default";
20
34
  export declare function resolveOrgId(node: Node, fallback: string): string;
21
35
  export declare function resolveOrgId(node: Node): string | undefined;
@@ -1,11 +1,13 @@
1
1
  import { ACCOUNT_TRAIL_STATES } from "@fjall/util/config";
2
+ import { BACKUP_VAULT_CONTEXT_KEY, BackupVaultVerdictSchema } from "@fjall/util";
2
3
  export const CDK_CONTEXT_KEYS = {
3
4
  ORG_ID: "orgId",
4
5
  ROOT_ID: "rootId",
5
6
  MANAGEMENT_ACCOUNT_ID: "managementAccountId",
6
7
  ORG_CONFIG: "orgConfig",
7
8
  ACCOUNT_TRAIL_STATE: "fjallAccountTrailState",
8
- DEV_OU_ID: "fjallDevOuId"
9
+ DEV_OU_ID: "fjallDevOuId",
10
+ BACKUP_VAULT: BACKUP_VAULT_CONTEXT_KEY
9
11
  };
10
12
  export { ACCOUNT_TRAIL_STATES };
11
13
  /**
@@ -25,6 +27,41 @@ export function resolveAccountTrailState(node) {
25
27
  }
26
28
  throw new Error(`${CDK_CONTEXT_KEYS.ACCOUNT_TRAIL_STATE} must be one of ${ACCOUNT_TRAIL_STATES.join(", ")}; received "${String(raw)}"`);
27
29
  }
30
+ const ABSENT_BACKUP_VAULT = { state: "absent" };
31
+ /**
32
+ * Reads the backup-vault ownership verdict the deploy engine injects before
33
+ * synth (`-c fjallBackupVault=<json>`; the wire contract is
34
+ * `BackupVaultVerdictSchema` in @fjall/util). Unset, or the `-c key=`
35
+ * empty-string boundary, is `absent` — the create template, which is what a
36
+ * synth with no engine in front of it (a local `cdk synth`, a construct test)
37
+ * has always produced. Malformed context is a LOUD synth error, never a
38
+ * default: a mis-read verdict would either re-CREATE a colliding fixed-name
39
+ * vault or declare a re-owned vault against the wrong key, and
40
+ * `EncryptionKeyArn` is create-only, so that second mistake replaces the vault.
41
+ */
42
+ export function readBackupVaultVerdict(node) {
43
+ const raw = node.tryGetContext(CDK_CONTEXT_KEYS.BACKUP_VAULT);
44
+ if (raw === undefined || raw === "") {
45
+ return ABSENT_BACKUP_VAULT;
46
+ }
47
+ let value = raw;
48
+ if (typeof raw === "string") {
49
+ // `-c key=value` always arrives as a string; tests may set the object form.
50
+ try {
51
+ value = JSON.parse(raw);
52
+ }
53
+ catch (error) {
54
+ throw new Error(`${CDK_CONTEXT_KEYS.BACKUP_VAULT} context is not valid JSON: ` +
55
+ `${error instanceof Error ? error.message : String(error)}`, { cause: error });
56
+ }
57
+ }
58
+ const parsed = BackupVaultVerdictSchema.safeParse(value);
59
+ if (!parsed.success) {
60
+ throw new Error(`${CDK_CONTEXT_KEYS.BACKUP_VAULT} context is malformed — refusing to ` +
61
+ `synth a backup vault whose ownership is unknown: ${parsed.error.message}`);
62
+ }
63
+ return parsed.data;
64
+ }
28
65
  export const DEFAULT_ORG_ID = "default";
29
66
  export function resolveOrgId(node, fallback) {
30
67
  const raw = node.tryGetContext(CDK_CONTEXT_KEYS.ORG_ID);
@@ -77,6 +77,28 @@ export declare class ResourceNaming {
77
77
  * reports.
78
78
  */
79
79
  static defaultKeyAlias(constructId: string): string;
80
+ /**
81
+ * Construct id of the one `StackEncryptionKey` an enterprise-tier stack
82
+ * mints (lib/resources/aws/secrets/stackEncryptionKey.ts). It is also the
83
+ * alias suffix, so the two are spelled once.
84
+ */
85
+ static stackEncryptionKeyConstructId(): string;
86
+ /**
87
+ * Alias the stack encryption key is created under. KMS alias names are
88
+ * unique per account-region, so the stack name is the namespace. Unprefixed
89
+ * like the other alias helpers; the deploy tests that find the retained key
90
+ * after its stack is gone derive the reported form through
91
+ * [[qualifiedKeyAlias]], the same way the construct's alias tag does.
92
+ */
93
+ static stackEncryptionKeyAlias(stackName: string): string;
94
+ /**
95
+ * An alias name as KMS reports it. The CDK `Alias` prepends `alias/` to a
96
+ * resolved name that lacks it, and to a token only when the token's
97
+ * leading literal fragment lacks it — every Fjall alias starts with the
98
+ * literal `cmk/`, so the two rules agree. An already-qualified name is
99
+ * returned as it is.
100
+ */
101
+ static qualifiedKeyAlias(aliasName: string): string;
80
102
  /**
81
103
  * S3 bucket holding a VPC's flow logs. `id` is the Network construct id —
82
104
  * the network name for an additional VPC, `${stackPrefix}Vpc` for the
@@ -1,6 +1,8 @@
1
1
  import { toKebab } from "./capitaliseString.js";
2
2
  /** ELBv2's hard limit on an Application Load Balancer's name. */
3
3
  const LOAD_BALANCER_NAME_MAX_LENGTH = 32;
4
+ /** What `kms list-aliases` prepends to every alias name. */
5
+ const KMS_ALIAS_PREFIX = "alias/";
4
6
  /**
5
7
  * Centralised resource naming for infrastructure.
6
8
  * Ensures consistent, predictable names across constructs and tests.
@@ -127,6 +129,36 @@ export class ResourceNaming {
127
129
  static defaultKeyAlias(constructId) {
128
130
  return `cmk/${constructId}`;
129
131
  }
132
+ /**
133
+ * Construct id of the one `StackEncryptionKey` an enterprise-tier stack
134
+ * mints (lib/resources/aws/secrets/stackEncryptionKey.ts). It is also the
135
+ * alias suffix, so the two are spelled once.
136
+ */
137
+ static stackEncryptionKeyConstructId() {
138
+ return "FjallStackEncryptionKey";
139
+ }
140
+ /**
141
+ * Alias the stack encryption key is created under. KMS alias names are
142
+ * unique per account-region, so the stack name is the namespace. Unprefixed
143
+ * like the other alias helpers; the deploy tests that find the retained key
144
+ * after its stack is gone derive the reported form through
145
+ * [[qualifiedKeyAlias]], the same way the construct's alias tag does.
146
+ */
147
+ static stackEncryptionKeyAlias(stackName) {
148
+ return `cmk/${stackName}/${ResourceNaming.stackEncryptionKeyConstructId()}`;
149
+ }
150
+ /**
151
+ * An alias name as KMS reports it. The CDK `Alias` prepends `alias/` to a
152
+ * resolved name that lacks it, and to a token only when the token's
153
+ * leading literal fragment lacks it — every Fjall alias starts with the
154
+ * literal `cmk/`, so the two rules agree. An already-qualified name is
155
+ * returned as it is.
156
+ */
157
+ static qualifiedKeyAlias(aliasName) {
158
+ return aliasName.startsWith(KMS_ALIAS_PREFIX)
159
+ ? aliasName
160
+ : `${KMS_ALIAS_PREFIX}${aliasName}`;
161
+ }
130
162
  /**
131
163
  * S3 bucket holding a VPC's flow logs. `id` is the Network construct id —
132
164
  * the network name for an additional VPC, `${stackPrefix}Vpc` for the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "36.0.0",
3
+ "version": "38.0.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": "^36.0.0",
84
- "@fjall/util": "^36.0.0",
83
+ "@fjall/generator": "^38.0.0",
84
+ "@fjall/util": "^38.0.0",
85
85
  "constructs": "^10.7.2",
86
86
  "zod": "^4.4.3"
87
87
  },