@friggframework/core 2.0.0-next.83 → 2.0.0-next.84

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 {
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.84",
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.84",
42
+ "@friggframework/prettier-config": "2.0.0-next.84",
43
+ "@friggframework/test": "2.0.0-next.84",
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": "c0b32d4d1f558fc8f737fd2731f0d67e84509ca2"
84
84
  }