@friggframework/core 2.0.0--canary.702.d5e6de1.0 → 2.0.0--canary.666.89f6545.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.
Files changed (35) hide show
  1. package/CLAUDE.md +41 -1
  2. package/application/commands/README.md +17 -11
  3. package/application/commands/entity-commands.js +18 -60
  4. package/application/commands/integration-commands.js +63 -0
  5. package/database/documentdb-encryption-service.js +2 -33
  6. package/database/encryption/README.md +3 -3
  7. package/database/encryption/ciphertext-format.js +24 -0
  8. package/database/encryption/encryption-schema-registry.js +109 -29
  9. package/database/encryption/field-encryption-service.js +13 -16
  10. package/database/prisma.js +10 -1
  11. package/integrations/integration-router.js +18 -22
  12. package/integrations/repositories/integration-repository-documentdb.js +32 -0
  13. package/integrations/repositories/integration-repository-interface.js +30 -0
  14. package/integrations/repositories/integration-repository-mongo.js +52 -0
  15. package/integrations/repositories/integration-repository-postgres.js +58 -0
  16. package/integrations/use-cases/add-entities-to-integration.js +92 -0
  17. package/integrations/use-cases/create-integration.js +57 -19
  18. package/integrations/use-cases/remove-entities-from-integration.js +70 -0
  19. package/integrations/utils/assert-entity-ids-input.js +42 -0
  20. package/jest-setup.js +13 -2
  21. package/jest-teardown.js +5 -2
  22. package/jest.config.js +4 -0
  23. package/jest.db.config.js +23 -0
  24. package/modules/use-cases/create-entity-from-credential.js +105 -0
  25. package/modules/use-cases/get-entity-options-by-id.js +7 -5
  26. package/modules/use-cases/get-module.js +29 -8
  27. package/modules/use-cases/refresh-entity-options.js +7 -5
  28. package/modules/use-cases/test-module-auth.js +7 -5
  29. package/modules/utils/find-module-definition.js +23 -0
  30. package/modules/utils/map-module-dto.js +5 -3
  31. package/modules/utils/to-credential-id.js +14 -0
  32. package/package.json +9 -8
  33. package/queues/queuer-util.js +6 -5
  34. package/user/tests/doubles/test-user-repository.js +1 -1
  35. package/user/use-cases/get-user-from-bearer-token.js +7 -2
package/CLAUDE.md CHANGED
@@ -206,11 +206,51 @@ packages/core/
206
206
  - `delete-integration-for-user.js` - Remove integration
207
207
  - `get-integration-instance.js` - Load integration with modules
208
208
  - `load-integration-context.js` - Full integration context loading
209
+ - `add-entities-to-integration.js` / `remove-entities-from-integration.js` - Attach or detach entities on an existing integration
209
210
 
210
211
  **Repositories**:
211
212
  - `integration-repository-factory.js` - Creates database-specific repositories
212
213
  - `integration-repository-mongo.js` - MongoDB implementation
213
214
  - `integration-repository-postgres.js` - PostgreSQL implementation
