@friggframework/core 2.0.0-next.83 → 2.0.0-next.85
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/database/encryption/encryption-schema-registry.js +133 -0
- package/database/encryption/field-encryption-service.js +30 -2
- package/database/encryption/prisma-encryption-extension.js +10 -2
- package/integrations/integration-base.js +18 -6
- package/modules/module.js +16 -0
- package/modules/requester/requester.js +7 -2
- package/package.json +5 -5
|
@@ -42,6 +42,17 @@ const CORE_ENCRYPTION_SCHEMA = {
|
|
|
42
42
|
|
|
43
43
|
let customSchema = {};
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Per-model write-side opt-out: fields registered here are NOT encrypted on
|
|
47
|
+
* write, but ARE still decrypted on read so legacy encrypted rows continue to
|
|
48
|
+
* deserialize. Lets apps migrate a model from encrypted to plain JSON without
|
|
49
|
+
* a data migration — touched rows naturally rewrite as plain on the next save,
|
|
50
|
+
* untouched rows stay encrypted-but-readable forever.
|
|
51
|
+
*
|
|
52
|
+
* Shape: `{ ModelName: ['field.path', ...] }`
|
|
53
|
+
*/
|
|
54
|
+
let encryptionOptOut = {};
|
|
55
|
+
|
|
45
56
|
/**
|
|
46
57
|
* Validates a custom encryption schema
|
|
47
58
|
* @returns {{valid: boolean, errors: string[]}}
|
|
@@ -218,6 +229,14 @@ function loadCustomEncryptionSchema() {
|
|
|
218
229
|
registerCustomSchema(customSchema);
|
|
219
230
|
}
|
|
220
231
|
|
|
232
|
+
// Load app-level encryption opt-out — apps can declare fields they
|
|
233
|
+
// don't want encrypted on write (decryption on read still works,
|
|
234
|
+
// so legacy data remains readable).
|
|
235
|
+
const disable = appDefinition.encryption?.disable;
|
|
236
|
+
if (disable && Object.keys(disable).length > 0) {
|
|
237
|
+
registerEncryptionOptOut(disable);
|
|
238
|
+
}
|
|
239
|
+
|
|
221
240
|
// Load module-level encryption schemas from integrations
|
|
222
241
|
const integrations = appDefinition.integrations;
|
|
223
242
|
if (integrations && Array.isArray(integrations)) {
|
|
@@ -240,6 +259,115 @@ function getEncryptedFields(modelName) {
|
|
|
240
259
|
return [...new Set(allFields)];
|
|
241
260
|
}
|
|
242
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Validates an encryption opt-out config.
|
|
264
|
+
*
|
|
265
|
+
* Unlike custom schema validation, opt-out IS allowed to target paths that
|
|
266
|
+
* already live in CORE_ENCRYPTION_SCHEMA — that's the entire point.
|
|
267
|
+
*
|
|
268
|
+
* @param {Object} optOut - Map of `{ ModelName: ['field.path', ...] }`
|
|
269
|
+
* @returns {{valid: boolean, errors: string[]}}
|
|
270
|
+
*/
|
|
271
|
+
function validateOptOut(optOut) {
|
|
272
|
+
const errors = [];
|
|
273
|
+
|
|
274
|
+
if (!optOut || typeof optOut !== 'object') {
|
|
275
|
+
errors.push('Encryption opt-out must be an object');
|
|
276
|
+
return { valid: false, errors };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const [modelName, fields] of Object.entries(optOut)) {
|
|
280
|
+
if (typeof modelName !== 'string' || !modelName) {
|
|
281
|
+
errors.push(`Invalid model name in opt-out: ${modelName}`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!Array.isArray(fields)) {
|
|
286
|
+
errors.push(
|
|
287
|
+
`Model "${modelName}" opt-out must be an array of field paths`
|
|
288
|
+
);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const fieldPath of fields) {
|
|
293
|
+
if (typeof fieldPath !== 'string' || !fieldPath) {
|
|
294
|
+
errors.push(
|
|
295
|
+
`Model "${modelName}" has invalid opt-out field path: ${fieldPath}`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return { valid: errors.length === 0, errors };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Registers an encryption opt-out config. Listed fields will be skipped during
|
|
306
|
+
* encryption on write while still being eligible for decryption on read (so
|
|
307
|
+
* legacy encrypted rows still deserialize correctly).
|
|
308
|
+
*
|
|
309
|
+
* Intended call site: `appDefinition.encryption.disable` via
|
|
310
|
+
* `loadCustomEncryptionSchema`.
|
|
311
|
+
*
|
|
312
|
+
* @param {Object} optOut - Map of `{ ModelName: ['field.path', ...] }`
|
|
313
|
+
* @throws {Error} If opt-out validation fails
|
|
314
|
+
*/
|
|
315
|
+
function registerEncryptionOptOut(optOut) {
|
|
316
|
+
if (!optOut || Object.keys(optOut).length === 0) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const validation = validateOptOut(optOut);
|
|
321
|
+
if (!validation.valid) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Invalid encryption opt-out:\n- ${validation.errors.join('\n- ')}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
encryptionOptOut = { ...optOut };
|
|
328
|
+
logger.info(
|
|
329
|
+
`Registered encryption opt-out for models: ${Object.keys(
|
|
330
|
+
encryptionOptOut
|
|
331
|
+
).join(', ')}`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Returns the field paths that should be encrypted when writing the given
|
|
337
|
+
* model. This is `getEncryptedFields` minus any paths the app has opted out
|
|
338
|
+
* of via `registerEncryptionOptOut`.
|
|
339
|
+
*
|
|
340
|
+
* Use this in the encrypt-on-write path of the FieldEncryptionService.
|
|
341
|
+
*/
|
|
342
|
+
function getFieldsToEncryptOnWrite(modelName) {
|
|
343
|
+
const allFields = getEncryptedFields(modelName);
|
|
344
|
+
const optedOut = new Set(encryptionOptOut[modelName] || []);
|
|
345
|
+
if (optedOut.size === 0) return allFields;
|
|
346
|
+
return allFields.filter((path) => !optedOut.has(path));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Returns the field paths that should be checked for decryption when reading
|
|
351
|
+
* the given model. Always includes opted-out paths so legacy encrypted rows
|
|
352
|
+
* remain readable after an app opts a field out.
|
|
353
|
+
*
|
|
354
|
+
* `FieldEncryptionService._isEncrypted` already short-circuits for plain JSON
|
|
355
|
+
* values, so listing more fields than necessary here is harmless.
|
|
356
|
+
*
|
|
357
|
+
* Use this in the decrypt-on-read path of the FieldEncryptionService.
|
|
358
|
+
*/
|
|
359
|
+
function getFieldsToDecryptOnRead(modelName) {
|
|
360
|
+
return getEncryptedFields(modelName);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Clears any registered encryption opt-outs. Test-helper; not intended for
|
|
365
|
+
* runtime use.
|
|
366
|
+
*/
|
|
367
|
+
function resetEncryptionOptOut() {
|
|
368
|
+
encryptionOptOut = {};
|
|
369
|
+
}
|
|
370
|
+
|
|
243
371
|
function hasEncryptedFields(modelName) {
|
|
244
372
|
return getEncryptedFields(modelName).length > 0;
|
|
245
373
|
}
|
|
@@ -257,12 +385,17 @@ function resetCustomSchema() {
|
|
|
257
385
|
module.exports = {
|
|
258
386
|
CORE_ENCRYPTION_SCHEMA,
|
|
259
387
|
getEncryptedFields,
|
|
388
|
+
getFieldsToEncryptOnWrite,
|
|
389
|
+
getFieldsToDecryptOnRead,
|
|
260
390
|
hasEncryptedFields,
|
|
261
391
|
getEncryptedModels,
|
|
262
392
|
registerCustomSchema,
|
|
393
|
+
registerEncryptionOptOut,
|
|
263
394
|
loadCustomEncryptionSchema,
|
|
264
395
|
loadModuleEncryptionSchemas,
|
|
265
396
|
extractCredentialFieldsFromModules,
|
|
266
397
|
validateCustomSchema,
|
|
398
|
+
validateOptOut,
|
|
267
399
|
resetCustomSchema,
|
|
400
|
+
resetEncryptionOptOut,
|
|
268
401
|
};
|
|
@@ -17,12 +17,40 @@ class FieldEncryptionService {
|
|
|
17
17
|
this.schema = schema;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the field paths to encrypt on write. Prefers
|
|
22
|
+
* `schema.getFieldsToEncryptOnWrite` (which respects app opt-outs); falls
|
|
23
|
+
* back to `schema.getEncryptedFields` for backwards compatibility with
|
|
24
|
+
* older schema adapters.
|
|
25
|
+
* @private
|
|
26
|
+
*/
|
|
27
|
+
_getWriteFields(modelName) {
|
|
28
|
+
if (typeof this.schema.getFieldsToEncryptOnWrite === 'function') {
|
|
29
|
+
return this.schema.getFieldsToEncryptOnWrite(modelName);
|
|
30
|
+
}
|
|
31
|
+
return this.schema.getEncryptedFields(modelName);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the field paths to attempt decryption on read. Prefers
|
|
36
|
+
* `schema.getFieldsToDecryptOnRead` (which IGNORES opt-outs so legacy
|
|
37
|
+
* encrypted rows still deserialize); falls back to
|
|
38
|
+
* `schema.getEncryptedFields` for backwards compatibility.
|
|
39
|
+
* @private
|
|
40
|
+
*/
|
|
41
|
+
_getReadFields(modelName) {
|
|
42
|
+
if (typeof this.schema.getFieldsToDecryptOnRead === 'function') {
|
|
43
|
+
return this.schema.getFieldsToDecryptOnRead(modelName);
|
|
44
|
+
}
|
|
45
|
+
return this.schema.getEncryptedFields(modelName);
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
async encryptFields(modelName, document) {
|
|
21
49
|
if (!document || typeof document !== 'object') {
|
|
22
50
|
return document;
|
|
23
51
|
}
|
|
24
52
|
|
|
25
|
-
const fields = this.
|
|
53
|
+
const fields = this._getWriteFields(modelName);
|
|
26
54
|
if (fields.length === 0) {
|
|
27
55
|
return document;
|
|
28
56
|
}
|
|
@@ -58,7 +86,7 @@ class FieldEncryptionService {
|
|
|
58
86
|
return document;
|
|
59
87
|
}
|
|
60
88
|
|
|
61
|
-
const fields = this.
|
|
89
|
+
const fields = this._getReadFields(modelName);
|
|
62
90
|
if (fields.length === 0) {
|
|
63
91
|
return document;
|
|
64
92
|
}
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* Intercepts Prisma queries to encrypt on write and decrypt on read.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const {
|
|
7
|
+
getEncryptedFields,
|
|
8
|
+
getFieldsToEncryptOnWrite,
|
|
9
|
+
getFieldsToDecryptOnRead,
|
|
10
|
+
} = require('./encryption-schema-registry');
|
|
7
11
|
const { FieldEncryptionService } = require('./field-encryption-service');
|
|
8
12
|
|
|
9
13
|
function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
@@ -19,7 +23,11 @@ function createEncryptionExtension({ cryptor, enabled = true }) {
|
|
|
19
23
|
|
|
20
24
|
const encryptionService = new FieldEncryptionService({
|
|
21
25
|
cryptor,
|
|
22
|
-
schema: {
|
|
26
|
+
schema: {
|
|
27
|
+
getEncryptedFields,
|
|
28
|
+
getFieldsToEncryptOnWrite,
|
|
29
|
+
getFieldsToDecryptOnRead,
|
|
30
|
+
},
|
|
23
31
|
});
|
|
24
32
|
|
|
25
33
|
return {
|
|
@@ -560,13 +560,25 @@ class IntegrationBase {
|
|
|
560
560
|
* @returns {Promise<void>}
|
|
561
561
|
*/
|
|
562
562
|
async receiveNotification(notifier, delegateString, object = null) {
|
|
563
|
-
if (delegateString !== 'CREDENTIAL_INVALIDATED') return;
|
|
564
563
|
if (!this.id) return;
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
564
|
+
|
|
565
|
+
if (delegateString === 'CREDENTIAL_INVALIDATED') {
|
|
566
|
+
console.log(
|
|
567
|
+
`[Frigg] Module ${notifier?.name || '?'} reported invalid credentials for integration ${this.id} — marking ERROR`
|
|
568
|
+
);
|
|
569
|
+
await this.updateIntegrationStatus.execute(this.id, 'ERROR');
|
|
570
|
+
this.status = 'ERROR';
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (delegateString === 'CREDENTIAL_VALIDATED') {
|
|
575
|
+
if (this.status !== 'ERROR') return;
|
|
576
|
+
console.log(
|
|
577
|
+
`[Frigg] Module ${notifier?.name || '?'} reported valid credentials for integration ${this.id} — clearing ERROR → ENABLED`
|
|
578
|
+
);
|
|
579
|
+
await this.updateIntegrationStatus.execute(this.id, 'ENABLED');
|
|
580
|
+
this.status = 'ENABLED';
|
|
581
|
+
}
|
|
570
582
|
}
|
|
571
583
|
}
|
|
572
584
|
|
package/modules/module.js
CHANGED
|
@@ -41,6 +41,8 @@ class Module extends Delegate {
|
|
|
41
41
|
// Module → parent delegate (typically IntegrationBase) events
|
|
42
42
|
this.DLGT_CREDENTIAL_INVALIDATED = 'CREDENTIAL_INVALIDATED';
|
|
43
43
|
this.delegateTypes.push(this.DLGT_CREDENTIAL_INVALIDATED);
|
|
44
|
+
this.DLGT_CREDENTIAL_VALIDATED = 'CREDENTIAL_VALIDATED';
|
|
45
|
+
this.delegateTypes.push(this.DLGT_CREDENTIAL_VALIDATED);
|
|
44
46
|
|
|
45
47
|
Object.assign(this, this.definition.requiredAuthMethods);
|
|
46
48
|
|
|
@@ -123,6 +125,20 @@ class Module extends Delegate {
|
|
|
123
125
|
credentialDetails
|
|
124
126
|
);
|
|
125
127
|
this.credential = persisted;
|
|
128
|
+
|
|
129
|
+
if (this.credential?.id) {
|
|
130
|
+
try {
|
|
131
|
+
await this.notify(this.DLGT_CREDENTIAL_VALIDATED, {
|
|
132
|
+
credentialId: this.credential.id,
|
|
133
|
+
moduleName: this.name,
|
|
134
|
+
});
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(
|
|
137
|
+
`[Frigg] Failed to propagate CREDENTIAL_VALIDATED for module ${this.name}:`,
|
|
138
|
+
err?.message || err
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
async receiveNotification(notifier, delegateString, object = null) {
|
|
@@ -151,9 +151,9 @@ class Requester extends Delegate {
|
|
|
151
151
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
152
152
|
return this._request(url, options, i + 1);
|
|
153
153
|
} else if (status === 401) {
|
|
154
|
-
if (!this.isRefreshable
|
|
154
|
+
if (!this.isRefreshable) {
|
|
155
155
|
await this.notify(this.DLGT_INVALID_AUTH);
|
|
156
|
-
} else {
|
|
156
|
+
} else if (this.refreshCount === 0) {
|
|
157
157
|
this.refreshCount++;
|
|
158
158
|
const refreshSucceeded = await this.refreshAuth();
|
|
159
159
|
if (refreshSucceeded) {
|
|
@@ -178,6 +178,11 @@ class Requester extends Delegate {
|
|
|
178
178
|
);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Successful response: reset the per-instance refresh budget so
|
|
182
|
+
// a later 401 in the same Requester lifetime can attempt refresh
|
|
183
|
+
// again instead of silently falling through.
|
|
184
|
+
this.refreshCount = 0;
|
|
185
|
+
|
|
181
186
|
// parsedBody consumes the response body stream. If the server
|
|
182
187
|
// stalls mid-stream the timer (still armed) aborts it.
|
|
183
188
|
return options.returnFullRes
|
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.
|
|
4
|
+
"version": "2.0.0-next.85",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
}
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@friggframework/eslint-config": "2.0.0-next.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
43
|
-
"@friggframework/test": "2.0.0-next.
|
|
41
|
+
"@friggframework/eslint-config": "2.0.0-next.85",
|
|
42
|
+
"@friggframework/prettier-config": "2.0.0-next.85",
|
|
43
|
+
"@friggframework/test": "2.0.0-next.85",
|
|
44
44
|
"@prisma/client": "^6.17.0",
|
|
45
45
|
"@types/lodash": "4.17.15",
|
|
46
46
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -80,5 +80,5 @@
|
|
|
80
80
|
"publishConfig": {
|
|
81
81
|
"access": "public"
|
|
82
82
|
},
|
|
83
|
-
"gitHead": "
|
|
83
|
+
"gitHead": "707cad86e779677700e84a9349791aca8db83348"
|
|
84
84
|
}
|