@fjall/components-infrastructure 27.0.0 → 28.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 (36) hide show
  1. package/dist/lib/app.js +8 -1
  2. package/dist/lib/config/aws/alarmTopic.js +20 -1
  3. package/dist/lib/config/aws/configRecorder.js +16 -5
  4. package/dist/lib/patterns/aws/computeLambda.d.ts +4 -1
  5. package/dist/lib/patterns/aws/computeLambda.js +4 -1
  6. package/dist/lib/patterns/aws/database.d.ts +11 -6
  7. package/dist/lib/patterns/aws/database.js +12 -3
  8. package/dist/lib/patterns/aws/interfaces/database.d.ts +6 -3
  9. package/dist/lib/patterns/aws/storage.d.ts +6 -0
  10. package/dist/lib/patterns/aws/storage.js +3 -0
  11. package/dist/lib/resources/aws/compute/applicationLoadBalancer.js +3 -0
  12. package/dist/lib/resources/aws/compute/ecs.js +11 -2
  13. package/dist/lib/resources/aws/compute/lambda.d.ts +46 -0
  14. package/dist/lib/resources/aws/compute/lambda.js +100 -2
  15. package/dist/lib/resources/aws/compute/listenerRouting.js +6 -2
  16. package/dist/lib/resources/aws/database/dynamodb.d.ts +1 -0
  17. package/dist/lib/resources/aws/database/dynamodb.js +14 -1
  18. package/dist/lib/resources/aws/database/rdsAurora.d.ts +8 -0
  19. package/dist/lib/resources/aws/database/rdsAurora.js +18 -1
  20. package/dist/lib/resources/aws/database/rdsAuroraGlobal.d.ts +7 -0
  21. package/dist/lib/resources/aws/database/rdsAuroraGlobal.js +15 -0
  22. package/dist/lib/resources/aws/database/rdsInstance.d.ts +15 -7
  23. package/dist/lib/resources/aws/database/rdsInstance.js +21 -6
  24. package/dist/lib/resources/aws/logging/cloudTrail.js +12 -3
  25. package/dist/lib/resources/aws/messaging/eventBridgeRule.js +5 -2
  26. package/dist/lib/resources/aws/messaging/sns.js +7 -0
  27. package/dist/lib/resources/aws/messaging/sqs.js +2 -0
  28. package/dist/lib/resources/aws/networking/vpc.js +13 -2
  29. package/dist/lib/resources/aws/secrets/parameter.js +9 -7
  30. package/dist/lib/resources/aws/secrets/secret.js +5 -1
  31. package/dist/lib/resources/aws/storage/s3.d.ts +13 -0
  32. package/dist/lib/resources/aws/storage/s3.js +34 -7
  33. package/dist/lib/resources/aws/utilities/codeBuild.js +12 -1
  34. package/dist/lib/utils/costAllocationTags.d.ts +2 -2
  35. package/dist/lib/utils/costAllocationTags.js +2 -2
  36. package/package.json +3 -3
package/dist/lib/app.js CHANGED
@@ -24,7 +24,7 @@ import { FjallLogger } from "./utils/validationLogger.js";
24
24
  import { getManifestCollector, writeManifest } from "./utils/manifestWriter.js";
25
25
  import { resetDnsRecordRegistry } from "./utils/dnsRecordRegistry.js";
26
26
  import { toPascalCase, toKebab } from "./utils/capitaliseString.js";
27
- import { COST_ALLOCATION_TAGS } from "./utils/costAllocationTags.js";
27
+ import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_OWNER } from "./utils/costAllocationTags.js";
28
28
  import { DEFAULT_ORG_ID, resolveOrgId } from "./utils/cdkContext.js";
29
29
  /**
30
30
  * The basic corner-stone of all Fjall-hosted applications.
@@ -792,6 +792,13 @@ export class App extends CdkApp {
792
792
  this.globalTags[COST_ALLOCATION_TAGS.OWNER] = orgId;
793
793
  }
794
794
  }
795
+ // Last-resort owner: without it, apps built directly on the construct
796
+ // library (no generator addTags block, no orgId context) ship every
797
+ // resource owner-less → MISSING_REQUIRED_TAGS across the estate.
798
+ if (this.globalTags[COST_ALLOCATION_TAGS.OWNER] === undefined) {
799
+ this.globalTags[COST_ALLOCATION_TAGS.OWNER] =
800
+ DEFAULT_COST_ALLOCATION_OWNER;
801
+ }
795
802
  // Apply standard tags using Tags.of(this).add()
796
803
  for (const [key, value] of Object.entries(this.globalTags)) {
797
804
  Tags.of(this).add(key, value);
@@ -1,4 +1,4 @@
1
- import { CfnOutput } from "aws-cdk-lib";
1
+ import { CfnOutput, Stack } from "aws-cdk-lib";
2
2
  import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
3
3
  import { EmailSubscription, UrlSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
4
4
  import { Construct } from "constructs";
@@ -38,6 +38,25 @@ export class SharedAlarmTopic extends Construct {
38
38
  ...(masterKey !== undefined && { masterKey })
39
39
  });
40
40
  this.topic = wrapped.getTopic();
41
+ // The wrapper's TLS-only TopicPolicy REPLACES the topic's implicit
42
+ // default policy, whose Allow was the SOLE authorisation for CloudWatch
43
+ // alarm-transition publishes — without this explicit Allow every alarm
44
+ // fires into a deny-only policy and pages nobody. Unlike the KMS path
45
+ // above, the topic-policy publish DOES carry the confused-deputy keys
46
+ // (the AWS-documented alarms-to-SNS shape), so scope to this account's
47
+ // alarms.
48
+ const stack = Stack.of(this);
49
+ this.topic.addToResourcePolicy(new PolicyStatement({
50
+ principals: [new ServicePrincipal("cloudwatch.amazonaws.com")],
51
+ actions: ["sns:Publish"],
52
+ resources: [wrapped.getTopicArn()],
53
+ conditions: {
54
+ StringEquals: { "aws:SourceAccount": stack.account },
55
+ ArnLike: {
56
+ "aws:SourceArn": `arn:${stack.partition}:cloudwatch:${stack.region}:${stack.account}:alarm:*`
57
+ }
58
+ }
59
+ }));
41
60
  for (const email of props?.subscriptions?.emails ?? []) {
42
61
  this.topic.addSubscription(new EmailSubscription(email));
43
62
  }
@@ -1,8 +1,9 @@
1
1
  import { Duration, RemovalPolicy } from "aws-cdk-lib";
2
2
  import { CfnConfigurationRecorder, CfnDeliveryChannel } from "aws-cdk-lib/aws-config";
3
3
  import { Role, ServicePrincipal, ManagedPolicy } from "aws-cdk-lib/aws-iam";
4
- import { Bucket, BucketEncryption, BlockPublicAccess } from "aws-cdk-lib/aws-s3";
4
+ import { BucketEncryption, BlockPublicAccess } from "aws-cdk-lib/aws-s3";
5
5
  import { Construct } from "constructs";
6
+ import { NONCURRENT_VERSION_EXPIRY_DAYS, S3Bucket } from "../../resources/aws/storage/index.js";
6
7
  /**
7
8
  * AWS Config recorder with S3 delivery channel.
8
9
  * Records configuration changes to all supported resources.
@@ -14,13 +15,23 @@ export class ConfigRecorder extends Construct {
14
15
  super(scope, id);
15
16
  const allResources = props?.allResources !== false;
16
17
  const includeGlobalResources = props?.includeGlobalResources !== false;
17
- // Delivery bucket -- S3-managed encryption, 90-day lifecycle, no public access
18
- const deliveryBucket = new Bucket(this, "DeliveryBucket", {
18
+ // S3Bucket keeps the raw Bucket's construct id, so the deployed bucket's
19
+ // logical ID is unchanged (a rename would replace the bucket on redeploy);
20
+ // the wrapper supplies enforceSSL + multipart hygiene. Custom
21
+ // lifecycleRules bypass its automatic noncurrentVersionExpiration
22
+ // pairing — carried explicitly, or noncurrent versions accumulate forever.
23
+ const deliveryBucket = new S3Bucket(this, "DeliveryBucket", {
19
24
  encryption: BucketEncryption.S3_MANAGED,
20
25
  blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
21
- enforceSSL: true,
26
+ versioned: true,
22
27
  removalPolicy: RemovalPolicy.RETAIN,
23
- lifecycleRules: [{ expiration: Duration.days(90), enabled: true }]
28
+ lifecycleRules: [
29
+ {
30
+ expiration: Duration.days(90),
31
+ noncurrentVersionExpiration: Duration.days(NONCURRENT_VERSION_EXPIRY_DAYS),
32
+ enabled: true
33
+ }
34
+ ]
24
35
  });
25
36
  this.deliveryBucketName = deliveryBucket.bucketName;
26
37
  // IAM role for Config service
@@ -201,7 +201,10 @@ export interface CodeLambdaProps extends BaseLambdaProps {
201
201
  code: Code;
202
202
  /** Handler function. Default: "index.handler" */
203
203
  handler?: string;
204
- /** Lambda runtime. Default: NODEJS_24_X */
204
+ /**
205
+ * Lambda runtime. Default: NODEJS_24_X. A runtime AWS Lambda has
206
+ * deprecated fails synth.
207
+ */
205
208
  runtime?: Runtime;