215
+ - `addEntitiesToIntegration(integrationId, entityIds)` /
216
+ `removeEntitiesFromIntegration(integrationId, entityIds)` change the entity
217
+ set of an existing integration (all three adapters; `null` for an unknown
218
+ integration). Reach them through the `addEntitiesToIntegration` /
219
+ `removeEntitiesFromIntegration` commands. The add use case
220
+ (`add-entities-to-integration.js`) rejects unknown entities
221
+ (`ENTITIES_NOT_FOUND`, 404), entities of another user
222
+ (`ENTITY_USER_MISMATCH`, 403) and entities of a module the integration does
223
+ not already hold (`ENTITY_MODULE_MISMATCH`, 400). The remove use case
224
+ (`remove-entities-from-integration.js`) refuses to leave a module with no
225
+ entity (`LAST_ENTITY_OF_MODULE`, 409). Re-adding an attached entity or
226
+ removing a detached one changes nothing; a detached entity is not deleted.
227
+ `integration-repository-entities-parity.db.test.js` runs every adapter against
228
+ real databases when `INTEGRATION_ENTITIES_PARITY_MONGO_URL` /
229
+ `INTEGRATION_ENTITIES_PARITY_POSTGRES_URL` are set. The DocumentDB adapter
230
+ writes only `Integration.entityIds` (as its `createIntegration` does), not
231
+ `Entity.integrationIds`.
232
+ **One credential, many accounts**: when one credential covers several
233
+ provider accounts, give each account its own entity (externalId = account
234
+ id) and attach them all to one integration; webhooks then route with
235
+ `findIntegrationByEntityExternalId`. Traps:
236
+ (1) `IntegrationBase` keys modules by module name, so `this.<moduleKey>`
237
+ holds only one entity's module, and on the queue/webhook path
238
+ (`get-integration-instance.js`) `this.entities` holds entity **ids**, not
239
+ records. For per-account work (a webhook for one account, teardown of every
240
+ account in `onDelete`), load
241
+ `this.commands.loadIntegrationContextById(this.id)` and use
242
+ `context.modules`: one module per entity, each with its record on
243
+ `module.entity` (pick by `module.entity.externalId`).
244
+ (2) `findIntegrationByEntityExternalId` counts every entity with that
245
+ externalId, attached or not, across users: reuse an existing entity
246
+ (`findEntity`) when an account comes back, or delete it when it is revoked,
247
+ never create a second one.
248
+ (3) `createIntegration` reuses an existing integration of the same user
249
+ and type whose entities include all requested ones (an exact match wins),
250
+ so a reconnect that sends the original entities finds the integration that
251
+ now holds more.
252
+ (4) Every module's delegate is the integration: an auth failure on any
253
+ account marks the shared credential invalid and the integration ERROR.
214
254
  - `integration-mapping-repository-*.js` - Mapping data persistence.
215
255
  `queryMappings(integrationId, { where, orderBy, skip, take, omit })`
216
256
  returns `{ mappings, total }`: one filtered, ordered page of an
@@ -226,7 +266,7 @@ packages/core/
226
266
  mapping keys to leave out of the rows; never write such rows back. Path
227
267
  segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and
228
268
  DocumentDB adapters give the same pages and totals;
229
- `integration-mapping-repository-query-parity.test.js` checks that against
269
+ `integration-mapping-repository-query-parity.db.test.js` checks that against
230
270
  real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` /
231
271
  `QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ:
