@friggframework/devtools 2.0.0-next.8 → 2.0.0-next.81

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 (240) hide show
  1. package/frigg-cli/README.md +1289 -0
  2. package/frigg-cli/__tests__/unit/commands/build.test.js +279 -0
  3. package/frigg-cli/__tests__/unit/commands/db-setup.test.js +649 -0
  4. package/frigg-cli/__tests__/unit/commands/deploy.test.js +320 -0
  5. package/frigg-cli/__tests__/unit/commands/doctor.test.js +309 -0
  6. package/frigg-cli/__tests__/unit/commands/install.test.js +400 -0
  7. package/frigg-cli/__tests__/unit/commands/ui.test.js +346 -0
  8. package/frigg-cli/__tests__/unit/dependencies.test.js +74 -0
  9. package/frigg-cli/__tests__/unit/utils/database-validator.test.js +397 -0
  10. package/frigg-cli/__tests__/unit/utils/error-messages.test.js +345 -0
  11. package/frigg-cli/__tests__/unit/version-detection.test.js +171 -0
  12. package/frigg-cli/__tests__/utils/mock-factory.js +270 -0
  13. package/frigg-cli/__tests__/utils/prisma-mock.js +194 -0
  14. package/frigg-cli/__tests__/utils/test-fixtures.js +463 -0
  15. package/frigg-cli/__tests__/utils/test-setup.js +287 -0
  16. package/frigg-cli/auth-command/CLAUDE.md +293 -0
  17. package/frigg-cli/auth-command/README.md +450 -0
  18. package/frigg-cli/auth-command/api-key-flow.js +153 -0
  19. package/frigg-cli/auth-command/auth-tester.js +344 -0
  20. package/frigg-cli/auth-command/credential-storage.js +182 -0
  21. package/frigg-cli/auth-command/index.js +256 -0
  22. package/frigg-cli/auth-command/json-schema-form.js +67 -0
  23. package/frigg-cli/auth-command/module-loader.js +172 -0
  24. package/frigg-cli/auth-command/oauth-callback-server.js +431 -0
  25. package/frigg-cli/auth-command/oauth-flow.js +195 -0
  26. package/frigg-cli/auth-command/utils/browser.js +30 -0
  27. package/frigg-cli/build-command/index.js +45 -12
  28. package/frigg-cli/db-setup-command/index.js +246 -0
  29. package/frigg-cli/deploy-command/SPEC-DEPLOY-DRY-RUN.md +981 -0
  30. package/frigg-cli/deploy-command/index.js +295 -23
  31. package/frigg-cli/doctor-command/index.js +335 -0
  32. package/frigg-cli/generate-command/__tests__/generate-command.test.js +301 -0
  33. package/frigg-cli/generate-command/azure-generator.js +43 -0
  34. package/frigg-cli/generate-command/gcp-generator.js +47 -0
  35. package/frigg-cli/generate-command/index.js +332 -0
  36. package/frigg-cli/generate-command/terraform-generator.js +555 -0
  37. package/frigg-cli/generate-iam-command.js +118 -0
  38. package/frigg-cli/index.js +174 -1
  39. package/frigg-cli/index.test.js +1 -4
  40. package/frigg-cli/init-command/backend-first-handler.js +756 -0
  41. package/frigg-cli/init-command/index.js +93 -0
  42. package/frigg-cli/init-command/template-handler.js +143 -0
  43. package/frigg-cli/install-command/index.js +1 -4
  44. package/frigg-cli/jest.config.js +124 -0
  45. package/frigg-cli/package.json +63 -0
  46. package/frigg-cli/repair-command/index.js +564 -0
  47. package/frigg-cli/start-command/index.js +118 -5
  48. package/frigg-cli/start-command/start-command.test.js +297 -0
  49. package/frigg-cli/test/init-command.test.js +180 -0
  50. package/frigg-cli/test/npm-registry.test.js +319 -0
  51. package/frigg-cli/ui-command/index.js +154 -0
  52. package/frigg-cli/utils/app-resolver.js +319 -0
  53. package/frigg-cli/utils/backend-path.js +16 -17
  54. package/frigg-cli/utils/database-validator.js +167 -0
  55. package/frigg-cli/utils/error-messages.js +329 -0
  56. package/frigg-cli/utils/npm-registry.js +167 -0
  57. package/frigg-cli/utils/process-manager.js +199 -0
  58. package/frigg-cli/utils/repo-detection.js +405 -0
  59. package/infrastructure/ARCHITECTURE.md +487 -0
  60. package/infrastructure/CLAUDE.md +481 -0
  61. package/infrastructure/HEALTH.md +468 -0
  62. package/infrastructure/README.md +522 -0
  63. package/infrastructure/__tests__/fixtures/mock-aws-resources.js +391 -0
  64. package/infrastructure/__tests__/helpers/test-utils.js +277 -0
  65. package/infrastructure/__tests__/postgres-config.test.js +914 -0
  66. package/infrastructure/__tests__/template-generation.test.js +687 -0
  67. package/infrastructure/create-frigg-infrastructure.js +129 -20
  68. package/infrastructure/docs/POSTGRES-CONFIGURATION.md +630 -0
  69. package/infrastructure/docs/PRE-DEPLOYMENT-HEALTH-CHECK-SPEC.md +1317 -0
  70. package/infrastructure/docs/WEBSOCKET-CONFIGURATION.md +105 -0
  71. package/infrastructure/docs/deployment-instructions.md +268 -0
  72. package/infrastructure/docs/generate-iam-command.md +278 -0
  73. package/infrastructure/docs/iam-policy-templates.md +193 -0
  74. package/infrastructure/domains/database/aurora-builder.js +857 -0
  75. package/infrastructure/domains/database/aurora-builder.test.js +960 -0
  76. package/infrastructure/domains/database/aurora-discovery.js +87 -0
  77. package/infrastructure/domains/database/aurora-discovery.test.js +188 -0
  78. package/infrastructure/domains/database/aurora-resolver.js +210 -0
  79. package/infrastructure/domains/database/aurora-resolver.test.js +347 -0
  80. package/infrastructure/domains/database/migration-builder.js +701 -0
  81. package/infrastructure/domains/database/migration-builder.test.js +321 -0
  82. package/infrastructure/domains/database/migration-resolver.js +163 -0
  83. package/infrastructure/domains/database/migration-resolver.test.js +337 -0
  84. package/infrastructure/domains/health/application/ports/IPropertyReconciler.js +164 -0
  85. package/infrastructure/domains/health/application/ports/IResourceDetector.js +129 -0
  86. package/infrastructure/domains/health/application/ports/IResourceImporter.js +142 -0
  87. package/infrastructure/domains/health/application/ports/IStackRepository.js +131 -0
  88. package/infrastructure/domains/health/application/ports/index.js +26 -0
  89. package/infrastructure/domains/health/application/use-cases/__tests__/execute-resource-import-use-case.test.js +679 -0
  90. package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
  91. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +1130 -0
  92. package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js +221 -0
  93. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +152 -0
  94. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.test.js +343 -0
  95. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +535 -0
  96. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.test.js +376 -0
  97. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +213 -0
  98. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +441 -0
  99. package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
  100. package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
  101. package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
  102. package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
  103. package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
  104. package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
  105. package/infrastructure/domains/health/domain/entities/issue.js +299 -0
  106. package/infrastructure/domains/health/domain/entities/issue.test.js +528 -0
  107. package/infrastructure/domains/health/domain/entities/property-mismatch.js +108 -0
  108. package/infrastructure/domains/health/domain/entities/property-mismatch.test.js +275 -0
  109. package/infrastructure/domains/health/domain/entities/resource.js +159 -0
  110. package/infrastructure/domains/health/domain/entities/resource.test.js +432 -0
  111. package/infrastructure/domains/health/domain/entities/stack-health-report.js +306 -0
  112. package/infrastructure/domains/health/domain/entities/stack-health-report.test.js +601 -0
  113. package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
  114. package/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +971 -0
  115. package/infrastructure/domains/health/domain/services/__tests__/import-template-generator.test.js +1150 -0
  116. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +672 -0
  117. package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
  118. package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +419 -0
  119. package/infrastructure/domains/health/domain/services/health-score-calculator.js +248 -0
  120. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +504 -0
  121. package/infrastructure/domains/health/domain/services/import-progress-monitor.js +195 -0
  122. package/infrastructure/domains/health/domain/services/import-template-generator.js +435 -0
  123. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +345 -0
  124. package/infrastructure/domains/health/domain/services/mismatch-analyzer.js +234 -0
  125. package/infrastructure/domains/health/domain/services/mismatch-analyzer.test.js +431 -0
  126. package/infrastructure/domains/health/domain/services/property-mutability-config.js +382 -0
  127. package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
  128. package/infrastructure/domains/health/domain/services/update-progress-monitor.js +192 -0
  129. package/infrastructure/domains/health/domain/value-objects/health-score.js +138 -0
  130. package/infrastructure/domains/health/domain/value-objects/health-score.test.js +267 -0
  131. package/infrastructure/domains/health/domain/value-objects/property-mutability.js +161 -0
  132. package/infrastructure/domains/health/domain/value-objects/property-mutability.test.js +198 -0
  133. package/infrastructure/domains/health/domain/value-objects/resource-state.js +167 -0
  134. package/infrastructure/domains/health/domain/value-objects/resource-state.test.js +196 -0
  135. package/infrastructure/domains/health/domain/value-objects/stack-identifier.js +192 -0
  136. package/infrastructure/domains/health/domain/value-objects/stack-identifier.test.js +262 -0
  137. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
  138. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
  139. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
  140. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +784 -0
  141. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +1133 -0
  142. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +565 -0
  143. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +554 -0
  144. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.js +318 -0
  145. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-importer.test.js +398 -0
  146. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +777 -0
  147. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.test.js +580 -0
  148. package/infrastructure/domains/integration/integration-builder.js +547 -0
  149. package/infrastructure/domains/integration/integration-builder.test.js +798 -0
  150. package/infrastructure/domains/integration/integration-resolver.js +170 -0
  151. package/infrastructure/domains/integration/integration-resolver.test.js +369 -0
  152. package/infrastructure/domains/integration/websocket-builder.js +69 -0
  153. package/infrastructure/domains/integration/websocket-builder.test.js +195 -0
  154. package/infrastructure/domains/networking/vpc-builder.js +2051 -0
  155. package/infrastructure/domains/networking/vpc-builder.test.js +1960 -0
  156. package/infrastructure/domains/networking/vpc-discovery.js +177 -0
  157. package/infrastructure/domains/networking/vpc-discovery.test.js +350 -0
  158. package/infrastructure/domains/networking/vpc-resolver.js +505 -0
  159. package/infrastructure/domains/networking/vpc-resolver.test.js +801 -0
  160. package/infrastructure/domains/parameters/ssm-builder.js +79 -0
  161. package/infrastructure/domains/parameters/ssm-builder.test.js +189 -0
  162. package/infrastructure/domains/parameters/ssm-discovery.js +84 -0
  163. package/infrastructure/domains/parameters/ssm-discovery.test.js +210 -0
  164. package/infrastructure/domains/scheduler/scheduler-builder.js +211 -0
  165. package/infrastructure/domains/security/iam-generator.js +816 -0
  166. package/infrastructure/domains/security/iam-generator.test.js +204 -0
  167. package/infrastructure/domains/security/kms-builder.js +415 -0
  168. package/infrastructure/domains/security/kms-builder.test.js +392 -0
  169. package/infrastructure/domains/security/kms-discovery.js +80 -0
  170. package/infrastructure/domains/security/kms-discovery.test.js +177 -0
  171. package/infrastructure/domains/security/kms-resolver.js +96 -0
  172. package/infrastructure/domains/security/kms-resolver.test.js +216 -0
  173. package/infrastructure/domains/security/templates/frigg-deployment-iam-stack.yaml +401 -0
  174. package/infrastructure/domains/security/templates/iam-policy-basic.json +218 -0
  175. package/infrastructure/domains/security/templates/iam-policy-full.json +288 -0
  176. package/infrastructure/domains/shared/base-builder.js +112 -0
  177. package/infrastructure/domains/shared/base-resolver.js +186 -0
  178. package/infrastructure/domains/shared/base-resolver.test.js +305 -0
  179. package/infrastructure/domains/shared/builder-orchestrator.js +212 -0
  180. package/infrastructure/domains/shared/builder-orchestrator.test.js +213 -0
  181. package/infrastructure/domains/shared/cloudformation-discovery-v2.js +334 -0
  182. package/infrastructure/domains/shared/cloudformation-discovery.js +681 -0
  183. package/infrastructure/domains/shared/cloudformation-discovery.test.js +1320 -0
  184. package/infrastructure/domains/shared/environment-builder.js +119 -0
  185. package/infrastructure/domains/shared/environment-builder.test.js +247 -0
  186. package/infrastructure/domains/shared/providers/aws-provider-adapter.js +579 -0
  187. package/infrastructure/domains/shared/providers/aws-provider-adapter.test.js +416 -0
  188. package/infrastructure/domains/shared/providers/azure-provider-adapter.stub.js +93 -0
  189. package/infrastructure/domains/shared/providers/cloud-provider-adapter.js +136 -0
  190. package/infrastructure/domains/shared/providers/gcp-provider-adapter.stub.js +82 -0
  191. package/infrastructure/domains/shared/providers/provider-factory.js +108 -0
  192. package/infrastructure/domains/shared/providers/provider-factory.test.js +170 -0
  193. package/infrastructure/domains/shared/resource-discovery.enhanced.test.js +306 -0
  194. package/infrastructure/domains/shared/resource-discovery.js +256 -0
  195. package/infrastructure/domains/shared/resource-discovery.test.js +757 -0
  196. package/infrastructure/domains/shared/types/app-definition.js +205 -0
  197. package/infrastructure/domains/shared/types/discovery-result.js +106 -0
  198. package/infrastructure/domains/shared/types/discovery-result.test.js +258 -0
  199. package/infrastructure/domains/shared/types/index.js +46 -0
  200. package/infrastructure/domains/shared/types/resource-ownership.js +108 -0
  201. package/infrastructure/domains/shared/types/resource-ownership.test.js +101 -0
  202. package/infrastructure/domains/shared/utilities/base-definition-factory.js +408 -0
  203. package/infrastructure/domains/shared/utilities/base-definition-factory.js.bak +338 -0
  204. package/infrastructure/domains/shared/utilities/base-definition-factory.test.js +291 -0
  205. package/infrastructure/domains/shared/utilities/handler-path-resolver.js +134 -0
  206. package/infrastructure/domains/shared/utilities/handler-path-resolver.test.js +268 -0
  207. package/infrastructure/domains/shared/utilities/prisma-layer-manager.js +159 -0
  208. package/infrastructure/domains/shared/utilities/prisma-layer-manager.test.js +444 -0
  209. package/infrastructure/domains/shared/validation/env-validator.js +78 -0
  210. package/infrastructure/domains/shared/validation/env-validator.test.js +173 -0
  211. package/infrastructure/domains/shared/validation/plugin-validator.js +187 -0
  212. package/infrastructure/domains/shared/validation/plugin-validator.test.js +323 -0
  213. package/infrastructure/esbuild.config.js +53 -0
  214. package/infrastructure/infrastructure-composer.js +119 -0
  215. package/infrastructure/infrastructure-composer.test.js +1896 -0
  216. package/infrastructure/integration.test.js +383 -0
  217. package/infrastructure/scripts/build-prisma-layer.js +701 -0
  218. package/infrastructure/scripts/build-prisma-layer.test.js +170 -0
  219. package/infrastructure/scripts/build-time-discovery.js +238 -0
  220. package/infrastructure/scripts/build-time-discovery.test.js +379 -0
  221. package/infrastructure/scripts/run-discovery.js +110 -0
  222. package/infrastructure/scripts/verify-prisma-layer.js +72 -0
  223. package/management-ui/README.md +203 -0
  224. package/package.json +44 -14
  225. package/test/index.js +2 -4
  226. package/test/mock-api.js +1 -3
  227. package/test/mock-integration.js +4 -14
  228. package/.eslintrc.json +0 -3
  229. package/CHANGELOG.md +0 -132
  230. package/infrastructure/app-handler-helpers.js +0 -57
  231. package/infrastructure/backend-utils.js +0 -87
  232. package/infrastructure/routers/auth.js +0 -26
  233. package/infrastructure/routers/integration-defined-routers.js +0 -42
  234. package/infrastructure/routers/middleware/loadUser.js +0 -15
  235. package/infrastructure/routers/middleware/requireLoggedInUser.js +0 -12
  236. package/infrastructure/routers/user.js +0 -41
  237. package/infrastructure/routers/websocket.js +0 -55
  238. package/infrastructure/serverless-template.js +0 -291
  239. package/infrastructure/workers/integration-defined-workers.js +0 -24
  240. package/test/auther-definition-tester.js +0 -125
