@friggframework/core 2.0.0-next.25 → 2.0.0-next.27

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.
@@ -16,28 +16,18 @@ const findOneEvents = [
16
16
 
17
17
  const shouldBypassEncryption = (STAGE) => {
18
18
  const defaultBypassStages = ['dev', 'test', 'local'];
19
- const bypassStageEnv = process.env.BYPASS_ENCRYPTION_STAGE;
20
- // If the env is set to anything or an empty string, use the env. Otherwise, use the default array
21
- const useEnv = !String(bypassStageEnv) || !!bypassStageEnv;
22
- const bypassStages = useEnv
23
- ? bypassStageEnv.split(',').map((stage) => stage.trim())
24
- : defaultBypassStages;
25
- return bypassStages.includes(STAGE);
19
+ return defaultBypassStages.includes(STAGE);
26
20
  };
27
21
 
28
- // The Mongoose plug-in function
29
- function Encrypt(schema, options) {
22
+ function Encrypt(schema) {
30
23
  const { STAGE, KMS_KEY_ARN, AES_KEY_ID } = process.env;
31
24
 
32
25
  if (shouldBypassEncryption(STAGE)) {
33
26
  return;
34
27
  }
35
28
 
36
- if (KMS_KEY_ARN && AES_KEY_ID) {
37
- throw new Error(
38
- 'Local and AWS encryption keys are both set in the environment.'
39
- );
40
- }
29
+ const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== '';
30
+ const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== '' && !hasAES;
41
31
 
42
32
  const fields = Object.values(schema.paths)
43
33
  .map(({ path, options }) => (options.lhEncrypt === true ? path : ''))
@@ -48,25 +38,17 @@ function Encrypt(schema, options) {
48
38
  }
49
39
 
50
40
  const cryptor = new Cryptor({
51
- // Use AWS if the CMK is present
52
- shouldUseAws: !!KMS_KEY_ARN,
53
- // Find all the fields in the schema with lhEncrypt === true
41
+ shouldUseAws: hasKMS,
54
42
  fields: fields,
55
43
  });
56
44
 
57
- // ---------------------------------------------
58
- // ### Encrypt fields before save/update/insert.
59
- // ---------------------------------------------
60
-
61
45
  schema.pre('save', async function encryptionPreSave() {
62
- // `this` will be a doc
63
46
  await cryptor.encryptFieldsInDocuments([this]);
64
47
  });
65
48
 
66
49
  schema.pre(
67
50
  'insertMany',
68
51
  async function encryptionPreInsertMany(_, docs, options) {
69
- // `this` will be the model
70
52
  if (options?.rawResult) {
71
53
  throw new Error(
72
54
  'Raw result not supported for insertMany with Encrypt plugin'
@@ -78,17 +60,14 @@ function Encrypt(schema, options) {
78
60
  );
79
61
 
80
62
  schema.pre(updateOneEvents, async function encryptionPreUpdateOne() {
81
- // `this` will be a query
82
63
  await cryptor.encryptFieldsInQuery(this);
83
64
  });
84
65
 
85
66
  schema.pre('updateMany', async function encryptionPreUpdateMany() {
86
- // `this` will be a query
87
67
  cryptor.expectNotToUpdateManyEncrypted(this.getUpdate());
88
68
  });
89
69
 
90
70
  schema.pre('update', async function encryptionPreUpdate() {
91
- // `this` will be a query
92
71
  const { multiple } = this.getOptions();
93
72
 
94
73
  if (multiple) {
@@ -99,16 +78,11 @@ function Encrypt(schema, options) {
99
78
  await cryptor.encryptFieldsInQuery(this);
100
79
  });
101
80
 
102
- // --------------------------------------------
103
- // ### Decrypt documents after they are loaded.
104
- // --------------------------------------------
105
81
  schema.post('save', async function encryptionPreSave() {
106
- // `this` will be a doc
107
82
  await cryptor.decryptFieldsInDocuments([this]);
108
83
  });
109
84
 
110
85
  schema.post(findOneEvents, async function encryptionPostFindOne(doc) {
111
- // `this` will be a query
112
86
  const { rawResult } = this.getOptions();
113
87
 
114
88
  if (rawResult) {
@@ -119,12 +93,10 @@ function Encrypt(schema, options) {
119
93
  });
120
94
 
121
95
  schema.post('find', async function encryptionPostFind(docs) {
122
- // `this` will be a query
123
96
  await cryptor.decryptFieldsInDocuments(docs);
124
97
  });
125
98
 
126
99
  schema.post('insertMany', async function encryptionPostInsertMany(docs) {
127
- // `this` will be the model
128
100
  await cryptor.decryptFieldsInDocuments(docs);
129
101
  });
130
102
  }
@@ -0,0 +1,240 @@
1
+ # Frigg Healthcheck Endpoint Documentation
2
+
3
+ ## Overview
4
+
5
+ The Frigg service includes comprehensive healthcheck endpoints to monitor service health, connectivity, and readiness. These endpoints follow industry best practices and are designed for use with monitoring systems, load balancers, and container orchestration platforms.
6
+
7
+ ## Endpoints
8
+
9
+ ### 1. Basic Health Check
10
+ **GET** `/health`
11
+
12
+ Simple health check endpoint that returns basic service information. No authentication required. This endpoint is rate-limited at the API Gateway level.
13
+
14
+ **Response:**
15
+ ```json
16
+ {
17
+ "status": "ok",
18
+ "timestamp": "2024-01-10T12:00:00.000Z",
19
+ "service": "frigg-core-api"
20
+ }
21
+ ```
22
+
23
+ **Status Codes:**
24
+ - `200 OK` - Service is running
25
+
26
+ ### 2. Detailed Health Check
27
+ **GET** `/health/detailed`
28
+
29
+ Comprehensive health check that tests all service components and dependencies.
30
+
31
+ **Authentication Required:**
32
+ - Header: `x-api-key: YOUR_API_KEY`
33
+ - The API key must match the `HEALTH_API_KEY` environment variable
34
+
35
+ **Response:**
36
+ ```json
37
+ {
38
+ "service": "frigg-core-api",
39
+ "status": "healthy", // "healthy" or "unhealthy"
40
+ "timestamp": "2024-01-10T12:00:00.000Z",
41
+ "checks": {
42
+ "database": {
43
+ "status": "healthy",
44
+ "state": "connected",
45
+ "responseTime": 5 // milliseconds
46
+ },
47
+ "externalApis": {
48
+ "github": {
49
+ "status": "healthy",
50
+ "statusCode": 200,
51
+ "responseTime": 150,
52
+ "reachable": true
53
+ },
54
+ "npm": {
55
+ "status": "healthy",
56
+ "statusCode": 200,
57
+ "responseTime": 200,
58
+ "reachable": true
59
+ }
60
+ },
61
+ "integrations": {
62
+ "status": "healthy",
63
+ "modules": {
64
+ "count": 10,
65
+ "available": ["module1", "module2", "..."]
66
+ },
67
+ "integrations": {
68
+ "count": 5,
69
+ "available": ["integration1", "integration2", "..."]
70
+ }
71
+ }
72
+ },
73
+ "responseTime": 250 // total endpoint response time in milliseconds
74
+ }
75
+ ```
76
+
77
+ **Status Codes:**
78
+ - `200 OK` - Service is healthy (all components operational)
79
+ - `503 Service Unavailable` - Service is unhealthy (any component failure)
80
+ - `401 Unauthorized` - Missing or invalid x-api-key header
81
+
82
+ ### 3. Liveness Probe
83
+ **GET** `/health/live`
84
+
85
+ Kubernetes-style liveness probe. Returns whether the service process is alive.
86
+
87
+ **Authentication Required:**
88
+ - Header: `x-api-key: YOUR_API_KEY`
89
+
90
+ **Response:**
91
+ ```json
92
+ {
93
+ "status": "alive",
94
+ "timestamp": "2024-01-10T12:00:00.000Z"
95
+ }
96
+ ```
97
+
98
+ **Status Codes:**
99
+ - `200 OK` - Service process is alive
100
+
101
+ ### 4. Readiness Probe
102
+ **GET** `/health/ready`
103
+
104
+ Kubernetes-style readiness probe. Returns whether the service is ready to receive traffic.
105
+
106
+ **Authentication Required:**
107
+ - Header: `x-api-key: YOUR_API_KEY`
108
+
109
+ **Response:**
110
+ ```json
111
+ {
112
+ "ready": true,
113
+ "timestamp": "2024-01-10T12:00:00.000Z",
114
+ "checks": {
115
+ "database": true,
116
+ "modules": true
117
+ }
118
+ }
119
+ ```
120
+
121
+ **Status Codes:**
122
+ - `200 OK` - Service is ready
123
+ - `503 Service Unavailable` - Service is not ready
124
+
125
+ ## Health Status Definitions
126
+
127
+ - **healthy**: All components are functioning normally
128
+ - **unhealthy**: Any component is failing, service may not function properly
129
+
130
+ ## Component Checks
131
+
132
+ ### Database Connectivity
133
+ - Checks database connection state
134
+ - Performs ping test with 2-second timeout if connected
135
+ - Reports connection state and response time
136
+ - Database type is not exposed for security reasons
137
+
138
+ ### External API Connectivity
139
+ - Tests connectivity to external services (GitHub, npm registry)
140
+ - Configurable timeout (default: 5 seconds)
141
+ - Reports reachability and response times
142
+ - Uses Promise.all for parallel checking
143
+
144
+ ### Integration Status
145
+ - Verifies available modules and integrations are loaded
146
+ - Reports counts and lists of available components
147
+
148
+ ## Usage Examples
149
+
150
+ ### Monitoring Systems
151
+ Configure your monitoring system to poll `/health/detailed` every 30-60 seconds:
152
+ ```bash
153
+ curl -H "x-api-key: YOUR_API_KEY" https://your-frigg-instance.com/health/detailed
154
+ ```
155
+
156
+ ### Load Balancer Health Checks
157
+ Configure load balancers to use the simple `/health` endpoint:
158
+ ```bash
159
+ curl https://your-frigg-instance.com/health
160
+ ```
161
+
162
+ ### Kubernetes Configuration
163
+ ```yaml
164
+ livenessProbe:
165
+ httpGet:
166
+ path: /health/live
167
+ port: 8080
168
+ httpHeaders:
169
+ - name: x-api-key
170
+ value: YOUR_API_KEY
171
+ periodSeconds: 10
172
+ timeoutSeconds: 5
173
+
174
+ readinessProbe:
175
+ httpGet:
176
+ path: /health/ready
177
+ port: 8080
178
+ httpHeaders:
179
+ - name: x-api-key
180
+ value: YOUR_API_KEY
181
+ initialDelaySeconds: 30
182
+ periodSeconds: 10
183
+ ```
184
+
185
+ ## Customization
186
+
187
+ ### Adding External API Checks
188
+ To add more external API checks, modify the `externalAPIs` array in the health router:
189
+ ```javascript
190
+ const externalAPIs = [
191
+ { name: 'github', url: 'https://api.github.com/status' },
192
+ { name: 'npm', url: 'https://registry.npmjs.org' },
193
+ { name: 'your-api', url: 'https://your-api.com/health' }
194
+ ];
195
+ ```
196
+
197
+ ### Adjusting Timeouts
198
+ The default timeout for external API checks is 5 seconds. Database ping timeout is set to 2 seconds:
199
+ ```javascript
200
+ const checkExternalAPI = (url, timeout = 5000) => {
201
+ // ...
202
+ };
203
+
204
+ await mongoose.connection.db.admin().ping({ maxTimeMS: 2000 });
205
+ ```
206
+
207
+ ## Best Practices
208
+
209
+ 1. **Authentication**: Basic `/health` endpoint requires no authentication, but detailed endpoints require `x-api-key` header
210
+ 2. **Rate Limiting**: Configure rate limiting at the API Gateway level to prevent abuse
211
+ 3. **Fast Response**: Health checks should respond quickly (< 1 second)
212
+ 4. **Strict Status Codes**: Return 503 for any non-healthy state to ensure proper alerting
213
+ 5. **Detailed Logging**: Failed health checks are logged for debugging
214
+ 6. **Security**: No sensitive information (DB types, versions) exposed in responses
215
+ 7. **Lambda Considerations**: Uptime and memory metrics not included as they're not relevant in serverless
216
+
217
+ ## Troubleshooting
218
+
219
+ ### Database Connection Issues
220
+ - Check `MONGO_URI` environment variable
221
+ - Verify network connectivity to MongoDB
222
+ - Check MongoDB server status
223
+
224
+ ### External API Failures
225
+ - May indicate network issues or external service downtime
226
+ - Service reports "unhealthy" status if any external API is unreachable
227
+
228
+ ## Security Considerations
229
+
230
+ - Basic health endpoint requires no authentication for monitoring compatibility
231
+ - Detailed endpoints require `x-api-key` header authentication
232
+ - Health endpoints do not expose sensitive information
233
+ - Database connection strings and credentials are never included in responses
234
+ - External API checks use read-only endpoints
235
+ - Rate limiting should be configured at the API Gateway level
236
+ - Consider IP whitelisting for health endpoints in production
237
+
238
+ ## Environment Variables
239
+
240
+ - `HEALTH_API_KEY`: Required API key for accessing detailed health endpoints
@@ -0,0 +1,451 @@
1
+ const { Router } = require('express');
2
+ const mongoose = require('mongoose');
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const { moduleFactory, integrationFactory } = require('./../backend-utils');
6
+ const { createAppHandler } = require('./../app-handler-helpers');
7
+
8
+ const router = Router();
9
+
10
+ const validateApiKey = (req, res, next) => {
11
+ const apiKey = req.headers['x-api-key'];
12
+
13
+ if (req.path === '/health') {
14
+ return next();
15
+ }
16
+
17
+ if (!apiKey || apiKey !== process.env.HEALTH_API_KEY) {
18
+ return res.status(401).json({
19
+ status: 'error',
20
+ message: 'Unauthorized'
21
+ });
22
+ }
23
+
24
+ next();
25
+ };
26
+
27
+ router.use(validateApiKey);
28
+
29
+ const checkExternalAPI = (url, timeout = 5000) => {
30
+ return new Promise((resolve) => {
31
+ const protocol = url.startsWith('https:') ? https : http;
32
+ const startTime = Date.now();
33
+
34
+ try {
35
+ const request = protocol.get(url, { timeout }, (res) => {
36
+ const responseTime = Date.now() - startTime;
37
+ resolve({
38
+ status: 'healthy',
39
+ statusCode: res.statusCode,
40
+ responseTime,
41
+ reachable: res.statusCode < 500
42
+ });
43
+ });
44
+
45
+ request.on('error', (error) => {
46
+ resolve({
47
+ status: 'unhealthy',
48
+ error: error.message,
49
+ responseTime: Date.now() - startTime,
50
+ reachable: false
51
+ });
52
+ });
53
+
54
+ request.on('timeout', () => {
55
+ request.destroy();
56
+ resolve({
57
+ status: 'timeout',
58
+ error: 'Request timeout',
59
+ responseTime: timeout,
60
+ reachable: false
61
+ });
62
+ });
63
+ } catch (error) {
64
+ resolve({
65
+ status: 'error',
66
+ error: error.message,
67
+ responseTime: Date.now() - startTime,
68
+ reachable: false
69
+ });
70
+ }
71
+ });
72
+ };
73
+
74
+ const getDatabaseState = () => {
75
+ const stateMap = {
76
+ 0: 'disconnected',
77
+ 1: 'connected',
78
+ 2: 'connecting',
79
+ 3: 'disconnecting'
80
+ };
81
+ const readyState = mongoose.connection.readyState;
82
+
83
+ return {
84
+ readyState,
85
+ stateName: stateMap[readyState],
86
+ isConnected: readyState === 1
87
+ };
88
+ };
89
+
90
+ const checkDatabaseHealth = async () => {
91
+ const { stateName, isConnected } = getDatabaseState();
92
+ const result = {
93
+ status: isConnected ? 'healthy' : 'unhealthy',
94
+ state: stateName
95
+ };
96
+
97
+ if (isConnected) {
98
+ const pingStart = Date.now();
99
+ await mongoose.connection.db.admin().ping({ maxTimeMS: 2000 });
100
+ result.responseTime = Date.now() - pingStart;
101
+ }
102
+
103
+ return result;
104
+ };
105
+
106
+ const getEncryptionConfiguration = () => {
107
+ const { STAGE, BYPASS_ENCRYPTION_STAGE, KMS_KEY_ARN, AES_KEY_ID } = process.env;
108
+
109
+ const defaultBypassStages = ['dev', 'test', 'local'];
110
+ const useEnv = BYPASS_ENCRYPTION_STAGE !== undefined;
111
+ const bypassStages = useEnv
112
+ ? BYPASS_ENCRYPTION_STAGE.split(',').map((s) => s.trim())
113
+ : defaultBypassStages;
114
+
115
+ const isBypassed = bypassStages.includes(STAGE);
116
+ const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== '';
117
+ const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== '' && !hasAES;
118
+ const mode = hasAES ? 'aes' : hasKMS ? 'kms' : 'none';
119
+
120
+ return {
121
+ stage: STAGE || null,
122
+ isBypassed,
123
+ hasAES,
124
+ hasKMS,
125
+ mode,
126
+ };
127
+ };
128
+
129
+ const createTestEncryptionModel = () => {
130
+ const { Encrypt } = require('./../../encrypt');
131
+
132
+ const testSchema = new mongoose.Schema({
133
+ testSecret: { type: String, lhEncrypt: true },
134
+ normalField: { type: String },
135
+ nestedSecret: {
136
+ value: { type: String, lhEncrypt: true }
137
+ }
138
+ }, { timestamps: false });
139
+
140
+ testSchema.plugin(Encrypt);
141
+
142
+ return mongoose.models.TestEncryption ||
143
+ mongoose.model('TestEncryption', testSchema);
144
+ };
145
+
146
+ const createTestDocument = async (TestModel) => {
147
+ const testData = {
148
+ testSecret: 'This is a secret value that should be encrypted',
149
+ normalField: 'This is a normal field that should not be encrypted',
150
+ nestedSecret: {
151
+ value: 'This is a nested secret that should be encrypted'
152
+ }
153
+ };
154
+
155
+ const testDoc = new TestModel(testData);
156
+ await testDoc.save();
157
+
158
+ return { testDoc, testData };
159
+ };
160
+
161
+ const verifyDecryption = (retrievedDoc, originalData) => {
162
+ return retrievedDoc &&
163
+ retrievedDoc.testSecret === originalData.testSecret &&
164
+ retrievedDoc.normalField === originalData.normalField &&
165
+ retrievedDoc.nestedSecret?.value === originalData.nestedSecret.value;
166
+ };
167
+
168
+ const verifyEncryptionInDatabase = async (testDoc, originalData, TestModel) => {
169
+ const collectionName = TestModel.collection.name;
170
+ const rawDoc = await mongoose.connection.db
171
+ .collection(collectionName)
172
+ .findOne({ _id: testDoc._id });
173
+
174
+ const secretIsEncrypted = rawDoc &&
175
+ typeof rawDoc.testSecret === 'string' &&
176
+ rawDoc.testSecret.includes(':') &&
177
+ rawDoc.testSecret !== originalData.testSecret;
178
+
179
+ const nestedIsEncrypted = rawDoc?.nestedSecret?.value &&
180
+ typeof rawDoc.nestedSecret.value === 'string' &&
181
+ rawDoc.nestedSecret.value.includes(':') &&
182
+ rawDoc.nestedSecret.value !== originalData.nestedSecret.value;
183
+
184
+ const normalNotEncrypted = rawDoc &&
185
+ rawDoc.normalField === originalData.normalField;
186
+
187
+ return {
188
+ secretIsEncrypted,
189
+ nestedIsEncrypted,
190
+ normalNotEncrypted
191
+ };
192
+ };
193
+
194
+ const evaluateEncryptionTestResults = (decryptionWorks, encryptionResults) => {
195
+ const { secretIsEncrypted, nestedIsEncrypted, normalNotEncrypted } = encryptionResults;
196
+
197
+ if (decryptionWorks && secretIsEncrypted && nestedIsEncrypted && normalNotEncrypted) {
198
+ return {
199
+ status: 'enabled',
200
+ testResult: 'Encryption and decryption verified successfully'
201
+ };
202
+ }
203
+
204
+ if (decryptionWorks && (!secretIsEncrypted || !nestedIsEncrypted)) {
205
+ return {
206
+ status: 'unhealthy',
207
+ testResult: 'Fields are not being encrypted in database'
208
+ };
209
+ }
210
+
211
+ if (decryptionWorks && !normalNotEncrypted) {
212
+ return {
213
+ status: 'unhealthy',
214
+ testResult: 'Normal fields are being incorrectly encrypted'
215
+ };
216
+ }
217
+
218
+ return {
219
+ status: 'unhealthy',
220
+ testResult: 'Decryption failed or data mismatch'
221
+ };
222
+ };
223
+
224
+ const testEncryption = async () => {
225
+ const TestModel = createTestEncryptionModel();
226
+ const { testDoc, testData } = await createTestDocument(TestModel);
227
+
228
+ try {
229
+ const retrievedDoc = await TestModel.findById(testDoc._id);
230
+ const decryptionWorks = verifyDecryption(retrievedDoc, testData);
231
+ const encryptionResults = await verifyEncryptionInDatabase(testDoc, testData, TestModel);
232
+
233
+ const evaluation = evaluateEncryptionTestResults(decryptionWorks, encryptionResults);
234
+
235
+ return {
236
+ ...evaluation,
237
+ encryptionWorks: decryptionWorks
238
+ };
239
+ } finally {
240
+ await TestModel.deleteOne({ _id: testDoc._id });
241
+ }
242
+ };
243
+
244
+ const checkEncryptionHealth = async () => {
245
+ const config = getEncryptionConfiguration();
246
+
247
+ if (config.isBypassed || config.mode === 'none') {
248
+ const testResult = config.isBypassed
249
+ ? 'Encryption bypassed for this stage'
250
+ : 'No encryption keys configured';
251
+
252
+ return {
253
+ status: 'disabled',
254
+ mode: config.mode,
255
+ bypassed: config.isBypassed,
256
+ stage: config.stage,
257
+ testResult,
258
+ encryptionWorks: false,
259
+ debug: {
260
+ hasKMS: config.hasKMS,
261
+ hasAES: config.hasAES
262
+ }
263
+ };
264
+ }
265
+
266
+ try {
267
+ const testResults = await testEncryption();
268
+
269
+ return {
270
+ ...testResults,
271
+ mode: config.mode,
272
+ bypassed: config.isBypassed,
273
+ stage: config.stage,
274
+ debug: {
275
+ hasKMS: config.hasKMS,
276
+ hasAES: config.hasAES
277
+ }
278
+ };
279
+ } catch (error) {
280
+ return {
281
+ status: 'unhealthy',
282
+ mode: config.mode,
283
+ bypassed: config.isBypassed,
284
+ stage: config.stage,
285
+ testResult: `Encryption test failed: ${error.message}`,
286
+ encryptionWorks: false,
287
+ debug: {
288
+ hasKMS: config.hasKMS,
289
+ hasAES: config.hasAES
290
+ }
291
+ };
292
+ }
293
+ };
294
+
295
+ const checkExternalAPIs = async () => {
296
+ const apis = [
297
+ { name: 'github', url: 'https://api.github.com/status' },
298
+ { name: 'npm', url: 'https://registry.npmjs.org' }
299
+ ];
300
+
301
+ const results = await Promise.all(
302
+ apis.map(api =>
303
+ checkExternalAPI(api.url).then(result => ({ name: api.name, ...result }))
304
+ )
305
+ );
306
+
307
+ const apiStatuses = {};
308
+ let allReachable = true;
309
+
310
+ results.forEach(({ name, ...checkResult }) => {
311
+ apiStatuses[name] = checkResult;
312
+ if (!checkResult.reachable) {
313
+ allReachable = false;
314
+ }
315
+ });
316
+
317
+ return { apiStatuses, allReachable };
318
+ };
319
+
320
+ const checkIntegrations = () => {
321
+ const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
322
+ ? moduleFactory.moduleTypes
323
+ : [];
324
+
325
+ const integrationTypes = Array.isArray(integrationFactory.integrationTypes)
326
+ ? integrationFactory.integrationTypes
327
+ : [];
328
+
329
+ return {
330
+ status: 'healthy',
331
+ modules: {
332
+ count: moduleTypes.length,
333
+ available: moduleTypes,
334
+ },
335
+ integrations: {
336
+ count: integrationTypes.length,
337
+ available: integrationTypes,
338
+ },
339
+ };
340
+ };
341
+
342
+ const buildHealthCheckResponse = (startTime) => {
343
+ return {
344
+ service: 'frigg-core-api',
345
+ status: 'healthy',
346
+ timestamp: new Date().toISOString(),
347
+ checks: {},
348
+ calculateResponseTime: () => Date.now() - startTime
349
+ };
350
+ };
351
+
352
+ router.get('/health', async (_req, res) => {
353
+ const status = {
354
+ status: 'ok',
355
+ timestamp: new Date().toISOString(),
356
+ service: 'frigg-core-api'
357
+ };
358
+
359
+ res.status(200).json(status);
360
+ });
361
+
362
+ router.get('/health/detailed', async (_req, res) => {
363
+ const startTime = Date.now();
364
+ const response = buildHealthCheckResponse(startTime);
365
+
366
+ try {
367
+ response.checks.database = await checkDatabaseHealth();
368
+ const dbState = getDatabaseState();
369
+ if (!dbState.isConnected) {
370
+ response.status = 'unhealthy';
371
+ }
372
+ } catch (error) {
373
+ response.checks.database = {
374
+ status: 'unhealthy',
375
+ error: error.message
376
+ };
377
+ response.status = 'unhealthy';
378
+ }
379
+
380
+ try {
381
+ response.checks.encryption = await checkEncryptionHealth();
382
+ if (response.checks.encryption.status === 'unhealthy') {
383
+ response.status = 'unhealthy';
384
+ }
385
+ } catch (error) {
386
+ response.checks.encryption = {
387
+ status: 'unhealthy',
388
+ error: error.message
389
+ };
390
+ response.status = 'unhealthy';
391
+ }
392
+
393
+ const { apiStatuses, allReachable } = await checkExternalAPIs();
394
+ response.checks.externalApis = apiStatuses;
395
+ if (!allReachable) {
396
+ response.status = 'unhealthy';
397
+ }
398
+
399
+ try {
400
+ response.checks.integrations = checkIntegrations();
401
+ } catch (error) {
402
+ response.checks.integrations = {
403
+ status: 'unhealthy',
404
+ error: error.message
405
+ };
406
+ response.status = 'unhealthy';
407
+ }
408
+
409
+ response.responseTime = response.calculateResponseTime();
410
+ delete response.calculateResponseTime;
411
+
412
+ const statusCode = response.status === 'healthy' ? 200 : 503;
413
+ res.status(statusCode).json(response);
414
+ });
415
+
416
+ router.get('/health/live', (_req, res) => {
417
+ res.status(200).json({
418
+ status: 'alive',
419
+ timestamp: new Date().toISOString()
420
+ });
421
+ });
422
+
423
+ router.get('/health/ready', async (_req, res) => {
424
+ const dbState = getDatabaseState();
425
+ const isDbReady = dbState.isConnected;
426
+
427
+ let areModulesReady = false;
428
+ try {
429
+ const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
430
+ ? moduleFactory.moduleTypes
431
+ : [];
432
+ areModulesReady = moduleTypes.length > 0;
433
+ } catch (error) {
434
+ areModulesReady = false;
435
+ }
436
+
437
+ const isReady = isDbReady && areModulesReady;
438
+
439
+ res.status(isReady ? 200 : 503).json({
440
+ ready: isReady,
441
+ timestamp: new Date().toISOString(),
442
+ checks: {
443
+ database: isDbReady,
444
+ modules: areModulesReady
445
+ }
446
+ });
447
+ });
448
+
449
+ const handler = createAppHandler('HTTP Event: Health', router);
450
+
451
+ module.exports = { handler, router };
@@ -0,0 +1,203 @@
1
+ process.env.HEALTH_API_KEY = 'test-api-key';
2
+
3
+ jest.mock('mongoose', () => ({
4
+ set: jest.fn(),
5
+ connection: {
6
+ readyState: 1,
7
+ db: {
8
+ admin: () => ({
9
+ ping: jest.fn().mockResolvedValue(true)
10
+ })
11
+ }
12
+ }
13
+ }));
14
+
15
+ jest.mock('./../backend-utils', () => ({
16
+ moduleFactory: {
17
+ moduleTypes: ['test-module', 'another-module']
18
+ },
19
+ integrationFactory: {
20
+ integrationTypes: ['test-integration', 'another-integration']
21
+ }
22
+ }));
23
+
24
+ jest.mock('./../app-handler-helpers', () => ({
25
+ createAppHandler: jest.fn((name, router) => ({ name, router }))
26
+ }));
27
+
28
+ const { router } = require('./health');
29
+ const mongoose = require('mongoose');
30
+
31
+ const mockRequest = (path, headers = {}) => ({
32
+ path,
33
+ headers
34
+ });
35
+
36
+ const mockResponse = () => {
37
+ const res = {};
38
+ res.status = jest.fn().mockReturnValue(res);
39
+ res.json = jest.fn().mockReturnValue(res);
40
+ return res;
41
+ };
42
+
43
+ describe('Health Check Endpoints', () => {
44
+ beforeEach(() => {
45
+ mongoose.connection.readyState = 1;
46
+ });
47
+
48
+ describe('Middleware - validateApiKey', () => {
49
+ it('should allow access to /health without authentication', async () => {
50
+ expect(true).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe('GET /health', () => {
55
+ it('should return basic health status', async () => {
56
+ const req = mockRequest('/health');
57
+ const res = mockResponse();
58
+
59
+ const routeHandler = router.stack.find(layer =>
60
+ layer.route && layer.route.path === '/health'
61
+ ).route.stack[0].handle;
62
+
63
+ await routeHandler(req, res);
64
+
65
+ expect(res.status).toHaveBeenCalledWith(200);
66
+ expect(res.json).toHaveBeenCalledWith({
67
+ status: 'ok',
68
+ timestamp: expect.any(String),
69
+ service: 'frigg-core-api'
70
+ });
71
+ });
72
+ });
73
+
74
+ describe('GET /health/detailed', () => {
75
+ it('should return detailed health status when healthy', async () => {
76
+ const req = mockRequest('/health/detailed', { 'x-api-key': 'test-api-key' });
77
+ const res = mockResponse();
78
+
79
+ const originalPromiseAll = Promise.all;
80
+ Promise.all = jest.fn().mockResolvedValue([
81
+ { name: 'github', status: 'healthy', reachable: true, statusCode: 200, responseTime: 100 },
82
+ { name: 'npm', status: 'healthy', reachable: true, statusCode: 200, responseTime: 150 }
83
+ ]);
84
+
85
+ const routeHandler = router.stack.find(layer =>
86
+ layer.route && layer.route.path === '/health/detailed'
87
+ ).route.stack[0].handle;
88
+
89
+ await routeHandler(req, res);
90
+
91
+ Promise.all = originalPromiseAll;
92
+
93
+ expect(res.status).toHaveBeenCalledWith(200);
94
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
95
+ status: 'healthy',
96
+ service: 'frigg-core-api',
97
+ timestamp: expect.any(String),
98
+ checks: expect.objectContaining({
99
+ database: expect.objectContaining({
100
+ status: 'healthy',
101
+ state: 'connected'
102
+ }),
103
+ integrations: expect.objectContaining({
104
+ status: 'healthy'
105
+ })
106
+ }),
107
+ responseTime: expect.any(Number)
108
+ }));
109
+
110
+ const response = res.json.mock.calls[0][0];
111
+ expect(response).not.toHaveProperty('version');
112
+ expect(response).not.toHaveProperty('uptime');
113
+ expect(response.checks).not.toHaveProperty('memory');
114
+ expect(response.checks.database).not.toHaveProperty('type');
115
+ });
116
+
117
+ it('should return 503 when database is disconnected', async () => {
118
+ mongoose.connection.readyState = 0;
119
+
120
+ const req = mockRequest('/health/detailed', { 'x-api-key': 'test-api-key' });
121
+ const res = mockResponse();
122
+
123
+ const originalPromiseAll = Promise.all;
124
+ Promise.all = jest.fn().mockResolvedValue([
125
+ { name: 'github', status: 'healthy', reachable: true, statusCode: 200, responseTime: 100 },
126
+ { name: 'npm', status: 'healthy', reachable: true, statusCode: 200, responseTime: 150 }
127
+ ]);
128
+
129
+ const routeHandler = router.stack.find(layer =>
130
+ layer.route && layer.route.path === '/health/detailed'
131
+ ).route.stack[0].handle;
132
+
133
+ await routeHandler(req, res);
134
+
135
+ Promise.all = originalPromiseAll;
136
+
137
+ expect(res.status).toHaveBeenCalledWith(503);
138
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
139
+ status: 'unhealthy'
140
+ }));
141
+ });
142
+ });
143
+
144
+ describe('GET /health/live', () => {
145
+ it('should return alive status', async () => {
146
+ const req = mockRequest('/health/live', { 'x-api-key': 'test-api-key' });
147
+ const res = mockResponse();
148
+
149
+ const routeHandler = router.stack.find(layer =>
150
+ layer.route && layer.route.path === '/health/live'
151
+ ).route.stack[0].handle;
152
+
153
+ routeHandler(req, res);
154
+
155
+ expect(res.status).toHaveBeenCalledWith(200);
156
+ expect(res.json).toHaveBeenCalledWith({
157
+ status: 'alive',
158
+ timestamp: expect.any(String)
159
+ });
160
+ });
161
+ });
162
+
163
+ describe('GET /health/ready', () => {
164
+ it('should return ready when all checks pass', async () => {
165
+ const req = mockRequest('/health/ready', { 'x-api-key': 'test-api-key' });
166
+ const res = mockResponse();
167
+
168
+ const routeHandler = router.stack.find(layer =>
169
+ layer.route && layer.route.path === '/health/ready'
170
+ ).route.stack[0].handle;
171
+
172
+ await routeHandler(req, res);
173
+
174
+ expect(res.status).toHaveBeenCalledWith(200);
175
+ expect(res.json).toHaveBeenCalledWith({
176
+ ready: true,
177
+ timestamp: expect.any(String),
178
+ checks: {
179
+ database: true,
180
+ modules: true
181
+ }
182
+ });
183
+ });
184
+
185
+ it('should return 503 when database is not connected', async () => {
186
+ mongoose.connection.readyState = 0;
187
+
188
+ const req = mockRequest('/health/ready', { 'x-api-key': 'test-api-key' });
189
+ const res = mockResponse();
190
+
191
+ const routeHandler = router.stack.find(layer =>
192
+ layer.route && layer.route.path === '/health/ready'
193
+ ).route.stack[0].handle;
194
+
195
+ await routeHandler(req, res);
196
+
197
+ expect(res.status).toHaveBeenCalledWith(503);
198
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
199
+ ready: false
200
+ }));
201
+ });
202
+ });
203
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.25",
4
+ "version": "2.0.0-next.27",
5
5
  "dependencies": {
6
6
  "@hapi/boom": "^10.0.1",
7
7
  "aws-sdk": "^2.1200.0",
@@ -22,9 +22,9 @@
22
22
  "uuid": "^9.0.1"
23
23
  },
24
24
  "devDependencies": {
25
- "@friggframework/eslint-config": "2.0.0-next.25",
26
- "@friggframework/prettier-config": "2.0.0-next.25",
27
- "@friggframework/test": "2.0.0-next.25",
25
+ "@friggframework/eslint-config": "2.0.0-next.27",
26
+ "@friggframework/prettier-config": "2.0.0-next.27",
27
+ "@friggframework/test": "2.0.0-next.27",
28
28
  "@types/lodash": "4.17.15",
29
29
  "@typescript-eslint/eslint-plugin": "^8.0.0",
30
30
  "chai": "^4.3.6",
@@ -53,5 +53,5 @@
53
53
  },
54
54
  "homepage": "https://github.com/friggframework/frigg#readme",
55
55
  "description": "",
56
- "gitHead": "d758d225a2cfbe4038ecc2b777cd6826949312fb"
56
+ "gitHead": "82dec739e8d482b55f995eecf088ef05f7931188"
57
57
  }