@friggframework/core 2.0.0-next.120 → 2.0.0-next.122
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.
- package/CLAUDE.md +2 -2
- package/application/commands/README.md +17 -11
- package/database/documentdb-encryption-service.js +2 -33
- package/database/encryption/README.md +3 -3
- package/database/encryption/ciphertext-format.js +24 -0
- package/database/encryption/encryption-schema-registry.js +109 -29
- package/database/encryption/field-encryption-service.js +13 -16
- package/database/prisma.js +10 -1
- package/infrastructure/scheduler/eventbridge-scheduler-adapter.js +67 -13
- package/jest-setup.js +13 -2
- package/jest-teardown.js +5 -2
- package/jest.config.js +4 -0
- package/jest.db.config.js +23 -0
- package/package.json +7 -5
- package/queues/queuer-util.js +6 -5
package/CLAUDE.md
CHANGED
|
@@ -224,7 +224,7 @@ packages/core/
|
|
|
224
224
|
(`remove-entities-from-integration.js`) refuses to leave a module with no
|
|
225
225
|
entity (`LAST_ENTITY_OF_MODULE`, 409). Re-adding an attached entity or
|
|
226
226
|
removing a detached one changes nothing; a detached entity is not deleted.
|
|
227
|
-
`integration-repository-entities-parity.test.js` runs every adapter against
|
|
227
|
+
`integration-repository-entities-parity.db.test.js` runs every adapter against
|
|
228
228
|
real databases when `INTEGRATION_ENTITIES_PARITY_MONGO_URL` /
|
|
229
229
|
`INTEGRATION_ENTITIES_PARITY_POSTGRES_URL` are set. The DocumentDB adapter
|
|
230
230
|
writes only `Integration.entityIds` (as its `createIntegration` does), not
|
|
@@ -266,7 +266,7 @@ packages/core/
|
|
|
266
266
|
mapping keys to leave out of the rows; never write such rows back. Path
|
|
267
267
|
segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and
|
|
268
268
|
DocumentDB adapters give the same pages and totals;
|
|
269
|
-
`integration-mapping-repository-query-parity.test.js` checks that against
|
|
269
|
+
`integration-mapping-repository-query-parity.db.test.js` checks that against
|
|
270
270
|
real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` /
|
|
271
271
|
`QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ:
|
|
272
272
|
strings compare by the database collation on PostgreSQL and by code point
|
|
@@ -67,9 +67,10 @@ class MyIntegration extends IntegrationBase {
|
|
|
67
67
|
async hydrateFromExternalUser(externalUserId) {
|
|
68
68
|
// Find integration context by external entity ID
|
|
69
69
|
const result =
|
|
70
|
-
await this.commands.findIntegrationContextByExternalEntityId(
|
|
71
|
-
externalUserId
|
|
72
|
-
|
|
70
|
+
await this.commands.findIntegrationContextByExternalEntityId({
|
|
71
|
+
externalId: externalUserId,
|
|
72
|
+
type: 'my-integration', // integration config.type to match
|
|
73
|
+
});
|
|
73
74
|
|
|
74
75
|
if (result.error) {
|
|
75
76
|
return { error: result.error };
|
|
@@ -231,9 +232,10 @@ Manage integration records and load full integration contexts.
|
|
|
231
232
|
```javascript
|
|
232
233
|
// Find integration context by external entity ID
|
|
233
234
|
// Returns { context, error } where context includes record + hydrated modules
|
|
234
|
-
const result = await commands.findIntegrationContextByExternalEntityId(
|
|
235
|
-
'external-user-or-workspace-id'
|
|
236
|
-
|
|
235
|
+
const result = await commands.findIntegrationContextByExternalEntityId({
|
|
236
|
+
externalId: 'external-user-or-workspace-id',
|
|
237
|
+
type: 'my-integration', // integration config.type to match
|
|
238
|
+
});
|
|
237
239
|
|
|
238
240
|
if (!result.error) {
|
|
239
241
|
integration.setIntegrationRecord(result.context);
|
|
@@ -299,7 +301,10 @@ try {
|
|
|
299
301
|
For integration context operations, errors are returned in the result:
|
|
300
302
|
|
|
301
303
|
```javascript
|
|
302
|
-
const result = await commands.findIntegrationContextByExternalEntityId(
|
|
304
|
+
const result = await commands.findIntegrationContextByExternalEntityId({
|
|
305
|
+
externalId: userId,
|
|
306
|
+
type: 'my-integration',
|
|
307
|
+
});
|
|
303
308
|
|
|
304
309
|
if (result.error) {
|
|
305
310
|
return { error: result.error };
|
|
@@ -349,9 +354,10 @@ const result =
|
|
|
349
354
|
const { createFriggCommands } = require('@friggframework/core');
|
|
350
355
|
|
|
351
356
|
this.commands = createFriggCommands({ integrationClass: MyIntegration });
|
|
352
|
-
const result = await this.commands.findIntegrationContextByExternalEntityId(
|
|
353
|
-
userId
|
|
354
|
-
|
|
357
|
+
const result = await this.commands.findIntegrationContextByExternalEntityId({
|
|
358
|
+
externalId: userId,
|
|
359
|
+
type: 'my-integration',
|
|
360
|
+
});
|
|
355
361
|
```
|
|
356
362
|
|
|
357
363
|
## Best Practices
|
|
@@ -433,7 +439,7 @@ if (!user) {
|
|
|
433
439
|
| | `findEntitiesByIds(ids)` | Find multiple by IDs |
|
|
434
440
|
| | `updateEntity(id, updates)` | Update entity properties |
|
|
435
441
|
| | `deleteEntity(id)` | Delete entity |
|
|
436
|
-
| **Integration** | `findIntegrationContextByExternalEntityId(externalId)`
|
|
442
|
+
| **Integration** | `findIntegrationContextByExternalEntityId({ externalId, type })` | Load integration + modules by external ID |
|
|
437
443
|
| | `loadIntegrationContextById(integrationId)` | Load integration + modules by ID |
|
|
438
444
|
|
|
439
445
|
## Support
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
2
|
+
const { isEncryptedEnvelope } = require('./encryption/ciphertext-format');
|
|
2
3
|
const {
|
|
3
4
|
getEncryptedFields,
|
|
4
5
|
getFieldsToEncryptOnWrite,
|
|
@@ -295,39 +296,7 @@ class DocumentDBEncryptionService {
|
|
|
295
296
|
* _isEncryptedValue({}) // false
|
|
296
297
|
*/
|
|
297
298
|
_isEncryptedValue(value) {
|
|
298
|
-
|
|
299
|
-
if (typeof value !== 'string') {
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Must have exactly 4 colon-separated parts
|
|
304
|
-
const parts = value.split(':');
|
|
305
|
-
if (parts.length !== 4) {
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Enhanced validation: check for base64 pattern
|
|
310
|
-
// This prevents false positives on URLs, connection strings, etc.
|
|
311
|
-
const base64Pattern = /^[A-Za-z0-9+/=]+$/;
|
|
312
|
-
|
|
313
|
-
// All parts should be base64-encoded
|
|
314
|
-
if (!parts.every(part => base64Pattern.test(part))) {
|
|
315
|
-
return false;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Encrypted values should be sufficiently long to be valid
|
|
319
|
-
// Real encrypted values from Cryptor are always >50 chars due to envelope encryption format:
|
|
320
|
-
// - keyId (base64): ~12 chars minimum
|
|
321
|
-
// - iv (base64): ~24 chars for 16-byte IV
|
|
322
|
-
// - ciphertext (base64): varies, minimum ~16 chars for small values
|
|
323
|
-
// - encryptedKey (base64): ~44 chars for 32-byte data key
|
|
324
|
-
// Total minimum: ~96 chars, so 50 is a safe lower bound
|
|
325
|
-
// This prevents false positives on non-encrypted strings that happen to have 4 colons
|
|
326
|
-
if (value.length < 50) {
|
|
327
|
-
return false;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return true;
|
|
299
|
+
return isEncryptedEnvelope(value);
|
|
331
300
|
}
|
|
332
301
|
}
|
|
333
302
|
|
|
@@ -508,9 +508,9 @@ return entities.map((e) => ({
|
|
|
508
508
|
|
|
509
509
|
**Verified:**
|
|
510
510
|
|
|
511
|
-
- ✅ `postgres-relation-decryption.test.js` - Proves the bug exists
|
|
512
|
-
- ✅ `postgres-decryption-fix-verification.test.js` - Verifies separate queries work
|
|
513
|
-
- ✅ `mongo-decryption-fix-verification.test.js` - Verifies fix for MongoDB
|
|
511
|
+
- ✅ `postgres-relation-decryption.db.test.js` - Proves the bug exists
|
|
512
|
+
- ✅ `postgres-decryption-fix-verification.db.test.js` - Verifies separate queries work
|
|
513
|
+
- ✅ `mongo-decryption-fix-verification.db.test.js` - Verifies fix for MongoDB
|
|
514
514
|
|
|
515
515
|
**Implementation Examples:**
|
|
516
516
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognises values produced by Cryptor.encrypt():
|
|
3
|
+
* keyId:iv:ciphertext:encryptedKey
|
|
4
|
+
* keyId and encryptedKey are base64, iv and ciphertext are hex, so all four
|
|
5
|
+
* parts match the base64 alphabet. Real envelopes are well over 50
|
|
6
|
+
* characters (the encrypted data key alone is longer).
|
|
7
|
+
*
|
|
8
|
+
* Shared by the Prisma (FieldEncryptionService) and DocumentDB paths so a
|
|
9
|
+
* plaintext value that happens to contain colons (`a:b:c:d`, a URL with
|
|
10
|
+
* userinfo and a port) is never mistaken for ciphertext: on write it gets
|
|
11
|
+
* encrypted, on read it is returned as plaintext.
|
|
12
|
+
*/
|
|
13
|
+
const BASE64_PART = /^[A-Za-z0-9+/=]+$/;
|
|
14
|
+
const MIN_ENVELOPE_LENGTH = 50;
|
|
15
|
+
|
|
16
|
+
function isEncryptedEnvelope(value) {
|
|
17
|
+
if (typeof value !== 'string' || value.length < MIN_ENVELOPE_LENGTH) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const parts = value.split(':');
|
|
21
|
+
return parts.length === 4 && parts.every((part) => BASE64_PART.test(part));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { isEncryptedEnvelope };
|
|
@@ -146,6 +146,16 @@ function extractCredentialFieldsFromModules(moduleDefinitions) {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
for (const field of credentialFields) {
|
|
149
|
+
// Validate entry by entry: one bad entry must not drop the
|
|
150
|
+
// module's (or any other module's) valid fields.
|
|
151
|
+
if (typeof field !== 'string' || field.trim() === '') {
|
|
152
|
+
logger.warn(
|
|
153
|
+
`Ignoring invalid encryption.credentialFields entry ${JSON.stringify(field)} ` +
|
|
154
|
+
`in module "${moduleDef.moduleName || moduleDef.name || 'unknown'}" ` +
|
|
155
|
+
'(expected a non-empty string)'
|
|
156
|
+
);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
149
159
|
const prefixedField = field.startsWith('data.') ? field : `data.${field}`;
|
|
150
160
|
fields.push(prefixedField);
|
|
151
161
|
}
|
|
@@ -174,26 +184,46 @@ function loadModuleEncryptionSchemas(integrations) {
|
|
|
174
184
|
return;
|
|
175
185
|
}
|
|
176
186
|
|
|
177
|
-
const { getModulesDefinitionFromIntegrationClasses } = require('
|
|
187
|
+
const { getModulesDefinitionFromIntegrationClasses } = require('../../integrations/utils/map-integration-dto');
|
|
188
|
+
|
|
189
|
+
// Integrations without modules contribute no credential fields.
|
|
190
|
+
const integrationsWithModules = integrations.filter(
|
|
191
|
+
(integration) => integration?.Definition?.modules
|
|
192
|
+
);
|
|
193
|
+
const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(
|
|
194
|
+
integrationsWithModules
|
|
195
|
+
);
|
|
178
196
|
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
// Fields the core schema already encrypts (e.g. data.api_key) are
|
|
198
|
+
// covered; registering them again would fail validation and drop every
|
|
199
|
+
// other module field with them.
|
|
200
|
+
const coreCredentialFields = CORE_ENCRYPTION_SCHEMA.Credential.fields;
|
|
201
|
+
const credentialFields = extractCredentialFieldsFromModules(
|
|
202
|
+
moduleDefinitions
|
|
203
|
+
).filter((field) => !coreCredentialFields.includes(field));
|
|
181
204
|
|
|
182
205
|
if (credentialFields.length === 0) {
|
|
183
206
|
return;
|
|
184
207
|
}
|
|
185
208
|
|
|
186
|
-
|
|
209
|
+
// Merge with whatever custom schema the app already registered rather
|
|
210
|
+
// than replacing it (registerCustomSchema overwrites).
|
|
211
|
+
const existingCredentialFields = customSchema.Credential?.fields || [];
|
|
212
|
+
const mergedSchema = {
|
|
213
|
+
...customSchema,
|
|
187
214
|
Credential: {
|
|
188
|
-
|
|
189
|
-
|
|
215
|
+
...customSchema.Credential,
|
|
216
|
+
fields: [
|
|
217
|
+
...new Set([...existingCredentialFields, ...credentialFields]),
|
|
218
|
+
],
|
|
219
|
+
},
|
|
190
220
|
};
|
|
191
221
|
|
|
192
222
|
logger.info(
|
|
193
223
|
`Registering module-level encryption for ${credentialFields.length} credential fields`
|
|
194
224
|
);
|
|
195
225
|
|
|
196
|
-
registerCustomSchema(
|
|
226
|
+
registerCustomSchema(mergedSchema);
|
|
197
227
|
}
|
|
198
228
|
|
|
199
229
|
/**
|
|
@@ -205,7 +235,40 @@ function loadModuleEncryptionSchemas(integrations) {
|
|
|
205
235
|
*
|
|
206
236
|
* Used by both Prisma (MongoDB/PostgreSQL) and DocumentDB encryption services.
|
|
207
237
|
*/
|
|
238
|
+
/**
|
|
239
|
+
* Raised when an app's encryption configuration is invalid. In deployed
|
|
240
|
+
* stages this fails startup instead of silently writing plaintext.
|
|
241
|
+
*/
|
|
242
|
+
class EncryptionSchemaError extends Error {
|
|
243
|
+
constructor(message, { cause } = {}) {
|
|
244
|
+
super(message, { cause });
|
|
245
|
+
this.name = 'EncryptionSchemaError';
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Stages where field-level encryption is bypassed (see prisma.js). */
|
|
250
|
+
function isLocalStage() {
|
|
251
|
+
const stage = process.env.STAGE || process.env.NODE_ENV || 'development';
|
|
252
|
+
return ['dev', 'test', 'local'].includes(stage.toLowerCase());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* An app definition was found but part of its encryption configuration
|
|
257
|
+
* could not be applied. Deployed stages fail closed; local stages warn and
|
|
258
|
+
* carry on so development isn't blocked.
|
|
259
|
+
*/
|
|
260
|
+
function reportInvalidEncryptionConfig(what, error) {
|
|
261
|
+
const message = `Invalid ${what}: ${error.message}`;
|
|
262
|
+
if (isLocalStage()) {
|
|
263
|
+
logger.warn(`${message} (ignored in local stage)`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
logger.error(message);
|
|
267
|
+
throw new EncryptionSchemaError(message, { cause: error });
|
|
268
|
+
}
|
|
269
|
+
|
|
208
270
|
function loadCustomEncryptionSchema() {
|
|
271
|
+
let appDefinition;
|
|
209
272
|
try {
|
|
210
273
|
// Lazy require to avoid circular dependency issues
|
|
211
274
|
const path = require('node:path');
|
|
@@ -220,38 +283,54 @@ function loadCustomEncryptionSchema() {
|
|
|
220
283
|
const backendIndexPath = path.join(backendDir, 'index.js');
|
|
221
284
|
|
|
222
285
|
const backendModule = require(backendIndexPath);
|
|
223
|
-
|
|
286
|
+
appDefinition = backendModule?.Definition;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
// No loadable backend (tests, standalone usage): custom schema is
|
|
289
|
+
// optional, so this stays at debug level.
|
|
290
|
+
logger.debug('Could not load custom encryption schema:', error.message);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
224
293
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
294
|
+
if (!appDefinition) {
|
|
295
|
+
return; // No app definition found
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// From here an app definition exists, so failures are configuration
|
|
299
|
+
// errors and must be visible.
|
|
228
300
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
301
|
+
// Load app-level custom schema
|
|
302
|
+
const customSchema = appDefinition.encryption?.schema;
|
|
303
|
+
if (customSchema && Object.keys(customSchema).length > 0) {
|
|
304
|
+
try {
|
|
232
305
|
registerCustomSchema(customSchema);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
reportInvalidEncryptionConfig('app encryption.schema', error);
|
|
233
308
|
}
|
|
309
|
+
}
|
|
234
310
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
311
|
+
// Load app-level encryption opt-out — apps can declare fields they
|
|
312
|
+
// don't want encrypted on write (decryption on read still works,
|
|
313
|
+
// so legacy data remains readable).
|
|
314
|
+
const disable = appDefinition.encryption?.disable;
|
|
315
|
+
if (disable && Object.keys(disable).length > 0) {
|
|
316
|
+
try {
|
|
240
317
|
registerEncryptionOptOut(disable);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
reportInvalidEncryptionConfig('app encryption.disable', error);
|
|
241
320
|
}
|
|
321
|
+
}
|
|
242
322
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
323
|
+
// Load module-level encryption schemas from integrations
|
|
324
|
+
const integrations = appDefinition.integrations;
|
|
325
|
+
if (integrations && Array.isArray(integrations)) {
|
|
326
|
+
try {
|
|
246
327
|
loadModuleEncryptionSchemas(integrations);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
reportInvalidEncryptionConfig(
|
|
330
|
+
'module encryption.credentialFields',
|
|
331
|
+
error
|
|
332
|
+
);
|
|
247
333
|
}
|
|
248
|
-
} catch (error) {
|
|
249
|
-
// Silently ignore errors - custom schema is optional
|
|
250
|
-
// This handles cases like:
|
|
251
|
-
// - Backend package.json not found (tests, standalone usage)
|
|
252
|
-
// - No appDefinition defined
|
|
253
|
-
// - No custom encryption schema specified
|
|
254
|
-
logger.debug('Could not load custom encryption schema:', error.message);
|
|
255
334
|
}
|
|
256
335
|
}
|
|
257
336
|
|
|
@@ -386,6 +465,7 @@ function resetCustomSchema() {
|
|
|
386
465
|
}
|
|
387
466
|
|
|
388
467
|
module.exports = {
|
|
468
|
+
EncryptionSchemaError,
|
|
389
469
|
CORE_ENCRYPTION_SCHEMA,
|
|
390
470
|
getEncryptedFields,
|
|
391
471
|
getFieldsToEncryptOnWrite,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { isEncryptedEnvelope } = require('./ciphertext-format');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Field Encryption Service
|
|
3
5
|
*
|
|
@@ -147,12 +149,7 @@ class FieldEncryptionService {
|
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
_isEncrypted(value) {
|
|
150
|
-
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const parts = value.split(':');
|
|
155
|
-
return parts.length >= 4;
|
|
152
|
+
return isEncryptedEnvelope(value);
|
|
156
153
|
}
|
|
157
154
|
|
|
158
155
|
_getNestedValue(obj, path) {
|
|
@@ -218,22 +215,22 @@ class FieldEncryptionService {
|
|
|
218
215
|
}
|
|
219
216
|
|
|
220
217
|
/**
|
|
221
|
-
* Serialize a value for encryption
|
|
222
|
-
*
|
|
218
|
+
* Serialize a value for encryption.
|
|
219
|
+
* Every value is JSON-encoded, strings included, so the type survives
|
|
220
|
+
* the round trip: '123456' is stored as "\"123456\"" and reads back as a
|
|
221
|
+
* string, 1234 reads back as a number.
|
|
223
222
|
* @private
|
|
224
223
|
*/
|
|
225
224
|
_serializeForEncryption(value) {
|
|
226
|
-
|
|
227
|
-
// JSON.stringify for objects and arrays
|
|
228
|
-
return JSON.stringify(value);
|
|
229
|
-
}
|
|
230
|
-
// For primitives (string, number, boolean), convert to string
|
|
231
|
-
return String(value);
|
|
225
|
+
return JSON.stringify(value);
|
|
232
226
|
}
|
|
233
227
|
|
|
234
228
|
/**
|
|
235
|
-
* Deserialize a value after decryption
|
|
236
|
-
*
|
|
229
|
+
* Deserialize a value after decryption.
|
|
230
|
+
* Parses the JSON written by _serializeForEncryption. Values encrypted by
|
|
231
|
+
* earlier versions stored strings raw: those that aren't valid JSON come
|
|
232
|
+
* back unchanged; a raw legacy string that is also valid JSON (e.g.
|
|
233
|
+
* '123456') still parses as it always did.
|
|
237
234
|
* @private
|
|
238
235
|
*/
|
|
239
236
|
_deserializeAfterDecryption(value) {
|
package/database/prisma.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const {
|
|
2
2
|
createEncryptionExtension,
|
|
3
3
|
} = require('./encryption/prisma-encryption-extension');
|
|
4
|
-
const {
|
|
4
|
+
const {
|
|
5
|
+
loadCustomEncryptionSchema,
|
|
6
|
+
EncryptionSchemaError,
|
|
7
|
+
} = require('./encryption/encryption-schema-registry');
|
|
5
8
|
const { logger } = require('./encryption/logger');
|
|
6
9
|
const { Cryptor } = require('../encrypt/Cryptor');
|
|
7
10
|
const config = require('./config');
|
|
@@ -125,6 +128,12 @@ const prismaClientSingleton = () => {
|
|
|
125
128
|
`Field-level encryption enabled using ${encryptionConfig.method.toUpperCase()}`
|
|
126
129
|
);
|
|
127
130
|
} catch (error) {
|
|
131
|
+
// An invalid encryption configuration must fail startup: carrying
|
|
132
|
+
// on without the extension would write encrypted fields in
|
|
133
|
+
// plaintext.
|
|
134
|
+
if (error instanceof EncryptionSchemaError) {
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
128
137
|
logger.error(
|
|
129
138
|
'Failed to initialize encryption extension:',
|
|
130
139
|
error
|
|
@@ -11,19 +11,65 @@
|
|
|
11
11
|
* This adapter implements SchedulerServiceInterface for AWS EventBridge Scheduler.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const {
|
|
15
|
-
SchedulerClient,
|
|
16
|
-
CreateScheduleCommand,
|
|
17
|
-
DeleteScheduleCommand,
|
|
18
|
-
GetScheduleCommand,
|
|
19
|
-
ResourceNotFoundException,
|
|
20
|
-
} = require('@aws-sdk/client-scheduler');
|
|
21
|
-
|
|
22
14
|
const { SchedulerServiceInterface } = require('./scheduler-service-interface');
|
|
23
15
|
|
|
16
|
+
const SCHEDULER_SDK = '@aws-sdk/client-scheduler';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Lazily load the AWS EventBridge Scheduler SDK.
|
|
20
|
+
*
|
|
21
|
+
* Requiring `@aws-sdk/client-scheduler` at module top level would put the AWS
|
|
22
|
+
* SDK on the load path of every consumer of `@friggframework/core`, including
|
|
23
|
+
* those using the mock scheduler or no scheduler at all. Loading it here, on
|
|
24
|
+
* construction of the EventBridge adapter, keeps it out of the module-load
|
|
25
|
+
* path. The adapter calls it exactly once, in its constructor, and keeps the
|
|
26
|
+
* classes its methods need on the instance.
|
|
27
|
+
*
|
|
28
|
+
* Only a MODULE_NOT_FOUND for the scheduler SDK itself is rewritten into an
|
|
29
|
+
* actionable message; any other failure (including a missing transitive
|
|
30
|
+
* dependency) is rethrown unchanged so the real cause is not masked.
|
|
31
|
+
*
|
|
32
|
+
* @returns {object} The relevant exports from `@aws-sdk/client-scheduler`.
|
|
33
|
+
*/
|
|
34
|
+
function loadSchedulerSdk() {
|
|
35
|
+
try {
|
|
36
|
+
// Keep the literal specifier so bundlers (esbuild) can resolve it.
|
|
37
|
+
return require('@aws-sdk/client-scheduler');
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const isSchedulerSdkMissing =
|
|
40
|
+
error &&
|
|
41
|
+
error.code === 'MODULE_NOT_FOUND' &&
|
|
42
|
+
typeof error.message === 'string' &&
|
|
43
|
+
error.message.includes(SCHEDULER_SDK);
|
|
44
|
+
if (isSchedulerSdkMissing) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`The EventBridge scheduler requires the "${SCHEDULER_SDK}" package, ` +
|
|
47
|
+
`which is not installed. Install it (\`npm install ${SCHEDULER_SDK}\`) ` +
|
|
48
|
+
'to use the EventBridge scheduler provider, or use the mock scheduler provider ' +
|
|
49
|
+
'(SCHEDULER_PROVIDER=mock) for local development.',
|
|
50
|
+
{ cause: error }
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
25
58
|
constructor({ region } = {}) {
|
|
26
59
|
super();
|
|
60
|
+
const {
|
|
61
|
+
SchedulerClient,
|
|
62
|
+
CreateScheduleCommand,
|
|
63
|
+
DeleteScheduleCommand,
|
|
64
|
+
GetScheduleCommand,
|
|
65
|
+
ResourceNotFoundException,
|
|
66
|
+
} = loadSchedulerSdk();
|
|
67
|
+
this.sdk = {
|
|
68
|
+
CreateScheduleCommand,
|
|
69
|
+
DeleteScheduleCommand,
|
|
70
|
+
GetScheduleCommand,
|
|
71
|
+
ResourceNotFoundException,
|
|
72
|
+
};
|
|
27
73
|
this.client = new SchedulerClient({
|
|
28
74
|
region: region || process.env.AWS_REGION || 'us-east-1',
|
|
29
75
|
});
|
|
@@ -42,7 +88,12 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
42
88
|
* @param {Object} params.payload - Message payload
|
|
43
89
|
* @returns {Promise<{scheduledJobId: string, scheduledAt: string}>}
|
|
44
90
|
*/
|
|
45
|
-
async scheduleOneTime({
|
|
91
|
+
async scheduleOneTime({
|
|
92
|
+
scheduleName,
|
|
93
|
+
scheduleAt,
|
|
94
|
+
queueResourceId,
|
|
95
|
+
payload,
|
|
96
|
+
}) {
|
|
46
97
|
if (!scheduleName) {
|
|
47
98
|
throw new Error('scheduleName is required');
|
|
48
99
|
}
|
|
@@ -59,8 +110,11 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
59
110
|
}
|
|
60
111
|
|
|
61
112
|
// Format date to AWS schedule expression (at(yyyy-mm-ddThh:mm:ss))
|
|
62
|
-
const scheduleExpression = `at(${scheduleAt
|
|
113
|
+
const scheduleExpression = `at(${scheduleAt
|
|
114
|
+
.toISOString()
|
|
115
|
+
.replace(/\.\d{3}Z$/, '')})`;
|
|
63
116
|
|
|
117
|
+
const { CreateScheduleCommand } = this.sdk;
|
|
64
118
|
const command = new CreateScheduleCommand({
|
|
65
119
|
Name: scheduleName,
|
|
66
120
|
GroupName: this.scheduleGroupName,
|
|
@@ -107,6 +161,7 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
107
161
|
throw new Error('scheduleName is required');
|
|
108
162
|
}
|
|
109
163
|
|
|
164
|
+
const { DeleteScheduleCommand, ResourceNotFoundException } = this.sdk;
|
|
110
165
|
const command = new DeleteScheduleCommand({
|
|
111
166
|
Name: scheduleName,
|
|
112
167
|
GroupName: this.scheduleGroupName,
|
|
@@ -141,6 +196,7 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
141
196
|
throw new Error('scheduleName is required');
|
|
142
197
|
}
|
|
143
198
|
|
|
199
|
+
const { GetScheduleCommand, ResourceNotFoundException } = this.sdk;
|
|
144
200
|
const command = new GetScheduleCommand({
|
|
145
201
|
Name: scheduleName,
|
|
146
202
|
GroupName: this.scheduleGroupName,
|
|
@@ -153,9 +209,7 @@ class EventBridgeSchedulerAdapter extends SchedulerServiceInterface {
|
|
|
153
209
|
// Format: at(yyyy-mm-ddThh:mm:ss)
|
|
154
210
|
let scheduledAt = null;
|
|
155
211
|
if (response.ScheduleExpression) {
|
|
156
|
-
const match = response.ScheduleExpression.match(
|
|
157
|
-
/^at\((.+)\)$/
|
|
158
|
-
);
|
|
212
|
+
const match = response.ScheduleExpression.match(/^at\((.+)\)$/);
|
|
159
213
|
if (match) {
|
|
160
214
|
scheduledAt = new Date(match[1] + 'Z').toISOString();
|
|
161
215
|
}
|
package/jest-setup.js
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Core's unit suites must not need a database, so this global setup only
|
|
2
|
+
// sets the test environment. It deliberately does not start an in-memory
|
|
3
|
+
// MongoDB (as @friggframework/test's globalSetup does): nothing in the default
|
|
4
|
+
// suite talks to a live server, and a binary that failed to start used to stop
|
|
5
|
+
// every core suite from running. Suites that need a real database are
|
|
6
|
+
// `*.db.test.js` files, run by `npm run test:db` (see jest.db.config.js).
|
|
7
|
+
const { overrideEnvironment } = require('@friggframework/test');
|
|
8
|
+
|
|
9
|
+
module.exports = async function () {
|
|
10
|
+
if (!process.env.STAGE) {
|
|
11
|
+
overrideEnvironment({ STAGE: 'dev' });
|
|
12
|
+
}
|
|
13
|
+
};
|
package/jest-teardown.js
CHANGED
package/jest.config.js
CHANGED
|
@@ -20,4 +20,8 @@ module.exports = {
|
|
|
20
20
|
globalTeardown: './jest-teardown.js',
|
|
21
21
|
|
|
22
22
|
setupFilesAfterEnv: ['./logs/jest-logger-setup.js'],
|
|
23
|
+
|
|
24
|
+
// `*.db.test.js` suites talk to a real MongoDB replica set / PostgreSQL
|
|
25
|
+
// and run separately via `npm run test:db` (jest.db.config.js).
|
|
26
|
+
testPathIgnorePatterns: ['/node_modules/', '\\.db\\.test\\.js$'],
|
|
23
27
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Database integration suites (`*.db.test.js`).
|
|
3
|
+
*
|
|
4
|
+
* These talk to real databases instead of mocking Prisma, so they are kept
|
|
5
|
+
* out of the default `npm test` run and executed with `npm run test:db`.
|
|
6
|
+
* They need generated Prisma clients (`npm run prisma:generate`) and:
|
|
7
|
+
* - a MongoDB replica set (Prisma requires one for MongoDB writes):
|
|
8
|
+
* FRIGG_TEST_MONGODB_URL, default
|
|
9
|
+
* mongodb://localhost:27017/frigg?replicaSet=rs0
|
|
10
|
+
* - PostgreSQL with the prisma-postgresql migrations applied:
|
|
11
|
+
* FRIGG_TEST_POSTGRES_URL, default
|
|
12
|
+
* postgresql://postgres:postgres@localhost:5432/frigg?schema=public
|
|
13
|
+
* The repository parity suites skip themselves unless their own URLs are
|
|
14
|
+
* set (INTEGRATION_ENTITIES_PARITY_* / QUERY_MAPPINGS_PARITY_*).
|
|
15
|
+
*/
|
|
16
|
+
const base = require('./jest.config');
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
...base,
|
|
20
|
+
coverageThreshold: undefined,
|
|
21
|
+
testMatch: ['**/*.db.test.js'],
|
|
22
|
+
testPathIgnorePatterns: ['/node_modules/'],
|
|
23
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0-next.
|
|
4
|
+
"version": "2.0.0-next.122",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
8
8
|
"@aws-sdk/client-lambda": "^3.714.0",
|
|
9
9
|
"@aws-sdk/client-s3": "^3.588.0",
|
|
10
|
+
"@aws-sdk/client-scheduler": "^3.588.0",
|
|
10
11
|
"@aws-sdk/client-sqs": "^3.588.0",
|
|
11
12
|
"@aws-sdk/client-ssm": "^3.588.0",
|
|
12
13
|
"@aws-sdk/s3-request-presigner": "^3.588.0",
|
|
@@ -47,9 +48,9 @@
|
|
|
47
48
|
}
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
|
-
"@friggframework/eslint-config": "2.0.0-next.
|
|
51
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
52
|
-
"@friggframework/test": "2.0.0-next.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0-next.122",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0-next.122",
|
|
53
|
+
"@friggframework/test": "2.0.0-next.122",
|
|
53
54
|
"@prisma/client": "^6.19.3",
|
|
54
55
|
"@types/lodash": "4.17.15",
|
|
55
56
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
"scripts": {
|
|
68
69
|
"lint:fix": "prettier --write --loglevel error . && eslint . --fix",
|
|
69
70
|
"test": "jest --passWithNoTests # TODO",
|
|
71
|
+
"test:db": "jest --config jest.db.config.js --runInBand",
|
|
70
72
|
"prisma:generate:mongo": "npx prisma generate --schema ./prisma-mongodb/schema.prisma",
|
|
71
73
|
"prisma:generate:postgres": "npx prisma generate --schema ./prisma-postgresql/schema.prisma",
|
|
72
74
|
"prisma:generate": "npm run prisma:generate:mongo && npm run prisma:generate:postgres",
|
|
@@ -89,5 +91,5 @@
|
|
|
89
91
|
"publishConfig": {
|
|
90
92
|
"access": "public"
|
|
91
93
|
},
|
|
92
|
-
"gitHead": "
|
|
94
|
+
"gitHead": "d9e2755c836c7cfb4f2de7a8e3d003c6d7167d03"
|
|
93
95
|
}
|
package/queues/queuer-util.js
CHANGED
|
@@ -97,16 +97,17 @@ const QueuerUtil = {
|
|
|
97
97
|
Id: uuid(),
|
|
98
98
|
MessageBody: JSON.stringify(entry),
|
|
99
99
|
});
|
|
100
|
-
// Sends 10, then
|
|
100
|
+
// Sends 10, then starts a fresh buffer. Each command gets its own
|
|
101
|
+
// array: clearing the shared buffer in place would also empty the
|
|
102
|
+
// Entries of a command that was already sent.
|
|
101
103
|
if (buffer.length === batchSize) {
|
|
104
|
+
const batch = buffer.splice(0, buffer.length);
|
|
102
105
|
const command = new SendMessageBatchCommand({
|
|
103
|
-
Entries:
|
|
106
|
+
Entries: batch,
|
|
104
107
|
QueueUrl: queueUrl,
|
|
105
108
|
});
|
|
106
109
|
const result = await sqs.send(command);
|
|
107
|
-
inspectBatchResult(result, queueUrl,
|
|
108
|
-
// Purge the buffer
|
|
109
|
-
buffer.splice(0, buffer.length);
|
|
110
|
+
inspectBatchResult(result, queueUrl, batch);
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|