206
209
  }
207
210
  /**
@@ -3,7 +3,7 @@ import { Connections } from "aws-cdk-lib/aws-ec2";
3
3
  import { Repository } from "aws-cdk-lib/aws-ecr";
4
4
  import { Construct } from "constructs";
5
5
  import { processConnections } from "../../utils/connections.js";
6
- import { LambdaFunction } from "../../resources/aws/compute/lambda.js";
6
+ import { LambdaFunction, validateRuntimeNotDeprecated } from "../../resources/aws/compute/lambda.js";
7
7
  import { getOrCreateImageTagParameter } from "../../resources/aws/compute/imageTagParameter.js";
8
8
  import { COMPUTE_DEFAULTS } from "./compute.js";
9
9
  import { LAMBDA_ARCHITECTURE_VALUES } from "@fjall/util/manifest/schemas";
@@ -114,6 +114,9 @@ export function resolveLambdaDeployment(scope, id, props) {
114
114
  runtime: Runtime.FROM_IMAGE
115
115
  };
116
116
  }
117
+ if (props.runtime !== undefined) {
118
+ validateRuntimeNotDeprecated(props.runtime, id);
119
+ }
117
120
  return {
118
121
  code: props.code,
119
122
  handler: props.handler || COMPUTE_DEFAULTS.LAMBDA.HANDLER,
@@ -117,7 +117,9 @@ export interface AuroraDatabaseProps extends BaseDatabaseProps {
117
117
  /**
118
118
  * IP address to allow access from when publiclyAccessible is true.
119
119
  * If not specified, access is restricted to VPC only even when public.
120
- * Format: CIDR notation (e.g., "203.0.113.0/32" for single IP)
120
+ * Format: IPv4 CIDR notation (e.g., "203.0.113.0/32" for single IP).
121
+ * The world-open CIDR "0.0.0.0/0" raises a synth-time warning; a non-IPv4
122
+ * CIDR (e.g. "::/0") fails synth outright — the ingress peer is IPv4-only.
121
123
  */
122
124
  allowedIpCidr?: string;
123
125
  }
