@friggframework/devtools 2.0.0--canary.640.b31eb4a.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.
@@ -57,9 +57,15 @@ describe('ADR-033: Aurora scale-to-zero + connectivity', () => {
57
57
  describe('validator (AuroraBuilder.validate)', () => {
58
58
  const build = new AuroraBuilder();
59
59
  // ValidationResult exposes hasErrors(); "valid" means no errors.
60
- const validateResult = (postgres) =>
61
- build.validate({ database: { postgres: { enable: true, ...postgres } } });
62
- const isValid = (postgres) => !validateResult(postgres).hasErrors();
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();
63
69
 
64
70
  test('accepts minCapacity: 0 (scale-to-zero)', () => {
65
71
  expect(isValid({ minCapacity: 0 })).toBe(true);
@@ -102,6 +108,57 @@ describe('ADR-033: Aurora scale-to-zero + connectivity', () => {
102
108
  expect(isValid({ allowedCidrs: ['10.0.0.0/8', '203.0.113.5/32'] })).toBe(true);
103
109
  });
104
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
+
105
162
  test('warns (does not fail) when minCapacity:0 with an older pinned engine version', () => {
106
163
  const r = validateResult({ minCapacity: 0, engineVersion: '15.4' });
107
164
  expect(r.hasErrors()).toBe(false); // warning, not error
@@ -188,6 +245,45 @@ describe('ADR-033: Aurora scale-to-zero + connectivity', () => {
188
245
  expect(nats).toHaveLength(0);
189
246
  });
190
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
+
191
287
  test('Lambda is NOT attached to the VPC (provider.vpc unset)', async () => {
192
288
  const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
193
289
  expect(t.provider.vpc).toBeUndefined();
@@ -251,5 +347,10 @@ describe('ADR-033: Aurora scale-to-zero + connectivity', () => {
251
347
  const url = t.provider.environment.DATABASE_URL;
252
348
  expect(url['Fn::Sub'][0]).not.toContain('sslmode=require');
253
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
+ });
254
355
  });
255
356
  });
@@ -107,6 +107,17 @@ class AuroraBuilder extends InfrastructureBuilder {
107
107
  if (dbConfig.maxCapacity !== undefined && (dbConfig.maxCapacity < 0.5 || dbConfig.maxCapacity > 128)) {
108
108
  result.addError('database.postgres.maxCapacity must be between 0.5 and 128');
109
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
+ }
110
121
 
111
122
  // Validate scale-to-zero auto-pause window (seconds). AWS-valid range is
112
123
  // 300–86400 (5 minutes to 24 hours) and it must be an integer.
@@ -115,6 +126,12 @@ class AuroraBuilder extends InfrastructureBuilder {
115
126
  if (!Number.isInteger(s) || s < 300 || s > 86400) {
116
127
  result.addError('database.postgres.secondsUntilAutoPause must be an integer between 300 and 86400');
117
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
+ }
118
135
  }
119
136
 
120
137
  // Validate connectivity mode. 'vpc' (default) keeps today's behavior;
@@ -129,20 +146,37 @@ class AuroraBuilder extends InfrastructureBuilder {
129
146
  }
130
147
  }
131
148
 
132
- // Validate allowedCidrs is an array of CIDR-looking strings when present.
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.
133
161
  if (dbConfig.allowedCidrs !== undefined) {
134
- const cidrPattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/[0-9]{1,2}$/;
135
162
  if (!Array.isArray(dbConfig.allowedCidrs)) {
136
163
  result.addError('database.postgres.allowedCidrs must be an array of CIDR strings');
137
164
  } else {
138
- const bad = dbConfig.allowedCidrs.filter(
139
- (c) => typeof c !== 'string' || !cidrPattern.test(c)
140
- );
165
+ const bad = dbConfig.allowedCidrs.filter((c) => !this.isValidIpv4Cidr(c));
141
166
  if (bad.length > 0) {
142
167
  result.addError(
143
168
  `database.postgres.allowedCidrs contains invalid CIDR value(s): ${bad.join(', ')}`
144
169
  );
145
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
+ }
146
180
  }
147
181
  }
148
182
 
@@ -171,18 +205,67 @@ class AuroraBuilder extends InfrastructureBuilder {
171
205
  'database.postgres.connectivity="public" exposes the Aurora endpoint to the internet. ' +
172
206
  'TLS is enforced (sslmode=require) and access is restricted to allowedCidrs, but prefer "vpc" for production.'
173
207
  );
174
- const cidrs = Array.isArray(dbConfig.allowedCidrs) ? dbConfig.allowedCidrs : ['0.0.0.0/0'];
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'];
175
214
  if (cidrs.includes('0.0.0.0/0')) {
176
215
  result.addWarning(
177
216
  'database.postgres.allowedCidrs allows 0.0.0.0/0 (the whole internet). ' +
178
217
  'Narrow this to known egress ranges wherever the deployment can.'
179
218
  );
180
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
+ }
181
241
  }
182
242
 
183
243
  return result;
184
244
  }
185
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
+
186
269
  /**
187
270
  * Determine whether an Aurora PostgreSQL engine version supports
188
271
  * Serverless v2 scale-to-zero (minCapacity: 0). Minimum capable versions:
@@ -429,9 +512,14 @@ class AuroraBuilder extends InfrastructureBuilder {
429
512
  }
430
513
  }
431
514
 
432
- // Preserve other database config.
433
- // NOTE: use `!== undefined` (not truthiness) so a requested minCapacity
434
- // of 0 (scale-to-zero) flows through instead of being dropped.
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.
435
523
  if (appDefinition.database?.postgres?.minCapacity !== undefined) {
436
524
  translated.database.postgres.config.minCapacity = appDefinition.database.postgres.minCapacity;
437
525
  }
@@ -679,6 +767,12 @@ class AuroraBuilder extends InfrastructureBuilder {
679
767
  throw new Error('database.postgres.endpoint is required when management="use-existing"');
680
768
  }
681
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
+
682
776
  // Set environment variables for existing cluster
683
777
  result.environment.DATABASE_HOST = dbConfig.endpoint;
684
778
  result.environment.DATABASE_PORT = String(dbConfig.port || 5432);
@@ -687,7 +781,9 @@ class AuroraBuilder extends InfrastructureBuilder {
687
781
  // Consumers that build DATABASE_URL from components at runtime MUST
688
782
  // append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
689
783
  // timeouts as the managed path.
690
- 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;
691
787
 
692
788
  console.log(` ✅ Using existing cluster: ${dbConfig.endpoint}`);
693
789
  }
@@ -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', () => {
@@ -603,6 +603,34 @@ class VpcBuilder extends InfrastructureBuilder {
603
603
  console.log(
604
604
  ' ⊝ NAT Gateway skipped (database.postgres.connectivity=public — Lambda is not VPC-attached, so no NAT is needed)'
605
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
+ }
606
634
  } else {
607
635
  // Build NAT Gateway based on ownership decision
608
636
  this.buildNatGatewayFromDecision(decisions.natGateway, appDefinition, discoveredResources, result);
@@ -620,8 +648,10 @@ class VpcBuilder extends InfrastructureBuilder {
620
648
  this.buildVpcEndpointsFromDecisions(decisions.vpcEndpoints, decisions.securityGroup, appDefinition, discoveredResources, result);
621
649
  }
622
650
 
623
- // Set VPC_ENABLED environment variable
624
- 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';
625
655
 
626
656
  console.log(`\n[${this.name}] ✅ VPC infrastructure built successfully`);
627
657
  console.log(` - VPC ID: ${result.vpcId || 'from discovery'}`);
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.640.b31eb4a.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.640.b31eb4a.0",
30
- "@friggframework/schemas": "2.0.0--canary.640.b31eb4a.0",
31
- "@friggframework/test": "2.0.0--canary.640.b31eb4a.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.640.b31eb4a.0",
60
- "@friggframework/prettier-config": "2.0.0--canary.640.b31eb4a.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": "b31eb4a3ddaeae1044d98050b6a436f6d9c08db6"
92
+ "gitHead": "51406015cf670753915e87f84a29f2d6067cd5be"
93
93
  }