@friggframework/devtools 2.0.0--canary.629.1a7ad06.0 → 2.0.0--canary.640.5140601.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.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * ADR-033: Aurora Serverless v2 scale-to-zero + NAT-free public connectivity
3
+ *
4
+ * These tests assert on the GENERATED CloudFormation template (via the full
5
+ * composer) plus the AuroraBuilder validator. They are deterministic and require
6
+ * neither a live AWS account nor Prisma client generation:
7
+ * - Validator cases call AuroraBuilder.validate() directly (pure, no I/O).
8
+ * - Template cases use vpc.management='create-new' + database.postgres
9
+ * management='managed', which resolve to STACK ownership without depending on
10
+ * any discovered AWS resource, so cloud discovery returning empty is fine.
11
+ *
12
+ * Both new capabilities are opt-in and default-off; the final describe block
13
+ * guards the no-regression promise: a default (vpc, no minCapacity) definition
14
+ * composes the same template as before.
15
+ */
16
+
17
+ const { composeServerlessDefinition } = require('../infrastructure-composer');
18
+ const { AuroraBuilder } = require('../domains/database/aurora-builder');
19
+
20
+ // Shared: an app definition that creates a fresh VPC + a Frigg-owned Aurora
21
+ // cluster in-stack, with overridable postgres config.
22
+ function makeApp(postgresOverrides = {}) {
23
+ return {
24
+ name: 'adr033-app',
25
+ provider: 'aws',
26
+ region: 'us-east-1',
27
+ integrations: [],
28
+ vpc: { enable: true, management: 'create-new' },
29
+ database: {
30
+ postgres: {
31
+ enable: true,
32
+ management: 'managed',
33
+ ...postgresOverrides,
34
+ },
35
+ },
36
+ };
37
+ }
38
+
39
+ function findResources(template, predicate) {
40
+ return Object.entries(template.resources.Resources).filter(([, r]) => predicate(r));
41
+ }
42
+
43
+ describe('ADR-033: Aurora scale-to-zero + connectivity', () => {
44
+ beforeAll(() => {
45
+ process.env.AWS_REGION = 'us-east-1';
46
+ // Intentionally NOT setting FRIGG_SKIP_AWS_DISCOVERY — the builders must
47
+ // execute. create-new/managed resolve to STACK without needing discovery.
48
+ });
49
+
50
+ afterAll(() => {
51
+ delete process.env.AWS_REGION;
52
+ });
53
+
54
+ // ---------------------------------------------------------------------
55
+ // Validator
56
+ // ---------------------------------------------------------------------
57
+ describe('validator (AuroraBuilder.validate)', () => {
58
+ const build = new AuroraBuilder();
59
+ // ValidationResult exposes hasErrors(); "valid" means no errors.
60
+ // vpc.enable defaults to true so connectivity:'public' cases don't trip the
61
+ // "public requires vpc.enable" rule; pass appOverrides to change it.
62
+ const validateResult = (postgres, appOverrides = {}) =>
63
+ build.validate({
64
+ vpc: { enable: true },
65
+ database: { postgres: { enable: true, ...postgres } },
66
+ ...appOverrides,
67
+ });
68
+ const isValid = (postgres, appOverrides) => !validateResult(postgres, appOverrides).hasErrors();
69
+
70
+ test('accepts minCapacity: 0 (scale-to-zero)', () => {
71
+ expect(isValid({ minCapacity: 0 })).toBe(true);
72
+ });
73
+
74
+ test('rejects minCapacity: 0.3 (inside the forbidden (0, 0.5) band)', () => {
75
+ const r = validateResult({ minCapacity: 0.3 });
76
+ expect(r.hasErrors()).toBe(true);
77
+ expect(r.errors.join(' ')).toMatch(/minCapacity must be 0 \(scale-to-zero\) or between 0\.5 and 128/);
78
+ });
79
+
80
+ test('accepts minCapacity: 0.5 and minCapacity: 64', () => {
81
+ expect(isValid({ minCapacity: 0.5 })).toBe(true);
82
+ expect(isValid({ minCapacity: 64 })).toBe(true);
83
+ });
84
+
85
+ test('rejects secondsUntilAutoPause: 100 (below 300)', () => {
86
+ const r = validateResult({ minCapacity: 0, secondsUntilAutoPause: 100 });
87
+ expect(r.hasErrors()).toBe(true);
88
+ expect(r.errors.join(' ')).toMatch(/secondsUntilAutoPause must be an integer between 300 and 86400/);
89
+ });
90
+
91
+ test('accepts secondsUntilAutoPause: 3600', () => {
92
+ expect(isValid({ minCapacity: 0, secondsUntilAutoPause: 3600 })).toBe(true);
93
+ });
94
+
95
+ test("accepts connectivity: 'public'", () => {
96
+ expect(isValid({ connectivity: 'public' })).toBe(true);
97
+ });
98
+
99
+ test("rejects connectivity: 'nope'", () => {
100
+ const r = validateResult({ connectivity: 'nope' });
101
+ expect(r.hasErrors()).toBe(true);
102
+ expect(r.errors.join(' ')).toMatch(/Invalid database\.postgres\.connectivity/);
103
+ });
104
+
105
+ test('rejects non-array / non-CIDR allowedCidrs, accepts valid CIDRs', () => {
106
+ expect(isValid({ allowedCidrs: 'nope' })).toBe(false);
107
+ expect(isValid({ allowedCidrs: ['not-a-cidr'] })).toBe(false);
108
+ expect(isValid({ allowedCidrs: ['10.0.0.0/8', '203.0.113.5/32'] })).toBe(true);
109
+ });
110
+
111
+ test('rejects out-of-range CIDR octets/prefix (numeric, not just shape)', () => {
112
+ expect(isValid({ allowedCidrs: ['999.999.999.999/99'] })).toBe(false);
113
+ expect(isValid({ allowedCidrs: ['10.0.0.0/33'] })).toBe(false);
114
+ expect(isValid({ allowedCidrs: ['256.1.1.1/24'] })).toBe(false);
115
+ expect(isValid({ allowedCidrs: ['0.0.0.0/0'] })).toBe(true);
116
+ });
117
+
118
+ test('rejects empty allowedCidrs in public mode (no silent full-internet fallback)', () => {
119
+ const r = validateResult({ connectivity: 'public', allowedCidrs: [] });
120
+ expect(r.hasErrors()).toBe(true);
121
+ expect(r.errors.join(' ')).toMatch(/allowedCidrs is empty with connectivity="public"/);
122
+ });
123
+
124
+ test('rejects minCapacity > maxCapacity', () => {
125
+ const r = validateResult({ minCapacity: 8, maxCapacity: 4 });
126
+ expect(r.hasErrors()).toBe(true);
127
+ expect(r.errors.join(' ')).toMatch(/minCapacity \(8\) must be <= maxCapacity \(4\)/);
128
+ // equal is fine
129
+ expect(isValid({ minCapacity: 4, maxCapacity: 4 })).toBe(true);
130
+ });
131
+
132
+ test("connectivity:'public' requires vpc.enable=true", () => {
133
+ const r = validateResult({ connectivity: 'public' }, { vpc: { enable: false } });
134
+ expect(r.hasErrors()).toBe(true);
135
+ expect(r.errors.join(' ')).toMatch(/connectivity="public" requires vpc\.enable=true/);
136
+ // With vpc.enable true (default helper), it's valid
137
+ expect(isValid({ connectivity: 'public' })).toBe(true);
138
+ });
139
+
140
+ test('warns when secondsUntilAutoPause set with minCapacity !== 0', () => {
141
+ const r = validateResult({ minCapacity: 0.5, secondsUntilAutoPause: 3600 });
142
+ expect(r.hasErrors()).toBe(false);
143
+ expect(r.warnings.join(' ')).toMatch(/secondsUntilAutoPause is ignored unless minCapacity is 0/);
144
+ // No such warning when minCapacity is 0
145
+ const r0 = validateResult({ minCapacity: 0, secondsUntilAutoPause: 3600 });
146
+ expect(r0.warnings.join(' ')).not.toMatch(/ignored unless minCapacity is 0/);
147
+ });
148
+
149
+ test("warns (not errors) for connectivity:'public' with discover/use-existing management", () => {
150
+ const rDiscover = validateResult({ connectivity: 'public', management: 'discover' });
151
+ expect(rDiscover.hasErrors()).toBe(false);
152
+ expect(rDiscover.warnings.join(' ')).toMatch(/assumes the EXISTING Aurora cluster is already publicly accessible/);
153
+
154
+ const rUseExisting = validateResult({
155
+ connectivity: 'public',
156
+ management: 'use-existing',
157
+ endpoint: 'db.example.com',
158
+ });
159
+ expect(rUseExisting.warnings.join(' ')).toMatch(/only affects TLS/);
160
+ });
161
+
162
+ test('warns (does not fail) when minCapacity:0 with an older pinned engine version', () => {
163
+ const r = validateResult({ minCapacity: 0, engineVersion: '15.4' });
164
+ expect(r.hasErrors()).toBe(false); // warning, not error
165
+ expect(r.warnings.join(' ')).toMatch(/may not support .*scale-to-zero/);
166
+ });
167
+
168
+ test('does not warn about engine when minCapacity:0 on a capable version', () => {
169
+ const r = validateResult({ minCapacity: 0, engineVersion: '15.13' });
170
+ expect(r.warnings.join(' ')).not.toMatch(/may not support .*scale-to-zero/);
171
+ });
172
+ });
173
+
174
+ // ---------------------------------------------------------------------
175
+ // Scale-to-zero (template shape)
176
+ // ---------------------------------------------------------------------
177
+ describe('scale-to-zero (minCapacity: 0)', () => {
178
+ test('MinCapacity is exactly 0 and SecondsUntilAutoPause defaults to 300', async () => {
179
+ const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
180
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
181
+
182
+ // Mutation guard: with the old `|| 0.5` bug this would be 0.5, not 0.
183
+ expect(scaling.MinCapacity).toBe(0);
184
+ expect(scaling.MinCapacity).not.toBe(0.5);
185
+ expect(scaling.SecondsUntilAutoPause).toBe(300);
186
+ });
187
+
188
+ test('SecondsUntilAutoPause honors a custom value', async () => {
189
+ const t = await composeServerlessDefinition(makeApp({ minCapacity: 0, secondsUntilAutoPause: 1800 }));
190
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
191
+ expect(scaling.MinCapacity).toBe(0);
192
+ expect(scaling.SecondsUntilAutoPause).toBe(1800);
193
+ });
194
+
195
+ test('MaxCapacity defaults to 4 and is preserved with scale-to-zero', async () => {
196
+ const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
197
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
198
+ expect(scaling.MaxCapacity).toBe(4);
199
+ });
200
+ });
201
+
202
+ // ---------------------------------------------------------------------
203
+ // Public connectivity (template shape)
204
+ // ---------------------------------------------------------------------
205
+ describe("connectivity: 'public'", () => {
206
+ test('Aurora ingress uses CidrIp — one rule per allowedCidr — not SourceSecurityGroupId', async () => {
207
+ const t = await composeServerlessDefinition(
208
+ makeApp({ connectivity: 'public', allowedCidrs: ['10.1.0.0/16', '203.0.113.7/32'] })
209
+ );
210
+ const ingress = findResources(
211
+ t,
212
+ (r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
213
+ );
214
+ expect(ingress).toHaveLength(2);
215
+ const cidrs = ingress.map(([, r]) => r.Properties.CidrIp).sort();
216
+ expect(cidrs).toEqual(['10.1.0.0/16', '203.0.113.7/32']);
217
+ ingress.forEach(([, r]) => {
218
+ expect(r.Properties.CidrIp).toBeDefined();
219
+ expect(r.Properties.SourceSecurityGroupId).toBeUndefined();
220
+ });
221
+ });
222
+
223
+ test('allowedCidrs defaults to 0.0.0.0/0 when omitted', async () => {
224
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
225
+ const ingress = findResources(
226
+ t,
227
+ (r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
228
+ );
229
+ expect(ingress).toHaveLength(1);
230
+ expect(ingress[0][1].Properties.CidrIp).toBe('0.0.0.0/0');
231
+ });
232
+
233
+ test('Aurora instance is PubliclyAccessible and cluster sits in public subnets', async () => {
234
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
235
+ expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(true);
236
+ expect(t.resources.Resources.FriggDBSubnetGroup.Properties.SubnetIds).toEqual([
237
+ { Ref: 'FriggPublicSubnet' },
238
+ { Ref: 'FriggPublicSubnet2' },
239
+ ]);
240
+ });
241
+
242
+ test('NO NAT Gateway resource is emitted', async () => {
243
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
244
+ const nats = findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway');
245
+ expect(nats).toHaveLength(0);
246
+ });
247
+
248
+ test('public subnets get an IGW default route + route table + both associations (create-new VPC)', async () => {
249
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
250
+ const R = t.resources.Resources;
251
+
252
+ // Public route table
253
+ expect(R.FriggPublicRouteTable).toBeDefined();
254
+ expect(R.FriggPublicRouteTable.Type).toBe('AWS::EC2::RouteTable');
255
+
256
+ // 0.0.0.0/0 -> Internet Gateway default route
257
+ const igwRoutes = findResources(
258
+ t,
259
+ (r) =>
260
+ r.Type === 'AWS::EC2::Route' &&
261
+ r.Properties.DestinationCidrBlock === '0.0.0.0/0' &&
262
+ r.Properties.GatewayId &&
263
+ r.Properties.GatewayId.Ref === 'FriggInternetGateway'
264
+ );
265
+ expect(igwRoutes).toHaveLength(1);
266
+
267
+ // Both public subnet associations
268
+ expect(R.FriggPublicSubnet1RouteTableAssociation).toBeDefined();
269
+ expect(R.FriggPublicSubnet2RouteTableAssociation).toBeDefined();
270
+ expect(R.FriggPublicSubnet1RouteTableAssociation.Properties.RouteTableId).toEqual({
271
+ Ref: 'FriggPublicRouteTable',
272
+ });
273
+
274
+ // And there must be NO NAT route (that would imply a NAT default route)
275
+ const natRoutes = findResources(
276
+ t,
277
+ (r) => r.Type === 'AWS::EC2::Route' && r.Properties.NatGatewayId
278
+ );
279
+ expect(natRoutes).toHaveLength(0);
280
+ });
281
+
282
+ test('VPC_ENABLED is false in public mode (Lambda not VPC-attached)', async () => {
283
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
284
+ expect(t.provider.environment.VPC_ENABLED).toBe('false');
285
+ });
286
+
287
+ test('Lambda is NOT attached to the VPC (provider.vpc unset)', async () => {
288
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
289
+ expect(t.provider.vpc).toBeUndefined();
290
+ });
291
+
292
+ test('DATABASE_URL enforces TLS (sslmode=require)', async () => {
293
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
294
+ const url = t.provider.environment.DATABASE_URL;
295
+ expect(url['Fn::Sub'][0]).toContain('sslmode=require');
296
+ });
297
+
298
+ test('combines with scale-to-zero: $0-idle NAT-free Aurora', async () => {
299
+ const t = await composeServerlessDefinition(makeApp({ connectivity: 'public', minCapacity: 0 }));
300
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
301
+ expect(scaling.MinCapacity).toBe(0);
302
+ expect(scaling.SecondsUntilAutoPause).toBe(300);
303
+ expect(findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway')).toHaveLength(0);
304
+ expect(t.provider.vpc).toBeUndefined();
305
+ });
306
+ });
307
+
308
+ // ---------------------------------------------------------------------
309
+ // No-regression: default (vpc) connectivity, no new fields
310
+ // ---------------------------------------------------------------------
311
+ describe("default connectivity: 'vpc' (no regression)", () => {
312
+ test('MinCapacity defaults to 0.5 with no SecondsUntilAutoPause', async () => {
313
+ const t = await composeServerlessDefinition(makeApp());
314
+ const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
315
+ expect(scaling.MinCapacity).toBe(0.5);
316
+ expect(scaling.MaxCapacity).toBe(4);
317
+ expect(scaling.SecondsUntilAutoPause).toBeUndefined();
318
+ });
319
+
320
+ test('Aurora ingress uses SourceSecurityGroupId (Lambda SG), not CidrIp', async () => {
321
+ const t = await composeServerlessDefinition(makeApp());
322
+ const ingress = findResources(
323
+ t,
324
+ (r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
325
+ );
326
+ expect(ingress).toHaveLength(1);
327
+ expect(ingress[0][1].Properties.SourceSecurityGroupId).toEqual({ Ref: 'FriggLambdaSecurityGroup' });
328
+ expect(ingress[0][1].Properties.CidrIp).toBeUndefined();
329
+ // Logical ID unchanged for the vpc path
330
+ expect(ingress[0][0]).toBe('FriggAuroraIngressRule');
331
+ });
332
+
333
+ test('Aurora instance is not publicly accessible by default', async () => {
334
+ const t = await composeServerlessDefinition(makeApp());
335
+ expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(false);
336
+ });
337
+
338
+ test('Lambda IS attached to the VPC (provider.vpc set)', async () => {
339
+ const t = await composeServerlessDefinition(makeApp());
340
+ expect(t.provider.vpc).toBeDefined();
341
+ expect(t.provider.vpc.subnetIds).toBeDefined();
342
+ expect(t.provider.vpc.securityGroupIds).toBeDefined();
343
+ });
344
+
345
+ test('DATABASE_URL does not add sslmode in vpc mode', async () => {
346
+ const t = await composeServerlessDefinition(makeApp());
347
+ const url = t.provider.environment.DATABASE_URL;
348
+ expect(url['Fn::Sub'][0]).not.toContain('sslmode=require');
349
+ });
350
+
351
+ test('VPC_ENABLED stays true in vpc mode', async () => {
352
+ const t = await composeServerlessDefinition(makeApp());
353
+ expect(t.provider.environment.VPC_ENABLED).toBe('true');
354
+ });
355
+ });
356
+ });
@@ -94,21 +94,206 @@ class AuroraBuilder extends InfrastructureBuilder {
94
94
  }
95
95
 
96
96
  // Validate capacity settings
97
- if (dbConfig.minCapacity !== undefined && (dbConfig.minCapacity < 0.5 || dbConfig.minCapacity > 128)) {
98
- result.addError('database.postgres.minCapacity must be between 0.5 and 128');
97
+ // minCapacity accepts 0 (Aurora Serverless v2 scale-to-zero, GA Nov 2024)
98
+ // OR a value in [0.5, 128]. Values in (0, 0.5) are not a valid Aurora
99
+ // capacity — reject them explicitly. See ADR-033.
100
+ if (
101
+ dbConfig.minCapacity !== undefined &&
102
+ dbConfig.minCapacity !== 0 &&
103
+ (dbConfig.minCapacity < 0.5 || dbConfig.minCapacity > 128)
104
+ ) {
105
+ result.addError('database.postgres.minCapacity must be 0 (scale-to-zero) or between 0.5 and 128');
99
106
  }
100
107
  if (dbConfig.maxCapacity !== undefined && (dbConfig.maxCapacity < 0.5 || dbConfig.maxCapacity > 128)) {
101
108
  result.addError('database.postgres.maxCapacity must be between 0.5 and 128');
102
109
  }
110
+ // Cross-check: minCapacity must not exceed maxCapacity, or CloudFormation
111
+ // rejects the ServerlessV2ScalingConfiguration at deploy time.
112
+ if (
113
+ dbConfig.minCapacity !== undefined &&
114
+ dbConfig.maxCapacity !== undefined &&
115
+ dbConfig.minCapacity > dbConfig.maxCapacity
116
+ ) {
117
+ result.addError(
118
+ `database.postgres.minCapacity (${dbConfig.minCapacity}) must be <= maxCapacity (${dbConfig.maxCapacity})`
119
+ );
120
+ }
121
+
122
+ // Validate scale-to-zero auto-pause window (seconds). AWS-valid range is
123
+ // 300–86400 (5 minutes to 24 hours) and it must be an integer.
124
+ if (dbConfig.secondsUntilAutoPause !== undefined) {
125
+ const s = dbConfig.secondsUntilAutoPause;
126
+ if (!Number.isInteger(s) || s < 300 || s > 86400) {
127
+ result.addError('database.postgres.secondsUntilAutoPause must be an integer between 300 and 86400');
128
+ }
129
+ // secondsUntilAutoPause only takes effect for scale-to-zero clusters.
130
+ if (dbConfig.minCapacity !== 0) {
131
+ result.addWarning(
132
+ 'database.postgres.secondsUntilAutoPause is ignored unless minCapacity is 0 (scale-to-zero).'
133
+ );
134
+ }
135
+ }
136
+
137
+ // Validate connectivity mode. 'vpc' (default) keeps today's behavior;
138
+ // 'public' places Aurora in public subnets and leaves the Lambda out of
139
+ // the VPC (NAT-free). See ADR-033.
140
+ if (dbConfig.connectivity !== undefined) {
141
+ const validConnectivity = ['vpc', 'public'];
142
+ if (!validConnectivity.includes(dbConfig.connectivity)) {
143
+ result.addError(
144
+ `Invalid database.postgres.connectivity: "${dbConfig.connectivity}". Must be one of: ${validConnectivity.join(', ')}`
145
+ );
146
+ }
147
+ }
148
+
149
+ // Public connectivity requires an enabled VPC (Aurora must live in a VPC —
150
+ // an AWS constraint — and the builder creates the public subnets there).
151
+ // Validate it here so the failure is a clear message rather than a downstream
152
+ // "Aurora requires 2 public subnets" throw.
153
+ if (dbConfig.connectivity === 'public' && appDefinition.vpc?.enable !== true) {
154
+ result.addError(
155
+ 'database.postgres.connectivity="public" requires vpc.enable=true (Aurora needs public subnets in a VPC).'
156
+ );
157
+ }
158
+
159
+ // Validate allowedCidrs is an array of syntactically valid IPv4 CIDRs when
160
+ // present. NOTE: IPv4 only — IPv6 (CidrIpv6) is not supported here.
161
+ if (dbConfig.allowedCidrs !== undefined) {
162
+ if (!Array.isArray(dbConfig.allowedCidrs)) {
163
+ result.addError('database.postgres.allowedCidrs must be an array of CIDR strings');
164
+ } else {
165
+ const bad = dbConfig.allowedCidrs.filter((c) => !this.isValidIpv4Cidr(c));
166
+ if (bad.length > 0) {
167
+ result.addError(
168
+ `database.postgres.allowedCidrs contains invalid CIDR value(s): ${bad.join(', ')}`
169
+ );
170
+ }
171
+ // An empty allowlist in public mode is almost always a mistake that
172
+ // would silently fall back to 0.0.0.0/0 (full internet exposure) —
173
+ // reject it so "allow nothing" cannot mean "allow everything".
174
+ if (dbConfig.allowedCidrs.length === 0 && dbConfig.connectivity === 'public') {
175
+ result.addError(
176
+ 'database.postgres.allowedCidrs is empty with connectivity="public". List at least one CIDR ' +
177
+ "(use ['0.0.0.0/0'] to intentionally allow the whole internet)."
178
+ );
179
+ }
180
+ }
181
+ }
182
+
183
+ // Scale-to-zero requires a supported engine version. Warn (do not
184
+ // hard-fail) if the user pins an older version with minCapacity: 0.
185
+ // Frigg's default engineVersion (15.13) qualifies. Minimum
186
+ // scale-to-zero-capable versions: Aurora PG 13.15 / 14.12 / 15.7 / 16.3.
187
+ if (dbConfig.minCapacity === 0 && dbConfig.engineVersion) {
188
+ if (!this.engineSupportsScaleToZero(dbConfig.engineVersion)) {
189
+ result.addWarning(
190
+ `database.postgres.engineVersion="${dbConfig.engineVersion}" may not support Aurora Serverless v2 scale-to-zero (minCapacity: 0). ` +
191
+ 'Scale-to-zero requires Aurora PostgreSQL 13.15+/14.12+/15.7+/16.3+.'
192
+ );
193
+ }
194
+ }
103
195
 
104
196
  // Warn about public accessibility in production
105
197
  if (dbConfig.publiclyAccessible === true) {
106
198
  result.addWarning('database.postgres.publiclyAccessible=true is not recommended for production');
107
199
  }
108
200
 
201
+ // ADR-033 security posture: public connectivity exposes the DB endpoint
202
+ // to the internet. Make the trade-off loud.
203
+ if (dbConfig.connectivity === 'public') {
204
+ result.addWarning(
205
+ 'database.postgres.connectivity="public" exposes the Aurora endpoint to the internet. ' +
206
+ 'TLS is enforced (sslmode=require) and access is restricted to allowedCidrs, but prefer "vpc" for production.'
207
+ );
208
+ // Only the default fallback (empty array is now an error above) or an
209
+ // explicit 0.0.0.0/0 reaches here as "whole internet".
210
+ const cidrs =
211
+ Array.isArray(dbConfig.allowedCidrs) && dbConfig.allowedCidrs.length > 0
212
+ ? dbConfig.allowedCidrs
213
+ : ['0.0.0.0/0'];
214
+ if (cidrs.includes('0.0.0.0/0')) {
215
+ result.addWarning(
216
+ 'database.postgres.allowedCidrs allows 0.0.0.0/0 (the whole internet). ' +
217
+ 'Narrow this to known egress ranges wherever the deployment can.'
218
+ );
219
+ }
220
+
221
+ // Public connectivity only makes Frigg place the cluster in public
222
+ // subnets when Frigg CREATES the cluster (management='managed'). In
223
+ // discover/use-existing mode Frigg cannot flip an existing cluster to
224
+ // public subnets / PubliclyAccessible, yet the Lambda is still detached
225
+ // from the VPC — so if the existing cluster is private it becomes
226
+ // unreachable. Warn loudly. (management defaults to 'discover'.)
227
+ const mgmt = dbConfig.management || 'discover';
228
+ if (mgmt === 'discover' || mgmt === 'use-existing') {
229
+ result.addWarning(
230
+ `database.postgres.connectivity="public" with management="${mgmt}" assumes the EXISTING Aurora cluster ` +
231
+ 'is already publicly accessible. Frigg detaches the Lambda from the VPC in public mode, so a private ' +
232
+ 'existing cluster will be unreachable. Ensure the cluster has a public endpoint and open security group.'
233
+ );
234
+ }
235
+ if (mgmt === 'use-existing') {
236
+ result.addWarning(
237
+ 'database.postgres.connectivity="public" with management="use-existing" only affects TLS (sslmode=require) on the ' +
238
+ 'connection string — Frigg does not manage the existing cluster\'s subnets or ingress.'
239
+ );
240
+ }
241
+ }
242
+
109
243
  return result;
110
244
  }
111
245
 
246
+ /**
247
+ * Validate an IPv4 CIDR string with real numeric range checks (each octet
248
+ * 0–255, prefix 0–32) — not just shape — so values like 999.999.999.999/99
249
+ * are rejected. IPv4 only; IPv6 is out of scope for allowedCidrs.
250
+ * @param {string} cidr
251
+ * @returns {boolean}
252
+ */
253
+ isValidIpv4Cidr(cidr) {
254
+ if (typeof cidr !== 'string') {
255
+ return false;
256
+ }
257
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(cidr);
258
+ if (!match) {
259
+ return false;
260
+ }
261
+ const octets = [match[1], match[2], match[3], match[4]].map(Number);
262
+ if (octets.some((o) => o > 255)) {
263
+ return false;
264
+ }
265
+ const prefix = Number(match[5]);
266
+ return prefix >= 0 && prefix <= 32;
267
+ }
268
+
269
+ /**
270
+ * Determine whether an Aurora PostgreSQL engine version supports
271
+ * Serverless v2 scale-to-zero (minCapacity: 0). Minimum capable versions:
272
+ * 13.15+, 14.12+, 15.7+, 16.3+. Unknown/unparseable versions return true
273
+ * (assume-capable) so we never hard-block on a version string we can't read;
274
+ * this only gates a warning, never a hard failure.
275
+ * @param {string} engineVersion e.g. '15.13'
276
+ * @returns {boolean}
277
+ */
278
+ engineSupportsScaleToZero(engineVersion) {
279
+ const minByMajor = { 13: 15, 14: 12, 15: 7, 16: 3 };
280
+ const match = /^(\d+)\.(\d+)/.exec(String(engineVersion));
281
+ if (!match) {
282
+ return true; // can't parse — don't warn spuriously
283
+ }
284
+ const major = Number(match[1]);
285
+ const minor = Number(match[2]);
286
+ // Majors newer than the table are assumed capable.
287
+ if (major > 16) {
288
+ return true;
289
+ }
290
+ // Majors older than 13 never support scale-to-zero.
291
+ if (!(major in minByMajor)) {
292
+ return false;
293
+ }
294
+ return minor >= minByMajor[major];
295
+ }
296
+
112
297
  /**
113
298
  * Build Aurora infrastructure using ownership-based architecture
114
299
  */
@@ -327,16 +512,32 @@ class AuroraBuilder extends InfrastructureBuilder {
327
512
  }
328
513
  }
329
514
 
330
- // Preserve other database config
331
- if (appDefinition.database?.postgres?.minCapacity) {
515
+ // Mirror config into translated.database.postgres.config for completeness.
516
+ // NOTE: this is NOT the load-bearing path — createNewAurora/discoverAurora
517
+ // read the TOP-LEVEL database.postgres fields (preserved by the deep clone
518
+ // above), and 0-propagation for scale-to-zero comes from that clone plus the
519
+ // `?? 0.5` in the scaling config, not from here. These `.config.*` copies are
520
+ // kept only so any future consumer that reads `config` sees the same values;
521
+ // `!== undefined` (not truthiness) is used so a minCapacity of 0 is mirrored
522
+ // rather than dropped.
523
+ if (appDefinition.database?.postgres?.minCapacity !== undefined) {
332
524
  translated.database.postgres.config.minCapacity = appDefinition.database.postgres.minCapacity;
333
525
  }
334
- if (appDefinition.database?.postgres?.maxCapacity) {
526
+ if (appDefinition.database?.postgres?.maxCapacity !== undefined) {
335
527
  translated.database.postgres.config.maxCapacity = appDefinition.database.postgres.maxCapacity;
336
528
  }
529
+ if (appDefinition.database?.postgres?.secondsUntilAutoPause !== undefined) {
530
+ translated.database.postgres.config.secondsUntilAutoPause = appDefinition.database.postgres.secondsUntilAutoPause;
531
+ }
337
532
  if (appDefinition.database?.postgres?.publiclyAccessible !== undefined) {
338
533
  translated.database.postgres.config.publiclyAccessible = appDefinition.database.postgres.publiclyAccessible;
339
534
  }
535
+ if (appDefinition.database?.postgres?.connectivity !== undefined) {
536
+ translated.database.postgres.config.connectivity = appDefinition.database.postgres.connectivity;
537
+ }
538
+ if (appDefinition.database?.postgres?.allowedCidrs !== undefined) {
539
+ translated.database.postgres.config.allowedCidrs = appDefinition.database.postgres.allowedCidrs;
540
+ }
340
541
 
341
542
  return translated;
342
543
  }
@@ -373,7 +574,10 @@ class AuroraBuilder extends InfrastructureBuilder {
373
574
  console.log(' Creating new Aurora Serverless v2 cluster...');
374
575
 
375
576
  const dbConfig = appDefinition.database.postgres;
376
- const publiclyAccessible = dbConfig.publiclyAccessible === true;
577
+ // ADR-033: connectivity 'public' implies a publicly-accessible cluster in
578
+ // public subnets. It combines with the legacy publiclyAccessible flag.
579
+ const publicConnectivity = dbConfig.connectivity === 'public';
580
+ const publiclyAccessible = publicConnectivity || dbConfig.publiclyAccessible === true;
377
581
 
378
582
  // Get subnet IDs for DB Subnet Group
379
583
  const subnetIds = publiclyAccessible
@@ -452,9 +656,16 @@ class AuroraBuilder extends InfrastructureBuilder {
452
656
  // min when idle) and gives the DB enough headroom to
453
657
  // absorb bursty sync traffic. Apps can still override both
454
658
  // via app definition dbConfig.
659
+ // ADR-033: use nullish coalescing — `|| 0.5` silently turned a
660
+ // requested MinCapacity of 0 (scale-to-zero) back into 0.5.
661
+ // When MinCapacity is 0, emit SecondsUntilAutoPause so the cluster
662
+ // pauses to 0 ACU after the idle window (default 300s).
455
663
  ServerlessV2ScalingConfiguration: {
456
- MinCapacity: dbConfig.minCapacity || 0.5,
457
- MaxCapacity: dbConfig.maxCapacity || 4,
664
+ MinCapacity: dbConfig.minCapacity ?? 0.5,
665
+ MaxCapacity: dbConfig.maxCapacity ?? 4,
666
+ ...(dbConfig.minCapacity === 0
667
+ ? { SecondsUntilAutoPause: dbConfig.secondsUntilAutoPause ?? 300 }
668
+ : {}),
458
669
  },
459
670
  EnableHttpEndpoint: false,
460
671
  BackupRetentionPeriod: 7,
@@ -482,12 +693,15 @@ class AuroraBuilder extends InfrastructureBuilder {
482
693
  },
483
694
  };
484
695
 
485
- // Environment variables
696
+ // Environment variables.
697
+ // ADR-033: public connectivity requires TLS (sslmode=require) since the
698
+ // endpoint is reachable over the internet.
486
699
  result.environment.DATABASE_URL = this.buildDatabaseUrl(
487
700
  { 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Address'] },
488
701
  { 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Port'] },
489
702
  dbConfig.database || 'frigg',
490
- { Ref: 'FriggDBSecret' }
703
+ { Ref: 'FriggDBSecret' },
704
+ { requireTls: publicConnectivity }
491
705
  );
492
706
 
493
707
  // IAM permissions for Secrets Manager
@@ -497,19 +711,46 @@ class AuroraBuilder extends InfrastructureBuilder {
497
711
  Resource: { Ref: 'FriggDBSecret' },
498
712
  });
499
713
 
500
- // Add self-referencing security group ingress rule to allow Lambda to connect to Aurora
501
- // Since both Lambda and Aurora share the same security group, we need to allow the SG to accept traffic from itself
502
- result.resources.FriggAuroraIngressRule = {
503
- Type: 'AWS::EC2::SecurityGroupIngress',
504
- Properties: {
505
- GroupId: { Ref: 'FriggLambdaSecurityGroup' },
506
- IpProtocol: 'tcp',
507
- FromPort: 5432,
508
- ToPort: 5432,
509
- SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
510
- Description: 'Allow Lambda functions to connect to Aurora PostgreSQL (self-referencing rule)',
511
- },
512
- };
714
+ if (publicConnectivity) {
715
+ // ADR-033 NAT-free public connectivity: the Lambda is NOT attached to
716
+ // the VPC (see vpc-builder), so FriggLambdaSecurityGroup is not on the
717
+ // Lambda side and a SourceSecurityGroupId rule would authorize nothing.
718
+ // Instead open 5432 to the configured CIDR allowlist (default
719
+ // 0.0.0.0/0 for demos — narrow it in production). One ingress rule per
720
+ // CIDR. GroupId stays FriggLambdaSecurityGroup because that is the SG
721
+ // attached to the Aurora cluster (see VpcSecurityGroupIds above).
722
+ const allowedCidrs =
723
+ Array.isArray(dbConfig.allowedCidrs) && dbConfig.allowedCidrs.length > 0
724
+ ? dbConfig.allowedCidrs
725
+ : ['0.0.0.0/0'];
726
+ allowedCidrs.forEach((cidr, index) => {
727
+ result.resources[`FriggAuroraIngressRule${index}`] = {
728
+ Type: 'AWS::EC2::SecurityGroupIngress',
729
+ Properties: {
730
+ GroupId: { Ref: 'FriggLambdaSecurityGroup' },
731
+ IpProtocol: 'tcp',
732
+ FromPort: 5432,
733
+ ToPort: 5432,
734
+ CidrIp: cidr,
735
+ Description: `Allow PostgreSQL access from ${cidr} (public connectivity)`,
736
+ },
737
+ };
738
+ });
739
+ } else {
740
+ // Add self-referencing security group ingress rule to allow Lambda to connect to Aurora
741
+ // Since both Lambda and Aurora share the same security group, we need to allow the SG to accept traffic from itself
742
+ result.resources.FriggAuroraIngressRule = {
743
+ Type: 'AWS::EC2::SecurityGroupIngress',
744
+ Properties: {
745
+ GroupId: { Ref: 'FriggLambdaSecurityGroup' },
746
+ IpProtocol: 'tcp',
747
+ FromPort: 5432,
748
+ ToPort: 5432,
749
+ SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
750
+ Description: 'Allow Lambda functions to connect to Aurora PostgreSQL (self-referencing rule)',
751
+ },
752
+ };
753
+ }
513
754
 
514
755
  console.log(' ✅ Aurora Serverless v2 cluster resources created');
515
756
  }
@@ -526,6 +767,12 @@ class AuroraBuilder extends InfrastructureBuilder {
526
767
  throw new Error('database.postgres.endpoint is required when management="use-existing"');
527
768
  }
528
769
 
770
+ // ADR-033: for use-existing, `connectivity` only affects TLS on the
771
+ // connection params — Frigg does not own the cluster, so it manages neither
772
+ // its subnets nor its ingress (the validator warns about this). Public mode
773
+ // still requires TLS, so append sslmode=require.
774
+ const publicConnectivity = dbConfig.connectivity === 'public';
775
+
529
776
  // Set environment variables for existing cluster
530
777
  result.environment.DATABASE_HOST = dbConfig.endpoint;
531
778
  result.environment.DATABASE_PORT = String(dbConfig.port || 5432);
@@ -534,7 +781,9 @@ class AuroraBuilder extends InfrastructureBuilder {
534
781
  // Consumers that build DATABASE_URL from components at runtime MUST
535
782
  // append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
536
783
  // timeouts as the managed path.
537
- result.environment.DATABASE_URL_PARAMS = LAMBDA_DATABASE_URL_QUERY_PARAMS;
784
+ result.environment.DATABASE_URL_PARAMS = publicConnectivity
785
+ ? `${LAMBDA_DATABASE_URL_QUERY_PARAMS}&sslmode=require`
786
+ : LAMBDA_DATABASE_URL_QUERY_PARAMS;
538
787
 
539
788
  console.log(` ✅ Using existing cluster: ${dbConfig.endpoint}`);
540
789
  }
@@ -554,6 +803,9 @@ class AuroraBuilder extends InfrastructureBuilder {
554
803
  console.log(` ✅ Using discovered Aurora cluster: ${discoveredResources.auroraClusterEndpoint}`);
555
804
 
556
805
  const dbConfig = appDefinition.database.postgres;
806
+ // ADR-033: public connectivity requires TLS on the connection string and a
807
+ // CIDR-based ingress rule (the Lambda is not VPC-attached).
808
+ const publicConnectivity = dbConfig.connectivity === 'public';
557
809
 
558
810
  // Use discovered cluster details
559
811
  result.environment.DATABASE_HOST = discoveredResources.auroraClusterEndpoint;
@@ -729,7 +981,8 @@ exports.handler = async (event, context) => {
729
981
  discoveredResources.auroraClusterEndpoint,
730
982
  discoveredResources.auroraPort || 5432,
731
983
  dbConfig.database || 'frigg',
732
- { Ref: 'FriggDBSecret' }
984
+ { Ref: 'FriggDBSecret' },
985
+ { requireTls: publicConnectivity }
733
986
  );
734
987
 
735
988
  // Grant Lambda functions permission to read the secret
@@ -747,7 +1000,8 @@ exports.handler = async (event, context) => {
747
1000
  discoveredResources.auroraClusterEndpoint,
748
1001
  discoveredResources.auroraPort || 5432,
749
1002
  dbConfig.database || 'frigg',
750
- discoveredResources.databaseSecretArn
1003
+ discoveredResources.databaseSecretArn,
1004
+ { requireTls: publicConnectivity }
751
1005
  );
752
1006
 
753
1007
  result.iamStatements.push({
@@ -768,7 +1022,10 @@ exports.handler = async (event, context) => {
768
1022
  // Consumers that build DATABASE_URL from components at runtime MUST
769
1023
  // append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
770
1024
  // timeouts as the managed path.
771
- result.environment.DATABASE_URL_PARAMS = LAMBDA_DATABASE_URL_QUERY_PARAMS;
1025
+ // ADR-033: public connectivity requires TLS — include sslmode=require.
1026
+ result.environment.DATABASE_URL_PARAMS = publicConnectivity
1027
+ ? `${LAMBDA_DATABASE_URL_QUERY_PARAMS}&sslmode=require`
1028
+ : LAMBDA_DATABASE_URL_QUERY_PARAMS;
772
1029
 
773
1030
  // Note: DATABASE_URL is NOT set here to avoid Serverless variable resolution errors
774
1031
  // The application (Frigg Core) should construct it at runtime from:
@@ -782,17 +1039,39 @@ exports.handler = async (event, context) => {
782
1039
 
783
1040
  // Add security group ingress rule to allow Lambda to connect to Aurora
784
1041
  if (discoveredResources.auroraSecurityGroupId) {
785
- result.resources.FriggAuroraIngressRule = {
786
- Type: 'AWS::EC2::SecurityGroupIngress',
787
- Properties: {
788
- GroupId: discoveredResources.auroraSecurityGroupId,
789
- IpProtocol: 'tcp',
790
- FromPort: discoveredResources.auroraPort || 5432,
791
- ToPort: discoveredResources.auroraPort || 5432,
792
- SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
793
- Description: 'Allow Lambda functions to connect to Aurora PostgreSQL',
794
- },
795
- };
1042
+ if (publicConnectivity) {
1043
+ // ADR-033 NAT-free public connectivity: the Lambda is not in the
1044
+ // VPC, so authorize the CIDR allowlist instead of the Lambda SG.
1045
+ const allowedCidrs =
1046
+ Array.isArray(dbConfig.allowedCidrs) && dbConfig.allowedCidrs.length > 0
1047
+ ? dbConfig.allowedCidrs
1048
+ : ['0.0.0.0/0'];
1049
+ allowedCidrs.forEach((cidr, index) => {
1050
+ result.resources[`FriggAuroraIngressRule${index}`] = {
1051
+ Type: 'AWS::EC2::SecurityGroupIngress',
1052
+ Properties: {
1053
+ GroupId: discoveredResources.auroraSecurityGroupId,
1054
+ IpProtocol: 'tcp',
1055
+ FromPort: discoveredResources.auroraPort || 5432,
1056
+ ToPort: discoveredResources.auroraPort || 5432,
1057
+ CidrIp: cidr,
1058
+ Description: `Allow PostgreSQL access from ${cidr} (public connectivity)`,
1059
+ },
1060
+ };
1061
+ });
1062
+ } else {
1063
+ result.resources.FriggAuroraIngressRule = {
1064
+ Type: 'AWS::EC2::SecurityGroupIngress',
1065
+ Properties: {
1066
+ GroupId: discoveredResources.auroraSecurityGroupId,
1067
+ IpProtocol: 'tcp',
1068
+ FromPort: discoveredResources.auroraPort || 5432,
1069
+ ToPort: discoveredResources.auroraPort || 5432,
1070
+ SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
1071
+ Description: 'Allow Lambda functions to connect to Aurora PostgreSQL',
1072
+ },
1073
+ };
1074
+ }
796
1075
  console.log(` ✅ Added security group ingress rule for Lambda → Aurora connectivity`);
797
1076
  }
798
1077
 
@@ -805,8 +1084,11 @@ exports.handler = async (event, context) => {
805
1084
  * @param {string|number|object} port - Database port (string/number or CloudFormation intrinsic function)
806
1085
  * @param {string} database - Database name
807
1086
  * @param {string|object} secretRef - Secret ARN (string) or CloudFormation Ref object
1087
+ * @param {object} [options]
1088
+ * @param {boolean} [options.requireTls] - Append sslmode=require (ADR-033 public connectivity)
808
1089
  */
809
- buildDatabaseUrl(host, port, database, secretRef) {
1090
+ buildDatabaseUrl(host, port, database, secretRef, options = {}) {
1091
+ const { requireTls = false } = options;
810
1092
  // Handle secretRef as either a string ARN or CloudFormation Ref object
811
1093
  const resolveSecretRef = (secretRefValue) => {
812
1094
  if (typeof secretRefValue === 'object' && secretRefValue.Ref) {
@@ -838,9 +1120,16 @@ exports.handler = async (event, context) => {
838
1120
 
839
1121
  // Query params are defined at module scope (LAMBDA_DATABASE_URL_QUERY_PARAMS)
840
1122
  // so runtime-URL-construction paths can emit the same timeouts as an env var.
1123
+ // ADR-033: for public connectivity, TLS is mandatory — append sslmode=require
1124
+ // unless it's already present in the base params.
1125
+ const queryParams =
1126
+ requireTls && !/(^|&)sslmode=/.test(LAMBDA_DATABASE_URL_QUERY_PARAMS)
1127
+ ? `${LAMBDA_DATABASE_URL_QUERY_PARAMS}&sslmode=require`
1128
+ : LAMBDA_DATABASE_URL_QUERY_PARAMS;
1129
+
841
1130
  return {
842
1131
  'Fn::Sub': [
843
- `postgresql://\${Username}:\${Password}@\${Host}:\${Port}/\${Database}?${LAMBDA_DATABASE_URL_QUERY_PARAMS}`,
1132
+ `postgresql://\${Username}:\${Password}@\${Host}:\${Port}/\${Database}?${queryParams}`,
844
1133
  {
845
1134
  Username: resolveSecretRef(secretRef),
846
1135
  Password: resolveSecretPassword(secretRef),
@@ -194,7 +194,7 @@ describe('AuroraBuilder', () => {
194
194
  const result = auroraBuilder.validate(appDefinition);
195
195
 
196
196
  expect(result.valid).toBe(false);
197
- expect(result.errors.some(e => e.includes('minCapacity must be between 0.5 and 128'))).toBe(true);
197
+ expect(result.errors.some(e => e.includes('minCapacity must be 0 (scale-to-zero) or between 0.5 and 128'))).toBe(true);
198
198
  });
199
199
 
200
200
  it('should error when maxCapacity is out of range', () => {
@@ -24,6 +24,31 @@ const { createEmptyDiscoveryResult } = require('../shared/types/discovery-result
24
24
  const { ResourceOwnership } = require('../shared/types/resource-ownership');
25
25
  const { isSsmOffloadActive } = require('../parameters/offload-utils');
26
26
 
27
+ /**
28
+ * ADR-033: NAT-free public database connectivity.
29
+ *
30
+ * When the app opts into `database.postgres.connectivity: 'public'`, Aurora is
31
+ * placed in public subnets with a public endpoint and the app's Lambdas are
32
+ * intentionally left OUTSIDE the VPC so they keep default internet egress and
33
+ * need no NAT Gateway. This function is the single seam that DECOUPLES the
34
+ * Lambda's VPC attachment + NAT from Aurora's networking:
35
+ *
36
+ * - The VpcBuilder still builds the VPC / public subnets / DB subnet group
37
+ * that Aurora requires (Aurora must live in a VPC — an AWS constraint).
38
+ * - But it does NOT emit a NAT Gateway, and it clears `result.vpcConfig` so the
39
+ * composer never sets `provider.vpc` (see infrastructure-composer.js:126) —
40
+ * leaving the Lambda un-attached with normal egress.
41
+ *
42
+ * @param {Object} appDefinition
43
+ * @returns {boolean}
44
+ */
45
+ function isPublicDatabaseConnectivity(appDefinition) {
46
+ return (
47
+ appDefinition?.database?.postgres?.enable === true &&
48
+ appDefinition?.database?.postgres?.connectivity === 'public'
49
+ );
50
+ }
51
+
27
52
  class VpcBuilder extends InfrastructureBuilder {
28
53
  constructor() {
29
54
  super();
@@ -565,20 +590,86 @@ class VpcBuilder extends InfrastructureBuilder {
565
590
  // Build Subnets based on ownership decision
566
591
  this.buildSubnetsFromDecision(decisions.subnets, appDefinition, discoveredResources, result);
567
592
 
568
- // Build NAT Gateway based on ownership decision
569
- this.buildNatGatewayFromDecision(decisions.natGateway, appDefinition, discoveredResources, result);
593
+ // ADR-033: NAT-free public database connectivity. When set, Aurora sits in
594
+ // public subnets with a public endpoint and the Lambda is NOT attached to
595
+ // the VPC — so it keeps default internet egress and needs no NAT Gateway.
596
+ // We still built the VPC + public subnets above (Aurora requires them), but
597
+ // we skip the NAT Gateway here and clear vpcConfig below so the composer
598
+ // leaves provider.vpc unset. This is the seam that decouples the Lambda's
599
+ // VPC attachment + NAT from Aurora's networking.
600
+ const publicDbConnectivity = isPublicDatabaseConnectivity(appDefinition);
601
+
602
+ if (publicDbConnectivity) {
603
+ console.log(
604
+ ' ⊝ NAT Gateway skipped (database.postgres.connectivity=public — Lambda is not VPC-attached, so no NAT is needed)'
605
+ );
606
+
607
+ // ADR-033: the public subnets Aurora sits in still need an Internet
608
+ // Gateway default route + subnet→route-table associations to be
609
+ // internet-routable. Those are normally emitted only as a SIDE EFFECT
610
+ // of the NAT build (createPublicRouting is called from inside the NAT
611
+ // methods), so skipping NAT would otherwise leave the public subnets on
612
+ // the VPC main route table (local-only) and the public Aurora endpoint
613
+ // unreachable — a green deploy with a dead DB. Decouple the public-subnet
614
+ // routing from NAT here.
615
+ //
616
+ // GUARD: only do this for a Frigg-created (stack) VPC — signalled by the
617
+ // presence of FriggInternetGateway in the template (emitted by
618
+ // buildVpcFromDecision only for STACK ownership). createPublicRouting
619
+ // references { Ref: 'FriggInternetGateway' } and DependsOn
620
+ // 'FriggVPCGatewayAttachment', and associates the stack-created
621
+ // FriggPublicSubnet* — all of which exist only in that case. For a
622
+ // discovered/existing VPC, its public subnets already route to an IGW, so
623
+ // creating our own public route table would be redundant/conflicting.
624
+ if (result.resources.FriggInternetGateway) {
625
+ console.log(
626
+ ' → Public-subnet routing (IGW default route + associations) for stack-created VPC'
627
+ );
628
+ this.createPublicRouting(appDefinition, discoveredResources, result);
629
+ } else {
630
+ console.log(
631
+ ' ℹ Public connectivity on a discovered/existing VPC — assuming its public subnets already route to an Internet Gateway; not creating conflicting routing'
632
+ );
633
+ }
634
+ } else {
635
+ // Build NAT Gateway based on ownership decision
636
+ this.buildNatGatewayFromDecision(decisions.natGateway, appDefinition, discoveredResources, result);
637
+ }
570
638
 
571
- // Build VPC Endpoints based on ownership decisions
572
- this.buildVpcEndpointsFromDecisions(decisions.vpcEndpoints, decisions.securityGroup, appDefinition, discoveredResources, result);
639
+ // Build VPC Endpoints based on ownership decisions.
640
+ // ADR-033: in public connectivity the Lambda is not in the VPC, so VPC
641
+ // endpoints (which give in-VPC functions private AWS access) would be
642
+ // wasted spend — skip them along with the NAT Gateway.
643
+ if (publicDbConnectivity) {
644
+ console.log(
645
+ ' ⊝ VPC Endpoints skipped (database.postgres.connectivity=public — Lambda is not VPC-attached)'
646
+ );
647
+ } else {
648
+ this.buildVpcEndpointsFromDecisions(decisions.vpcEndpoints, decisions.securityGroup, appDefinition, discoveredResources, result);
649
+ }
573
650
 
574
- // Set VPC_ENABLED environment variable
575
- result.environment.VPC_ENABLED = 'true';
651
+ // Set VPC_ENABLED environment variable.
652
+ // ADR-033: in public connectivity the Lambda is NOT attached to the VPC, so
653
+ // report VPC_ENABLED=false — otherwise /health would misreport isInVpc:true.
654
+ result.environment.VPC_ENABLED = publicDbConnectivity ? 'false' : 'true';
576
655
 
577
656
  console.log(`\n[${this.name}] ✅ VPC infrastructure built successfully`);
578
657
  console.log(` - VPC ID: ${result.vpcId || 'from discovery'}`);
579
658
  console.log(` - Subnets: ${result.vpcConfig.subnetIds.length}`);
580
659
  console.log(` - Security Groups: ${result.vpcConfig.securityGroupIds.length}`);
581
660
 
661
+ if (publicDbConnectivity) {
662
+ // Do NOT attach the Lambda to the VPC: leaving vpcConfig null means the
663
+ // composer never sets provider.vpc, so the function keeps normal
664
+ // internet egress. The Aurora subnets/DB subnet group built above are
665
+ // still emitted (AuroraBuilder consumes discoveredResources.publicSubnetId*),
666
+ // and the Lambda reaches Aurora over its public endpoint + TLS.
667
+ console.log(
668
+ ' ⊝ Lambda VPC attachment skipped (database.postgres.connectivity=public — provider.vpc will be unset)'
669
+ );
670
+ result.vpcConfig = null;
671
+ }
672
+
582
673
  return result;
583
674
  }
584
675
 
@@ -55,10 +55,26 @@
55
55
  *
56
56
  * @property {Object} [config] - Configuration preferences
57
57
  * @property {'aurora-postgresql'|'aurora-mysql'} [config.engine] - Database engine
58
- * @property {number} [config.minCapacity] - Min serverless capacity (default: 0.5)
59
- * @property {number} [config.maxCapacity] - Max serverless capacity (default: 1)
58
+ * @property {number} [config.minCapacity] - Min serverless capacity in ACU. Accepts
59
+ * `0` for Aurora Serverless v2 scale-to-zero (cluster pauses to 0 ACU when idle;
60
+ * requires Aurora PostgreSQL 13.15+/14.12+/15.7+/16.3+, and the default 15.13
61
+ * qualifies) OR a value in [0.5, 128]. Values in (0, 0.5) are invalid. (default: 0.5)
62
+ * @property {number} [config.maxCapacity] - Max serverless capacity in ACU, range
63
+ * [0.5, 128] (default: 4)
64
+ * @property {number} [config.secondsUntilAutoPause] - Idle window before a
65
+ * scale-to-zero cluster (minCapacity: 0) pauses to 0 ACU. Integer seconds in
66
+ * [300, 86400]. Only applied when minCapacity === 0 (default: 300)
60
67
  * @property {string} [config.database] - Database name (default: 'frigg')
61
68
  * @property {boolean} [config.publiclyAccessible] - Public access (default: false)
69
+ * @property {'vpc'|'public'} [config.connectivity] - Lambda↔Aurora connectivity
70
+ * mode. `'vpc'` (default): Aurora in private subnets, Lambda attached to the VPC,
71
+ * ingress from the Lambda security group (needs a NAT for external egress).
72
+ * `'public'` (NAT-free): Aurora in public subnets with a public endpoint, Lambda
73
+ * left OUTSIDE the VPC (no NAT Gateway, no VPC endpoints), ingress opened to
74
+ * `allowedCidrs`, TLS enforced (sslmode=require). (default: 'vpc')
75
+ * @property {string[]} [config.allowedCidrs] - CIDR blocks allowed to reach Aurora
76
+ * on 5432 when connectivity is 'public'. One ingress rule is emitted per CIDR.
77
+ * (default: ['0.0.0.0/0'] — narrow this for production)
62
78
  * @property {boolean} [config.autoCreateCredentials] - Auto-create credentials in Secrets Manager
63
79
  */
64
80
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.629.1a7ad06.0",
4
+ "version": "2.0.0--canary.640.5140601.0",
5
5
  "bin": {
6
6
  "frigg": "./frigg-cli/index.js"
7
7
  },
@@ -26,9 +26,9 @@
26
26
  "@babel/eslint-parser": "^7.18.9",
27
27
  "@babel/parser": "^7.25.3",
28
28
  "@babel/traverse": "^7.25.3",
29
- "@friggframework/core": "2.0.0--canary.629.1a7ad06.0",
30
- "@friggframework/schemas": "2.0.0--canary.629.1a7ad06.0",
31
- "@friggframework/test": "2.0.0--canary.629.1a7ad06.0",
29
+ "@friggframework/core": "2.0.0--canary.640.5140601.0",
30
+ "@friggframework/schemas": "2.0.0--canary.640.5140601.0",
31
+ "@friggframework/test": "2.0.0--canary.640.5140601.0",
32
32
  "@hapi/boom": "^10.0.1",
33
33
  "@inquirer/prompts": "^5.3.8",
34
34
  "axios": "^1.18.0",
@@ -56,8 +56,8 @@
56
56
  "validate-npm-package-name": "^5.0.0"
57
57
  },
58
58
  "devDependencies": {
59
- "@friggframework/eslint-config": "2.0.0--canary.629.1a7ad06.0",
60
- "@friggframework/prettier-config": "2.0.0--canary.629.1a7ad06.0",
59
+ "@friggframework/eslint-config": "2.0.0--canary.640.5140601.0",
60
+ "@friggframework/prettier-config": "2.0.0--canary.640.5140601.0",
61
61
  "aws-sdk-client-mock": "^4.1.0",
62
62
  "aws-sdk-client-mock-jest": "^4.1.0",
63
63
  "jest": "^30.1.3",
@@ -89,5 +89,5 @@
89
89
  "publishConfig": {
90
90
  "access": "public"
91
91
  },
92
- "gitHead": "1a7ad067d7f61e2afca452d66706665c7c15b926"
92
+ "gitHead": "51406015cf670753915e87f84a29f2d6067cd5be"
93
93
  }