@friggframework/devtools 2.0.0--canary.640.b31eb4a.0 → 2.0.0--canary.643.5ac10b7.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.
@@ -94,123 +94,21 @@ class AuroraBuilder extends InfrastructureBuilder {
94
94
  }
95
95
 
96
96
  // Validate capacity settings
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');
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');
106
99
  }
107
100
  if (dbConfig.maxCapacity !== undefined && (dbConfig.maxCapacity < 0.5 || dbConfig.maxCapacity > 128)) {
108
101
  result.addError('database.postgres.maxCapacity must be between 0.5 and 128');
109
102
  }
110
103
 
111
- // Validate scale-to-zero auto-pause window (seconds). AWS-valid range is
112
- // 300–86400 (5 minutes to 24 hours) and it must be an integer.
113
- if (dbConfig.secondsUntilAutoPause !== undefined) {
114
- const s = dbConfig.secondsUntilAutoPause;
115
- if (!Number.isInteger(s) || s < 300 || s > 86400) {
116
- result.addError('database.postgres.secondsUntilAutoPause must be an integer between 300 and 86400');
117
- }
118
- }
119
-
120
- // Validate connectivity mode. 'vpc' (default) keeps today's behavior;
121
- // 'public' places Aurora in public subnets and leaves the Lambda out of
122
- // the VPC (NAT-free). See ADR-033.
123
- if (dbConfig.connectivity !== undefined) {
124
- const validConnectivity = ['vpc', 'public'];
125
- if (!validConnectivity.includes(dbConfig.connectivity)) {
126
- result.addError(
127
- `Invalid database.postgres.connectivity: "${dbConfig.connectivity}". Must be one of: ${validConnectivity.join(', ')}`
128
- );
129
- }
130
- }
131
-
132
- // Validate allowedCidrs is an array of CIDR-looking strings when present.
133
- if (dbConfig.allowedCidrs !== undefined) {
134
- const cidrPattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/[0-9]{1,2}$/;
135
- if (!Array.isArray(dbConfig.allowedCidrs)) {
136
- result.addError('database.postgres.allowedCidrs must be an array of CIDR strings');
137
- } else {
138
- const bad = dbConfig.allowedCidrs.filter(
139
- (c) => typeof c !== 'string' || !cidrPattern.test(c)
140
- );
141
- if (bad.length > 0) {
142
- result.addError(
143
- `database.postgres.allowedCidrs contains invalid CIDR value(s): ${bad.join(', ')}`
144
- );
145
- }
146
- }
147
- }
148
-
149
- // Scale-to-zero requires a supported engine version. Warn (do not
150
- // hard-fail) if the user pins an older version with minCapacity: 0.
151
- // Frigg's default engineVersion (15.13) qualifies. Minimum
152
- // scale-to-zero-capable versions: Aurora PG 13.15 / 14.12 / 15.7 / 16.3.
153
- if (dbConfig.minCapacity === 0 && dbConfig.engineVersion) {
154
- if (!this.engineSupportsScaleToZero(dbConfig.engineVersion)) {
155
- result.addWarning(
156
- `database.postgres.engineVersion="${dbConfig.engineVersion}" may not support Aurora Serverless v2 scale-to-zero (minCapacity: 0). ` +
157
- 'Scale-to-zero requires Aurora PostgreSQL 13.15+/14.12+/15.7+/16.3+.'
158
- );
159
- }
160
- }
161
-
162
104
  // Warn about public accessibility in production
163
105
  if (dbConfig.publiclyAccessible === true) {
164
106
  result.addWarning('database.postgres.publiclyAccessible=true is not recommended for production');
165
107
  }
166
108
 
167
- // ADR-033 security posture: public connectivity exposes the DB endpoint
168
- // to the internet. Make the trade-off loud.
169
- if (dbConfig.connectivity === 'public') {
170
- result.addWarning(
171
- 'database.postgres.connectivity="public" exposes the Aurora endpoint to the internet. ' +
172
- 'TLS is enforced (sslmode=require) and access is restricted to allowedCidrs, but prefer "vpc" for production.'
173
- );
174
- const cidrs = Array.isArray(dbConfig.allowedCidrs) ? dbConfig.allowedCidrs : ['0.0.0.0/0'];
175
- if (cidrs.includes('0.0.0.0/0')) {
176
- result.addWarning(
177
- 'database.postgres.allowedCidrs allows 0.0.0.0/0 (the whole internet). ' +
178
- 'Narrow this to known egress ranges wherever the deployment can.'
179
- );
180
- }
181
- }
182
-
183
109
  return result;
