@friggframework/devtools 2.0.0-next.52 → 2.0.0-next.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/frigg-cli/README.md +13 -14
  2. package/frigg-cli/__tests__/unit/commands/db-setup.test.js +267 -166
  3. package/frigg-cli/__tests__/unit/utils/database-validator.test.js +45 -14
  4. package/frigg-cli/__tests__/unit/utils/error-messages.test.js +44 -3
  5. package/frigg-cli/db-setup-command/index.js +75 -22
  6. package/frigg-cli/deploy-command/index.js +6 -3
  7. package/frigg-cli/utils/database-validator.js +18 -5
  8. package/frigg-cli/utils/error-messages.js +84 -12
  9. package/infrastructure/README.md +28 -0
  10. package/infrastructure/domains/database/migration-builder.js +26 -20
  11. package/infrastructure/domains/database/migration-builder.test.js +27 -0
  12. package/infrastructure/domains/integration/integration-builder.js +17 -10
  13. package/infrastructure/domains/integration/integration-builder.test.js +97 -0
  14. package/infrastructure/domains/networking/vpc-builder.js +240 -18
  15. package/infrastructure/domains/networking/vpc-builder.test.js +711 -13
  16. package/infrastructure/domains/networking/vpc-resolver.js +221 -40
  17. package/infrastructure/domains/networking/vpc-resolver.test.js +318 -18
  18. package/infrastructure/domains/security/kms-builder.js +55 -6
  19. package/infrastructure/domains/security/kms-builder.test.js +19 -1
  20. package/infrastructure/domains/shared/cloudformation-discovery.js +310 -13
  21. package/infrastructure/domains/shared/cloudformation-discovery.test.js +395 -0
  22. package/infrastructure/domains/shared/providers/aws-provider-adapter.js +41 -6
  23. package/infrastructure/domains/shared/providers/aws-provider-adapter.test.js +39 -0
  24. package/infrastructure/domains/shared/resource-discovery.js +17 -5
  25. package/infrastructure/domains/shared/resource-discovery.test.js +36 -0
  26. package/infrastructure/domains/shared/utilities/base-definition-factory.js +30 -20
  27. package/infrastructure/domains/shared/utilities/base-definition-factory.test.js +43 -0
  28. package/infrastructure/infrastructure-composer.js +11 -3
  29. package/infrastructure/scripts/build-prisma-layer.js +153 -78
  30. package/infrastructure/scripts/build-prisma-layer.test.js +27 -11
  31. package/layers/prisma/.build-complete +2 -2
  32. package/package.json +7 -7
@@ -9,7 +9,7 @@ describe('VpcResourceResolver', () => {
9
9
  });
10
10
 