232
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(userId);
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)` | Load integration + modules by external ID |
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,12 +1,24 @@
1
1
  const {
2
2
  createModuleRepository,
3
3
  } = require('../../modules/repositories/module-repository-factory');
4
+ const { toCredentialId } = require('../../modules/utils/to-credential-id');
4
5
 
5
6
  const ERROR_CODE_MAP = {
6
7
  ENTITY_NOT_FOUND: 404,
7
8
  INVALID_ENTITY_DATA: 400,
8
9
  };
9
10
 
11
+ function toEntityDto(entity) {
12
+ return {
13
+ id: entity.id,
14
+ userId: entity.userId,
15
+ externalId: entity.externalId,
16
+ name: entity.name,
17
+ moduleName: entity.moduleName,
18
+ credentialId: toCredentialId(entity.credential),
19
+ };
20
+ }
21
+
10
22
  function mapErrorToResponse(error) {
11
23
  const status = ERROR_CODE_MAP[error?.code] || 500;
12
24
  return {
@@ -66,16 +78,7 @@ function createEntityCommands() {
66
78
 
67
79
  const entity = await moduleRepo.createEntity(entityData);
68
80
 
69
- return {
70
- id: entity.id,
71
- userId: entity.userId,
72
- externalId: entity.externalId,
73
- name: entity.name,
74
- moduleName: entity.moduleName,
75
- credentialId: entity.credential?._id
76
- ? entity.credential._id.toString()
77
- : entity.credential,
78
- };
81
+ return toEntityDto(entity);
79
82
  } catch (error) {
80
83
  return mapErrorToResponse(error);
81
84
  }
@@ -109,16 +112,7 @@ function createEntityCommands() {
109
112
  return null;
110
113
  }
111
114
 
112
- return {
113
- id: entity.id,
114
- userId: entity.userId,
115
- externalId: entity.externalId,
116
- name: entity.name,
117
- moduleName: entity.moduleName,
118
- credentialId: entity.credential?._id
119
- ? entity.credential._id.toString()
120
- : entity.credential,
121
- };
115
+ return toEntityDto(entity);
122
116
  } catch (error) {
123
117
  return mapErrorToResponse(error);
124
118
  }
@@ -139,16 +133,7 @@ function createEntityCommands() {
139
133
 
140
134
  const entities = await moduleRepo.findEntitiesByUserId(userId);
141
135
 
142
- return entities.map((entity) => ({
143
- id: entity.id,
144
- userId: entity.userId,
145
- externalId: entity.externalId,
146
- name: entity.name,
147
- moduleName: entity.moduleName,
148
- credentialId: entity.credential?._id
149
- ? entity.credential._id.toString()
150
- : entity.credential,
151
- }));
136
+ return entities.map((entity) => toEntityDto(entity));
152
137
  } catch (error) {
153
138
  if (error.code) {
154
139
  return mapErrorToResponse(error);
@@ -180,16 +165,7 @@ function createEntityCommands() {
180
165
  moduleName
181
166
  );
182
167
 
183
- return entities.map((entity) => ({
184
- id: entity.id,
185
- userId: entity.userId,
186
- externalId: entity.externalId,
187
- name: entity.name,
188
- moduleName: entity.moduleName,
189
- credentialId: entity.credential?._id
190
- ? entity.credential._id.toString()
191
- : entity.credential,
192
- }));
168
+ return entities.map((entity) => toEntityDto(entity));
193
169
  } catch (error) {
194
170
  if (error.code) {
195
171
  return mapErrorToResponse(error);
@@ -213,16 +189,7 @@ function createEntityCommands() {
213
189
 
214
190
  const entity = await moduleRepo.findEntityById(entityId);
215
191
 
216
- return {
217
- id: entity.id,
218
- userId: entity.userId,
219
- externalId: entity.externalId,
220
- name: entity.name,
221
- moduleName: entity.moduleName,
222
- credentialId: entity.credential?._id
223
- ? entity.credential._id.toString()
224
- : entity.credential,
225
- };
192
+ return toEntityDto(entity);
226
193
  } catch (error) {
227
194
  return mapErrorToResponse(error);
228
195
  }
@@ -250,16 +217,7 @@ function createEntityCommands() {
250
217
  throw error;
251
218
  }
252
219
 
253
- return {
254
- id: entity.id,
255
- userId: entity.userId,
256
- externalId: entity.externalId,
257
- name: entity.name,
258
- moduleName: entity.moduleName,
259
- credentialId: entity.credential?._id
260
- ? entity.credential._id.toString()
261
- : entity.credential,
262
- };
220
+ return toEntityDto(entity);
263
221
  } catch (error) {
264
222
  return mapErrorToResponse(error);
265
223
  }
@@ -29,6 +29,12 @@ const {
29
29
  const {
30
30
  PatchIntegrationConfig,
31
31
  } = require('../../integrations/use-cases/patch-integration-config');
32
+ const {
33
+ AddEntitiesToIntegration,
34
+ } = require('../../integrations/use-cases/add-entities-to-integration');
35
+ const {
36
+ RemoveEntitiesFromIntegration,
37
+ } = require('../../integrations/use-cases/remove-entities-from-integration');
32
38
  const {
33
39
  getModulesDefinitionFromIntegrationClasses,
34
40
  } = require('../../integrations/utils/map-integration-dto');
@@ -40,6 +46,11 @@ const ERROR_CODE_MAP = {
40
46
  EXTERNAL_ID_REQUIRED: 400,
41
47
  TYPE_REQUIRED: 400,
42
48
  INTEGRATION_RECORD_NOT_FOUND: 404,
49
+ INVALID_INTEGRATION_DATA: 400,
50
+ ENTITIES_NOT_FOUND: 404,
51
+ ENTITY_USER_MISMATCH: 403,
52
+ ENTITY_MODULE_MISMATCH: 400,
53
+ LAST_ENTITY_OF_MODULE: 409,
43
54
  };
44
55
 
45
56
  function mapErrorToResponse(error) {
@@ -163,6 +174,17 @@ function createIntegrationCommands({ integrationClass } = {}) {
163
174
  integrationRepository,
164
175
  });
165
176
 
177
+ const addEntitiesToIntegrationUseCase = new AddEntitiesToIntegration({
178
+ integrationRepository,
179
+ moduleRepository,
180
+ });
181
+
182
+ const removeEntitiesFromIntegrationUseCase =
183
+ new RemoveEntitiesFromIntegration({
184
+ integrationRepository,
185
+ moduleRepository,
186
+ });
187
+
166
188
  return {
167
189
  findIntegrationById,
168
190
  listIntegrations,
@@ -307,6 +329,47 @@ function createIntegrationCommands({ integrationClass } = {}) {
307
329
  }
308
330
  },
309
331
 
332
+ /**
333
+ * Attach entities to an existing integration. Every entity must
334
+ * exist, belong to the integration's user, and be of a module the
335
+ * integration already holds; entities that are already attached are
336
+ * left as they are.
337
+ * @param {Object} params
338
+ * @param {string} params.integrationId - Integration ID
339
+ * @param {Array<string>} params.entityIds - Entity IDs to attach
340
+ * @returns {Promise<Object>} Updated integration
341
+ */
342
+ async addEntitiesToIntegration({ integrationId, entityIds }) {
343
+ try {
344
+ return await addEntitiesToIntegrationUseCase.execute(
345
+ integrationId,
346
+ entityIds
347
+ );
348
+ } catch (error) {
349
+ return mapErrorToResponse(error);
350
+ }
351
+ },
352
+
353
+ /**
354
+ * Detach entities from an integration. Entities that are not
355
+ * attached are ignored; detached entities are not deleted. Each
356
+ * module must keep at least one entity.
357
+ * @param {Object} params
358
+ * @param {string} params.integrationId - Integration ID
359
+ * @param {Array<string>} params.entityIds - Entity IDs to detach
360
+ * @returns {Promise<Object>} Updated integration
361
+ */
362
+ async removeEntitiesFromIntegration({ integrationId, entityIds }) {
363
+ try {
364
+ return await removeEntitiesFromIntegrationUseCase.execute(
365
+ integrationId,
366
+ entityIds
367
+ );
368
+ } catch (error) {
369
+ return mapErrorToResponse(error);
370
+ }
371
+ },
372
+
310
373
  /**
311
374
  * Delete an integration by ID
312
375
  * @param {string} integrationId - Integration ID to delete
@@ -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
- // Must be string
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('../integrations/utils/map-integration-dto');
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
- const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations);
180
- const credentialFields = extractCredentialFieldsFromModules(moduleDefinitions);
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
- const moduleSchema = {
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
- fields: credentialFields
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(moduleSchema);
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
- const appDefinition = backendModule?.Definition;
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
- if (!appDefinition) {
226
- return; // No app definition found
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
- // Load app-level custom schema
230
- const customSchema = appDefinition.encryption?.schema;
231
- if (customSchema && Object.keys(customSchema).length > 0) {
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
- // Load app-level encryption opt-out — apps can declare fields they
236
- // don't want encrypted on write (decryption on read still works,
237
- // so legacy data remains readable).
238
- const disable = appDefinition.encryption?.disable;
239
- if (disable && Object.keys(disable).length > 0) {
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
- // Load module-level encryption schemas from integrations
244
- const integrations = appDefinition.integrations;
245
- if (integrations && Array.isArray(integrations)) {
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
- if (typeof value !== 'string') {
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
- * Objects/arrays are JSON stringified, primitives are converted to strings
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
- if (typeof value === 'object' && value !== null) {
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
- * Attempts to parse as JSON, returns string if parsing fails
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) {