184
110
  }
185
111
 
186
- /**
187
- * Determine whether an Aurora PostgreSQL engine version supports
188
- * Serverless v2 scale-to-zero (minCapacity: 0). Minimum capable versions:
189
- * 13.15+, 14.12+, 15.7+, 16.3+. Unknown/unparseable versions return true
190
- * (assume-capable) so we never hard-block on a version string we can't read;
191
- * this only gates a warning, never a hard failure.
192
- * @param {string} engineVersion e.g. '15.13'
193
- * @returns {boolean}
194
- */
195
- engineSupportsScaleToZero(engineVersion) {
196
- const minByMajor = { 13: 15, 14: 12, 15: 7, 16: 3 };
197
- const match = /^(\d+)\.(\d+)/.exec(String(engineVersion));
198
- if (!match) {
199
- return true; // can't parse — don't warn spuriously
200
- }
201
- const major = Number(match[1]);
202
- const minor = Number(match[2]);
203
- // Majors newer than the table are assumed capable.
204
- if (major > 16) {
205
- return true;
206
- }
207
- // Majors older than 13 never support scale-to-zero.
208
- if (!(major in minByMajor)) {
209
- return false;
210
- }
211
- return minor >= minByMajor[major];
212
- }
213
-
214
112
  /**
215
113
  * Build Aurora infrastructure using ownership-based architecture
216
114
  */
@@ -429,27 +327,16 @@ class AuroraBuilder extends InfrastructureBuilder {
429
327
  }
430
328
  }
