@friggframework/core 2.0.0--canary.404.e9d4980.0 → 2.0.0--canary.405.1f6792c.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,6 @@ const findOneEvents = [
17
17
  const shouldBypassEncryption = (STAGE) => {
18
18
  const defaultBypassStages = ['dev', 'test', 'local'];
19
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
20
  const useEnv = !String(bypassStageEnv) || !!bypassStageEnv;
22
21
  const bypassStages = useEnv
23
22
  ? bypassStageEnv.split(',').map((stage) => stage.trim())
@@ -25,19 +24,15 @@ const shouldBypassEncryption = (STAGE) => {
25
24
  return bypassStages.includes(STAGE);
26
25
  };
27
26
 
28
- // The Mongoose plug-in function
29
- function Encrypt(schema, options) {
27
+ function Encrypt(schema) {
30
28
  const { STAGE, KMS_KEY_ARN, AES_KEY_ID } = process.env;
31
29
 
32
30
  if (shouldBypassEncryption(STAGE)) {
33
31
  return;
34
32
  }
35
33
 
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
- }
34
+ const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== '';
35
+ const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== '' && !hasAES;
41
36
 
42
37
  const fields = Object.values(schema.paths)
43
38
  .map(({ path, options }) => (options.lhEncrypt === true ? path : ''))
@@ -48,25 +43,17 @@ function Encrypt(schema, options) {
48
43
  }
49
44
 
50
45
  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
46
+ shouldUseAws: hasKMS,
54
47
  fields: fields,
55
48
  });
56
49
 
57
- // ---------------------------------------------
58
- // ### Encrypt fields before save/update/insert.
59
- // ---------------------------------------------
60
-
61
50
  schema.pre('save', async function encryptionPreSave() {
62
- // `this` will be a doc
63
51
  await cryptor.encryptFieldsInDocuments([this]);
64
52
  });
65
53
 
66
54
  schema.pre(
67
55
  'insertMany',
68
56
  async function encryptionPreInsertMany(_, docs, options) {
69
- // `this` will be the model
70
57
  if (options?.rawResult) {
71
58
  throw new Error(
72
59
  'Raw result not supported for insertMany with Encrypt plugin'
@@ -78,17 +65,14 @@ function Encrypt(schema, options) {
78
65
  );
79
66
 
80
67
  schema.pre(updateOneEvents, async function encryptionPreUpdateOne() {
81
- // `this` will be a query
82
68
  await cryptor.encryptFieldsInQuery(this);
83
69
  });
84
70
 
85
71
  schema.pre('updateMany', async function encryptionPreUpdateMany() {
86
- // `this` will be a query
87
72
  cryptor.expectNotToUpdateManyEncrypted(this.getUpdate());
88
73
  });
89
74
 
90
75
  schema.pre('update', async function encryptionPreUpdate() {
91
- // `this` will be a query
92
76
  const { multiple } = this.getOptions();
93
77
 
94
78
  if (multiple) {
@@ -99,16 +83,11 @@ function Encrypt(schema, options) {
99
83
  await cryptor.encryptFieldsInQuery(this);
100
84
  });
101
85
 
102
- // --------------------------------------------
103
- // ### Decrypt documents after they are loaded.
104
- // --------------------------------------------
105
86
  schema.post('save', async function encryptionPreSave() {
106
- // `this` will be a doc
107
87
  await cryptor.decryptFieldsInDocuments([this]);
108
88
  });
109
89
 
110
90
  schema.post(findOneEvents, async function encryptionPostFindOne(doc) {
111
- // `this` will be a query
112
91
  const { rawResult } = this.getOptions();
113
92
 
114
93
  if (rawResult) {
@@ -119,12 +98,10 @@ function Encrypt(schema, options) {
119
98
  });
120
99
 
121
100
  schema.post('find', async function encryptionPostFind(docs) {
122
- // `this` will be a query
123
101
  await cryptor.decryptFieldsInDocuments(docs);
124
102
  });
125
103
 
126
104
  schema.post('insertMany', async function encryptionPostInsertMany(docs) {
127
- // `this` will be the model
128
105
  await cryptor.decryptFieldsInDocuments(docs);
129
106
  });
130
107
  }
@@ -71,103 +71,354 @@ const checkExternalAPI = (url, timeout = 5000) => {
71
71
  });
72
72
  };
73
73
 
74
- router.get('/health', async (_req, res) => {
75
- const status = {
76
- status: 'ok',
77
- timestamp: new Date().toISOString(),
78
- service: 'frigg-core-api'
74
+ const getDatabaseState = () => {
75
+ const stateMap = {
76
+ 0: 'disconnected',
77
+ 1: 'connected',
78
+ 2: 'connecting',
79
+ 3: 'disconnecting'
79
80
  };
81
+ const readyState = mongoose.connection.readyState;
82
+
83
+ return {
84
+ readyState,
85
+ stateName: stateMap[readyState],
86
+ isConnected: readyState === 1
87
+ };
88
+ };
80
89
 
81
- res.status(200).json(status);
82
- });
90
+ const checkDatabaseHealth = async () => {
91
+ const { stateName, isConnected } = getDatabaseState();
92
+ const result = {
93
+ status: isConnected ? 'healthy' : 'unhealthy',
94
+ state: stateName
95
+ };
83
96
 
84
- router.get('/health/detailed', async (_req, res) => {
85
- const startTime = Date.now();
86
- const checks = {
87
- service: 'frigg-core-api',
88
- status: 'healthy',
89
- timestamp: new Date().toISOString(),
90
- checks: {}
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
+ kmsKeyArn: KMS_KEY_ARN || '(not set)',
127
+ aesKeyId: AES_KEY_ID || '(not set)'
128
+ };
129
+ };
130
+
131
+ const createTestEncryptionModel = () => {
132
+ const { Encrypt } = require('./../../encrypt');
133
+
134
+ const testSchema = new mongoose.Schema({
135
+ testSecret: { type: String, lhEncrypt: true },
136
+ normalField: { type: String },
137
+ nestedSecret: {
138
+ value: { type: String, lhEncrypt: true }
139
+ }
140
+ }, { timestamps: false });
141
+
142
+ testSchema.plugin(Encrypt);
143
+
144
+ return mongoose.models.TestEncryption ||
145
+ mongoose.model('TestEncryption', testSchema);
146
+ };
147
+
148
+ const createTestDocument = async (TestModel) => {
149
+ const testData = {
150
+ testSecret: 'This is a secret value that should be encrypted',
151
+ normalField: 'This is a normal field that should not be encrypted',
152
+ nestedSecret: {
153
+ value: 'This is a nested secret that should be encrypted'
154
+ }
91
155
  };
92
156
 
157
+ const testDoc = new TestModel(testData);
158
+ await testDoc.save();
159
+
160
+ return { testDoc, testData };
161
+ };
162
+
163
+ const verifyDecryption = (retrievedDoc, originalData) => {
164
+ return retrievedDoc &&
165
+ retrievedDoc.testSecret === originalData.testSecret &&
166
+ retrievedDoc.normalField === originalData.normalField &&
167
+ retrievedDoc.nestedSecret?.value === originalData.nestedSecret.value;
168
+ };
169
+
170
+ const verifyEncryptionInDatabase = async (testDoc, originalData, TestModel) => {
171
+ const collectionName = TestModel.collection.name;
172
+ const rawDoc = await mongoose.connection.db
173
+ .collection(collectionName)
174
+ .findOne({ _id: testDoc._id });
175
+
176
+ const secretIsEncrypted = rawDoc &&
177
+ typeof rawDoc.testSecret === 'string' &&
178
+ rawDoc.testSecret.includes(':') &&
179
+ rawDoc.testSecret !== originalData.testSecret;
180
+
181
+ const nestedIsEncrypted = rawDoc?.nestedSecret?.value &&
182
+ typeof rawDoc.nestedSecret.value === 'string' &&
183
+ rawDoc.nestedSecret.value.includes(':') &&
184
+ rawDoc.nestedSecret.value !== originalData.nestedSecret.value;
185
+
186
+ const normalNotEncrypted = rawDoc &&
187
+ rawDoc.normalField === originalData.normalField;
188
+
189
+ return {
190
+ secretIsEncrypted,
191
+ nestedIsEncrypted,
192
+ normalNotEncrypted
193
+ };
194
+ };
195
+
196
+ const evaluateEncryptionTestResults = (decryptionWorks, encryptionResults) => {
197
+ const { secretIsEncrypted, nestedIsEncrypted, normalNotEncrypted } = encryptionResults;
198
+
199
+ if (decryptionWorks && secretIsEncrypted && nestedIsEncrypted && normalNotEncrypted) {
200
+ return {
201
+ status: 'enabled',
202
+ testResult: 'Encryption and decryption verified successfully'
203
+ };
204
+ }
205
+
206
+ if (decryptionWorks && (!secretIsEncrypted || !nestedIsEncrypted)) {
207
+ return {
208
+ status: 'unhealthy',
209
+ testResult: 'Fields are not being encrypted in database'
210
+ };
211
+ }
212
+
213
+ if (decryptionWorks && !normalNotEncrypted) {
214
+ return {
215
+ status: 'unhealthy',
216
+ testResult: 'Normal fields are being incorrectly encrypted'
217
+ };
218
+ }
219
+
220
+ return {
221
+ status: 'unhealthy',
222
+ testResult: 'Decryption failed or data mismatch'
223
+ };
224
+ };
225
+
226
+ const testEncryption = async () => {
227
+ const TestModel = createTestEncryptionModel();
228
+ const { testDoc, testData } = await createTestDocument(TestModel);
229
+
93
230
  try {
94
- const dbState = mongoose.connection.readyState;
95
- const dbStateMap = {
96
- 0: 'disconnected',
97
- 1: 'connected',
98
- 2: 'connecting',
99
- 3: 'disconnecting'
231
+ const retrievedDoc = await TestModel.findById(testDoc._id);
232
+ const decryptionWorks = verifyDecryption(retrievedDoc, testData);
233
+ const encryptionResults = await verifyEncryptionInDatabase(testDoc, testData, TestModel);
234
+
235
+ const evaluation = evaluateEncryptionTestResults(decryptionWorks, encryptionResults);
236
+
237
+ return {
238
+ ...evaluation,
239
+ encryptionWorks: decryptionWorks
100
240
  };
241
+ } finally {
242
+ await TestModel.deleteOne({ _id: testDoc._id });
243
+ }
244
+ };
245
+
246
+ const checkEncryptionHealth = async () => {
247
+ const config = getEncryptionConfiguration();
248
+
249
+ if (config.isBypassed || config.mode === 'none') {
250
+ const testResult = config.isBypassed
251
+ ? 'Encryption bypassed for this stage'
252
+ : 'No encryption keys configured';
101
253
 
102
- checks.checks.database = {
103
- status: dbState === 1 ? 'healthy' : 'unhealthy',
104
- state: dbStateMap[dbState]
254
+ return {
255
+ status: 'disabled',
256
+ mode: config.mode,
257
+ bypassed: config.isBypassed,
258
+ stage: config.stage,
259
+ testResult,
260
+ encryptionWorks: false,
261
+ debug: {
262
+ KMS_KEY_ARN: config.kmsKeyArn,
263
+ AES_KEY_ID: config.aesKeyId,
264
+ hasKMS: config.hasKMS,
265
+ hasAES: config.hasAES
266
+ }
105
267
  };
268
+ }
106
269
 
107
- if (dbState === 1) {
108
- const pingStart = Date.now();
109
- await mongoose.connection.db.admin().ping({ maxTimeMS: 2000 });
110
- checks.checks.database.responseTime = Date.now() - pingStart;
111
- } else {
112
- checks.status = 'unhealthy';
113
- }
270
+ try {
271
+ const testResults = await testEncryption();
272
+
273
+ return {
274
+ ...testResults,
275
+ mode: config.mode,
276
+ bypassed: config.isBypassed,
277
+ stage: config.stage,
278
+ debug: {
279
+ KMS_KEY_ARN: config.kmsKeyArn,
280
+ AES_KEY_ID: config.aesKeyId,
281
+ hasKMS: config.hasKMS,
282
+ hasAES: config.hasAES
283
+ }
284
+ };
114
285
  } catch (error) {
115
- checks.checks.database = {
286
+ return {
116
287
  status: 'unhealthy',
117
- error: error.message
288
+ mode: config.mode,
289
+ bypassed: config.isBypassed,
290
+ stage: config.stage,
291
+ testResult: `Encryption test failed: ${error.message}`,
292
+ encryptionWorks: false,
293
+ debug: {
294
+ KMS_KEY_ARN: config.kmsKeyArn,
295
+ AES_KEY_ID: config.aesKeyId,
296
+ hasKMS: config.hasKMS,
297
+ hasAES: config.hasAES
298
+ }
118
299
  };
119
- checks.status = 'unhealthy';
120
300
  }
301
+ };
121
302
 
122
- const externalAPIs = [
303
+ const checkExternalAPIs = async () => {
304
+ const apis = [
123
305
  { name: 'github', url: 'https://api.github.com/status' },
124
306
  { name: 'npm', url: 'https://registry.npmjs.org' }
125
307
  ];
126
308
 
127
- checks.checks.externalApis = {};
128
-
129
- const apiChecks = await Promise.all(
130
- externalAPIs.map(api =>
309
+ const results = await Promise.all(
310
+ apis.map(api =>
131
311
  checkExternalAPI(api.url).then(result => ({ name: api.name, ...result }))
132
312
  )
133
313
  );
134
314
 
135
- apiChecks.forEach(result => {
136
- const { name, ...checkResult } = result;
137
- checks.checks.externalApis[name] = checkResult;
315
+ const apiStatuses = {};
316
+ let allReachable = true;
317
+
318
+ results.forEach(({ name, ...checkResult }) => {
319
+ apiStatuses[name] = checkResult;
138
320
  if (!checkResult.reachable) {
139
- checks.status = 'unhealthy';
321
+ allReachable = false;
140
322
  }
141
323
  });
324
+
325
+ return { apiStatuses, allReachable };
326
+ };
327
+
328
+ const checkIntegrations = () => {
329
+ const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
330
+ ? moduleFactory.moduleTypes
331
+ : [];
332
+
333
+ const integrationTypes = Array.isArray(integrationFactory.integrationTypes)
334
+ ? integrationFactory.integrationTypes
335
+ : [];
336
+
337
+ return {
338
+ status: 'healthy',
339
+ modules: {
340
+ count: moduleTypes.length,
341
+ available: moduleTypes,
342
+ },
343
+ integrations: {
344
+ count: integrationTypes.length,
345
+ available: integrationTypes,
346
+ },
347
+ };
348
+ };
349
+
350
+ const buildHealthCheckResponse = (startTime) => {
351
+ return {
352
+ service: 'frigg-core-api',
353
+ status: 'healthy',
354
+ timestamp: new Date().toISOString(),
355
+ checks: {},
356
+ calculateResponseTime: () => Date.now() - startTime
357
+ };
358
+ };
359
+
360
+ router.get('/health', async (_req, res) => {
361
+ const status = {
362
+ status: 'ok',
363
+ timestamp: new Date().toISOString(),
364
+ service: 'frigg-core-api'
365
+ };
366
+
367
+ res.status(200).json(status);
368
+ });
369
+
370
+ router.get('/health/detailed', async (_req, res) => {
371
+ const startTime = Date.now();
372
+ const response = buildHealthCheckResponse(startTime);
142
373
 
143
374
  try {
144
- const availableModules = moduleFactory.getAll();
145
- const availableIntegrations = integrationFactory.getAll();
146
-
147
- checks.checks.integrations = {
148
- status: 'healthy',
149
- modules: {
150
- count: Object.keys(availableModules).length,
151
- available: Object.keys(availableModules)
152
- },
153
- integrations: {
154
- count: Object.keys(availableIntegrations).length,
155
- available: Object.keys(availableIntegrations)
156
- }
375
+ response.checks.database = await checkDatabaseHealth();
376
+ const dbState = getDatabaseState();
377
+ if (!dbState.isConnected) {
378
+ response.status = 'unhealthy';
379
+ }
380
+ } catch (error) {
381
+ response.checks.database = {
382
+ status: 'unhealthy',
383
+ error: error.message
157
384
  };
385
+ response.status = 'unhealthy';
386
+ }
387
+
388
+ try {
389
+ response.checks.encryption = await checkEncryptionHealth();
390
+ if (response.checks.encryption.status === 'unhealthy') {
391
+ response.status = 'unhealthy';
392
+ }
158
393
  } catch (error) {
159
- checks.checks.integrations = {
394
+ response.checks.encryption = {
160
395
  status: 'unhealthy',
161
396
  error: error.message
162
397
  };
163
- checks.status = 'unhealthy';
398
+ response.status = 'unhealthy';
399
+ }
400
+
401
+ const { apiStatuses, allReachable } = await checkExternalAPIs();
402
+ response.checks.externalApis = apiStatuses;
403
+ if (!allReachable) {
404
+ response.status = 'unhealthy';
164
405
  }
165
406
 
166
- checks.responseTime = Date.now() - startTime;
407
+ try {
408
+ response.checks.integrations = checkIntegrations();
409
+ } catch (error) {
410
+ response.checks.integrations = {
411
+ status: 'unhealthy',
412
+ error: error.message
413
+ };
414
+ response.status = 'unhealthy';
415
+ }
167
416
 
168
- const statusCode = checks.status === 'healthy' ? 200 : 503;
417
+ response.responseTime = response.calculateResponseTime();
418
+ delete response.calculateResponseTime;
169
419
 
170
- res.status(statusCode).json(checks);
420
+ const statusCode = response.status === 'healthy' ? 200 : 503;
421
+ res.status(statusCode).json(response);
171
422
  });
172
423
 
173
424
  router.get('/health/live', (_req, res) => {
@@ -178,26 +429,29 @@ router.get('/health/live', (_req, res) => {
178
429
  });
179
430
 
180
431
  router.get('/health/ready', async (_req, res) => {
181
- const checks = {
182
- ready: true,
183
- timestamp: new Date().toISOString(),
184
- checks: {}
185
- };
186
-
187
- const dbState = mongoose.connection.readyState;
188
- checks.checks.database = dbState === 1;
432
+ const dbState = getDatabaseState();
433
+ const isDbReady = dbState.isConnected;
189
434
 
435
+ let areModulesReady = false;
190
436
  try {
191
- const modules = moduleFactory.getAll();
192
- checks.checks.modules = Object.keys(modules).length > 0;
437
+ const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
438
+ ? moduleFactory.moduleTypes
439
+ : [];
440
+ areModulesReady = moduleTypes.length > 0;
193
441
  } catch (error) {
194
- checks.checks.modules = false;
442
+ areModulesReady = false;
195
443
  }
196
444
 
197
- checks.ready = checks.checks.database && checks.checks.modules;
445
+ const isReady = isDbReady && areModulesReady;
198
446
 
199
- const statusCode = checks.ready ? 200 : 503;
200
- res.status(statusCode).json(checks);
447
+ res.status(isReady ? 200 : 503).json({
448
+ ready: isReady,
449
+ timestamp: new Date().toISOString(),
450
+ checks: {
451
+ database: isDbReady,
452
+ modules: areModulesReady
453
+ }
454
+ });
201
455
  });
202
456
 
203
457
  const handler = createAppHandler('HTTP Event: Health', router);
@@ -14,16 +14,10 @@ jest.mock('mongoose', () => ({
14
14
 
15
15
  jest.mock('./../backend-utils', () => ({
16
16
  moduleFactory: {
17
- getAll: () => ({
18
- 'test-module': {},
19
- 'another-module': {}
20
- })
17
+ moduleTypes: ['test-module', 'another-module']
21
18
  },
22
19
  integrationFactory: {
23
- getAll: () => ({
24
- 'test-integration': {},
25
- 'another-integration': {}
26
- })
20
+ integrationTypes: ['test-integration', 'another-integration']
27
21
  }
28
22
  }));
29
23
 
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--canary.404.e9d4980.0",
4
+ "version": "2.0.0--canary.405.1f6792c.0",
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--canary.404.e9d4980.0",
26
- "@friggframework/prettier-config": "2.0.0--canary.404.e9d4980.0",
27
- "@friggframework/test": "2.0.0--canary.404.e9d4980.0",
25
+ "@friggframework/eslint-config": "2.0.0--canary.405.1f6792c.0",
26
+ "@friggframework/prettier-config": "2.0.0--canary.405.1f6792c.0",
27
+ "@friggframework/test": "2.0.0--canary.405.1f6792c.0",
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": "e9d4980828d7deda79e33dfbb2fed93cb6fef84d"
56
+ "gitHead": "1f6792ccfcbd08502e14072f07ccbebdf99394cb"
57
57
  }