@@ -162,10 +164,11 @@ export interface InstanceDatabaseProps extends BaseDatabaseProps {
162
164
  encryption?: EncryptionConfig;
163
165
  publiclyAccessible?: boolean;
164
166
  /**
165
- * Enable RDS IAM database authentication (opt-in; default off). Instance
166
- * databases only. When true, IAM principals granted via
167
+ * RDS IAM database authentication (default ON; pass false to opt out).
168
+ * Instance databases only. IAM principals granted via
167
169
  * {@link RelationalDatabase.grantIamConnect} connect with short-lived
168
- * `rds-db:connect` tokens instead of a stored password. See ADR
170
+ * `rds-db:connect` tokens instead of a stored password; password auth keeps
171
+ * working in parallel. See ADR
169
172
  * decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
170
173
  */
171
174
  iamAuthentication?: boolean;
@@ -189,6 +192,7 @@ export interface DynamoDBDatabaseProps {
189
192
  stream?: "NEW_IMAGE" | "OLD_IMAGE" | "NEW_AND_OLD_IMAGES" | "KEYS_ONLY";
190
193
  pointInTimeRecovery?: boolean;
191
194
  encryption?: "AWS_OWNED" | "AWS_MANAGED" | "CUSTOMER_MANAGED";
195
+ deletionProtection?: boolean;
192
196
  removalPolicy?: "DESTROY" | "RETAIN" | "SNAPSHOT";
193
197
  }
194
198
  /**
@@ -323,8 +327,9 @@ export declare class RelationalDatabase extends Construct implements IRelational
323
327
  /**
324
328
  * Grant an IAM principal permission to connect as `dbUsername` via RDS IAM
325
329
  * database authentication. Instance databases only — Aurora uses a different
326
- * mechanism, so this throws for non-Instance databases. Requires the instance
327
- * to be created with `iamAuthentication: true`. The L2 `grantConnect` scopes
330
+ * mechanism, so this throws for non-Instance databases. IAM auth is on by
331
+ * default; this throws if the instance opted out with
332
+ * `iamAuthentication: false`. The L2 `grantConnect` scopes
328
333
  * `rds-db:connect` to the exact `dbuser:<resourceId>/<dbUsername>` ARN — never
329
334
  * a bare wildcard. See ADR
330
335
  * decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
@@ -12,7 +12,7 @@ import { Port } from "aws-cdk-lib/aws-ec2";
12
12
  import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
13
13
  import { MIGRATION_SNAPSHOT_NAME_PREFIX } from "./interfaces/database.js";
14
14
  import { pickLatestPrismaMigration } from "../../utils/migrationVersionResolvers.js";
15
- import { SCHEMA_GATE_DB_PASSWORD_ENV, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_DB_USER_ENV } from "@fjall/util/migration";
15
+ import { MIGRATION_SNAPSHOT_RETENTION_DAYS_ENV, SCHEMA_GATE_DB_PASSWORD_ENV, SCHEMA_GATE_DB_URL_BASE_ENV, SCHEMA_GATE_DB_USER_ENV } from "@fjall/util/migration";
16
16
  import { DatabaseClusterEngine, DatabaseInstanceEngine, AuroraPostgresEngineVersion, AuroraMysqlEngineVersion, PostgresEngineVersion, MysqlEngineVersion } from "aws-cdk-lib/aws-rds";
17
17
  import { Duration } from "aws-cdk-lib";
18
18
  export { isAwsManagedKey, isCMKRequested, AWS_MANAGED, USE_CMK } from "../../utils/databaseTypes.js";
@@ -222,6 +222,7 @@ export class DynamoDBDatabase extends Construct {
222
222
  stream: props.stream,
223
223
  pointInTimeRecovery: props.pointInTimeRecovery,
224
224
  encryption: props.encryption,
225
+ deletionProtection: props.deletionProtection,
225
226
  removalPolicy: props.removalPolicy
226
227
  };
227
228
  this.table = new DynamoDBTable(this, id, tableProps);
@@ -296,11 +297,13 @@ const SNAPSHOT_ACTION_TABLE = {
296
297
  instance: {
297
298
  createAction: "rds:CreateDBSnapshot",
298
299
  describeAction: "rds:DescribeDBSnapshots",
300
+ deleteAction: "rds:DeleteDBSnapshot",
299
301
  resourceArnSegment: "snapshot"
300
302
  },
301
303
  cluster: {
302
304
  createAction: "rds:CreateDBClusterSnapshot",
303
305
  describeAction: "rds:DescribeDBClusterSnapshots",
306
+ deleteAction: "rds:DeleteDBClusterSnapshot",
304
307
  resourceArnSegment: "cluster-snapshot"
305
308
  }
306
309
  };
@@ -540,6 +543,10 @@ export class RelationalDatabase extends Construct {
540
543
  actions: [
541
544
  triple.createAction,
542
545
  triple.describeAction,
546
+ // Delete is confined to fjall-premigrate-* by IAM: a Delete request's
547
+ // resource is always a snapshot ARN, which only the prefix glob below
548
+ // can match — never the instance/cluster ARN.
549
+ triple.deleteAction,
543
550
  "rds:AddTagsToResource"
544
551
  ],
545
552
  resources: [
@@ -605,6 +612,7 @@ export class RelationalDatabase extends Construct {
605
612
  const target = this.getSnapshotTarget();
606
613
  environment.SNAPSHOT_TARGET_KIND = target.kind;
607
614
  environment.SNAPSHOT_TARGET_ARN = target.arn;
615
+ environment[MIGRATION_SNAPSHOT_RETENTION_DAYS_ENV] = String(this.database.getBackupRetentionDays());
608
616
  }
609
617
  const credentials = this.getCredentials();
610
618
  const secretsImport = {
@@ -640,8 +648,9 @@ export class RelationalDatabase extends Construct {
640
648
  /**
641
649
  * Grant an IAM principal permission to connect as `dbUsername` via RDS IAM
642
650
  * database authentication. Instance databases only — Aurora uses a different
643
- * mechanism, so this throws for non-Instance databases. Requires the instance
644
- * to be created with `iamAuthentication: true`. The L2 `grantConnect` scopes
651
+ * mechanism, so this throws for non-Instance databases. IAM auth is on by
652
+ * default; this throws if the instance opted out with
653
+ * `iamAuthentication: false`. The L2 `grantConnect` scopes
645
654
  * `rds-db:connect` to the exact `dbuser:<resourceId>/<dbUsername>` ARN — never
646
655
  * a bare wildcard. See ADR
647
656
  * decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
@@ -127,9 +127,12 @@ export interface IRelationalDatabaseBase extends IDatabase, IConnectable, IMigra
127
127
  getSnapshotTarget(): SnapshotTarget;
128
128
  /**
129
129
  * Returns the IAM PolicyStatement granting the migration runner the rights
130
- * needed to take and tag a pre-migration snapshot of this database. Action
131
- * triple and resource ARN segment derive from `this.getSnapshotTarget().kind`.
132
- * Snapshot name glob is bound to `MIGRATION_SNAPSHOT_NAME_PREFIX`.
130
+ * needed to take, tag, describe, and prune pre-migration snapshots of this
131
+ * database. Actions and resource ARN segment derive from
132
+ * `this.getSnapshotTarget().kind`. Snapshot name glob is bound to
133
+ * `MIGRATION_SNAPSHOT_NAME_PREFIX`; the delete action can only ever match
134
+ * that glob (a Delete request's resource is a snapshot ARN, never the
135
+ * database ARN), confining pruning to premigrate snapshots.
133
136
  */
134
137
  getMigrationSnapshotPolicy(): PolicyStatement;
135
138
  /**
@@ -36,6 +36,12 @@ export interface S3Props {
36
36
  readonly versioned?: boolean;
37
37
  readonly encryption?: "AES256" | "KMS";
38
38
  readonly kmsKeyArn?: string;
39
+ /**
40
+ * S3 Bucket Keys for KMS-encrypted buckets — defaults to true when
41
+ * encryption resolves to KMS (cuts per-object KMS request charges); pass
42
+ * false to opt out. Ignored for non-KMS encryption.
43
+ */
44
+ readonly bucketKeyEnabled?: boolean;
39
45
  readonly backupVaultTier?: BackupTier;
40
46
  readonly cors?: CorsRule[];
41
47
  readonly deployment?: S3DeploymentConfig;
@@ -80,6 +80,9 @@ export class Storage extends Construct {
80
80
  versioned: props.versioned,
81
81
  encryption: toBucketEncryption(props.encryption),
82
82
  encryptionKey,
83
+ ...(props.bucketKeyEnabled !== undefined && {
84
+ bucketKeyEnabled: props.bucketKeyEnabled
85
+ }),
83
86
  backupVaultTier: props.backupVaultTier,
84
87
  publicReadAccess: props.publicReadAccess,
85
88
  websiteHosting: props.websiteHosting,
@@ -16,6 +16,9 @@ export function createApplicationLoadBalancer(scope, id, options) {
16
16
  // ALB-fronted containers receive it as FJALL_ALB_IDLE_TIMEOUT_SECONDS
17
17
  // (ecsTaskDefinition.ts) for keep-alive tuning — one constant, both sides.
18
18
  idleTimeout: Duration.seconds(DEFAULT_ALB_IDLE_TIMEOUT_SECONDS),
19
+ // ELB.4 — invalid HTTP headers are a request-smuggling surface; drop them
20
+ // at the ALB. Attribute-only, so existing LBs update in place.
21
+ dropInvalidHeaderFields: true,
19
22
  ...(options.securityGroup && { securityGroup: options.securityGroup }),
20
23
  ...(options.loadBalancerName && {
21
24
  loadBalancerName: options.loadBalancerName
@@ -1,8 +1,9 @@
1
- import { Cluster as CdkCluster, ContainerInsights } from "aws-cdk-lib/aws-ecs";
1
+ import { Cluster as CdkCluster, ContainerInsights, ExecuteCommandLogging } from "aws-cdk-lib/aws-ecs";
2
2
  import { Connections, Port } from "aws-cdk-lib/aws-ec2";
3
3
  import { Construct } from "constructs";
4
4
  import { CfnOutput, Aspects } from "aws-cdk-lib";
5
5
  import { processConnections } from "../../../utils/connections.js";
6
+ import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../logging/logGroup.js";
6
7
  import { stackScopedExportName } from "../../../utils/exportNaming.js";
7
8
  import { toPascalCase } from "../../../utils/capitaliseString.js";
8
9
  import { createEcsServiceAlarms, createEcsTaskStopWatchdog, createLogPatternAlarms } from "../monitoring/index.js";
@@ -382,13 +383,21 @@ export default class EcsCluster extends Construct {
382
383
  }
383
384
  addCluster(props) {
384
385
  const needsFargate = this.anyServiceUsesFargate();
386
+ // Every service enables execute command; without an OVERRIDE here the
387
+ // exec session output interleaves into the container app log groups
388
+ // (DEFAULT mode). No kmsKey — session encryption is tier-gated.
389
+ const execAuditLogGroup = new LogGroup(this, `${props.clusterName}ExecAuditLogGroup`, { retention: DEFAULT_FRAMEWORK_LOG_RETENTION });
385
390
  const cluster = new CdkCluster(this, `${props.clusterName}Cluster`, {
386
391
  vpc: props.vpc,
387
392
  clusterName: props.clusterName,
388
393
  containerInsightsV2: props.containerInsights === true
389
394
  ? ContainerInsights.ENABLED
390
395
  : ContainerInsights.DISABLED,
391
- enableFargateCapacityProviders: needsFargate
396
+ enableFargateCapacityProviders: needsFargate,
397
+ executeCommandConfiguration: {
398
+ logging: ExecuteCommandLogging.OVERRIDE,
399
+ logConfiguration: { cloudWatchLogGroup: execAuditLogGroup }
400
+ }
392
401
  });
393
402
  new CfnOutput(this, `${this.outputName}DeployableCluster`, {
394
403
  key: `${this.outputName}DeployableCluster`,
@@ -10,6 +10,23 @@ import { type KeyValue } from "../../../types.js";
10
10
  import { type SecretImport } from "../secrets/index.js";
11
11
  import type { ITopic } from "aws-cdk-lib/aws-sns";
12
12
  import { type LambdaAlarmThresholds } from "../monitoring/index.js";
13
+ /**
14
+ * Runtime names AWS Lambda has deprecated, mirrored from the `@deprecated`
15
+ * markers on `Runtime`'s static members in the vendored aws-cdk-lib
16
+ * (aws-lambda/lib/runtime.d.ts). The SDK exposes no runtime-readable
17
+ * deprecation flag on `Runtime` instances, so this set can drift when
18
+ * aws-cdk-lib is upgraded — lambda-deprecated-runtime.test.ts re-derives it
19
+ * from the vendored declaration file and fails on any mismatch.
20
+ */
21
+ export declare const DEPRECATED_RUNTIME_NAMES: ReadonlySet<string>;
22
+ /**
23
+ * Throw at synth time when a deprecated Lambda runtime is configured —
24
+ * shift-left of the posture scan's LAMBDA_DEPRECATED_RUNTIME finding, which
25
+ * previously let the deploy succeed and flagged it days later. A deprecated
26
+ * runtime receives no security patches and AWS eventually blocks function
27
+ * creation on it.
28
+ */
29
+ export declare function validateRuntimeNotDeprecated(runtime: Runtime, functionId: string): void;
13
30
  export interface LambdaFunctionProps {
14
31
  code: Code;
15
32
  handler: string;
@@ -53,6 +70,16 @@ export interface LambdaFunctionProps {
53
70
  functionUrlCors?: FunctionUrlCorsOptions;
54
71
  /** Invoke mode for Function URL. Use RESPONSE_STREAM for Lambda streaming. */
55
72
  functionUrlInvokeMode?: InvokeMode;
73
+ /**
74
+ * Failure destination for asynchronous invocations. S3 event notifications
75
+ * invoke the function asynchronously, and a failed event is silently
76
+ * dropped after the default two retries. Unless set to `false`, the first
77
+ * `addS3EventSource` call lazily creates one SQS queue for the function
78
+ * (SSL-enforced, 14-day retention) and wires it as the async dead-letter
79
+ * queue; pass an existing queue to reuse it instead. Functions with no
80
+ * async event source get no queue.
81
+ */
82
+ asyncFailureQueue?: IQueue | false;
56
83
  environment?: KeyValue;
57
84
  secrets?: string[];
58
85
  ssmSecretsPath?: string;
@@ -79,6 +106,8 @@ export declare class SingletonFunction extends singletonFunction {
79
106
  }
80
107
  export declare class LambdaFunction extends Function {
81
108
  private functionUrlValue?;
109
+ private readonly asyncFailureQueueProp?;
110
+ private asyncFailureQueueWired?;
82
111
  constructor(scope: Construct, id: string, props: LambdaFunctionProps);
83
112
  /**
84
113
  * The Lambda's execution role (auto-generated by CDK)
@@ -114,6 +143,9 @@ export declare class LambdaFunction extends Function {
114
143
  * Add an S3 bucket as an event source for this Lambda function.
115
144
  * This will trigger the Lambda when objects are created, modified, or deleted.
116
145
  * Useful for ISR cache invalidation and file processing workflows.
146
+ *
147
+ * S3 invokes asynchronously, so the first call also wires the async
148
+ * failure queue (see `asyncFailureQueue`) unless it is set to `false`.
117
149
  */
118
150
  addS3EventSource(bucket: Bucket, options?: {
119
151
  events?: Array<"OBJECT_CREATED" | "OBJECT_REMOVED" | "OBJECT_CREATED_PUT" | "OBJECT_CREATED_POST" | "OBJECT_CREATED_COPY" | "OBJECT_REMOVED_DELETE">;
@@ -122,6 +154,20 @@ export declare class LambdaFunction extends Function {
122
154
  suffix?: string;
123
155
  }>;
124
156
  }): void;
157
+ /**
158
+ * Get the SQS queue wired as the async-invocation failure destination, if
159
+ * one has been created or supplied.
160
+ */
161
+ getAsyncFailureQueue(): IQueue | undefined;
162
+ /**
163
+ * Wire the async-invocation dead-letter queue on first use. Without one,
164
+ * an event that fails all retries is silently dropped. `Function`'s
165
+ * `deadLetterQueue` prop is constructor-only and the queue is only wanted
166
+ * once an async event source exists, so reach the L1 `CfnFunction` and
167
+ * mirror what the prop does: DeadLetterConfig plus an sqs:SendMessage
168
+ * grant to the execution role.
169
+ */
170
+ private ensureAsyncFailureQueue;
125
171
  /**
126
172
  * Add secrets support using AWS Parameters and Secrets Lambda Extension.
127
173
  *
@@ -1,4 +1,4 @@
1
- import { CfnOutput, Duration, Stack, Size } from "aws-cdk-lib";
1
+ import { Annotations, CfnOutput, Duration, Stack, Size } from "aws-cdk-lib";
2
2
  import { SingletonFunction as singletonFunction, Function, Code, Architecture, FunctionUrlAuthType, StartingPosition, LayerVersion } from "aws-cdk-lib/aws-lambda";
3
3
  import { FactName } from "aws-cdk-lib/region-info";
4
4
  import path from "node:path";
@@ -8,6 +8,8 @@ import { SqsEventSource, DynamoEventSource, S3EventSource } from "aws-cdk-lib/aw
8
8
  import { EventType } from "aws-cdk-lib/aws-s3";
9
9
  import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
10
10
  import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../logging/logGroup.js";
11
+ import { Queue, QueueEncryption } from "aws-cdk-lib/aws-sqs";
12
+ import { SQS_LIMITS } from "../messaging/sqs.js";
11
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
14
  import { buildParameterPath } from "@fjall/util";
13
15
  import { resolveImportedSecret } from "../secrets/index.js";
@@ -66,6 +68,54 @@ const SECRETS_EXTENSION = {
66
68
  * mis-tune alarms relative to runtime behaviour.
67
69
  */
68
70
  const LAMBDA_DEFAULT_TIMEOUT_SECONDS = 300;
71
+ /**
72
+ * Runtime names AWS Lambda has deprecated, mirrored from the `@deprecated`
73
+ * markers on `Runtime`'s static members in the vendored aws-cdk-lib
74
+ * (aws-lambda/lib/runtime.d.ts). The SDK exposes no runtime-readable
75
+ * deprecation flag on `Runtime` instances, so this set can drift when
76
+ * aws-cdk-lib is upgraded — lambda-deprecated-runtime.test.ts re-derives it
77
+ * from the vendored declaration file and fails on any mismatch.
78
+ */
79
+ export const DEPRECATED_RUNTIME_NAMES = new Set([
80
+ "nodejs",
81
+ "nodejs4.3",
82
+ "nodejs6.10",
83
+ "nodejs8.10",
84
+ "nodejs10.x",
85
+ "nodejs12.x",
86
+ "nodejs14.x",
87
+ "nodejs16.x",
88
+ "nodejs18.x",
89
+ "python2.7",
90
+ "python3.6",
91
+ "python3.7",
92
+ "python3.8",
93
+ "python3.9",
94
+ "java8",
95
+ "dotnet6",
96
+ "dotnetcore1.0",
97
+ "dotnetcore2.0",
98
+ "dotnetcore2.1",
99
+ "dotnetcore3.1",
100
+ "go1.x",
101
+ "ruby2.5",
102
+ "ruby2.7",
103
+ "provided"
104
+ ]);
105
+ /**
106
+ * Throw at synth time when a deprecated Lambda runtime is configured —
107
+ * shift-left of the posture scan's LAMBDA_DEPRECATED_RUNTIME finding, which
108
+ * previously let the deploy succeed and flagged it days later. A deprecated
109
+ * runtime receives no security patches and AWS eventually blocks function
110
+ * creation on it.
111
+ */
112
+ export function validateRuntimeNotDeprecated(runtime, functionId) {
113
+ if (DEPRECATED_RUNTIME_NAMES.has(runtime.name)) {
114
+ throw new Error(`Lambda '${functionId}': runtime '${runtime.name}' is deprecated by ` +
115
+ `AWS Lambda. Migrate to a supported runtime — e.g. ` +
116
+ `Runtime.NODEJS_24_X for Node.js.`);
117
+ }
118
+ }
69
119
  /**
70
120
  * Stable, deterministic uuid for a SingletonFunction so its logical ID
71
121
  * (`SingletonLambda${uuid-without-dashes}`) does not drift across synths. A
@@ -86,6 +136,7 @@ function deriveStableSingletonUuid(scope, id) {
86
136
  }
87
137
  export class SingletonFunction extends singletonFunction {
88
138
  constructor(scope, id, props) {
139
+ validateRuntimeNotDeprecated(props.runtime, id);
89
140
  super(scope, id, {
90
141
  ...props,
91
142
  uuid: props.uuid ?? deriveStableSingletonUuid(scope, id),
@@ -112,7 +163,10 @@ export class SingletonFunction extends singletonFunction {
112
163
  }
113
164
  export class LambdaFunction extends Function {
114
165
  functionUrlValue;
166
+ asyncFailureQueueProp;
167
+ asyncFailureQueueWired;
115
168
  constructor(scope, id, props) {
169
+ validateRuntimeNotDeprecated(props.runtime, id);
116
170
  const vpcSubnets = props.vpc
117
171
  ? { subnetType: resolvePrivateSubnetType(props.vpc) }
118
172
  : undefined;
@@ -130,6 +184,7 @@ export class LambdaFunction extends Function {
130
184
  retention: props.logGroupRetention ?? DEFAULT_FRAMEWORK_LOG_RETENTION
131
185
  })
132
186
  });
187
+ this.asyncFailureQueueProp = props.asyncFailureQueue;
133
188
  addPoliciesToRole(this, props.inlinePolicy);
134
189
  applyRoleDescription(this, props.roleDescription);
135
190
  applyRolePathAndName(this, props.rolePath, props.roleName);
@@ -137,11 +192,19 @@ export class LambdaFunction extends Function {
137
192
  // Sanitise id for CloudFormation output keys (must be alphanumeric)
138
193
  const outputName = toPascalCase(id);
139
194
  if (props.enableFunctionUrl) {
195
+ const authType = props.functionUrlAuthType ?? FunctionUrlAuthType.AWS_IAM;
140
196
  const functionUrl = this.addFunctionUrl({
141
- authType: props.functionUrlAuthType ?? FunctionUrlAuthType.AWS_IAM,
197
+ authType,
142
198
  cors: props.functionUrlCors,
143
199
  invokeMode: props.functionUrlInvokeMode
144
200
  });
201
+ if (authType === FunctionUrlAuthType.NONE &&
202
+ props.reservedConcurrentExecutions === undefined) {
203
+ Annotations.of(this).addWarning(`Lambda '${id}' exposes a public Function URL (authType NONE) ` +
204
+ `without reservedConcurrentExecutions. An unauthenticated caller ` +
205
+ `can drive unbounded invocations, taking spend and account-wide ` +
206
+ `concurrency with it — set reservedConcurrentExecutions to cap it.`);
207
+ }
145
208
  this.functionUrlValue = functionUrl.url;
146
209
  new CfnOutput(this, `${outputName}FunctionUrl`, {
147
210
  key: `${outputName}FunctionUrl`,
@@ -218,6 +281,9 @@ export class LambdaFunction extends Function {
218
281
  * Add an S3 bucket as an event source for this Lambda function.
219
282
  * This will trigger the Lambda when objects are created, modified, or deleted.
220
283
  * Useful for ISR cache invalidation and file processing workflows.
284
+ *
285
+ * S3 invokes asynchronously, so the first call also wires the async
286
+ * failure queue (see `asyncFailureQueue`) unless it is set to `false`.
221
287
  */
222
288
  addS3EventSource(bucket, options) {
223
289
  const eventTypes = (options?.events ?? ["OBJECT_CREATED"]).map((event) => {
@@ -246,6 +312,38 @@ export class LambdaFunction extends Function {
246
312
  };
247
313
  const eventSource = new S3EventSource(bucket, s3EventSourceProps);
248
314
  this.addEventSource(eventSource);
315
+ this.ensureAsyncFailureQueue();
316
+ }
317
+ /**
318
+ * Get the SQS queue wired as the async-invocation failure destination, if
319
+ * one has been created or supplied.
320
+ */
321
+ getAsyncFailureQueue() {
322
+ return this.asyncFailureQueueWired;
323
+ }
324
+ /**
325
+ * Wire the async-invocation dead-letter queue on first use. Without one,
326
+ * an event that fails all retries is silently dropped. `Function`'s
327
+ * `deadLetterQueue` prop is constructor-only and the queue is only wanted
328
+ * once an async event source exists, so reach the L1 `CfnFunction` and
329
+ * mirror what the prop does: DeadLetterConfig plus an sqs:SendMessage
330
+ * grant to the execution role.
331
+ */
332
+ ensureAsyncFailureQueue() {
333
+ if (this.asyncFailureQueueProp === false)
334
+ return;
335
+ if (this.asyncFailureQueueWired !== undefined)
336
+ return;
337
+ const queue = this.asyncFailureQueueProp ??
338
+ new Queue(this, "AsyncFailureQueue", {
339
+ enforceSSL: true,
340
+ encryption: QueueEncryption.SQS_MANAGED,
341
+ retentionPeriod: Duration.days(SQS_LIMITS.DEAD_LETTER_QUEUE.DEFAULT_RETENTION_DAYS)
342
+ });
343
+ this.asyncFailureQueueWired = queue;
344
+ const cfnFunction = this.node.defaultChild;
345
+ cfnFunction.deadLetterConfig = { targetArn: queue.queueArn };
346
+ queue.grantSendMessages(this);
249
347
  }
250
348
  /**
251
349
  * Add secrets support using AWS Parameters and Secrets Lambda Extension.
@@ -1,4 +1,4 @@
1
- import { ListenerAction } from "aws-cdk-lib/aws-elasticloadbalancingv2";
1
+ import { ListenerAction, SslPolicy } from "aws-cdk-lib/aws-elasticloadbalancingv2";
2
2
  /**
3
3
  * Context-free ALB listener factory shared by the ECS cluster path and the dev
4
4
  * substrate. When `default404` is set, the listener's default action is a plain
@@ -25,7 +25,11 @@ export function addRoutingListener(loadBalancer, id, options) {
25
25
  certificates: [
26
26
  options.certificate,
27
27
  ...(options.additionalCertificates ?? [])
28
- ]
28
+ ],
29
+ // TLS 1.3/1.2 only (ELBSecurityPolicy-TLS13-1-2-2021-06) — drops
30
+ // TLS 1.0/1.1 clients. HTTPS-only: CloudFormation rejects SslPolicy on
31
+ // a plain HTTP listener.
32
+ sslPolicy: SslPolicy.RECOMMENDED_TLS
29
33
  }),
30
34
  ...(defaultAction !== undefined && { defaultAction })
31
35
  });
@@ -25,6 +25,7 @@ export interface DynamoDBTableProps {
25
25
  stream?: "NEW_IMAGE" | "OLD_IMAGE" | "NEW_AND_OLD_IMAGES" | "KEYS_ONLY";
26
26
  pointInTimeRecovery?: boolean;
27
27
  encryption?: "AWS_OWNED" | "AWS_MANAGED" | "CUSTOMER_MANAGED";
28
+ deletionProtection?: boolean;
28
29
  removalPolicy?: "DESTROY" | "RETAIN" | "SNAPSHOT";
29
30
  }
30
31
  export declare class DynamoDBTable extends Construct {
@@ -1,7 +1,7 @@
1
1
  import { Construct } from "constructs";
2
2
  import { CfnOutput } from "aws-cdk-lib";
3
3
  import { toRemovalPolicy } from "../../../utils/removalPolicy.js";
4
- import { Table, AttributeType, BillingMode, StreamViewType, ProjectionType } from "aws-cdk-lib/aws-dynamodb";
4
+ import { Table, AttributeType, BillingMode, StreamViewType, ProjectionType, TableEncryption } from "aws-cdk-lib/aws-dynamodb";
5
5
  function toAttributeType(type) {
6
6
  switch (type) {
7
7
  case "S":
@@ -21,6 +21,17 @@ function toBillingMode(mode) {
21
21
  return BillingMode.PAY_PER_REQUEST;
22
22
  }
23
23
  }
24
+ function toTableEncryption(encryption) {
25
+ switch (encryption) {
26
+ case "AWS_OWNED":
27
+ return TableEncryption.DEFAULT;
28
+ case "CUSTOMER_MANAGED":
29
+ return TableEncryption.CUSTOMER_MANAGED;
30
+ case "AWS_MANAGED":
31
+ default:
32
+ return TableEncryption.AWS_MANAGED;
33
+ }
34
+ }
24
35
  function toStreamViewType(stream) {
25
36
  switch (stream) {
26
37
  case "NEW_IMAGE":
@@ -72,6 +83,8 @@ export class DynamoDBTable extends Construct {
72
83
  pointInTimeRecoverySpecification: {
73
84
  pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true
74
85
  },
86
+ encryption: toTableEncryption(props.encryption),
87
+ deletionProtection: props.deletionProtection ?? false,
75
88
  removalPolicy: toRemovalPolicy(props.removalPolicy),
76
89
  timeToLiveAttribute: props.timeToLiveAttribute
77
90
  });
@@ -97,6 +97,7 @@ export declare class RdsAurora extends Construct implements IConnectable {
97
97
  private databaseCredentials;
98
98
  private cfnCluster?;
99
99
  private databaseNameValue;
100
+ private backupRetentionDaysValue;
100
101
  constructor(scope: Construct, id: string, props: RdsProps);
101
102
  private buildReaders;
102
103
  private addProxy;
@@ -108,6 +109,13 @@ export declare class RdsAurora extends Construct implements IConnectable {
108
109
  getSnapshotTarget(): Extract<SnapshotTarget, {
109
110
  kind: "cluster";
110
111
  }>;
112
+ /**
113
+ * Effective automated-backup retention in whole days (the applied
114
+ * `backupRetention`, defaulted). Feeds `MIGRATION_SNAPSHOT_RETENTION_DAYS`
115
+ * so the migration runner prunes pre-migration snapshots against the same
116
+ * window the cluster's PITR actually covers.
117
+ */
118
+ getBackupRetentionDays(): number;
111
119
  getDatabaseName(): string;
112
120
  getConnectionString(): string;
113
121
  getCfnCluster(): CfnDBCluster | undefined;
@@ -62,6 +62,7 @@ export class RdsAurora extends Construct {
62
62
  databaseCredentials;
63
63
  cfnCluster;
64
64
  databaseNameValue;
65
+ backupRetentionDaysValue;
65
66
  constructor(scope, id, props) {
66
67
  super(scope, id);
67
68
  validateServerlessV2DevKnobs(props);
@@ -123,6 +124,11 @@ export class RdsAurora extends Construct {
123
124
  }
124
125
  // Allow external access when publicly accessible with allowed IP
125
126
  if (props.publiclyAccessible && props.allowedIpCidr) {
127
+ // "::/0" is deliberately not matched here: Peer.ipv4 below throws on any
128
+ // non-IPv4 CIDR before a warning could ever render.
129
+ if (props.allowedIpCidr === "0.0.0.0/0") {
130
+ Annotations.of(this).addWarningV2("@fjall/components-infrastructure:rdsWorldOpenIngress", `Database '${this.databaseNameValue}' is publicly accessible and allowedIpCidr '${props.allowedIpCidr}' opens its database port to the entire internet. Scope allowedIpCidr to the addresses that need access (e.g. "203.0.113.4/32").`);
131
+ }
126
132
  clusterSecurityGroup.addIngressRule(Peer.ipv4(props.allowedIpCidr), Port.tcp(this.port), "Allow external access from allowed IP");
127
133
  }
128
134
  this.connections = new Connections({
@@ -163,6 +169,8 @@ export class RdsAurora extends Construct {
163
169
  const vpcSubnets = props.publiclyAccessible
164
170
  ? { subnetType: SubnetType.PUBLIC }
165
171
  : { subnetType: SubnetType.PRIVATE_WITH_EGRESS };
172
+ this.backupRetentionDaysValue =
173
+ props.backupRetention ?? RDS_DEFAULTS.BACKUP_RETENTION_DEFAULT_DAYS;
166
174
  // Common props shared across all cluster creation paths
167
175
  const baseClusterProps = {
168
176
  vpc: props.vpc,
@@ -171,7 +179,7 @@ export class RdsAurora extends Construct {
171
179
  engine,
172
180
  parameterGroup,
173
181
  backup: {
174
- retention: Duration.days(props.backupRetention ?? RDS_DEFAULTS.BACKUP_RETENTION_DEFAULT_DAYS)
182
+ retention: Duration.days(this.backupRetentionDaysValue)
175
183
  },
176
184
  storageEncrypted: true,
177
185
  ...(storageEncryptionKey && { storageEncryptionKey }),
@@ -361,6 +369,15 @@ export class RdsAurora extends Construct {
361
369
  getSnapshotTarget() {
362
370
  return { kind: "cluster", arn: this.databaseCluster.clusterArn };
363
371
  }
372
+ /**
373
+ * Effective automated-backup retention in whole days (the applied
374
+ * `backupRetention`, defaulted). Feeds `MIGRATION_SNAPSHOT_RETENTION_DAYS`
375
+ * so the migration runner prunes pre-migration snapshots against the same
376
+ * window the cluster's PITR actually covers.
377
+ */
378
+ getBackupRetentionDays() {
379
+ return this.backupRetentionDaysValue;
380
+ }
364
381
  getDatabaseName() {
365
382
  return this.databaseNameValue;
366
383
  }
@@ -81,6 +81,13 @@ export declare class RdsAuroraGlobal extends Construct implements IConnectable {
81
81
  kind: "cluster";
82
82
  }>;
83
83
  getDatabaseName(): string;
84
+ /**
85
+ * Effective automated-backup retention in whole days, delegated to the
86
+ * regional cluster that actually applies the default. Feeds
87
+ * `MIGRATION_SNAPSHOT_RETENTION_DAYS` so the migration runner prunes
88
+ * pre-migration snapshots against the same window PITR actually covers.
89
+ */
90
+ getBackupRetentionDays(): number;
84
91
  getConnectionString(): string;
85
92
  }
86
93
  export {};
@@ -182,6 +182,21 @@ export class RdsAuroraGlobal extends Construct {
182
182
  return this.regionalCluster.getDatabaseName();
183
183
  throw new Error("No Aurora cluster available to return database name from");
184
184
  }
185
+ /**
186
+ * Effective automated-backup retention in whole days, delegated to the
187
+ * regional cluster that actually applies the default. Feeds
188
+ * `MIGRATION_SNAPSHOT_RETENTION_DAYS` so the migration runner prunes
189
+ * pre-migration snapshots against the same window PITR actually covers.
190
+ */
191
+ getBackupRetentionDays() {
192
+ if (this.primaryCluster) {
193
+ return this.primaryCluster.getBackupRetentionDays();
194
+ }
195
+ if (this.regionalCluster) {
196
+ return this.regionalCluster.getBackupRetentionDays();
197
+ }
198
+ throw new Error("No Aurora cluster available to return backup retention from");
199
+ }
185
200
  getConnectionString() {
186
201
  if (this.primaryCluster)
187
202
  return this.primaryCluster.getConnectionString();
@@ -33,11 +33,11 @@ interface RdsProps {
33
33
  publiclyAccessible?: boolean;
34
34
  deletionProtection?: boolean;
35
35
  /**
36
- * Enable RDS IAM database authentication on the instance. Opt-in; defaults to
37
- * off (undefined CDK omits the property, leaving existing consumers' synth
38
- * unchanged). When true, IAM principals granted via {@link grantIamConnect}
39
- * connect with short-lived `rds-db:connect` tokens instead of a stored
40
- * password password auth keeps working in parallel. See ADR
36
+ * RDS IAM database authentication on the instance (and its read replica).
37
+ * Defaults to ON free, and it grants nothing until {@link grantIamConnect};
38
+ * password auth keeps working in parallel. Pass false to opt out. For stacks
39
+ * deployed under the old opt-in default the false→true flip is a
40
+ * No-interruption CFN modify, so redeploying is safe. See ADR
41
41
  * decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
42
42
  */
43
43
  iamAuthentication?: boolean;
@@ -71,6 +71,7 @@ export declare class RdsInstance extends Construct implements IConnectable {
71
71
  private databaseProxySecurityGroup?;
72
72
  private readReplicaSecurityGroup?;
73
73
  private databaseNameValue;
74
+ private backupRetentionDaysValue;
74
75
  private readonly constructId;
75
76
  constructor(scope: Construct, id: string, props: RdsProps);
76
77
  private addDatabase;
@@ -84,12 +85,19 @@ export declare class RdsInstance extends Construct implements IConnectable {
84
85
  getSnapshotTarget(): Extract<SnapshotTarget, {
85
86
  kind: "instance";
86
87
  }>;
88
+ /**
89
+ * Effective automated-backup retention in whole days (the applied
90
+ * `backupRetention`, defaulted). Feeds `MIGRATION_SNAPSHOT_RETENTION_DAYS`
91
+ * so the migration runner prunes pre-migration snapshots against the same
92
+ * window the instance's PITR actually covers.
93
+ */
94
+ getBackupRetentionDays(): number;
87
95
  getDatabaseName(): string;
88
96
  getConnectionString(): string;
89
97
  /**
90
98
  * Grant an IAM principal permission to connect as `dbUsername` via RDS IAM
91
- * database authentication. Requires the instance to be created with
92
- * `iamAuthentication: true`. Delegates to the L2 `grantConnect`, which scopes
99
+ * database authentication. Enabled by default; throws if the instance opted
100
+ * out with `iamAuthentication: false`. Delegates to the L2 `grantConnect`, which scopes
93
101
  * `rds-db:connect` to the exact `dbuser:<dbiResourceId>/<dbUsername>` ARN —
94
102
  * never a bare wildcard. See ADR
95
103
  * decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
@@ -29,6 +29,7 @@ export class RdsInstance extends Construct {
29
29
  databaseProxySecurityGroup;
30
30
  readReplicaSecurityGroup;
31
31
  databaseNameValue;
32
+ backupRetentionDaysValue;
32
33
  constructId;
33
34
  constructor(scope, id, props) {
34
35
  super(scope, id);
@@ -121,6 +122,11 @@ export class RdsInstance extends Construct {
121
122
  const subnetType = props.publiclyAccessible
122
123
  ? SubnetType.PUBLIC
123
124
  : SubnetType.PRIVATE_WITH_EGRESS;
125
+ const backupRetention = props.backupRetention ??
126
+ Duration.days(RDS_DEFAULTS.BACKUP_RETENTION_DEFAULT_DAYS);
127
+ // toDays() throws on a fractional Duration — same whole-days contract the
128
+ // L2 DatabaseInstance enforces, surfaced at synth either way.
129
+ this.backupRetentionDaysValue = backupRetention.toDays();
124
130
  const commonInstanceProps = {
125
131
  vpc: this.vpc,
126
132
  vpcSubnets: {
@@ -130,8 +136,7 @@ export class RdsInstance extends Construct {
130
136
  engine,
131
137
  parameterGroup,
132
138
  allocatedStorage: props.allocatedStorage,
133
- backupRetention: props.backupRetention ??
134
- Duration.days(RDS_DEFAULTS.BACKUP_RETENTION_DEFAULT_DAYS),
139
+ backupRetention,
135
140
  preferredBackupWindow: props.preferredBackupWindow ?? "02:00-03:00",
136
141
  storageEncrypted: true,
137
142
  storageEncryptionKey,
@@ -156,7 +161,7 @@ export class RdsInstance extends Construct {
156
161
  preferredMaintenanceWindow: props.preferredMaintenanceWindow ??
157
162
  RDS_DEFAULTS.PREFERRED_MAINTENANCE_WINDOW,
158
163
  publiclyAccessible: props.publiclyAccessible ?? false,
159
- iamAuthentication: props.iamAuthentication
164
+ iamAuthentication: props.iamAuthentication ?? true
160
165
  };
161
166
  if (props.snapshotIdentifier) {
162
167
  // Create from snapshot
@@ -333,7 +338,8 @@ exports.handler = async (event) => {
333
338
  port: this.port,
334
339
  deletionProtection: props.deletionProtection ?? true,
335
340
  preferredMaintenanceWindow: props.preferredMaintenanceWindow ??
336
- RDS_DEFAULTS.PREFERRED_MAINTENANCE_WINDOW
341
+ RDS_DEFAULTS.PREFERRED_MAINTENANCE_WINDOW,
342
+ iamAuthentication: props.iamAuthentication ?? true
337
343
  });
338
344
  readReplica.node.addDependency(deletionWaiter.resource);
339
345
  }
@@ -357,6 +363,15 @@ exports.handler = async (event) => {
357
363
  getSnapshotTarget() {
358
364
  return { kind: "instance", arn: this.database.instanceArn };
359
365
  }
366
+ /**
367
+ * Effective automated-backup retention in whole days (the applied
368
+ * `backupRetention`, defaulted). Feeds `MIGRATION_SNAPSHOT_RETENTION_DAYS`
369
+ * so the migration runner prunes pre-migration snapshots against the same
370
+ * window the instance's PITR actually covers.
371
+ */
372
+ getBackupRetentionDays() {
373
+ return this.backupRetentionDaysValue;
374
+ }
360
375
  getDatabaseName() {
361
376
  return this.databaseNameValue;
362
377
  }
@@ -365,8 +380,8 @@ exports.handler = async (event) => {
365
380
  }
366
381
  /**
367
382
  * Grant an IAM principal permission to connect as `dbUsername` via RDS IAM
368
- * database authentication. Requires the instance to be created with
369
- * `iamAuthentication: true`. Delegates to the L2 `grantConnect`, which scopes
383
+ * database authentication. Enabled by default; throws if the instance opted
384
+ * out with `iamAuthentication: false`. Delegates to the L2 `grantConnect`, which scopes
370
385
  * `rds-db:connect` to the exact `dbuser:<dbiResourceId>/<dbUsername>` ARN —
371
386
  * never a bare wildcard. See ADR
372
387
  * decisions/2026-06-17-rls-role-auth-and-launch-gating.md.
@@ -2,7 +2,7 @@ import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
2
2
  import * as CloudTrail from "aws-cdk-lib/aws-cloudtrail";
3
3
  import { Construct } from "constructs";
4
4
  import { CustomerManagedKey } from "../secrets/index.js";
5
- import { S3Bucket } from "../storage/index.js";
5
+ import { NONCURRENT_VERSION_EXPIRY_DAYS, S3Bucket } from "../storage/index.js";
6
6
  import { BucketEncryption } from "aws-cdk-lib/aws-s3";
7
7
  import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
8
8
  export function validateTrailProps(props) {
@@ -40,9 +40,18 @@ export class Trail extends Construct {
40
40
  bucketKeyEnabled: true,
41
41
  encryption: BucketEncryption.KMS,
42
42
  encryptionKey: this.encryptionKey.key,
43
- versioned: false,
43
+ versioned: true,
44
44
  removalPolicy: storagePolicy,
45
- lifecycleRules: [{ expiration: Duration.days(365), enabled: true }]
45
+ // Custom lifecycleRules bypass S3Bucket's automatic
46
+ // noncurrentVersionExpiration pairing — carried explicitly, or
47
+ // noncurrent versions accumulate forever.
48
+ lifecycleRules: [
49
+ {
50
+ expiration: Duration.days(365),
51
+ noncurrentVersionExpiration: Duration.days(NONCURRENT_VERSION_EXPIRY_DAYS),
52
+ enabled: true
53
+ }
54
+ ]
46
55
  });
47
56
  const effectiveTrailName = props.trailName || `${id}Trail`;
48
57
  if (props.isOrganizationTrail === true) {
@@ -2,7 +2,7 @@ import { Construct } from "constructs";
2
2
  import { Duration } from "aws-cdk-lib";
3
3
  import { Rule } from "aws-cdk-lib/aws-events";
4
4
  import { ServicePrincipal } from "aws-cdk-lib/aws-iam";
5
- import { SQSQueue } from "./sqs.js";
5
+ import { SQSQueue, SQS_LIMITS } from "./sqs.js";
6
6
  import { resolveTarget } from "./eventTargets.js";
7
7
  import { createScheduleAlarms } from "../monitoring/scheduleAlarms.js";
8
8
  import { createSqsDlqAlarms } from "../monitoring/sqsAlarms.js";
@@ -69,7 +69,10 @@ export class EventBridgeRule extends Construct {
69
69
  }
70
70
  return {
71
71
  queue: new SQSQueue(this, `${id}Dlq`, {
72
- queueType: "standard"
72
+ queueType: "standard",
73
+ // Same 14-day window as the SQS wrapper's auto-DLQ — without it the
74
+ // provisioned DLQ falls back to the 4-day CloudFormation default.
75
+ messageRetentionPeriod: Duration.days(SQS_LIMITS.DEAD_LETTER_QUEUE.DEFAULT_RETENTION_DAYS).toSeconds()
73
76
  }),
74
77
  provisioned: true
75
78
  };
@@ -22,6 +22,13 @@ export class SNSTopic extends Construct {
22
22
  ? (props.contentBasedDeduplication ?? true)
23
23
  : undefined,
24
24
  signatureVersion: props.signatureVersion ?? "2",
25
+ // The TLS-only deny synthesises an AWS::SNS::TopicPolicy, and that
26
+ // resource REPLACES the topic's implicit default policy — so any AWS
27
+ // service principal that published under the default Allow needs an
28
+ // explicit Allow merged in via getTopic().addToResourcePolicy(...)
29
+ // (SharedAlarmTopic carries the cloudwatch.amazonaws.com one).
30
+ // Same-account IAM publishers are unaffected: identity policies suffice.
31
+ enforceSSL: true,
25
32
  masterKey: props.masterKey
26
33
  });
27
34
  // An SNS topic is a transient fan-out medium: it holds no durable state
@@ -118,6 +118,7 @@ export class SQSQueue extends Construct {
118
118
  queueName: dlqName,
119
119
  fifo: isFifo,
120
120
  encryption: toEncryption(props.encryption),
121
+ enforceSSL: true,
121
122
  retentionPeriod: Duration.days(SQS_LIMITS.DEAD_LETTER_QUEUE.DEFAULT_RETENTION_DAYS),
122
123
  removalPolicy: resolvedRemovalPolicy
123
124
  });
@@ -156,6 +157,7 @@ export class SQSQueue extends Construct {
156
157
  : undefined,
157
158
  deadLetterQueue,
158
159
  encryption: toEncryption(props.encryption),
160
+ enforceSSL: true,
159
161
  contentBasedDeduplication: isFifo
160
162
  ? (props.contentBasedDeduplication ?? true)
161
163
  : undefined,
@@ -2,7 +2,7 @@ import { CfnOutput, Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
2
2
  import * as ec2 from "aws-cdk-lib/aws-ec2";
3
3
  import * as s3 from "aws-cdk-lib/aws-s3";
4
4
  import { LogGroup } from "../logging/logGroup.js";
5
- import { S3Bucket } from "../storage/index.js";
5
+ import { NONCURRENT_VERSION_EXPIRY_DAYS, S3Bucket } from "../storage/index.js";
6
6
  import { ResourceNaming } from "../../../utils/resourceNaming.js";
7
7
  export const FLOW_LOG_TRAFFIC_TYPES = ["ALL", "ACCEPT", "REJECT"];
8
8
  // Read at two sites (resources-layer constructor default + patterns-layer natGateways guard) that must agree.
@@ -183,8 +183,19 @@ export class Vpc extends ec2.Vpc {
183
183
  const bucket = new S3Bucket(scope, `${id}FlowLogBucket`, {
184
184
  bucketName: ResourceNaming.vpcFlowLogBucketName(id, Stack.of(scope).account),
185
185
  encryption: s3.BucketEncryption.S3_MANAGED,
186
+ versioned: true,
187
+ // Custom lifecycleRules bypass S3Bucket's automatic
188
+ // noncurrentVersionExpiration pairing — carried explicitly, or
189
+ // noncurrent versions accumulate forever. The no-retentionDays branch
190
+ // passes undefined, so the wrapper's own pairing applies there.
186
191
  lifecycleRules: config.retentionDays
187
- ? [{ expiration: Duration.days(config.retentionDays), enabled: true }]
192
+ ? [
193
+ {
194
+ expiration: Duration.days(config.retentionDays),
195
+ noncurrentVersionExpiration: Duration.days(NONCURRENT_VERSION_EXPIRY_DAYS),
196
+ enabled: true
197
+ }
198
+ ]
188
199
  : undefined
189
200
  });
190
201
  return {
@@ -1,4 +1,4 @@
1
- import { aws_ssm as ssm, Stack } from "aws-cdk-lib";
1
+ import { Annotations, aws_ssm as ssm, Stack } from "aws-cdk-lib";
2
2
  import { PolicyStatement } from "aws-cdk-lib/aws-iam";
3
3
  import { AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources";
4
4
  import { Construct } from "constructs";
@@ -20,6 +20,12 @@ export class SecureStringParameter extends Construct {
20
20
  constructor(scope, id, props) {
21
21
  super(scope, id);
22
22
  this.name = props.name;
23
+ // Matches the || fall-through below: an empty-string value or env var
24
+ // also lands on the placeholder.
25
+ const resolvedValue = props.value || process.env[`CDK_SECURE_STRING_${id}`];
26
+ if (!resolvedValue) {
27
+ Annotations.of(this).addWarningV2("@fjall/components-infrastructure:secrets:secureStringPlaceholderValue", `SecureStringParameter '${props.name}' has no value source — the well-known literal 'placeholderValue' will be written to the SecureString on create and update, and consumers will silently read it. Supply a real value via the 'value' prop or the CDK_SECURE_STRING_${id} environment variable at synth time.`);
28
+ }
23
29
  if (props.cmk) {
24
30
  this.cmk = props.cmk;
25
31
  }
@@ -39,9 +45,7 @@ export class SecureStringParameter extends Construct {
39
45
  parameters: {
40
46
  Name: props.name,
41
47
  Description: props.description || `${id} secure parameter`,
42
- Value: props.value ||
43
- process.env[`CDK_SECURE_STRING_${id}`] ||
44
- "placeholderValue",
48
+ Value: resolvedValue || "placeholderValue",
45
49
  Type: "SecureString",
46
50
  KeyId: this.cmk.alias.keyId
47
51
  // TODO: Add tags to the parameter
@@ -54,9 +58,7 @@ export class SecureStringParameter extends Construct {
54
58
  parameters: {
55
59
  Name: props.name,
56
60
  Description: props.description || `${id} secure parameter`,
57
- Value: props.value ||
58
- process.env[`CDK_SECURE_STRING_${id}`] ||
59
- "placeholderValue",
61
+ Value: resolvedValue || "placeholderValue",
60
62
  Overwrite: props.overwrite,
61
63
  Type: "SecureString",
62
64
  KeyId: this.cmk.alias.keyId
@@ -1,4 +1,4 @@
1
- import { SecretValue } from "aws-cdk-lib";
1
+ import { Annotations, SecretValue } from "aws-cdk-lib";
2
2
  import { Secret as CdkSecret } from "aws-cdk-lib/aws-secretsmanager";
3
3
  import { Construct } from "constructs";
4
4
  import { CustomerManagedKey } from "./kms.js";
@@ -81,6 +81,10 @@ export class Secret extends Construct {
81
81
  /**
82
82
  * If a secretStringValue is provided, use it to create the secret.
83
83
  */
84
+ if (props.secretStringValue) {
85
+ // Gotcha: never interpolate the secret value into this message.
86
+ Annotations.of(this).addWarningV2("@fjall/components-infrastructure:secrets:plainTextSecretValue", `Secret '${id}' uses secretStringValue — the plain-text value is embedded verbatim in the synthesised CloudFormation template and every synth artefact (SecretValue.unsafePlainText). Prefer generateSecretString (server-generated, never leaves AWS) or secretObjectValue with SecretValue references (e.g. SecretValue.ssmSecure).`);
87
+ }
84
88
  const secretStringValue = props.secretStringValue
85
89
  ? {
86
90
  secretStringValue: SecretValue.unsafePlainText(props.secretStringValue)
@@ -2,6 +2,14 @@ import { Bucket, type BucketProps } from "aws-cdk-lib/aws-s3";
2
2
  import { type Construct } from "constructs";
3
3
  import { type BackupTier } from "../../../utils/backupTierMapping.js";
4
4
  export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
5
+ /**
6
+ * Days a noncurrent object version is retained before lifecycle expiry.
7
+ * The wrapper pairs this automatically when it enables versioning; constructs
8
+ * passing custom `lifecycleRules` (cloudTrail, configRecorder, vpc flow logs)
9
+ * bypass that pairing and must carry the same value explicitly — coupled at
10
+ * every site so the retention window cannot drift.
11
+ */
12
+ export declare const NONCURRENT_VERSION_EXPIRY_DAYS = 30;
5
13
  export interface WebsiteHostingConfig {
6
14
  readonly indexDocument: string;
7
15
  readonly errorDocument?: string;
@@ -31,6 +39,11 @@ export interface ResourcePolicyStatement {
31
39
  * `autoDeleteObjects` is accepted for source compatibility but always
32
40
  * overridden to `false` (ADR D4.3) — passing `true` raises a synth-time
33
41
  * warning rather than a compile error so older scaffolds keep building.
42
+ *
43
+ * Non-public buckets assert `BlockPublicAccess.BLOCK_ALL` in the template
44
+ * (caller `blockPublicAccess` wins); public/website buckets keep the two
45
+ * policy halves open but block the ACL halves. KMS-encrypted buckets default
46
+ * `bucketKeyEnabled: true` (pass `false` to opt out).
34
47
  */
35
48
  export interface S3BucketProps extends BucketProps {
36
49
  backupVaultTier?: BackupTier;
@@ -1,12 +1,20 @@
1
1
  import { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
2
2
  import { Annotations, CfnOutput, Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
3
- import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3";
3
+ import { BlockPublicAccess, Bucket, BucketEncryption } from "aws-cdk-lib/aws-s3";
4
4
  import { ArnPrincipal, Effect, PolicyStatement, StarPrincipal } from "aws-cdk-lib/aws-iam";
5
5
  import { RegionInfo } from "aws-cdk-lib/region-info";
6
6
  import { toPascalCase } from "../../../utils/capitaliseString.js";
7
7
  import { bucketWebsiteEndpointExportName, bucketWebsiteHostedZoneIdExportName } from "@fjall/util";
8
8
  import { envAwareRemovalPolicyDefault, toRemovalPolicy } from "../../../utils/removalPolicy.js";
9
9
  export { SDK_PRE_EMPTY_TAG_KEY } from "@fjall/util/aws";
10
+ /**
11
+ * Days a noncurrent object version is retained before lifecycle expiry.
12
+ * The wrapper pairs this automatically when it enables versioning; constructs
13
+ * passing custom `lifecycleRules` (cloudTrail, configRecorder, vpc flow logs)
14
+ * bypass that pairing and must carry the same value explicitly — coupled at
15
+ * every site so the retention window cannot drift.
16
+ */
17
+ export const NONCURRENT_VERSION_EXPIRY_DAYS = 30;
10
18
  function shouldAutoVersion(tier) {
11
19
  return tier === "resilient" || tier === "enterprise";
12
20
  }
@@ -22,6 +30,12 @@ export class S3Bucket extends Bucket {
22
30
  const isPublic = props.publicReadAccess === true || websiteHosting !== undefined;
23
31
  const versioned = props.versioned ?? shouldAutoVersion(backupVaultTier);
24
32
  const removalPolicy = props.removalPolicy ?? toRemovalPolicy(envAwareRemovalPolicyDefault());
33
+ // Mirrors the CDK Bucket's own resolution: an encryptionKey with no
34
+ // explicit encryption infers KMS. DSSE is excluded deliberately — S3
35
+ // Bucket Keys are not supported for dual-layer SSE-KMS.
36
+ const kmsEncryption = props.encryption === BucketEncryption.KMS ||
37
+ props.encryption === BucketEncryption.KMS_MANAGED ||
38
+ (props.encryption === undefined && props.encryptionKey !== undefined);
25
39
  super(scope, id, {
26
40
  ...cdkProps,
27
41
  enforceSSL: true,
@@ -31,14 +45,22 @@ export class S3Bucket extends Bucket {
31
45
  autoDeleteObjects: false,
32
46
  removalPolicy,
33
47
  publicReadAccess: isPublic,
34
- ...(isPublic && {
35
- blockPublicAccess: new BlockPublicAccess({
36
- blockPublicAcls: false,
48
+ // Public access here is bucket-policy-based, so only the policy halves
49
+ // stay open (grantPublicAccess validates against blockPublicPolicy
50
+ // alone); the ACL halves close — ACLs are disabled on modern buckets.
51
+ // Non-public buckets assert BLOCK_ALL in the template rather than
52
+ // relying on the creation-time account default, so a manually loosened
53
+ // bucket is healed on the next deploy.
54
+ blockPublicAccess: isPublic
55
+ ? new BlockPublicAccess({
56
+ blockPublicAcls: true,
37
57
  blockPublicPolicy: false,
38
- ignorePublicAcls: false,
58
+ ignorePublicAcls: true,
39
59
  restrictPublicBuckets: false
40
60
  })
41
- }),
61
+ : (props.blockPublicAccess ?? BlockPublicAccess.BLOCK_ALL),
62
+ ...(props.bucketKeyEnabled === undefined &&
63
+ kmsEncryption && { bucketKeyEnabled: true }),
42
64
  ...(websiteHosting && {
43
65
  websiteIndexDocument: websiteHosting.indexDocument,
44
66
  websiteErrorDocument: websiteHosting.errorDocument ?? "error.html"
@@ -54,7 +76,12 @@ export class S3Bucket extends Bucket {
54
76
  enabled: true
55
77
  },
56
78
  ...(versioned && !props.lifecycleRules
57
- ? [{ noncurrentVersionExpiration: Duration.days(30), enabled: true }]
79
+ ? [
80
+ {
81
+ noncurrentVersionExpiration: Duration.days(NONCURRENT_VERSION_EXPIRY_DAYS),
82
+ enabled: true
83
+ }
84
+ ]
58
85
  : (props.lifecycleRules ?? []))
59
86
  ]
60
87
  });
@@ -1,13 +1,24 @@
1
1
  import { Stack } from "aws-cdk-lib";
2
2
  import { Project } from "aws-cdk-lib/aws-codebuild";
3
3
  import { Construct } from "constructs";
4
+ import { DEFAULT_FRAMEWORK_LOG_RETENTION, LogGroup } from "../logging/logGroup.js";
4
5
  export class CodeBuildProject extends Construct {
5
6
  project;
6
7
  constructor(scope, id, props) {
7
8
  super(scope, id);
8
9
  this.project = new Project(this, id, {
9
10
  ...props,
10
- description: props.description || `Code build project for ${id}`
11
+ description: props.description || `Code build project for ${id}`,
12
+ // Without a logging prop CodeBuild auto-creates a never-expiring
13
+ // runtime log group outside CloudFormation; default to a framework
14
+ // group so retention and removal policy are governed.
15
+ logging: props.logging ?? {
16
+ cloudWatch: {
17
+ logGroup: new LogGroup(this, `${id}LogGroup`, {
18
+ retention: DEFAULT_FRAMEWORK_LOG_RETENTION
19
+ })
20
+ }
21
+ }
11
22
  });
12
23
  }
13
24
  }
@@ -1,6 +1,6 @@
1
- import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT } from "@fjall/util/aws";
1
+ import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT, DEFAULT_COST_ALLOCATION_OWNER } from "@fjall/util/aws";
2
2
  import type { IConstruct } from "constructs";
3
- export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT };
3
+ export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT, DEFAULT_COST_ALLOCATION_OWNER };
4
4
  export interface CostAllocationTagsArgs {
5
5
  readonly service: string;
6
6
  readonly domain: string;
@@ -1,9 +1,9 @@
1
- import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT } from "@fjall/util/aws";
1
+ import { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT, DEFAULT_COST_ALLOCATION_OWNER } from "@fjall/util/aws";
2
2
  import { Tags } from "aws-cdk-lib";
3
3
  // Canonical home: @fjall/util (`util/src/aws/costAllocationTags.ts`) — shared
4
4
  // with deploy-core's `cdk bootstrap --tags` stamping. Re-exported here so
5
5
  // construct-package consumers keep their existing import path.
6
- export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT };
6
+ export { COST_ALLOCATION_TAGS, DEFAULT_COST_ALLOCATION_ENVIRONMENT, DEFAULT_COST_ALLOCATION_OWNER };
7
7
  export function applyCostAllocationTags(scope, args) {
8
8
  if (args.environment !== undefined) {
9
9
  Tags.of(scope).add(COST_ALLOCATION_TAGS.ENVIRONMENT, args.environment);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "27.0.0",
3
+ "version": "28.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -78,8 +78,8 @@
78
78
  },
79
79
  "dependencies": {
80
80
  "@aws-sdk/client-organizations": "^3.1098.0",
81
- "@fjall/generator": "^27.0.0",
82
- "@fjall/util": "^27.0.0",
81
+ "@fjall/generator": "^28.0.0",
82
+ "@fjall/util": "^28.0.0",
83
83
  "constructs": "^10.7.2",
84
84
  "zod": "^4.4.3"
85
85
  },