@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.
@@ -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.schema.getEncryptedFields(modelName);
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.schema.getEncryptedFields(modelName);
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 { getEncryptedFields } = require('./encryption-schema-registry');
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: { getEncryptedFields },
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
- console.log(
566
- `[Frigg] Module ${notifier?.name || '?'} reported invalid credentials for integration ${this.id} — marking ERROR`
567
- );
568
- await this.updateIntegrationStatus.execute(this.id, 'ERROR');
569
- this.status = 'ERROR';
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 || this.refreshCount > 0) {
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.83",
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.83",
42
- "@friggframework/prettier-config": "2.0.0-next.83",
43
- "@friggframework/test": "2.0.0-next.83",
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": "e01d96a234ebb71582e5f1f2e6d53bfe8bf8ce33"
83
+ "gitHead": "707cad86e779677700e84a9349791aca8db83348"
84
84
  }