@@ -0,0 +1,857 @@
1
+ /**
2
+ * Aurora PostgreSQL Builder
3
+ *
4
+ * Domain Layer - Hexagonal Architecture
5
+ *
6
+ * Responsible for:
7
+ * - Aurora Serverless v2 cluster creation or discovery
8
+ * - Database subnet groups
9
+ * - Database security groups
10
+ * - Secrets Manager integration for credentials
11
+ * - Database connection environment variables
12
+ *
13
+ * Uses ownership-based architecture:
14
+ * - STACK: Resources in our CloudFormation template (definitions + Refs)
15
+ * - EXTERNAL: Resources outside our stack (reference by physical ID)
16
+ * - AUTO: System decides based on discovery
17
+ */
18
+
19
+ const { InfrastructureBuilder, ValidationResult } = require('../shared/base-builder');
20
+ const AuroraResourceResolver = require('./aurora-resolver');
21
+ const { createEmptyDiscoveryResult } = require('../shared/types/discovery-result');
22
+ const { ResourceOwnership } = require('../shared/types/resource-ownership');
23
+
24
+ // Pool + timeout query params appended to DATABASE_URL for Lambda-to-Aurora
25
+ // connections. Chosen to make worker Lambdas fail loud and fast on any DB
26
+ // contention rather than silently hanging for Lambda's 900s timeout.
27
+ //
28
+ // connection_limit=2 — two pg connections per Lambda container. One is too
29
+ // tight: several core use cases (get-process.executeMany,
30
+ // field-encryption-service batches) issue in-handler
31
+ // Promise.all against Prisma, and would serialize
32
+ // behind a single slot. Two removes that cliff while
33
+ // still being safe against max_connections (at 4 ACU
34
+ // Aurora pg 15 allows ~400 connections; 200 concurrent
35
+ // Lambdas × 2 = 400, leaves cluster room for maint).
36
+ // pool_timeout=20 — wait up to 20s for a pool slot, then throw P2024.
37
+ // Still fail-fast relative to 900s Lambda cap; gives
38
+ // in-handler fan-outs headroom.
39
+ // connect_timeout=10 — bound TCP/TLS handshake.
40
+ // socket_timeout=60 — kill dead client sockets (server never responds).
41
+ // options=-c statement_timeout=30000 -c lock_timeout=10000
42
+ // — Postgres-side hard caps. A query stuck >30s aborts
43
+ // with SQLSTATE 57014; a lock wait >10s aborts with
44
+ // SQLSTATE 55P03. URL encoding per libpq URI rules
45
+ // (space→%20, `=`→%3D inside the options value).
46
+ const LAMBDA_DATABASE_URL_QUERY_PARAMS = [
47
+ 'connection_limit=2',
48
+ 'pool_timeout=20',
49
+ 'connect_timeout=10',
50
+ 'socket_timeout=60',
51
+ 'options=-c%20statement_timeout%3D30000%20-c%20lock_timeout%3D10000',
52
+ ].join('&');
53
+
54
+ class AuroraBuilder extends InfrastructureBuilder {
55
+ constructor() {
56
+ super();
57
+ this.name = 'AuroraBuilder';
58
+ }
59
+
60
+ shouldExecute(appDefinition) {
61
+ // Skip Aurora in local mode (when FRIGG_SKIP_AWS_DISCOVERY is set)
62
+ // Aurora is an AWS-specific service that should only be created in production
63
+ if (process.env.FRIGG_SKIP_AWS_DISCOVERY === 'true') {
64
+ return false;
65
+ }
66
+
67
+ return appDefinition.database?.postgres?.enable === true;
68
+ }
69
+
70
+ getDependencies() {
71
+ return ['VpcBuilder']; // Aurora requires VPC to be configured first
72
+ }
73
+
74
+ validate(appDefinition) {
75
+ const result = new ValidationResult();
76
+
77
+ if (!appDefinition.database?.postgres) {
78
+ result.addError('PostgreSQL database configuration is missing');
79
+ return result;
80
+ }
81
+
82
+ const dbConfig = appDefinition.database.postgres;
83
+
84
+ // Validate management mode
85
+ const validModes = ['discover', 'managed', 'use-existing'];
86
+ const management = dbConfig.management || 'discover';
87
+ if (!validModes.includes(management)) {
88
+ result.addError(`Invalid database.postgres.management: "${management}"`);
89
+ }
90
+
91
+ // Validate use-existing requirements
92
+ if (management === 'use-existing' && !dbConfig.endpoint) {
93
+ result.addError('database.postgres.endpoint is required when management="use-existing"');
94
+ }
95
+
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');
99
+ }
100
+ if (dbConfig.maxCapacity !== undefined && (dbConfig.maxCapacity < 0.5 || dbConfig.maxCapacity > 128)) {
101
+ result.addError('database.postgres.maxCapacity must be between 0.5 and 128');
102
+ }
103
+
104
+ // Warn about public accessibility in production
105
+ if (dbConfig.publiclyAccessible === true) {
106
+ result.addWarning('database.postgres.publiclyAccessible=true is not recommended for production');
107
+ }
108
+
109
+ return result;
110
+ }
111
+
112
+ /**
113
+ * Build Aurora infrastructure using ownership-based architecture
114
+ */
115
+ async build(appDefinition, discoveredResources) {
116
+ console.log(`\n[${this.name}] Configuring Aurora PostgreSQL...`);
117
+
118
+ // Backwards compatibility: Translate old schema to new ownership schema
119
+ appDefinition = this.translateLegacyConfig(appDefinition, discoveredResources);
120
+
121
+ // Initialize result
122
+ const result = {
123
+ resources: {},
124
+ iamStatements: [],
125
+ environment: {},
126
+ };
127
+
128
+ // Special case: use-existing with endpoint (bypass resolver)
129
+ if (appDefinition.database?.postgres?._useExistingEndpoint) {
130
+ console.log(' Using provided database endpoint (use-existing mode)');
131
+ await this.useExistingAurora(appDefinition, discoveredResources, result);
132
+ console.log(`\n[${this.name}] ✅ Aurora PostgreSQL configuration completed`);
133
+ return result;
134
+ }
135
+
136
+ // Get structured discovery result
137
+ const discovery = discoveredResources._structured || this.convertFlatDiscoveryToStructured(discoveredResources, appDefinition);
138
+
139
+ // Use AuroraResourceResolver to make ownership decisions
140
+ const resolver = new AuroraResourceResolver();
141
+ const decisions = resolver.resolveAll(appDefinition, discovery);
142
+
143
+ console.log('\n 📋 Resource Ownership Decisions:');
144
+ console.log(` Cluster: ${decisions.cluster.ownership} - ${decisions.cluster.reason}`);
145
+ console.log(` Instance: ${decisions.instance.ownership} - ${decisions.instance.reason}`);
146
+ console.log(` Subnet Group: ${decisions.subnetGroup.ownership} - ${decisions.subnetGroup.reason}`);
147
+ console.log(` Secret: ${decisions.secret.ownership} - ${decisions.secret.reason}`);
148
+
149
+ // Build resources based on ownership decisions
150
+ await this.buildFromDecisions(decisions, appDefinition, discoveredResources, result);
151
+
152
+ console.log(`\n[${this.name}] ✅ Aurora PostgreSQL configuration completed`);
153
+ return result;
154
+ }
155
+
156
+ /**
157
+ * Convert flat discovery to structured discovery
158
+ * Provides backwards compatibility for tests
159
+ */
160
+ convertFlatDiscoveryToStructured(flatDiscovery, appDefinition = {}) {
161
+ const discovery = createEmptyDiscoveryResult();
162
+
163
+ if (!flatDiscovery) {
164
+ return discovery;
165
+ }
166
+
167
+ // Check if resources are from CloudFormation stack
168
+ const isManagedIsolated = appDefinition.managementMode === 'managed' &&
169
+ (appDefinition.vpcIsolation === 'isolated' || !appDefinition.vpcIsolation);
170
+ const hasExistingStackResources = isManagedIsolated && flatDiscovery.auroraClusterId &&
171
+ typeof flatDiscovery.auroraClusterId === 'string';
172
+
173
+ if (flatDiscovery.fromCloudFormationStack || hasExistingStackResources) {
174
+ discovery.fromCloudFormation = true;
175
+ discovery.stackName = flatDiscovery.stackName || 'assumed-stack';
176
+
177
+ // Add stack-managed resources
178
+ let existingLogicalIds = flatDiscovery.existingLogicalIds || [];
179
+
180
+ // Infer logical IDs from physical IDs if needed
181
+ if (hasExistingStackResources && existingLogicalIds.length === 0) {
182
+ if (flatDiscovery.auroraClusterId) existingLogicalIds.push('FriggAuroraCluster');
183
+ if (flatDiscovery.auroraInstanceId) existingLogicalIds.push('FriggAuroraInstance');
184
+ if (flatDiscovery.dbSubnetGroupName) existingLogicalIds.push('FriggDBSubnetGroup');
185
+ if (flatDiscovery.dbSecretArn) existingLogicalIds.push('FriggDBSecret');
186
+ }
187
+
188
+ existingLogicalIds.forEach(logicalId => {
189
+ let resourceType = '';
190
+ let physicalId = '';
191
+
192
+ if (logicalId === 'FriggAuroraCluster') {
193
+ resourceType = 'AWS::RDS::DBCluster';
194
+ physicalId = flatDiscovery.auroraClusterId;
195
+ } else if (logicalId === 'FriggAuroraInstance') {
196
+ resourceType = 'AWS::RDS::DBInstance';
197
+ physicalId = flatDiscovery.auroraInstanceId;
198
+ } else if (logicalId === 'FriggDBSubnetGroup') {
199
+ resourceType = 'AWS::RDS::DBSubnetGroup';
200
+ physicalId = flatDiscovery.dbSubnetGroupName;
201
+ } else if (logicalId === 'FriggDBSecret') {
202
+ resourceType = 'AWS::SecretsManager::Secret';
203
+ physicalId = flatDiscovery.dbSecretArn;
204
+ }
205
+
206
+ if (physicalId && typeof physicalId === 'string') {
207
+ discovery.stackManaged.push({
208
+ logicalId,
209
+ physicalId,
210
+ resourceType
211
+ });
212
+ }
213
+ });
214
+ } else {
215
+ // Resources discovered from AWS API (external)
216
+ // Handle both cluster ID and endpoint
217
+ if (flatDiscovery.auroraClusterId && typeof flatDiscovery.auroraClusterId === 'string') {
218
+ discovery.external.push({
219
+ physicalId: flatDiscovery.auroraClusterId,
220
+ resourceType: 'AWS::RDS::DBCluster',
221
+ source: 'aws-discovery'
222
+ });
223
+ } else if (flatDiscovery.auroraClusterEndpoint && typeof flatDiscovery.auroraClusterEndpoint === 'string') {
224
+ // Endpoint provided (discover mode) - treat as external
225
+ discovery.external.push({
226
+ physicalId: flatDiscovery.auroraClusterEndpoint,
227
+ resourceType: 'AWS::RDS::DBCluster',
228
+ source: 'aws-discovery',
229
+ properties: { Endpoint: flatDiscovery.auroraClusterEndpoint }
230
+ });
231
+ }
232
+
233
+ if (flatDiscovery.auroraInstanceId && typeof flatDiscovery.auroraInstanceId === 'string') {
234
+ discovery.external.push({
235
+ physicalId: flatDiscovery.auroraInstanceId,
236
+ resourceType: 'AWS::RDS::DBInstance',
237
+ source: 'aws-discovery'
238
+ });
239
+ }
240
+ }
241
+
242
+ return discovery;
243
+ }
244
+
245
+ /**
246
+ * Translate legacy configuration to ownership-based configuration
247
+ * Provides backwards compatibility
248
+ */
249
+ translateLegacyConfig(appDefinition, discoveredResources) {
250
+ // If already using ownership schema, return as-is
251
+ if (appDefinition.database?.postgres?.ownership) {
252
+ return appDefinition;
253
+ }
254
+
255
+ const translated = JSON.parse(JSON.stringify(appDefinition));
256
+
257
+ // Initialize ownership sections
258
+ if (!translated.database) translated.database = {};
259
+ if (!translated.database.postgres) translated.database.postgres = {};
260
+ if (!translated.database.postgres.ownership) {
261
+ translated.database.postgres.ownership = {};
262
+ }
263
+ if (!translated.database.postgres.external) {
264
+ translated.database.postgres.external = {};
265
+ }
266
+ if (!translated.database.postgres.config) {
267
+ translated.database.postgres.config = {};
268
+ }
269
+
270
+ // Handle top-level managementMode
271
+ const globalMode = appDefinition.managementMode || 'discover';
272
+ const vpcIsolation = appDefinition.vpcIsolation || 'shared';
273
+
274
+ if (globalMode === 'managed') {
275
+ if (appDefinition.database?.postgres?.management) {
276
+ console.log(` ⚠️ managementMode='managed' ignoring: database.postgres.management`);
277
+ }
278
+
279
+ if (vpcIsolation === 'isolated') {
280
+ const hasStackAurora = discoveredResources?.auroraClusterId &&
281
+ typeof discoveredResources.auroraClusterId === 'string';
282
+
283
+ if (hasStackAurora) {
284
+ translated.database.postgres.ownership.cluster = 'auto';
285
+ translated.database.postgres.ownership.instance = 'auto';
286
+ translated.database.postgres.ownership.subnetGroup = 'auto';
287
+ translated.database.postgres.ownership.secret = 'auto';
288
+ console.log(` managementMode='managed' + vpcIsolation='isolated' → stack has Aurora, reusing`);
289
+ } else {
290
+ translated.database.postgres.ownership.cluster = 'stack';
291
+ translated.database.postgres.ownership.instance = 'stack';
292
+ translated.database.postgres.ownership.subnetGroup = 'stack';
293
+ translated.database.postgres.ownership.secret = 'stack';
294
+ console.log(` managementMode='managed' + vpcIsolation='isolated' → no stack Aurora, creating new`);
295
+ }
296
+ } else {
297
+ translated.database.postgres.ownership.cluster = 'auto';
298
+ translated.database.postgres.ownership.instance = 'auto';
299
+ translated.database.postgres.ownership.subnetGroup = 'auto';
300
+ translated.database.postgres.ownership.secret = 'auto';
301
+ console.log(` managementMode='managed' + vpcIsolation='shared' → discovering Aurora`);
302
+ }
303
+ } else if (globalMode === 'existing') {
304
+ translated.database.postgres.ownership.cluster = 'external';
305
+ translated.database.postgres.ownership.instance = 'external';
306
+ }
307
+
308
+ // Handle legacy database.postgres.management
309
+ // BUT: if managementMode (top-level) is set, it takes precedence
310
+ const dbManagement = appDefinition.database?.postgres?.management;
311
+ if (dbManagement && globalMode !== 'managed' && globalMode !== 'existing') {
312
+ if (dbManagement === 'managed') {
313
+ translated.database.postgres.ownership.cluster = 'stack';
314
+ translated.database.postgres.ownership.instance = 'stack';
315
+ translated.database.postgres.ownership.subnetGroup = 'stack';
316
+ translated.database.postgres.ownership.secret = 'stack';
317
+ } else if (dbManagement === 'use-existing') {
318
+ // For use-existing with endpoint, we bypass resolver entirely
319
+ // Mark this with a special flag
320
+ translated.database.postgres._useExistingEndpoint = true;
321
+ if (appDefinition.database.postgres.endpoint) {
322
+ translated.database.postgres.external.endpoint = appDefinition.database.postgres.endpoint;
323
+ }
324
+ } else if (dbManagement === 'discover') {
325
+ translated.database.postgres.ownership.cluster = 'auto';
326
+ translated.database.postgres.ownership.instance = 'auto';
327
+ }
328
+ }
329
+
330
+ // Preserve other database config
331
+ if (appDefinition.database?.postgres?.minCapacity) {
332
+ translated.database.postgres.config.minCapacity = appDefinition.database.postgres.minCapacity;
333
+ }
334
+ if (appDefinition.database?.postgres?.maxCapacity) {
335
+ translated.database.postgres.config.maxCapacity = appDefinition.database.postgres.maxCapacity;
336
+ }
337
+ if (appDefinition.database?.postgres?.publiclyAccessible !== undefined) {
338
+ translated.database.postgres.config.publiclyAccessible = appDefinition.database.postgres.publiclyAccessible;
339
+ }
340
+
341
+ return translated;
342
+ }
343
+
344
+ /**
345
+ * Build all Aurora resources based on ownership decisions
346
+ */
347
+ async buildFromDecisions(decisions, appDefinition, discoveredResources, result) {
348
+ // Determine build strategy from ownership decisions
349
+
350
+ if (decisions.cluster.ownership === ResourceOwnership.EXTERNAL) {
351
+ // External cluster discovered - reference it without creating infrastructure
352
+ console.log(' → Discovering and referencing external Aurora cluster');
353
+ await this.discoverAurora(appDefinition, discoveredResources, result);
354
+ } else if (decisions.cluster.ownership === ResourceOwnership.STACK && decisions.cluster.physicalId) {
355
+ // Cluster exists in stack - add definitions (CloudFormation idempotency)
356
+ console.log(' → Adding Aurora definitions to template (existing in stack)');
357
+ await this.createNewAurora(appDefinition, discoveredResources, result);
358
+ } else if (decisions.cluster.ownership === ResourceOwnership.STACK && !decisions.cluster.physicalId) {
359
+ // Create new cluster (stack, no existing)
360
+ console.log(' → Creating new Aurora cluster in stack');
361
+ await this.createNewAurora(appDefinition, discoveredResources, result);
362
+ } else {
363
+ // Fallback: discover mode
364
+ console.log(' → Discovering Aurora resources');
365
+ await this.discoverAurora(appDefinition, discoveredResources, result);
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Create new Aurora cluster
371
+ */
372
+ async createNewAurora(appDefinition, discoveredResources, result) {
373
+ console.log(' Creating new Aurora Serverless v2 cluster...');
374
+
375
+ const dbConfig = appDefinition.database.postgres;
376
+ const publiclyAccessible = dbConfig.publiclyAccessible === true;
377
+
378
+ // Get subnet IDs for DB Subnet Group
379
+ const subnetIds = publiclyAccessible
380
+ ? [discoveredResources.publicSubnetId1, discoveredResources.publicSubnetId2]
381
+ : [discoveredResources.privateSubnetId1, discoveredResources.privateSubnetId2];
382
+
383
+ if (!subnetIds[0] || !subnetIds[1]) {
384
+ throw new Error(
385
+ `Aurora requires 2 ${publiclyAccessible ? 'public' : 'private'} subnets in different AZs. ` +
386
+ 'Ensure VPC is configured correctly.'
387
+ );
388
+ }
389
+
390
+ // Database Subnet Group
391
+ result.resources.FriggDBSubnetGroup = {
392
+ Type: 'AWS::RDS::DBSubnetGroup',
393
+ Properties: {
394
+ DBSubnetGroupName: '${self:service}-${self:provider.stage}-db-subnet-group',
395
+ DBSubnetGroupDescription: 'Subnet group for Frigg Aurora cluster',
396
+ SubnetIds: subnetIds,
397
+ Tags: [
398
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-db-subnet' },
399
+ { Key: 'ManagedBy', Value: 'Frigg' },
400
+ ],
401
+ },
402
+ };
403
+
404
+ // Database Credentials Secret
405
+ result.resources.FriggDBSecret = {
406
+ Type: 'AWS::SecretsManager::Secret',
407
+ Properties: {
408
+ Name: '${self:service}-${self:provider.stage}-db-credentials',
409
+ Description: 'Aurora database credentials',
410
+ GenerateSecretString: {
411
+ SecretStringTemplate: JSON.stringify({ username: dbConfig.username || 'postgres' }),
412
+ GenerateStringKey: 'password',
413
+ PasswordLength: 32,
414
+ // Exclude URL-special characters for Prisma connection string compatibility
415
+ // Prisma docs: https://www.prisma.io/docs/reference/database-reference/connection-urls#special-characters
416
+ // Exclude: " @ : / ? # [ ] % \ (all have special meaning in URLs or need escaping)
417
+ ExcludeCharacters: '"@:/?#[]%\\\\',
418
+ },
419
+ Tags: [
420
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-db-secret' },
421
+ { Key: 'ManagedBy', Value: 'Frigg' },
422
+ ],
423
+ },
424
+ };
425
+
426
+ // Aurora Cluster
427
+ result.resources.FriggAuroraCluster = {
428
+ Type: 'AWS::RDS::DBCluster',
429
+ DeletionPolicy: 'Snapshot',
430
+ Properties: {
431
+ Engine: 'aurora-postgresql',
432
+ EngineMode: 'provisioned',
433
+ EngineVersion: dbConfig.engineVersion || '15.13', // Configurable, defaults to 15.13 (latest as of Oct 2025)
434
+ Port: 5432, // Explicitly set PostgreSQL port (AWS may not auto-detect)
435
+ DatabaseName: dbConfig.database || 'frigg',
436
+ MasterUsername: {
437
+ 'Fn::Sub': '{{resolve:secretsmanager:${FriggDBSecret}:SecretString:username}}',
438
+ },
439
+ MasterUserPassword: {
440
+ 'Fn::Sub': '{{resolve:secretsmanager:${FriggDBSecret}:SecretString:password}}',
441
+ },
442
+ DBSubnetGroupName: { Ref: 'FriggDBSubnetGroup' },
443
+ VpcSecurityGroupIds: discoveredResources.vpcSecurityGroupIds || [
444
+ { Ref: 'FriggLambdaSecurityGroup' },
445
+ ],
446
+ // Note: PubliclyAccessible is NOT supported on Aurora clusters
447
+ // It should only be set on DB instances (see FriggAuroraInstance below)
448
+ // MaxCapacity default bumped 1 → 4 ACU: at 0.5–1 ACU Aurora is
449
+ // CPU-starved under 20-way concurrent writes from a Lambda
450
+ // fan-out sync, which starves worker queries and compounds
451
+ // the tail-latency problem. 4 ACU is still cheap (scales to
452
+ // min when idle) and gives the DB enough headroom to
453
+ // absorb bursty sync traffic. Apps can still override both
454
+ // via app definition dbConfig.
455
+ ServerlessV2ScalingConfiguration: {
456
+ MinCapacity: dbConfig.minCapacity || 0.5,
457
+ MaxCapacity: dbConfig.maxCapacity || 4,
458
+ },
459
+ EnableHttpEndpoint: false,
460
+ BackupRetentionPeriod: 7,
461
+ PreferredBackupWindow: '03:00-04:00',
462
+ PreferredMaintenanceWindow: 'sun:04:00-sun:05:00',
463
+ Tags: [
464
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-aurora' },
465
+ { Key: 'ManagedBy', Value: 'Frigg' },
466
+ ],
467
+ },
468
+ };
469
+
470
+ // Aurora Instance
471
+ result.resources.FriggAuroraInstance = {
472
+ Type: 'AWS::RDS::DBInstance',
473
+ Properties: {
474
+ Engine: 'aurora-postgresql',
475
+ DBInstanceClass: 'db.serverless',
476
+ DBClusterIdentifier: { Ref: 'FriggAuroraCluster' },
477
+ PubliclyAccessible: publiclyAccessible,
478
+ Tags: [
479
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-aurora-instance' },
480
+ { Key: 'ManagedBy', Value: 'Frigg' },
481
+ ],
482
+ },
483
+ };
484
+
485
+ // Environment variables
486
+ result.environment.DATABASE_URL = this.buildDatabaseUrl(
487
+ { 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Address'] },
488
+ { 'Fn::GetAtt': ['FriggAuroraCluster', 'Endpoint.Port'] },
489
+ dbConfig.database || 'frigg',
490
+ { Ref: 'FriggDBSecret' }
491
+ );
492
+
493
+ // IAM permissions for Secrets Manager
494
+ result.iamStatements.push({
495
+ Effect: 'Allow',
496
+ Action: ['secretsmanager:GetSecretValue'],
497
+ Resource: { Ref: 'FriggDBSecret' },
498
+ });
499
+
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
+ };
513
+
514
+ console.log(' ✅ Aurora Serverless v2 cluster resources created');
515
+ }
516
+
517
+ /**
518
+ * Use existing Aurora cluster
519
+ */
520
+ async useExistingAurora(appDefinition, discoveredResources, result) {
521
+ console.log(' Using existing Aurora cluster...');
522
+
523
+ const dbConfig = appDefinition.database.postgres;
524
+
525
+ if (!dbConfig.endpoint) {
526
+ throw new Error('database.postgres.endpoint is required when management="use-existing"');
527
+ }
528
+
529
+ // Set environment variables for existing cluster
530
+ result.environment.DATABASE_HOST = dbConfig.endpoint;
531
+ result.environment.DATABASE_PORT = String(dbConfig.port || 5432);
532
+ result.environment.DATABASE_NAME = dbConfig.database || 'frigg';
533
+ result.environment.DATABASE_USER = dbConfig.username || 'postgres';
534
+ // Consumers that build DATABASE_URL from components at runtime MUST
535
+ // append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
536
+ // timeouts as the managed path.
537
+ result.environment.DATABASE_URL_PARAMS = LAMBDA_DATABASE_URL_QUERY_PARAMS;
538
+
539
+ console.log(` ✅ Using existing cluster: ${dbConfig.endpoint}`);
540
+ }
541
+
542
+ /**
543
+ * Discover existing Aurora cluster
544
+ */
545
+ async discoverAurora(appDefinition, discoveredResources, result) {
546
+ console.log(' Discovering Aurora cluster...');
547
+
548
+ if (!discoveredResources.auroraClusterEndpoint) {
549
+ throw new Error(
550
+ 'No Aurora cluster found in discovery mode. Set management to "managed" or provide endpoint with "use-existing".'
551
+ );
552
+ }
553
+
554
+ console.log(` ✅ Using discovered Aurora cluster: ${discoveredResources.auroraClusterEndpoint}`);
555
+
556
+ const dbConfig = appDefinition.database.postgres;
557
+
558
+ // Use discovered cluster details
559
+ result.environment.DATABASE_HOST = discoveredResources.auroraClusterEndpoint;
560
+ result.environment.DATABASE_PORT = String(discoveredResources.auroraPort || 5432);
561
+
562
+ // Check if we should auto-create credentials
563
+ if (dbConfig.autoCreateCredentials && !discoveredResources.databaseSecretArn) {
564
+ console.log(' Creating Secrets Manager secret and rotating Aurora password...');
565
+
566
+ // Create Secrets Manager secret with auto-generated password
567
+ result.resources.FriggDBSecret = {
568
+ Type: 'AWS::SecretsManager::Secret',
569
+ Properties: {
570
+ Name: '${self:service}-${self:provider.stage}-db-credentials',
571
+ Description: 'Aurora database credentials (auto-created for discovered cluster)',
572
+ GenerateSecretString: {
573
+ SecretStringTemplate: JSON.stringify({ username: dbConfig.username || 'postgres' }),
574
+ GenerateStringKey: 'password',
575
+ PasswordLength: 32,
576
+ // Exclude URL-special characters for Prisma connection string compatibility
577
+ // Prisma docs: https://www.prisma.io/docs/reference/database-reference/connection-urls#special-characters
578
+ // Exclude: " @ : / ? # [ ] % \ (all have special meaning in URLs or need escaping)
579
+ ExcludeCharacters: '"@:/?#[]%\\\\',
580
+ },
581
+ Tags: [
582
+ { Key: 'Name', Value: '${self:service}-${self:provider.stage}-db-secret' },
583
+ { Key: 'ManagedBy', Value: 'Frigg' },
584
+ { Key: 'Purpose', Value: 'DiscoveredClusterCredentials' },
585
+ ],
586
+ },
587
+ };
588
+
589
+ // Get the cluster identifier from the endpoint
590
+ // Format: cluster-name.cluster-xyz.region.rds.amazonaws.com
591
+ const clusterIdentifier = discoveredResources.auroraClusterEndpoint.split('.')[0];
592
+
593
+ // Create custom resource to rotate the Aurora master password
594
+ // This uses a Lambda-backed CloudFormation custom resource
595
+ result.resources.FriggAuroraPasswordRotator = {
596
+ Type: 'Custom::AuroraPasswordRotator',
597
+ Properties: {
598
+ ServiceToken: { 'Fn::GetAtt': ['PasswordRotatorLambda', 'Arn'] },
599
+ ClusterIdentifier: clusterIdentifier,
600
+ SecretArn: { Ref: 'FriggDBSecret' },
601
+ Region: '${self:provider.region}',
602
+ },
603
+ DependsOn: ['FriggDBSecret', 'PasswordRotatorLambda'],
604
+ };
605
+
606
+ // Lambda function to rotate the password
607
+ result.resources.PasswordRotatorLambda = {
608
+ Type: 'AWS::Lambda::Function',
609
+ Properties: {
610
+ FunctionName: '${self:service}-${self:provider.stage}-password-rotator',
611
+ Runtime: 'nodejs22.x',
612
+ Handler: 'index.handler',
613
+ Role: { 'Fn::GetAtt': ['PasswordRotatorRole', 'Arn'] },
614
+ Timeout: 60,
615
+ Code: {
616
+ ZipFile: `
617
+ const { RDSClient, ModifyDBClusterCommand } = require('@aws-sdk/client-rds');
618
+ const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
619
+
620
+ exports.handler = async (event, context) => {
621
+ console.log('Event:', JSON.stringify(event, null, 2));
622
+
623
+ const { RequestType, ResourceProperties } = event;
624
+ const { ClusterIdentifier, SecretArn, Region } = ResourceProperties;
625
+
626
+ const sendResponse = async (status, data = {}) => {
627
+ const responseBody = JSON.stringify({
628
+ Status: status,
629
+ Reason: data.Reason || 'See CloudWatch logs',
630
+ PhysicalResourceId: context.logStreamName,
631
+ StackId: event.StackId,
632
+ RequestId: event.RequestId,
633
+ LogicalResourceId: event.LogicalResourceId,
634
+ Data: data,
635
+ });
636
+
637
+ await fetch(event.ResponseURL, {
638
+ method: 'PUT',
639
+ body: responseBody,
640
+ headers: { 'Content-Type': '' },
641
+ });
642
+ };
643
+
644
+ try {
645
+ if (RequestType === 'Delete') {
646
+ await sendResponse('SUCCESS', { Message: 'Delete not required' });
647
+ return;
648
+ }
649
+
650
+ // Get the new password from Secrets Manager
651
+ const smClient = new SecretsManagerClient({ region: Region });
652
+ const secretResponse = await smClient.send(
653
+ new GetSecretValueCommand({ SecretId: SecretArn })
654
+ );
655
+ const secret = JSON.parse(secretResponse.SecretString);
656
+ const newPassword = secret.password;
657
+
658
+ // Rotate the Aurora cluster master password
659
+ const rdsClient = new RDSClient({ region: Region });
660
+ await rdsClient.send(
661
+ new ModifyDBClusterCommand({
662
+ DBClusterIdentifier: ClusterIdentifier,
663
+ MasterUserPassword: newPassword,
664
+ ApplyImmediately: true,
665
+ })
666
+ );
667
+
668
+ console.log('Successfully rotated password for cluster: ' + ClusterIdentifier);
669
+ await sendResponse('SUCCESS', {
670
+ Message: 'Password rotated successfully',
671
+ ClusterIdentifier,
672
+ });
673
+ } catch (error) {
674
+ console.error('Error rotating password:', error);
675
+ await sendResponse('FAILED', { Reason: error.message });
676
+ }
677
+ };
678
+ `,
679
+ },
680
+ },
681
+ };
682
+
683
+ // IAM role for the password rotator Lambda
684
+ result.resources.PasswordRotatorRole = {
685
+ Type: 'AWS::IAM::Role',
686
+ Properties: {
687
+ AssumeRolePolicyDocument: {
688
+ Version: '2012-10-17',
689
+ Statement: [
690
+ {
691
+ Effect: 'Allow',
692
+ Principal: { Service: 'lambda.amazonaws.com' },
693
+ Action: 'sts:AssumeRole',
694
+ },
695
+ ],
696
+ },
697
+ ManagedPolicyArns: [
698
+ 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
699
+ ],
700
+ Policies: [
701
+ {
702
+ PolicyName: 'PasswordRotatorPolicy',
703
+ PolicyDocument: {
704
+ Version: '2012-10-17',
705
+ Statement: [
706
+ {
707
+ Effect: 'Allow',
708
+ Action: [
709
+ 'rds:ModifyDBCluster',
710
+ 'rds:DescribeDBClusters',
711
+ ],
712
+ Resource: '*',
713
+ },
714
+ {
715
+ Effect: 'Allow',
716
+ Action: ['secretsmanager:GetSecretValue'],
717
+ Resource: { Ref: 'FriggDBSecret' },
718
+ },
719
+ ],
720
+ },
721
+ },
722
+ ],
723
+ },
724
+ };
725
+
726
+ // Use the secret for DATABASE_URL
727
+ result.environment.DATABASE_SECRET_ARN = { Ref: 'FriggDBSecret' };
728
+ result.environment.DATABASE_URL = this.buildDatabaseUrl(
729
+ discoveredResources.auroraClusterEndpoint,
730
+ discoveredResources.auroraPort || 5432,
731
+ dbConfig.database || 'frigg',
732
+ { Ref: 'FriggDBSecret' }
733
+ );
734
+
735
+ // Grant Lambda functions permission to read the secret
736
+ result.iamStatements.push({
737
+ Effect: 'Allow',
738
+ Action: ['secretsmanager:GetSecretValue'],
739
+ Resource: { Ref: 'FriggDBSecret' },
740
+ });
741
+
742
+ console.log(' ✅ Credentials auto-creation configured');
743
+ } else if (discoveredResources.databaseSecretArn) {
744
+ // Use existing discovered secret
745
+ result.environment.DATABASE_SECRET_ARN = discoveredResources.databaseSecretArn;
746
+ result.environment.DATABASE_URL = this.buildDatabaseUrl(
747
+ discoveredResources.auroraClusterEndpoint,
748
+ discoveredResources.auroraPort || 5432,
749
+ dbConfig.database || 'frigg',
750
+ discoveredResources.databaseSecretArn
751
+ );
752
+
753
+ result.iamStatements.push({
754
+ Effect: 'Allow',
755
+ Action: ['secretsmanager:GetSecretValue'],
756
+ Resource: discoveredResources.databaseSecretArn,
757
+ });
758
+
759
+ console.log(' ✅ Using discovered Secrets Manager credentials');
760
+ } else {
761
+ // No secret and no auto-create - set individual DB connection components
762
+ // The application will construct DATABASE_URL at runtime from these components + DATABASE_USER + DATABASE_PASSWORD
763
+ const dbName = dbConfig.database || 'frigg';
764
+
765
+ result.environment.DATABASE_HOST = discoveredResources.auroraClusterEndpoint;
766
+ result.environment.DATABASE_PORT = String(discoveredResources.auroraPort || 5432);
767
+ result.environment.DATABASE_NAME = dbName;
768
+ // Consumers that build DATABASE_URL from components at runtime MUST
769
+ // append `?${DATABASE_URL_PARAMS}` to get the same hang-prevention
770
+ // timeouts as the managed path.
771
+ result.environment.DATABASE_URL_PARAMS = LAMBDA_DATABASE_URL_QUERY_PARAMS;
772
+
773
+ // Note: DATABASE_URL is NOT set here to avoid Serverless variable resolution errors
774
+ // The application (Frigg Core) should construct it at runtime from:
775
+ // DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD, DATABASE_URL_PARAMS
776
+
777
+ console.log(' ℹ️ No Secrets Manager secret found - set DATABASE_USER and DATABASE_PASSWORD in Lambda environment');
778
+ console.log(' ℹ️ Application will construct DATABASE_URL at runtime from DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD');
779
+ console.log(' ℹ️ Append `?${DATABASE_URL_PARAMS}` to the constructed URL for pool/timeout safety.');
780
+ console.log(' ℹ️ Or enable autoCreateCredentials=true to automatically create and rotate credentials');
781
+ }
782
+
783
+ // Add security group ingress rule to allow Lambda to connect to Aurora
784
+ 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
+ };
796
+ console.log(` ✅ Added security group ingress rule for Lambda → Aurora connectivity`);
797
+ }
798
+
799
+ console.log(` ✅ Discovered cluster configuration complete`);
800
+ }
801
+
802
+ /**
803
+ * Build DATABASE_URL connection string
804
+ * @param {string|object} host - Database host (string or CloudFormation intrinsic function)
805
+ * @param {string|number|object} port - Database port (string/number or CloudFormation intrinsic function)
806
+ * @param {string} database - Database name
807
+ * @param {string|object} secretRef - Secret ARN (string) or CloudFormation Ref object
808
+ */
809
+ buildDatabaseUrl(host, port, database, secretRef) {
810
+ // Handle secretRef as either a string ARN or CloudFormation Ref object
811
+ const resolveSecretRef = (secretRefValue) => {
812
+ if (typeof secretRefValue === 'object' && secretRefValue.Ref) {
813
+ // CloudFormation Ref - use nested Fn::Sub to resolve it
814
+ return {
815
+ 'Fn::Sub': [
816
+ '{{resolve:secretsmanager:${SecretArn}:SecretString:username}}',
817
+ { SecretArn: secretRefValue },
818
+ ],
819
+ };
820
+ }
821
+ // String ARN - use directly
822
+ return `{{resolve:secretsmanager:${secretRefValue}:SecretString:username}}`;
823
+ };
824
+
825
+ const resolveSecretPassword = (secretRefValue) => {
826
+ if (typeof secretRefValue === 'object' && secretRefValue.Ref) {
827
+ // CloudFormation Ref - use nested Fn::Sub to resolve it
828
+ return {
829
+ 'Fn::Sub': [
830
+ '{{resolve:secretsmanager:${SecretArn}:SecretString:password}}',
831
+ { SecretArn: secretRefValue },
832
+ ],
833
+ };
834
+ }
835
+ // String ARN - use directly
836
+ return `{{resolve:secretsmanager:${secretRefValue}:SecretString:password}}`;
837
+ };
838
+
839
+ // Query params are defined at module scope (LAMBDA_DATABASE_URL_QUERY_PARAMS)
840
+ // so runtime-URL-construction paths can emit the same timeouts as an env var.
841
+ return {
842
+ 'Fn::Sub': [
843
+ `postgresql://\${Username}:\${Password}@\${Host}:\${Port}/\${Database}?${LAMBDA_DATABASE_URL_QUERY_PARAMS}`,
844
+ {
845
+ Username: resolveSecretRef(secretRef),
846
+ Password: resolveSecretPassword(secretRef),
847
+ Host: host,
848
+ Port: port,
849
+ Database: database,
850
+ },
851
+ ],
852
+ };
853
+ }
854
+ }
855
+
856
+ module.exports = { AuroraBuilder };
857
+