11
11
  describe('resolveVpc', () => {
12
- it('should resolve to EXTERNAL when user specifies external', () => {
12
+ it('should resolve to EXTERNAL with hardcoded vpcId', () => {
13
13
  const appDefinition = {
14
14
  vpc: {
15
15
  ownership: { vpc: 'external' },
@@ -22,20 +22,46 @@ describe('VpcResourceResolver', () => {
22
22
 
23
23
  expect(decision.ownership).toBe(ResourceOwnership.EXTERNAL);
24
24
  expect(decision.physicalId).toBe('vpc-external-123');
25
- expect(decision.reason).toContain('User specified ownership=external');
25
+ expect(decision.reason).toContain('hardcoded vpcId');
26
26
  });
27
27
 
28
- it('should throw when external specified but vpcId missing', () => {
28
+ it('should resolve to EXTERNAL using discovered VPC when no hardcoded ID', () => {
29
29
  const appDefinition = {
30
30
  vpc: {
31
- ownership: { vpc: 'external' },
32
- external: {}
31
+ ownership: { vpc: 'external' }
32
+ // No external.vpcId provided
33
33
  }
34
34
  };
35
- const discovery = { stackManaged: [], external: [], fromCloudFormation: false };
35
+ const discovery = {
36
+ stackManaged: [],
37
+ external: [],
38
+ fromCloudFormation: false,
39
+ defaultVpcId: 'vpc-discovered-123'
40
+ };
41
+
42
+ const decision = resolver.resolveVpc(appDefinition, discovery);
43
+
44
+ expect(decision.ownership).toBe(ResourceOwnership.EXTERNAL);
45
+ expect(decision.physicalId).toBe('vpc-discovered-123');
46
+ expect(decision.reason).toContain('discovered VPC');
47
+ });
48
+
49
+ it('should throw when external specified but no vpcId and no discovery', () => {
50
+ const appDefinition = {
51
+ vpc: {
52
+ ownership: { vpc: 'external' }
53
+ // No external.vpcId provided
54
+ }
55
+ };
56
+ const discovery = {
57
+ stackManaged: [],
58
+ external: [],
59
+ fromCloudFormation: false
60
+ // No defaultVpcId discovered
61
+ };
36
62
 
37
63
  expect(() => resolver.resolveVpc(appDefinition, discovery)).toThrow(
38
- "ownership='external' for vpcId requires external.vpcId"
64
+ /ownership='external' for VPC requires either/
39
65
  );
40
66
  });
41
67
 
@@ -97,22 +123,63 @@ describe('VpcResourceResolver', () => {
97
123
  expect(decision.physicalId).toBe('vpc-external');
98
124
  });
99
125
 
100
- it('should auto-resolve to STACK when not found (create new)', () => {
126
+ it('should throw error when auto mode finds no VPC (changed behavior)', () => {
101
127
  const appDefinition = {
102
128
  vpc: { ownership: { vpc: 'auto' } }
129
+ // No management specified - defaults to discover
103
130
  };
104
131
  const discovery = { stackManaged: [], external: [], fromCloudFormation: false };
105
132
 
106
- const decision = resolver.resolveVpc(appDefinition, discovery);
133
+ // NEW BEHAVIOR: Auto mode with no VPC found should throw error (prevent accidental VPC creation)
134
+ expect(() => resolver.resolveVpc(appDefinition, discovery)).toThrow(
135
+ 'VPC discovery failed: No VPC found'
136
+ );
137
+ });
107
138
 
139
+ it('should throw error when auto mode finds no VPC and management is not create-new', () => {
140
+ const appDefinition = {
141
+ vpc: {
142
+ enable: true,
143
+ // No management specified - defaults to discover
144
+ // No ownership specified - defaults to auto
145
+ }
146
+ };
147
+ const discovery = {
148
+ stackManaged: [],
149
+ external: [],
150
+ fromCloudFormation: false
151
+ };
152
+
153
+ // Should throw error instead of trying to create VPC
154
+ expect(() => resolver.resolveVpc(appDefinition, discovery)).toThrow(
155
+ 'VPC discovery failed: No VPC found'
156
+ );
157
+ });
158
+
159
+ it('should allow creating VPC when management is create-new', () => {
160
+ const appDefinition = {
161
+ vpc: {
162
+ enable: true,
163
+ management: 'create-new',
164
+ ownership: { vpc: 'auto' }
165
+ }
166
+ };
167
+ const discovery = {
168
+ stackManaged: [],
169
+ external: [],
170
+ fromCloudFormation: false
171
+ };
172
+
173
+ // Should NOT throw - create-new explicitly allows VPC creation
174
+ const decision = resolver.resolveVpc(appDefinition, discovery);
175
+
108
176
  expect(decision.ownership).toBe(ResourceOwnership.STACK);
109
- expect(decision.physicalId).toBeUndefined();
110
- expect(decision.reason).toContain('No existing resource found');
177
+ expect(decision.physicalId).toBeUndefined(); // resolveResourceOwnership returns undefined, not null
111
178
  });
112
179
  });
113
180
 
114
181
  describe('resolveSecurityGroup', () => {
115
- it('should resolve to EXTERNAL with user-provided IDs', () => {
182
+ it('should resolve to EXTERNAL with user-provided hardcoded IDs', () => {
116
183
  const appDefinition = {
117
184
  vpc: {
118
185
  ownership: { securityGroup: 'external' },
@@ -125,6 +192,71 @@ describe('VpcResourceResolver', () => {
125
192
 
126
193
  expect(decision.ownership).toBe(ResourceOwnership.EXTERNAL);
127
194
  expect(decision.physicalIds).toEqual(['sg-1', 'sg-2']);
195
+ expect(decision.reason).toContain('hardcoded');
196
+ });
197
+
198
+ it('should resolve to EXTERNAL using discovered default SG when no hardcoded IDs', () => {
199
+ const appDefinition = {
200
+ vpc: {
201
+ ownership: { securityGroup: 'external' }
202
+ // No external.securityGroupIds provided
203
+ }
204
+ };
205
+ const discovery = {
206
+ stackManaged: [],
207
+ external: [],
208
+ fromCloudFormation: false,
209
+ defaultSecurityGroupId: 'sg-discovered-default'
210
+ };
211
+
212
+ const decision = resolver.resolveSecurityGroup(appDefinition, discovery);
213
+
214
+ expect(decision.ownership).toBe(ResourceOwnership.EXTERNAL);
215
+ expect(decision.physicalIds).toEqual(['sg-discovered-default']);
216
+ expect(decision.reason).toContain('discovered default security group');
217
+ });
218
+
219
+ it('should throw error when ownership=external but no IDs and no discovery', () => {
220
+ const appDefinition = {
221
+ vpc: {
222
+ ownership: { securityGroup: 'external' }
223
+ // No external.securityGroupIds provided
224
+ }
225
+ };
226
+ const discovery = {
227
+ stackManaged: [],
228
+ external: [],
229
+ fromCloudFormation: false
230
+ // No defaultSecurityGroupId discovered
231
+ };
232
+
233
+ expect(() => resolver.resolveSecurityGroup(appDefinition, discovery)).toThrow(
234
+ /ownership='external' for securityGroup requires either/
235
+ );
236
+ });
237
+
238
+ it('should prefer default SG over stack-managed SG when ownership=external and both discovered', () => {
239
+ const appDefinition = {
240
+ vpc: {
241
+ ownership: { securityGroup: 'external' }
242
+ }
243
+ };
244
+ const discovery = {
245
+ stackManaged: [
246
+ { logicalId: 'FriggLambdaSecurityGroup', physicalId: 'sg-stack-managed', resourceType: 'AWS::EC2::SecurityGroup' }
247
+ ],
248
+ external: [],
249
+ fromCloudFormation: true,
250
+ lambdaSecurityGroupId: 'sg-stack-managed', // Stack-managed SG
251
+ defaultSecurityGroupId: 'sg-default-vpc' // Default VPC SG
252
+ };
253
+
254
+ const decision = resolver.resolveSecurityGroup(appDefinition, discovery);
255
+
256
+ // Should use default SG, NOT the stack-managed one
257
+ expect(decision.ownership).toBe(ResourceOwnership.EXTERNAL);
258
+ expect(decision.physicalIds).toEqual(['sg-default-vpc']);
259
+ expect(decision.reason).toContain('discovered default security group');
128
260
  });
129
261
 
130
262
  it('should auto-resolve to STACK when FriggLambdaSecurityGroup in stack', () => {
@@ -146,7 +278,7 @@ describe('VpcResourceResolver', () => {
146
278
  });
147
279
 
148
280
  describe('resolveSubnets', () => {
149
- it('should resolve to EXTERNAL with user-provided subnet IDs', () => {
281
+ it('should resolve to EXTERNAL with user-provided hardcoded subnet IDs', () => {
150
282
  const appDefinition = {
151
283
  vpc: {
152
284
  ownership: { subnets: 'external' },
@@ -159,6 +291,48 @@ describe('VpcResourceResolver', () => {
159
291
 
160
292
  expect(decision.ownership).toBe(ResourceOwnership.EXTERNAL);
161
293
  expect(decision.physicalIds).toEqual(['subnet-1', 'subnet-2', 'subnet-3']);
294
+ expect(decision.reason).toContain('hardcoded');
295
+ });
296
+
297
+ it('should resolve to EXTERNAL using discovered subnets when no hardcoded IDs', () => {
298
+ const appDefinition = {
299
+ vpc: {
300
+ ownership: { subnets: 'external' }
301
+ // No external.subnetIds provided
302
+ }
303
+ };
304
+ const discovery = {
305
+ stackManaged: [],
306
+ external: [],
307
+ fromCloudFormation: false,
308
+ privateSubnetId1: 'subnet-discovered-1',
309
+ privateSubnetId2: 'subnet-discovered-2'
310
+ };
311
+
312
+ const decision = resolver.resolveSubnets(appDefinition, discovery);
313
+
314
+ expect(decision.ownership).toBe(ResourceOwnership.EXTERNAL);
315
+ expect(decision.physicalIds).toEqual(['subnet-discovered-1', 'subnet-discovered-2']);
316
+ expect(decision.reason).toContain('discovered subnets');
317
+ });
318
+
319
+ it('should throw error when ownership=external but no IDs and no discovery', () => {
320
+ const appDefinition = {
321
+ vpc: {
322
+ ownership: { subnets: 'external' }
323
+ // No external.subnetIds provided
324
+ }
325
+ };
326
+ const discovery = {
327
+ stackManaged: [],
328
+ external: [],
329
+ fromCloudFormation: false
330
+ // No privateSubnetId1/2 discovered
331
+ };
332
+
333
+ expect(() => resolver.resolveSubnets(appDefinition, discovery)).toThrow(
334
+ /ownership='external' for subnets requires either/
335
+ );
162
336
  });
163
337
 
164
338
  it('should resolve to STACK when subnets found in stack', () => {
@@ -227,7 +401,7 @@ describe('VpcResourceResolver', () => {
227
401
  expect(decision.reason).toContain('NAT Gateway disabled');
228
402
  });
229
403
 
230
- it('should resolve to EXTERNAL with user-provided ID', () => {
404
+ it('should resolve to EXTERNAL with user-provided hardcoded ID', () => {
231
405
  const appDefinition = {
232
406
  vpc: {
233
407
  ownership: { natGateway: 'external' },
@@ -240,6 +414,47 @@ describe('VpcResourceResolver', () => {
240
414
 
241
415
  expect(decision.ownership).toBe(ResourceOwnership.EXTERNAL);
242
416
  expect(decision.physicalId).toBe('nat-external-123');
417
+ expect(decision.reason).toContain('hardcoded');
418
+ });
419
+
420
+ it('should resolve to EXTERNAL using discovered NAT when no hardcoded ID', () => {
421
+ const appDefinition = {
422
+ vpc: {
423
+ ownership: { natGateway: 'external' }
424
+ // No external.natGatewayId provided
425
+ }
426
+ };
427
+ const discovery = {
428
+ stackManaged: [],
429
+ external: [],
430
+ fromCloudFormation: false,
431
+ natGatewayId: 'nat-discovered-123'
432
+ };
433
+
434
+ const decision = resolver.resolveNatGateway(appDefinition, discovery);
435
+
436
+ expect(decision.ownership).toBe(ResourceOwnership.EXTERNAL);
437
+ expect(decision.physicalId).toBe('nat-discovered-123');
438
+ expect(decision.reason).toContain('discovered NAT gateway');
439
+ });
440
+
441
+ it('should throw error when ownership=external but no ID and no discovery', () => {
442
+ const appDefinition = {
443
+ vpc: {
444
+ ownership: { natGateway: 'external' }
445
+ // No external.natGatewayId provided
446
+ }
447
+ };
448
+ const discovery = {
449
+ stackManaged: [],
450
+ external: [],
451
+ fromCloudFormation: false
452
+ // No natGatewayId discovered
453
+ };
454
+
455
+ expect(() => resolver.resolveNatGateway(appDefinition, discovery)).toThrow(
456
+ /ownership='external' for NAT gateway requires either/
457
+ );
243
458
  });
244
459
 
245
460
  it('should auto-resolve to STACK when found in stack', () => {
@@ -260,6 +475,82 @@ describe('VpcResourceResolver', () => {
260
475
  });
261
476
 
262
477
  describe('resolveVpcEndpoints', () => {
478
+ it('should skip DynamoDB endpoint when application uses MongoDB', () => {
479
+ const appDefinition = {
480
+ vpc: { ownership: { vpcEndpoints: 'auto' } },
481
+ database: { mongoDB: { enable: true } }, // Using MongoDB, not DynamoDB
482
+ encryption: { fieldLevelEncryptionMethod: 'kms' }
483
+ };
484
+ const discovery = {
485
+ stackManaged: [],
486
+ external: [],
487
+ fromCloudFormation: false
488
+ };
489
+
490
+ const decisions = resolver.resolveVpcEndpoints(appDefinition, discovery);
491
+
492
+ expect(decisions.s3.ownership).toBe('stack'); // S3 always needed
493
+ expect(decisions.dynamodb.ownership).toBeNull(); // DynamoDB NOT needed
494
+ expect(decisions.dynamodb.reason).toContain('MongoDB/PostgreSQL');
495
+ expect(decisions.kms.ownership).toBe('stack'); // KMS needed (encryption enabled)
496
+ expect(decisions.secretsManager.ownership).toBe('stack'); // SM always needed
497
+ expect(decisions.sqs.ownership).toBe('stack'); // SQS always needed
498
+ });
499
+
500
+ it('should skip DynamoDB endpoint when application uses PostgreSQL', () => {
501
+ const appDefinition = {
502
+ vpc: { ownership: { vpcEndpoints: 'auto' } },
503
+ database: { postgres: { enable: true } }, // Using PostgreSQL, not DynamoDB
504
+ };
505
+ const discovery = {
506
+ stackManaged: [],
507
+ external: [],
508
+ fromCloudFormation: false
509
+ };
510
+
511
+ const decisions = resolver.resolveVpcEndpoints(appDefinition, discovery);
512
+
513
+ expect(decisions.dynamodb.ownership).toBeNull();
514
+ expect(decisions.dynamodb.reason).toContain('MongoDB/PostgreSQL');
515
+ });
516
+
517
+ it('should create DynamoDB endpoint when explicitly enabled', () => {
518
+ const appDefinition = {
519
+ vpc: { ownership: { vpcEndpoints: 'auto' } },
520
+ database: { dynamodb: { enable: true } }, // Explicitly using DynamoDB
521
+ };
522
+ const discovery = {
523
+ stackManaged: [],
524
+ external: [],
525
+ fromCloudFormation: false
526
+ };
527
+
528
+ const decisions = resolver.resolveVpcEndpoints(appDefinition, discovery);
529
+
530
+ expect(decisions.dynamodb.ownership).toBe('stack'); // DynamoDB needed
531
+ });
532
+
533
+ it('should allow deletion of DynamoDB endpoint when not needed', () => {
534
+ const appDefinition = {
535
+ vpc: { ownership: { vpcEndpoints: 'auto' } },
536
+ database: { mongoDB: { enable: true } }, // Using MongoDB (DynamoDB not needed)
537
+ };
538
+ const discovery = {
539
+ stackManaged: [
540
+ { logicalId: 'FriggDynamoDBVPCEndpoint', physicalId: 'vpce-ddb-legacy', resourceType: 'AWS::EC2::VPCEndpoint' }
541
+ ],
542
+ external: [],
543
+ fromCloudFormation: true
544
+ };
545
+
546
+ const decisions = resolver.resolveVpcEndpoints(appDefinition, discovery);
547
+
548
+ // Should return null (allow CloudFormation to delete it since not needed)
549
+ expect(decisions.dynamodb.ownership).toBeNull();
550
+ expect(decisions.dynamodb.reason).toContain('MongoDB/PostgreSQL');
551
+ });
552
+
553
+
263
554
  it('should return null decisions when endpoints disabled', () => {
264
555
  const appDefinition = {
265
556
  vpc: {
@@ -288,7 +579,8 @@ describe('VpcResourceResolver', () => {
288
579
  dynamodb: 'vpce-ddb-456'
289
580
  }
290
581
  }
291
- }
582
+ },
583
+ database: { dynamodb: { enable: true } } // Enable DynamoDB
292
584
  };
293
585
  const discovery = { stackManaged: [], external: [], fromCloudFormation: false };
294
586
 
@@ -302,7 +594,10 @@ describe('VpcResourceResolver', () => {
302
594
  });
303
595
 
304
596
  it('should auto-resolve to STACK when endpoints found in stack', () => {
305
- const appDefinition = { vpc: { ownership: { vpcEndpoints: 'auto' } } };
597
+ const appDefinition = {
598
+ vpc: { ownership: { vpcEndpoints: 'auto' } },
599
+ database: { dynamodb: { enable: true } } // Enable DynamoDB
600
+ };
306
601
  const discovery = {
307
602
  stackManaged: [
308
603
  { logicalId: 'FriggS3VPCEndpoint', physicalId: 'vpce-s3-stack', resourceType: 'AWS::EC2::VPCEndpoint' },
@@ -323,7 +618,8 @@ describe('VpcResourceResolver', () => {
323
618
  it('should auto-resolve mixed: some in stack, some new', () => {
324
619
  const appDefinition = {
325
620
  vpc: { ownership: { vpcEndpoints: 'auto' } },
326
- encryption: { fieldLevelEncryptionMethod: 'kms' } // Enable KMS endpoint
621
+ encryption: { fieldLevelEncryptionMethod: 'kms' }, // Enable KMS endpoint
622
+ database: { dynamodb: { enable: true } } // Enable DynamoDB
327
623
  };
328
624
  const discovery = {
329
625
  stackManaged: [
@@ -420,7 +716,11 @@ describe('VpcResourceResolver', () => {
420
716
  describe('real-world scenarios', () => {
421
717
  it('scenario: fresh deploy, no resources exist', () => {
422
718
  const appDefinition = {
423
- vpc: { enable: true, ownership: {} }
719
+ vpc: {
720
+ enable: true,
721
+ ownership: {},
722
+ management: 'create-new' // Explicitly allow VPC creation
723
+ }
424
724
  };
425
725
  const discovery = { stackManaged: [], external: [], fromCloudFormation: false };
426
726
 
@@ -78,8 +78,20 @@ class KmsBuilder extends InfrastructureBuilder {
78
78
  const resolver = new KmsResourceResolver();
79
79
  const decisions = resolver.resolveAll(appDefinition, discovery);
80
80
 
81
+ // Check if external key exists (for accurate logging)
82
+ const externalKmsKey = discoveredResources?.defaultKmsKeyId ||
83
+ discoveredResources?.kmsKeyArn ||
84
+ discoveredResources?.kmsKeyId;
85
+ const willUseExternal = decisions.key.ownership === ResourceOwnership.STACK &&
86
+ !decisions.key.physicalId &&
87
+ externalKmsKey;
88
+
81
89
  console.log('\n 📋 Resource Ownership Decisions:');
82
- console.log(` Key: ${decisions.key.ownership} - ${decisions.key.reason}`);
90
+ if (willUseExternal) {
91
+ console.log(` Key: external - Found external KMS key (not in stack)`);
92
+ } else {
93
+ console.log(` Key: ${decisions.key.ownership} - ${decisions.key.reason}`);
94
+ }
83
95
 
84
96
  // Build resources based on ownership decisions
85
97
  await this.buildFromDecisions(decisions, appDefinition, discoveredResources, result);
@@ -241,13 +253,30 @@ class KmsBuilder extends InfrastructureBuilder {
241
253
  if (decisions.key.ownership === ResourceOwnership.STACK && decisions.key.physicalId) {
242
254
  // Key exists in stack - add definitions (CloudFormation idempotency)
243
255
  console.log(' → Adding KMS definitions to template (existing in stack)');
256
+
257
+ // CRITICAL: Check if alias exists in stack before trying to create it
258
+ // Matches old serverless-template.js behavior: only create alias if it doesn't exist
259
+ const aliasExistsInStack = discoveredResources?.existingLogicalIds?.includes('FriggKMSKeyAlias');
260
+ if (!aliasExistsInStack) {
261
+ if (appDefinition.encryption?.kmsKeyAlias !== true) {
262
+ // Alias doesn't exist in stack - skip creation unless explicitly enabled
263
+ // This avoids kms:CreateAlias permission errors
264
+ console.log(' ℹ KMS alias not in stack - skipping creation (set kmsKeyAlias: true to force)');
265
+ appDefinition.encryption = appDefinition.encryption || {};
266
+ appDefinition.encryption.kmsKeyAlias = false;
267
+ } else {
268
+ console.log(' → Will create KMS alias (kmsKeyAlias: true explicitly set)');
269
+ }
270
+ } else {
271
+ console.log(' ✓ KMS alias found in stack - will keep in template');
272
+ }
273
+
244
274
  result.resources = this.createKmsKey(appDefinition);
245
275
  result.environment.KMS_KEY_ARN = { 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] };
246
276
  console.log(' ✅ KMS key resources created');
247
277
  } else if (decisions.key.ownership === ResourceOwnership.STACK && !decisions.key.physicalId && externalKmsKey) {
248
278
  // ORPHANED KEY FIX: Key exists externally but not in stack
249
279
  // Use it as external instead of trying to create (would fail with "already exists")
250
- console.log(' ⚠️ KMS key exists externally but not in stack - using as external resource');
251
280
  console.log(` → Using external KMS key: ${externalKmsKey}`);
252
281
 
253
282
  // Format as ARN if it's just a key ID
@@ -259,6 +288,17 @@ class KmsBuilder extends InfrastructureBuilder {
259
288
  } else if (decisions.key.ownership === ResourceOwnership.STACK && !decisions.key.physicalId && !useEnvVarFallback) {
260
289
  // Create new KMS key (only if not using env var fallback and no external key found)
261
290
  console.log(' → Creating new KMS key in stack');
291
+
292
+ // CRITICAL: Don't create alias by default to avoid kms:CreateAlias permission errors
293
+ // Matches old serverless-template.js behavior: only create alias if explicitly requested
294
+ if (appDefinition.encryption?.kmsKeyAlias !== true) {
295
+ console.log(' ℹ Skipping KMS alias creation by default (set kmsKeyAlias: true to enable)');
296
+ appDefinition.encryption = appDefinition.encryption || {};
297
+ appDefinition.encryption.kmsKeyAlias = false;
298
+ } else {
299
+ console.log(' → Will create KMS alias (kmsKeyAlias: true explicitly set)');
300
+ }
301
+
262
302
  result.resources = this.createKmsKey(appDefinition);
263
303
  result.environment.KMS_KEY_ARN = { 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] };
264
304
  console.log(' ✅ KMS key resources created');
@@ -296,7 +336,7 @@ class KmsBuilder extends InfrastructureBuilder {
296
336
  * Create KMS key CloudFormation resources
297
337
  */
298
338
  createKmsKey(appDefinition) {
299
- return {
339
+ const resources = {
300
340
  FriggKMSKey: {
301
341
  Type: 'AWS::KMS::Key',
302
342
  DeletionPolicy: 'Retain',
@@ -350,15 +390,24 @@ class KmsBuilder extends InfrastructureBuilder {
350
390
  ],
351
391
  },
352
392
  },
353
- FriggKMSKeyAlias: {
393
+ };
394
+
395
+ // Only create alias if explicitly enabled (default: true for backwards compatibility)
396
+ const createAlias = appDefinition.encryption?.kmsKeyAlias !== false;
397
+ if (createAlias) {
398
+ resources.FriggKMSKeyAlias = {
354
399
  Type: 'AWS::KMS::Alias',
355
400
  DeletionPolicy: 'Retain',
356
401
  Properties: {
357
402
  AliasName: 'alias/${self:service}-${self:provider.stage}-frigg-kms',
358
403
  TargetKeyId: { 'Fn::GetAtt': ['FriggKMSKey', 'Arn'] },
359
404
  },
360
- },
361
- };
405
+ };
406
+ } else {
407
+ console.log(' ℹ Skipping KMS key alias creation (kmsKeyAlias: false)');
408
+ }
409
+
410
+ return resources;
362
411
  }
363
412
  }
364
413
 
@@ -209,11 +209,12 @@ describe('KmsBuilder', () => {
209
209
  expect(result.resources.FriggKMSKey.Type).toBe('AWS::KMS::Key');
210
210
  });
211
211
 
212
- it('should create KMS key alias', async () => {
212
+ it('should create KMS key alias when explicitly enabled', async () => {
213
213
  const appDefinition = {
214
214
  encryption: {
215
215
  fieldLevelEncryptionMethod: 'kms',
216
216
  createResourceIfNoneFound: true,
217
+ kmsKeyAlias: true, // Explicitly enable alias creation
217
218
  },
218
219
  };
219
220
 
@@ -225,6 +226,23 @@ describe('KmsBuilder', () => {
225
226
  expect(result.resources.FriggKMSKeyAlias.Type).toBe('AWS::KMS::Alias');
226
227
  });
227
228
 
229
+ it('should skip alias creation when kmsKeyAlias: false', async () => {
230
+ const appDefinition = {
231
+ encryption: {
232
+ fieldLevelEncryptionMethod: 'kms',
233
+ createResourceIfNoneFound: true,
234
+ kmsKeyAlias: false,
235
+ },
236
+ };
237
+
238
+ const discoveredResources = {};
239
+
240
+ const result = await kmsBuilder.build(appDefinition, discoveredResources);
241
+
242
+ expect(result.resources.FriggKMSKey).toBeDefined();
243
+ expect(result.resources.FriggKMSKeyAlias).toBeUndefined();
244
+ });
245
+
228
246
  it('should enable key rotation for new keys', async () => {
229
247
  const appDefinition = {
230
248
  encryption: {