431
329
 
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.
435
- if (appDefinition.database?.postgres?.minCapacity !== undefined) {
330
+ // Preserve other database config
331
+ if (appDefinition.database?.postgres?.minCapacity) {
436
332
  translated.database.postgres.config.minCapacity = appDefinition.database.postgres.minCapacity;
437
333
  }
438
- if (appDefinition.database?.postgres?.maxCapacity !== undefined) {
334
+ if (appDefinition.database?.postgres?.maxCapacity) {
439
335
  translated.database.postgres.config.maxCapacity = appDefinition.database.postgres.maxCapacity;
440
336
  }
441
- if (appDefinition.database?.postgres?.secondsUntilAutoPause !== undefined) {
442
- translated.database.postgres.config.secondsUntilAutoPause = appDefinition.database.postgres.secondsUntilAutoPause;
443
- }
444
337
  if (appDefinition.database?.postgres?.publiclyAccessible !== undefined) {
445
338
  translated.database.postgres.config.publiclyAccessible = appDefinition.database.postgres.publiclyAccessible;
446
339
  }
447
- if (appDefinition.database?.postgres?.connectivity !== undefined) {
448
- translated.database.postgres.config.connectivity = appDefinition.database.postgres.connectivity;
449
- }
450
- if (appDefinition.database?.postgres?.allowedCidrs !== undefined) {
451
- translated.database.postgres.config.allowedCidrs = appDefinition.database.postgres.allowedCidrs;
452
- }
453
340
 
454
341
  return translated;
455
342
  }
@@ -486,10 +373,7 @@ class AuroraBuilder extends InfrastructureBuilder {
486
373
  console.log(' Creating new Aurora Serverless v2 cluster...');
487
374
 
488
375
  const dbConfig = appDefinition.database.postgres;
489
- // ADR-033: connectivity 'public' implies a publicly-accessible cluster in
490
- // public subnets. It combines with the legacy publiclyAccessible flag.
491
- const publicConnectivity = dbConfig.connectivity === 'public';
492
- const publiclyAccessible = publicConnectivity || dbConfig.publiclyAccessible === true;
376
+ const publiclyAccessible = dbConfig.publiclyAccessible === true;
493
377
 
494
378
  // Get subnet IDs for DB Subnet Group
495
379
  const subnetIds = publiclyAccessible
@@ -568,16 +452,9 @@ class AuroraBuilder extends InfrastructureBuilder {
568
452
  // min when idle) and gives the DB enough headroom to
569
453
  // absorb bursty sync traffic. Apps can still override both
570
454
  // via app definition dbConfig.
571
- // ADR-033: use nullish coalescing — `|| 0.5` silently turned a
572
- // requested MinCapacity of 0 (scale-to-zero) back into 0.5.
573
- // When MinCapacity is 0, emit SecondsUntilAutoPause so the cluster
574
- // pauses to 0 ACU after the idle window (default 300s).
575
455
  ServerlessV2ScalingConfiguration: {
576
- MinCapacity: dbConfig.minCapacity ?? 0.5,
577
- MaxCapacity: dbConfig.maxCapacity ?? 4,
578
- ...(dbConfig.minCapacity === 0
579
- ? { SecondsUntilAutoPause: dbConfig.secondsUntilAutoPause ?? 300 }
580
- : {}),
456
+ MinCapacity: dbConfig.minCapacity || 0.5,
457
+ MaxCapacity: dbConfig.maxCapacity || 4,
581
458
  },
582
459
  EnableHttpEndpoint: false,
583
460
  BackupRetentionPeriod: 7,
@@ -605,15 +482,12 @@ class AuroraBuilder extends InfrastructureBuilder {
605
482
  },
606
483
  };
607
484
 
608
- // Environment variables.
609
- // ADR-033: public connectivity requires TLS (sslmode=require) since the
610
- // endpoint is reachable over the internet.
485
+ // Environment variables
611
486
  result.environment.DATABASE_URL = this.buildDatabaseUrl(
612
487
  { 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Address'] },
613
488
  { 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Port'] },
614
489
  dbConfig.database || 'frigg',
615
- { Ref: 'FriggDBSecret' },
616
- { requireTls: publicConnectivity }
490
+ { Ref: 'FriggDBSecret' }
617
491
  );
618
492
 
619
493
  // IAM permissions for Secrets Manager
@@ -623,46 +497,19 @@ class AuroraBuilder extends InfrastructureBuilder {
623
497
  Resource: { Ref: 'FriggDBSecret' },
624
498
  });
625
499
 
626
- if (publicConnectivity) {
627
- // ADR-033 NAT-free public connectivity: the Lambda is NOT attached to
628
- // the VPC (see vpc-builder), so FriggLambdaSecurityGroup is not on the
629
- // Lambda side and a SourceSecurityGroupId rule would authorize nothing.
630
- // Instead open 5432 to the configured CIDR allowlist (default
631
- // 0.0.0.0/0 for demos — narrow it in production). One ingress rule per
632
- // CIDR. GroupId stays FriggLambdaSecurityGroup because that is the SG
633
- // attached to the Aurora cluster (see VpcSecurityGroupIds above).
634
- const allowedCidrs =
635
- Array.isArray(dbConfig.allowedCidrs) && dbConfig.allowedCidrs.length > 0
636
- ? dbConfig.allowedCidrs
637
- : ['0.0.0.0/0'];
638
- allowedCidrs.forEach((cidr, index) => {
639
- result.resources[`FriggAuroraIngressRule${index}`] = {
640
- Type: 'AWS::EC2::SecurityGroupIngress',
641
- Properties: {
642
- GroupId: { Ref: 'FriggLambdaSecurityGroup' },
643
- IpProtocol: 'tcp',
644
- FromPort: 5432,
645
- ToPort: 5432,
646
- CidrIp: cidr,
647
- Description: `Allow PostgreSQL access from ${cidr} (public connectivity)`,
648
- },
649
- };
650
- });
651
- } else {
652
- // Add self-referencing security group ingress rule to allow Lambda to connect to Aurora
653
- // Since both Lambda and Aurora share the same security group, we need to allow the SG to accept traffic from itself
654
- result.resources.FriggAuroraIngressRule = {
655
- Type: 'AWS::EC2::SecurityGroupIngress',
656
- Properties: {
657
- GroupId: { Ref: 'FriggLambdaSecurityGroup' },
658
- IpProtocol: 'tcp',
659
- FromPort: 5432,
660
- ToPort: 5432,
661
- SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
662
- Description: 'Allow Lambda functions to connect to Aurora PostgreSQL (self-referencing rule)',
663
- },
664
- };
665
- }
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
+ };
666
513
 
667
514
  console.log(' ✅ Aurora Serverless v2 cluster resources created');
668
515
  }
@@ -707,9 +554,6 @@ class AuroraBuilder extends InfrastructureBuilder {
707
554
  console.log(` ✅ Using discovered Aurora cluster: ${discoveredResources.auroraClusterEndpoint}`);
708
555
 
709
556
  const dbConfig = appDefinition.database.postgres;
710
- // ADR-033: public connectivity requires TLS on the connection string and a
711
- // CIDR-based ingress rule (the Lambda is not VPC-attached).
712
- const publicConnectivity = dbConfig.connectivity === 'public';
713
557
 
714
558
  // Use discovered cluster details
715
559
  result.environment.DATABASE_HOST = discoveredResources.auroraClusterEndpoint;
@@ -885,8 +729,7 @@ exports.handler = async (event, context) => {
885
729
  discoveredResources.auroraClusterEndpoint,
886
730
  discoveredResources.auroraPort || 5432,
887
731
  dbConfig.database || 'frigg',
888
- { Ref: 'FriggDBSecret' },
889
- { requireTls: publicConnectivity }
732
+ { Ref: 'FriggDBSecret' }
890
733
  );
891
734
 
892
735
  // Grant Lambda functions permission to read the secret
@@ -904,8 +747,7 @@ exports.handler = async (event, context) => {
904
747
  discoveredResources.auroraClusterEndpoint,
905
748
  discoveredResources.auroraPort || 5432,
906
749
  dbConfig.database || 'frigg',
907
- discoveredResources.databaseSecretArn,
908
- { requireTls: publicConnectivity }
750
+ discoveredResources.databaseSecretArn
909
751
  );
910
752
 
911
753
  result.iamStatements.push({
@@ -926,10 +768,7 @@ exports.handler = async (event, context) => {
926
768
  // Consumers that build DATABASE_URL from components at runtime MUST
927
769
  // append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
928
770
  // timeouts as the managed path.
929
- // ADR-033: public connectivity requires TLS — include sslmode=require.
930
- result.environment.DATABASE_URL_PARAMS = publicConnectivity
931
- ? `${LAMBDA_DATABASE_URL_QUERY_PARAMS}&sslmode=require`
932
- : LAMBDA_DATABASE_URL_QUERY_PARAMS;
771
+ result.environment.DATABASE_URL_PARAMS = LAMBDA_DATABASE_URL_QUERY_PARAMS;
933
772
 
934
773
  // Note: DATABASE_URL is NOT set here to avoid Serverless variable resolution errors
935
774
  // The application (Frigg Core) should construct it at runtime from:
@@ -943,39 +782,17 @@ exports.handler = async (event, context) => {
943
782
 
944
783
  // Add security group ingress rule to allow Lambda to connect to Aurora
945
784
  if (discoveredResources.auroraSecurityGroupId) {
946
- if (publicConnectivity) {
947
- // ADR-033 NAT-free public connectivity: the Lambda is not in the
948
- // VPC, so authorize the CIDR allowlist instead of the Lambda SG.
949
- const allowedCidrs =
950
- Array.isArray(dbConfig.allowedCidrs) && dbConfig.allowedCidrs.length > 0
951
- ? dbConfig.allowedCidrs
952
- : ['0.0.0.0/0'];
953
- allowedCidrs.forEach((cidr, index) => {
954
- result.resources[`FriggAuroraIngressRule${index}`] = {
955
- Type: 'AWS::EC2::SecurityGroupIngress',
956
- Properties: {
957
- GroupId: discoveredResources.auroraSecurityGroupId,
958
- IpProtocol: 'tcp',
959
- FromPort: discoveredResources.auroraPort || 5432,
960
- ToPort: discoveredResources.auroraPort || 5432,
961
- CidrIp: cidr,
962
- Description: `Allow PostgreSQL access from ${cidr} (public connectivity)`,
963
- },
964
- };
965
- });
966
- } else {
967
- result.resources.FriggAuroraIngressRule = {
968
- Type: 'AWS::EC2::SecurityGroupIngress',
969
- Properties: {
970
- GroupId: discoveredResources.auroraSecurityGroupId,
971
- IpProtocol: 'tcp',
972
- FromPort: discoveredResources.auroraPort || 5432,
973
- ToPort: discoveredResources.auroraPort || 5432,
974
- SourceSecurityGroupId: { Ref: 'FriggLambdaSecurityGroup' },
975
- Description: 'Allow Lambda functions to connect to Aurora PostgreSQL',
976
- },
977
- };
978
- }
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
+ };
979
796
  console.log(` ✅ Added security group ingress rule for Lambda → Aurora connectivity`);
980
797
  }
981
798
 
@@ -988,11 +805,8 @@ exports.handler = async (event, context) => {
988
805
  * @param {string|number|object} port - Database port (string/number or CloudFormation intrinsic function)
989
806
  * @param {string} database - Database name
990
807
  * @param {string|object} secretRef - Secret ARN (string) or CloudFormation Ref object
991
- * @param {object} [options]
992
- * @param {boolean} [options.requireTls] - Append sslmode=require (ADR-033 public connectivity)
993
808
  */
994
- buildDatabaseUrl(host, port, database, secretRef, options = {}) {
995
- const { requireTls = false } = options;
809
+ buildDatabaseUrl(host, port, database, secretRef) {
996
810
  // Handle secretRef as either a string ARN or CloudFormation Ref object
997
811
  const resolveSecretRef = (secretRefValue) => {
998
812
  if (typeof secretRefValue === 'object' && secretRefValue.Ref) {
@@ -1024,16 +838,9 @@ exports.handler = async (event, context) => {
1024
838
 
1025
839
  // Query params are defined at module scope (LAMBDA_DATABASE_URL_QUERY_PARAMS)
1026
840
  // so runtime-URL-construction paths can emit the same timeouts as an env var.
1027
- // ADR-033: for public connectivity, TLS is mandatory — append sslmode=require
1028
- // unless it's already present in the base params.
1029
- const queryParams =
1030
- requireTls && !/(^|&)sslmode=/.test(LAMBDA_DATABASE_URL_QUERY_PARAMS)
1031
- ? `${LAMBDA_DATABASE_URL_QUERY_PARAMS}&sslmode=require`
1032
- : LAMBDA_DATABASE_URL_QUERY_PARAMS;
1033
-
1034
841
  return {
1035
842
  'Fn::Sub': [
1036
- `postgresql://\${Username}:\${Password}@\${Host}:\${Port}/\${Database}?${queryParams}`,
843
+ `postgresql://\${Username}:\${Password}@\${Host}:\${Port}/\${Database}?${LAMBDA_DATABASE_URL_QUERY_PARAMS}`,
1037
844
  {
1038
845
  Username: resolveSecretRef(secretRef),
1039
846
  Password: resolveSecretPassword(secretRef),
@@ -24,31 +24,6 @@ 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
-
52
27
  class VpcBuilder extends InfrastructureBuilder {
53
28
  constructor() {
54
29
  super();
@@ -590,35 +565,11 @@ class VpcBuilder extends InfrastructureBuilder {
590
565
  // Build Subnets based on ownership decision
591
566
  this.buildSubnetsFromDecision(decisions.subnets, appDefinition, discoveredResources, result);
592
567
 
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
- } else {
607
- // Build NAT Gateway based on ownership decision
608
- this.buildNatGatewayFromDecision(decisions.natGateway, appDefinition, discoveredResources, result);
609
- }
568
+ // Build NAT Gateway based on ownership decision
569
+ this.buildNatGatewayFromDecision(decisions.natGateway, appDefinition, discoveredResources, result);
610
570
 
611
- // Build VPC Endpoints based on ownership decisions.
612
- // ADR-033: in public connectivity the Lambda is not in the VPC, so VPC
613
- // endpoints (which give in-VPC functions private AWS access) would be
614
- // wasted spend — skip them along with the NAT Gateway.
615
- if (publicDbConnectivity) {
616
- console.log(
617
- ' ⊝ VPC Endpoints skipped (database.postgres.connectivity=public — Lambda is not VPC-attached)'
618
- );
619
- } else {
620
- this.buildVpcEndpointsFromDecisions(decisions.vpcEndpoints, decisions.securityGroup, appDefinition, discoveredResources, result);
621
- }
571
+ // Build VPC Endpoints based on ownership decisions
572
+ this.buildVpcEndpointsFromDecisions(decisions.vpcEndpoints, decisions.securityGroup, appDefinition, discoveredResources, result);
622
573
 
623
574
  // Set VPC_ENABLED environment variable
624
575
  result.environment.VPC_ENABLED = 'true';
@@ -628,18 +579,6 @@ class VpcBuilder extends InfrastructureBuilder {
628
579
  console.log(` - Subnets: ${result.vpcConfig.subnetIds.length}`);
629
580
  console.log(` - Security Groups: ${result.vpcConfig.securityGroupIds.length}`);
630
581
 
631
- if (publicDbConnectivity) {
632
- // Do NOT attach the Lambda to the VPC: leaving vpcConfig null means the
633
- // composer never sets provider.vpc, so the function keeps normal
634
- // internet egress. The Aurora subnets/DB subnet group built above are
635
- // still emitted (AuroraBuilder consumes discoveredResources.publicSubnetId*),
636
- // and the Lambda reaches Aurora over its public endpoint + TLS.
637
- console.log(
638
- ' ⊝ Lambda VPC attachment skipped (database.postgres.connectivity=public — provider.vpc will be unset)'
639
- );
640
- result.vpcConfig = null;
641
- }
642
-
643
582
  return result;
644
583
  }
645
584
 
@@ -55,26 +55,10 @@
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 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)
58
+ * @property {number} [config.minCapacity] - Min serverless capacity (default: 0.5)
59
+ * @property {number} [config.maxCapacity] - Max serverless capacity (default: 1)
67
60
  * @property {string} [config.database] - Database name (default: 'frigg')
68
61
  * @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)
78
62
  * @property {boolean} [config.autoCreateCredentials] - Auto-create credentials in Secrets Manager
79
63
  */
80
64
 
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.643.5ac10b7.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.643.5ac10b7.0",
30
+ "@friggframework/schemas": "2.0.0--canary.643.5ac10b7.0",
31
+ "@friggframework/test": "2.0.0--canary.643.5ac10b7.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.643.5ac10b7.0",
60
+ "@friggframework/prettier-config": "2.0.0--canary.643.5ac10b7.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": "5ac10b78dec8cf766dd80584f201c7e132aeee12"
93
93
  }
@@ -1,255 +0,0 @@
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
- const validateResult = (postgres) =>
61
- build.validate({ database: { postgres: { enable: true, ...postgres } } });
62
- const isValid = (postgres) => !validateResult(postgres).hasErrors();
63
-
64
- test('accepts minCapacity: 0 (scale-to-zero)', () => {
65
- expect(isValid({ minCapacity: 0 })).toBe(true);
66
- });
67
-
68
- test('rejects minCapacity: 0.3 (inside the forbidden (0, 0.5) band)', () => {
69
- const r = validateResult({ minCapacity: 0.3 });
70
- expect(r.hasErrors()).toBe(true);
71
- expect(r.errors.join(' ')).toMatch(/minCapacity must be 0 \(scale-to-zero\) or between 0\.5 and 128/);
72
- });
73
-
74
- test('accepts minCapacity: 0.5 and minCapacity: 64', () => {
75
- expect(isValid({ minCapacity: 0.5 })).toBe(true);
76
- expect(isValid({ minCapacity: 64 })).toBe(true);
77
- });
78
-
79
- test('rejects secondsUntilAutoPause: 100 (below 300)', () => {
80
- const r = validateResult({ minCapacity: 0, secondsUntilAutoPause: 100 });
81
- expect(r.hasErrors()).toBe(true);
82
- expect(r.errors.join(' ')).toMatch(/secondsUntilAutoPause must be an integer between 300 and 86400/);
83
- });
84
-
85
- test('accepts secondsUntilAutoPause: 3600', () => {
86
- expect(isValid({ minCapacity: 0, secondsUntilAutoPause: 3600 })).toBe(true);
87
- });
88
-
89
- test("accepts connectivity: 'public'", () => {
90
- expect(isValid({ connectivity: 'public' })).toBe(true);
91
- });
92
-
93
- test("rejects connectivity: 'nope'", () => {
94
- const r = validateResult({ connectivity: 'nope' });
95
- expect(r.hasErrors()).toBe(true);
96
- expect(r.errors.join(' ')).toMatch(/Invalid database\.postgres\.connectivity/);
97
- });
98
-
99
- test('rejects non-array / non-CIDR allowedCidrs, accepts valid CIDRs', () => {
100
- expect(isValid({ allowedCidrs: 'nope' })).toBe(false);
101
- expect(isValid({ allowedCidrs: ['not-a-cidr'] })).toBe(false);
102
- expect(isValid({ allowedCidrs: ['10.0.0.0/8', '203.0.113.5/32'] })).toBe(true);
103
- });
104
-
105
- test('warns (does not fail) when minCapacity:0 with an older pinned engine version', () => {
106
- const r = validateResult({ minCapacity: 0, engineVersion: '15.4' });
107
- expect(r.hasErrors()).toBe(false); // warning, not error
108
- expect(r.warnings.join(' ')).toMatch(/may not support .*scale-to-zero/);
109
- });
110
-
111
- test('does not warn about engine when minCapacity:0 on a capable version', () => {
112
- const r = validateResult({ minCapacity: 0, engineVersion: '15.13' });
113
- expect(r.warnings.join(' ')).not.toMatch(/may not support .*scale-to-zero/);
114
- });
115
- });
116
-
117
- // ---------------------------------------------------------------------
118
- // Scale-to-zero (template shape)
119
- // ---------------------------------------------------------------------
120
- describe('scale-to-zero (minCapacity: 0)', () => {
121
- test('MinCapacity is exactly 0 and SecondsUntilAutoPause defaults to 300', async () => {
122
- const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
123
- const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
124
-
125
- // Mutation guard: with the old `|| 0.5` bug this would be 0.5, not 0.
126
- expect(scaling.MinCapacity).toBe(0);
127
- expect(scaling.MinCapacity).not.toBe(0.5);
128
- expect(scaling.SecondsUntilAutoPause).toBe(300);
129
- });
130
-
131
- test('SecondsUntilAutoPause honors a custom value', async () => {
132
- const t = await composeServerlessDefinition(makeApp({ minCapacity: 0, secondsUntilAutoPause: 1800 }));
133
- const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
134
- expect(scaling.MinCapacity).toBe(0);
135
- expect(scaling.SecondsUntilAutoPause).toBe(1800);
136
- });
137
-
138
- test('MaxCapacity defaults to 4 and is preserved with scale-to-zero', async () => {
139
- const t = await composeServerlessDefinition(makeApp({ minCapacity: 0 }));
140
- const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
141
- expect(scaling.MaxCapacity).toBe(4);
142
- });
143
- });
144
-
145
- // ---------------------------------------------------------------------
146
- // Public connectivity (template shape)
147
- // ---------------------------------------------------------------------
148
- describe("connectivity: 'public'", () => {
149
- test('Aurora ingress uses CidrIp — one rule per allowedCidr — not SourceSecurityGroupId', async () => {
150
- const t = await composeServerlessDefinition(
151
- makeApp({ connectivity: 'public', allowedCidrs: ['10.1.0.0/16', '203.0.113.7/32'] })
152
- );
153
- const ingress = findResources(
154
- t,
155
- (r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
156
- );
157
- expect(ingress).toHaveLength(2);
158
- const cidrs = ingress.map(([, r]) => r.Properties.CidrIp).sort();
159
- expect(cidrs).toEqual(['10.1.0.0/16', '203.0.113.7/32']);
160
- ingress.forEach(([, r]) => {
161
- expect(r.Properties.CidrIp).toBeDefined();
162
- expect(r.Properties.SourceSecurityGroupId).toBeUndefined();
163
- });
164
- });
165
-
166
- test('allowedCidrs defaults to 0.0.0.0/0 when omitted', async () => {
167
- const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
168
- const ingress = findResources(
169
- t,
170
- (r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
171
- );
172
- expect(ingress).toHaveLength(1);
173
- expect(ingress[0][1].Properties.CidrIp).toBe('0.0.0.0/0');
174
- });
175
-
176
- test('Aurora instance is PubliclyAccessible and cluster sits in public subnets', async () => {
177
- const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
178
- expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(true);
179
- expect(t.resources.Resources.FriggDBSubnetGroup.Properties.SubnetIds).toEqual([
180
- { Ref: 'FriggPublicSubnet' },
181
- { Ref: 'FriggPublicSubnet2' },
182
- ]);
183
- });
184
-
185
- test('NO NAT Gateway resource is emitted', async () => {
186
- const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
187
- const nats = findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway');
188
- expect(nats).toHaveLength(0);
189
- });
190
-
191
- test('Lambda is NOT attached to the VPC (provider.vpc unset)', async () => {
192
- const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
193
- expect(t.provider.vpc).toBeUndefined();
194
- });
195
-
196
- test('DATABASE_URL enforces TLS (sslmode=require)', async () => {
197
- const t = await composeServerlessDefinition(makeApp({ connectivity: 'public' }));
198
- const url = t.provider.environment.DATABASE_URL;
199
- expect(url['Fn::Sub'][0]).toContain('sslmode=require');
200
- });
201
-
202
- test('combines with scale-to-zero: $0-idle NAT-free Aurora', async () => {
203
- const t = await composeServerlessDefinition(makeApp({ connectivity: 'public', minCapacity: 0 }));
204
- const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
205
- expect(scaling.MinCapacity).toBe(0);
206
- expect(scaling.SecondsUntilAutoPause).toBe(300);
207
- expect(findResources(t, (r) => r.Type === 'AWS::EC2::NatGateway')).toHaveLength(0);
208
- expect(t.provider.vpc).toBeUndefined();
209
- });
210
- });
211
-
212
- // ---------------------------------------------------------------------
213
- // No-regression: default (vpc) connectivity, no new fields
214
- // ---------------------------------------------------------------------
215
- describe("default connectivity: 'vpc' (no regression)", () => {
216
- test('MinCapacity defaults to 0.5 with no SecondsUntilAutoPause', async () => {
217
- const t = await composeServerlessDefinition(makeApp());
218
- const scaling = t.resources.Resources.FriggAuroraCluster.Properties.ServerlessV2ScalingConfiguration;
219
- expect(scaling.MinCapacity).toBe(0.5);
220
- expect(scaling.MaxCapacity).toBe(4);
221
- expect(scaling.SecondsUntilAutoPause).toBeUndefined();
222
- });
223
-
224
- test('Aurora ingress uses SourceSecurityGroupId (Lambda SG), not CidrIp', async () => {
225
- const t = await composeServerlessDefinition(makeApp());
226
- const ingress = findResources(
227
- t,
228
- (r) => r.Type === 'AWS::EC2::SecurityGroupIngress' && r.Properties.FromPort === 5432
229
- );
230
- expect(ingress).toHaveLength(1);
231
- expect(ingress[0][1].Properties.SourceSecurityGroupId).toEqual({ Ref: 'FriggLambdaSecurityGroup' });
232
- expect(ingress[0][1].Properties.CidrIp).toBeUndefined();
233
- // Logical ID unchanged for the vpc path
234
- expect(ingress[0][0]).toBe('FriggAuroraIngressRule');
235
- });
236
-
237
- test('Aurora instance is not publicly accessible by default', async () => {
238
- const t = await composeServerlessDefinition(makeApp());
239
- expect(t.resources.Resources.FriggAuroraInstance.Properties.PubliclyAccessible).toBe(false);
240
- });
241
-
242
- test('Lambda IS attached to the VPC (provider.vpc set)', async () => {
243
- const t = await composeServerlessDefinition(makeApp());
244
- expect(t.provider.vpc).toBeDefined();
245
- expect(t.provider.vpc.subnetIds).toBeDefined();
246
- expect(t.provider.vpc.securityGroupIds).toBeDefined();
247
- });
248
-
249
- test('DATABASE_URL does not add sslmode in vpc mode', async () => {
250
- const t = await composeServerlessDefinition(makeApp());
251
- const url = t.provider.environment.DATABASE_URL;
252
- expect(url['Fn::Sub'][0]).not.toContain('sslmode=require');
253
- });
254
- });
255
- });