@fjall/components-infrastructure 37.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 (37) 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/utils/cdkContext.d.ts +14 -0
  36. package/dist/lib/utils/cdkContext.js +38 -1
  37. 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
  /**
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "37.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": "^37.0.0",
84
- "@fjall/util": "^37.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
  },