@friggframework/core 2.0.0-next.53 → 2.0.0-next.55

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 (70) hide show
  1. package/CLAUDE.md +2 -1
  2. package/application/commands/credential-commands.js +1 -1
  3. package/application/commands/integration-commands.js +1 -1
  4. package/application/index.js +1 -1
  5. package/core/create-handler.js +12 -0
  6. package/credential/repositories/credential-repository-documentdb.js +304 -0
  7. package/credential/repositories/credential-repository-factory.js +8 -1
  8. package/credential/repositories/credential-repository-mongo.js +16 -54
  9. package/credential/repositories/credential-repository-postgres.js +14 -41
  10. package/credential/use-cases/get-credential-for-user.js +7 -3
  11. package/database/config.js +4 -4
  12. package/database/documentdb-encryption-service.js +330 -0
  13. package/database/documentdb-utils.js +136 -0
  14. package/database/encryption/README.md +50 -1
  15. package/database/encryption/documentdb-encryption-service.md +3270 -0
  16. package/database/encryption/encryption-schema-registry.js +46 -0
  17. package/database/prisma.js +7 -47
  18. package/database/repositories/health-check-repository-documentdb.js +134 -0
  19. package/database/repositories/health-check-repository-factory.js +6 -1
  20. package/database/repositories/health-check-repository-interface.js +29 -34
  21. package/database/repositories/health-check-repository-mongodb.js +1 -3
  22. package/database/use-cases/check-database-state-use-case.js +3 -3
  23. package/database/use-cases/run-database-migration-use-case.js +6 -4
  24. package/database/use-cases/trigger-database-migration-use-case.js +2 -2
  25. package/database/utils/mongodb-schema-init.js +5 -5
  26. package/database/utils/prisma-runner.js +15 -9
  27. package/errors/client-safe-error.js +26 -0
  28. package/errors/fetch-error.js +2 -1
  29. package/errors/index.js +2 -0
  30. package/generated/prisma-mongodb/edge.js +3 -3
  31. package/generated/prisma-mongodb/index.d.ts +10 -4
  32. package/generated/prisma-mongodb/index.js +3 -3
  33. package/generated/prisma-mongodb/package.json +1 -1
  34. package/generated/prisma-mongodb/schema.prisma +1 -3
  35. package/generated/prisma-mongodb/wasm.js +2 -2
  36. package/generated/prisma-postgresql/edge.js +3 -3
  37. package/generated/prisma-postgresql/index.d.ts +10 -4
  38. package/generated/prisma-postgresql/index.js +3 -3
  39. package/generated/prisma-postgresql/package.json +1 -1
  40. package/generated/prisma-postgresql/schema.prisma +1 -3
  41. package/generated/prisma-postgresql/wasm.js +2 -2
  42. package/handlers/routers/db-migration.js +2 -3
  43. package/handlers/routers/health.js +0 -3
  44. package/handlers/workers/db-migration.js +8 -8
  45. package/integrations/integration-router.js +6 -6
  46. package/integrations/repositories/integration-mapping-repository-documentdb.js +280 -0
  47. package/integrations/repositories/integration-mapping-repository-factory.js +8 -1
  48. package/integrations/repositories/integration-repository-documentdb.js +210 -0
  49. package/integrations/repositories/integration-repository-factory.js +8 -1
  50. package/integrations/repositories/process-repository-documentdb.js +243 -0
  51. package/integrations/repositories/process-repository-factory.js +8 -1
  52. package/modules/repositories/module-repository-documentdb.js +307 -0
  53. package/modules/repositories/module-repository-factory.js +8 -1
  54. package/package.json +5 -5
  55. package/prisma-mongodb/schema.prisma +1 -3
  56. package/prisma-postgresql/migrations/20251112195422_update_user_unique_constraints/migration.sql +69 -0
  57. package/prisma-postgresql/schema.prisma +1 -3
  58. package/syncs/repositories/sync-repository-documentdb.js +240 -0
  59. package/syncs/repositories/sync-repository-factory.js +6 -1
  60. package/token/repositories/token-repository-documentdb.js +137 -0
  61. package/token/repositories/token-repository-factory.js +8 -1
  62. package/token/repositories/token-repository-mongo.js +10 -3
  63. package/token/repositories/token-repository-postgres.js +10 -3
  64. package/user/repositories/user-repository-documentdb.js +432 -0
  65. package/user/repositories/user-repository-factory.js +6 -1
  66. package/user/repositories/user-repository-mongo.js +3 -2
  67. package/user/repositories/user-repository-postgres.js +3 -2
  68. package/user/use-cases/login-user.js +1 -1
  69. package/websocket/repositories/websocket-connection-repository-documentdb.js +119 -0
  70. package/websocket/repositories/websocket-connection-repository-factory.js +8 -1
@@ -36,7 +36,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
36
36
 
37
37
  /**
38
38
  * Find credential by ID
39
- * Replaces: Credential.findById(id)
40
39
  *
41
40
  * @param {string} id - Credential ID (string from application layer)
42
41
  * @returns {Promise<Object|null>} Credential object with string IDs or null
@@ -51,14 +50,11 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
51
50
  return null;
52
51
  }
53
52
 
54
- // Extract data from JSON field
55
53
  const data = credential.data || {};
56
54
 
57
55
  return {
58
- _id: credential.id.toString(),
59
56
  id: credential.id.toString(),
60
- user: credential.userId?.toString(),
61
- userId: credential.userId?.toString(),
57
+ userId: credential.userId.toString(),
62
58
  externalId: credential.externalId,
63
59
  authIsValid: credential.authIsValid,
64
60
  ...data, // Spread OAuth tokens from JSON field
@@ -67,7 +63,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
67
63
 
68
64
  /**
69
65
  * Update authentication status
70
- * Replaces: Credential.updateOne({ _id: credentialId }, { $set: { authIsValid } })
71
66
  *
72
67
  * @param {string} credentialId - Credential ID (string from application layer)
73
68
  * @param {boolean} authIsValid - Authentication validity status
@@ -85,7 +80,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
85
80
 
86
81
  /**
87
82
  * Permanently remove a credential document
88
- * Replaces: Credential.deleteOne({ _id: credentialId })
89
83
  *
90
84
  * @param {string} credentialId - Credential ID (string from application layer)
91
85
  * @returns {Promise<Object>} Deletion result
@@ -108,7 +102,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
108
102
 
109
103
  /**
110
104
  * Create or update credential matching identifiers
111
- * Replaces: Credential.findOneAndUpdate(query, update, { upsert: true })
112
105
  *
113
106
  * @param {{identifiers: Object, details: Object}} credentialDetails
114
107
  * @returns {Promise<Object>} The persisted credential with string IDs
@@ -118,22 +111,21 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
118
111
  if (!identifiers)
119
112
  throw new Error('identifiers required to upsert credential');
120
113
 
121
- if (!identifiers.user && !identifiers.userId) {
122
- throw new Error('user or userId required in identifiers');
114
+ if (!identifiers.userId) {
115
+ throw new Error('userId required in identifiers');
123
116
  }
124
117
  if (!identifiers.externalId) {
125
118
  throw new Error(
126
119
  'externalId required in identifiers to prevent credential collision. ' +
127
- 'When multiple credentials exist for the same user, both userId and externalId ' +
128
- 'are needed to uniquely identify which credential to update.'
120
+ 'When multiple credentials exist for the same user, both userId and externalId ' +
121
+ 'are needed to uniquely identify which credential to update.'
129
122
  );
130
123
  }
131
124
 
132
125
  const where = this._convertIdentifiersToWhere(identifiers);
133
126
 
134
- const { user, externalId } = identifiers;
127
+ const { externalId } = identifiers;
135
128
 
136
- // Separate schema fields from dynamic OAuth data
137
129
  const { authIsValid, ...oauthData } = details;
138
130
 
139
131
  const existing = await this.prisma.credential.findFirst({ where });
@@ -144,15 +136,9 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
144
136
  const updated = await this.prisma.credential.update({
145
137
  where: { id: existing.id },
146
138
  data: {
147
- userId: this._convertId(user || existing.userId),
148
- externalId:
149
- externalId !== undefined
150
- ? externalId
151
- : existing.externalId,
152
- authIsValid:
153
- authIsValid !== undefined
154
- ? authIsValid
155
- : existing.authIsValid,
139
+ userId: this._convertId(existing.userId),
140
+ externalId: existing.externalId,
141
+ authIsValid: authIsValid,
156
142
  data: mergedData,
157
143
  },
158
144
  });
@@ -168,10 +154,9 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
168
154
 
169
155
  const created = await this.prisma.credential.create({
170
156
  data: {
171
- userId: this._convertId(user),
157
+ userId: this._convertId(identifiers.userId),
172
158
  externalId,
173
159
  authIsValid: authIsValid,
174
-
175
160
  data: oauthData,
176
161
  },
177
162
  });
@@ -187,7 +172,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
187
172
 
188
173
  /**
189
174
  * Find a credential by filter criteria
190
- * Replaces: Credential.findOne(query)
191
175
  *
192
176
  * @param {Object} filter
193
177
  * @param {string} [filter.userId] - User ID (string from application layer)
@@ -215,21 +199,18 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
215
199
  authIsValid: credential.authIsValid,
216
200
  access_token: data.access_token,
217
201
  refresh_token: data.refresh_token,
218
- domain: data.domain,
219
202
  ...data,
220
203
  };
221
204
  }
222
205
 
223
206
  /**
224
207
  * Update a credential by ID
225
- * Replaces: Credential.findByIdAndUpdate(credentialId, { $set: updates })
226
208
  *
227
209
  * @param {string} credentialId - Credential ID (string from application layer)
228
210
  * @param {Object} updates - Fields to update
229
211
  * @returns {Promise<Object|null>} Updated credential object with string IDs or null if not found
230
212
  */
231
213
  async updateCredential(credentialId, updates) {
232
- // Get existing credential to merge OAuth data
233
214
  const intId = this._convertId(credentialId);
234
215
  const existing = await this.prisma.credential.findUnique({
235
216
  where: { id: intId },
@@ -239,21 +220,16 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
239
220
  return null;
240
221
  }
241
222
 
242
- // Separate schema fields from OAuth data
243
- const { user, authIsValid, ...oauthData } =
244
- updates;
223
+ const { authIsValid, ...oauthData } = updates;
245
224
 
246
- // Merge OAuth data with existing
247
225
  const mergedData = { ...(existing.data || {}), ...oauthData };
248
226
 
249
227
  const updated = await this.prisma.credential.update({
250
228
  where: { id: intId },
251
229
  data: {
252
- userId: this._convertId(userId || user || existing.userId),
253
- externalId:
254
- externalId !== undefined ? externalId : existing.externalId,
255
- authIsValid:
256
- authIsValid !== undefined ? authIsValid : existing.authIsValid,
230
+ userId: this._convertId(existing.userId),
231
+ externalId: existing.externalId,
232
+ authIsValid: authIsValid,
257
233
  data: mergedData,
258
234
  },
259
235
  });
@@ -267,7 +243,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
267
243
  authIsValid: updated.authIsValid,
268
244
  access_token: data.access_token,
269
245
  refresh_token: data.refresh_token,
270
- domain: data.domain,
271
246
  ...data,
272
247
  };
273
248
  }
@@ -282,7 +257,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
282
257
  const where = {};
283
258
 
284
259
  if (identifiers.id) where.id = this._convertId(identifiers.id);
285
- if (identifiers.user) where.userId = this._convertId(identifiers.user);
286
260
  if (identifiers.userId)
287
261
  where.userId = this._convertId(identifiers.userId);
288
262
  if (identifiers.externalId) where.externalId = identifiers.externalId;
@@ -302,7 +276,6 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
302
276
  if (filter.credentialId)
303
277
  where.id = this._convertId(filter.credentialId);
304
278
  if (filter.id) where.id = this._convertId(filter.id);
305
- if (filter.user) where.userId = this._convertId(filter.user);
306
279
  if (filter.userId) where.userId = this._convertId(filter.userId);
307
280
  if (filter.externalId) where.externalId = filter.externalId;
308
281
 
@@ -4,14 +4,18 @@ class GetCredentialForUser {
4
4
  }
5
5
 
6
6
  async execute(credentialId, userId) {
7
- const credential = await this.credentialRepository.findCredentialById(credentialId);
7
+ const credential = await this.credentialRepository.findCredentialById(
8
+ credentialId
9
+ );
8
10
 
9
11
  if (!credential) {
10
12
  throw new Error(`Credential with id ${credentialId} not found`);
11
13
  }
12
14
 
13
- if (credential.user.toString() !== userId.toString()) {
14
- throw new Error(`Credential ${credentialId} does not belong to user ${userId}`);
15
+ if (credential.userId.toString() !== userId.toString()) {
16
+ throw new Error(
17
+ `Credential ${credentialId} does not belong to user ${userId}`
18
+ );
15
19
  }
16
20
 
17
21
  return credential;
@@ -10,7 +10,7 @@
10
10
  * 1. DB_TYPE environment variable (set for migration handlers)
11
11
  * 2. App definition (backend/index.js Definition.database configuration)
12
12
  *
13
- * @returns {'mongodb'|'postgresql'} Database type
13
+ * @returns {'mongodb'|'postgresql'|'documentdb'} Database type
14
14
  * @throws {Error} If database type cannot be determined or app definition missing
15
15
  */
16
16
  function getDatabaseType() {
@@ -93,7 +93,7 @@ function getDatabaseType() {
93
93
  return 'mongodb';
94
94
  }
95
95
  if (database.documentDB?.enable === true) {
96
- return 'mongodb'; // DocumentDB is MongoDB-compatible
96
+ return 'documentdb';
97
97
  }
98
98
 
99
99
  throw new Error(
@@ -114,7 +114,7 @@ function getDatabaseType() {
114
114
 
115
115
  /**
116
116
  * Cached database type (lazy evaluation)
117
- * @type {'mongodb'|'postgresql'|null}
117
+ * @type {'mongodb'|'postgresql'|'documentdb'|null}
118
118
  */
119
119
  let cachedDbType = null;
120
120
 
@@ -140,7 +140,7 @@ module.exports = {
140
140
  /**
141
141
  * Lazy-evaluated database type determined from app definition
142
142
  * Only evaluates when accessed, preventing module load failures in test environments
143
- * @type {'mongodb'|'postgresql'}
143
+ * @type {'mongodb'|'postgresql'|'documentdb'}
144
144
  */
145
145
  Object.defineProperty(module.exports, 'DB_TYPE', {
146
146
  get() {
@@ -0,0 +1,330 @@
1
+ const { Cryptor } = require('../encrypt/Cryptor');
2
+ const { getEncryptedFields, loadCustomEncryptionSchema } = require('./encryption/encryption-schema-registry');
3
+
4
+ /**
5
+ * Encryption service specifically for DocumentDB repositories
6
+ * that use $runCommandRaw and bypass Prisma Client Extensions.
7
+ *
8
+ * Provides document-level encryption/decryption, handling nested fields
9
+ * according to the encryption schema registry.
10
+ *
11
+ * @class DocumentDBEncryptionService
12
+ * @example
13
+ * const service = new DocumentDBEncryptionService();
14
+ *
15
+ * // Encrypt before write
16
+ * const encrypted = await service.encryptFields('Credential', document);
17
+ * await insertOne(prisma, 'Credential', encrypted);
18
+ *
19
+ * // Decrypt after read
20
+ * const doc = await findOne(prisma, 'Credential', filter);
21
+ * const decrypted = await service.decryptFields('Credential', doc);
22
+ */
23
+ class DocumentDBEncryptionService {
24
+ /**
25
+ * @param {Object} options - Configuration options
26
+ * @param {Cryptor} [options.cryptor] - Optional Cryptor instance for dependency injection (useful for testing)
27
+ */
28
+ constructor({ cryptor = null } = {}) {
29
+ if (cryptor) {
30
+ // Dependency injection - use provided Cryptor (for testing)
31
+ this.cryptor = cryptor;
32
+ this.enabled = true;
33
+ } else {
34
+ // Default behavior - create Cryptor from environment
35
+ this._initializeCryptor();
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Initialize Cryptor with environment-based configuration.
41
+ * Matches the logic from @friggframework/core/database/prisma.js
42
+ *
43
+ * Encryption is bypassed in dev/test/local stages.
44
+ * Production uses AWS KMS (if available) or AES encryption.
45
+ *
46
+ * @private
47
+ */
48
+ _initializeCryptor() {
49
+ // Load custom encryption schema from app definition BEFORE checking configuration
50
+ // This ensures custom fields (like User.username) are registered before any encryption operations
51
+ loadCustomEncryptionSchema();
52
+
53
+ // Match logic from packages/core/database/prisma.js
54
+ const stage = process.env.STAGE || process.env.NODE_ENV || 'development';
55
+ const bypassEncryption = ['dev', 'test', 'local'].includes(stage.toLowerCase());
56
+
57
+ if (bypassEncryption) {
58
+ this.cryptor = null;
59
+ this.enabled = false;
60
+ return;
61
+ }
62
+
63
+ // Determine encryption method (ensure boolean values)
64
+ const hasKMS = !!(process.env.KMS_KEY_ARN && process.env.KMS_KEY_ARN.trim() !== '');
65
+ const hasAES = !!(process.env.AES_KEY_ID && process.env.AES_KEY_ID.trim() !== '');
66
+
67
+ if (!hasKMS && !hasAES) {
68
+ console.warn('[DocumentDBEncryptionService] No encryption keys configured. Encryption disabled.');
69
+ this.cryptor = null;
70
+ this.enabled = false;
71
+ return;
72
+ }
73
+
74
+ // KMS takes precedence over AES
75
+ const shouldUseAws = hasKMS;
76
+ this.cryptor = new Cryptor({ shouldUseAws });
77
+ this.enabled = true;
78
+ }
79
+
80
+ /**
81
+ * Encrypt sensitive fields in a document before storing to DocumentDB.
82
+ *
83
+ * Reads field paths from encryption-schema-registry.js and encrypts
84
+ * only the fields defined for the given model.
85
+ *
86
+ * @param {string} modelName - Model name from schema registry (e.g., 'User', 'Credential')
87
+ * @param {Object} document - Document to encrypt
88
+ * @returns {Promise<Object>} - New document with encrypted fields (original unchanged)
89
+ *
90
+ * @example
91
+ * const plainDoc = {
92
+ * userId: '123',
93
+ * data: { access_token: 'plain_secret' }
94
+ * };
95
+ * const encrypted = await service.encryptFields('Credential', plainDoc);
96
+ * // encrypted.data.access_token = "keyId:iv:cipher:encKey"
97
+ */
98
+ async encryptFields(modelName, document) {
99
+ // Bypass if encryption disabled
100
+ if (!this.enabled || !this.cryptor) {
101
+ return document;
102
+ }
103
+
104
+ // Validate input
105
+ if (!document || typeof document !== 'object') {
106
+ return document;
107
+ }
108
+
109
+ // Get encrypted fields from registry
110
+ const encryptedFieldsConfig = getEncryptedFields(modelName);
111
+ if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
112
+ return document;
113
+ }
114
+
115
+ // Deep clone to prevent mutation (preserves Date, RegExp, Buffer)
116
+ const result = structuredClone(document);
117
+
118
+ // Encrypt each field path
119
+ for (const fieldPath of encryptedFieldsConfig) {
120
+ await this._encryptFieldPath(result, fieldPath, modelName);
121
+ }
122
+
123
+ return result;
124
+ }
125
+
126
+ /**
127
+ * Decrypt sensitive fields in a document after reading from DocumentDB.
128
+ *
129
+ * Reads field paths from encryption-schema-registry.js and decrypts
130
+ * only the fields defined for the given model.
131
+ *
132
+ * @param {string} modelName - Model name from schema registry
133
+ * @param {Object} document - Document to decrypt
134
+ * @returns {Promise<Object>} - New document with decrypted fields (original unchanged)
135
+ *
136
+ * @example
137
+ * const encryptedDoc = {
138
+ * userId: '123',
139
+ * data: { access_token: 'keyId:iv:cipher:encKey' }
140
+ * };
141
+ * const decrypted = await service.decryptFields('Credential', encryptedDoc);
142
+ * // decrypted.data.access_token = "plain_secret"
143
+ */
144
+ async decryptFields(modelName, document) {
145
+ // Bypass if encryption disabled
146
+ if (!this.enabled || !this.cryptor) {
147
+ return document;
148
+ }
149
+
150
+ // Validate input
151
+ if (!document || typeof document !== 'object') {
152
+ return document;
153
+ }
154
+
155
+ // Get encrypted fields from registry
156
+ const encryptedFieldsConfig = getEncryptedFields(modelName);
157
+ if (!encryptedFieldsConfig || encryptedFieldsConfig.length === 0) {
158
+ return document;
159
+ }
160
+
161
+ // Deep clone to prevent mutation (preserves Date, RegExp, Buffer)
162
+ const result = structuredClone(document);
163
+
164
+ // Decrypt each field path
165
+ for (const fieldPath of encryptedFieldsConfig) {
166
+ await this._decryptFieldPath(result, fieldPath, modelName);
167
+ }
168
+
169
+ return result;
170
+ }
171
+
172
+ /**
173
+ * Encrypt a specific field path in a document (handles nested fields).
174
+ *
175
+ * @private
176
+ * @param {Object} document - Document to modify (mutated in place)
177
+ * @param {string} fieldPath - Field path from schema registry (e.g., 'data.access_token')
178
+ * @param {string} modelName - For error logging context
179
+ */
180
+ async _encryptFieldPath(document, fieldPath, modelName) {
181
+ // Parse field path
182
+ const parts = fieldPath.split('.');
183
+
184
+ // Navigate to parent object
185
+ let current = document;
186
+ for (let i = 0; i < parts.length - 1; i++) {
187
+ if (!current[parts[i]]) {
188
+ // Path doesn't exist, nothing to encrypt
189
+ return;
190
+ }
191
+ current = current[parts[i]];
192
+ }
193
+
194
+ // Get field name and value
195
+ const fieldName = parts[parts.length - 1];
196
+ const value = current[fieldName];
197
+
198
+ // Skip if already encrypted or empty
199
+ if (!value || this._isEncryptedValue(value)) {
200
+ return;
201
+ }
202
+
203
+ try {
204
+ // Convert to string if needed
205
+ const stringValue = typeof value === 'string'
206
+ ? value
207
+ : JSON.stringify(value);
208
+
209
+ // Encrypt using Cryptor
210
+ current[fieldName] = await this.cryptor.encrypt(stringValue);
211
+ } catch (error) {
212
+ console.error(`[DocumentDBEncryptionService] Failed to encrypt ${modelName}.${fieldPath}:`, error.message);
213
+ throw error;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Decrypt a specific field path in a document (handles nested fields).
219
+ *
220
+ * @private
221
+ * @param {Object} document - Document to modify (mutated in place)
222
+ * @param {string} fieldPath - Field path from schema registry
223
+ * @param {string} modelName - For error logging context
224
+ */
225
+ async _decryptFieldPath(document, fieldPath, modelName) {
226
+ // Parse field path
227
+ const parts = fieldPath.split('.');
228
+
229
+ // Navigate to parent object
230
+ let current = document;
231
+ for (let i = 0; i < parts.length - 1; i++) {
232
+ if (!current[parts[i]]) {
233
+ // Path doesn't exist, nothing to decrypt
234
+ return;
235
+ }
236
+ current = current[parts[i]];
237
+ }
238
+
239
+ // Get field name and encrypted value
240
+ const fieldName = parts[parts.length - 1];
241
+ const encryptedValue = current[fieldName];
242
+
243
+ // Skip if not encrypted format
244
+ if (!encryptedValue || !this._isEncryptedValue(encryptedValue)) {
245
+ return;
246
+ }
247
+
248
+ try {
249
+ // Decrypt using Cryptor
250
+ const decryptedString = await this.cryptor.decrypt(encryptedValue);
251
+
252
+ // Try to parse as JSON (for objects/arrays)
253
+ try {
254
+ current[fieldName] = JSON.parse(decryptedString);
255
+ } catch {
256
+ // Not JSON, return as string
257
+ current[fieldName] = decryptedString;
258
+ }
259
+ } catch (error) {
260
+ const errorContext = {
261
+ modelName,
262
+ fieldPath,
263
+ encryptedValuePrefix: encryptedValue.substring(0, 20),
264
+ errorMessage: error.message
265
+ };
266
+
267
+ console.error(
268
+ `[DocumentDBEncryptionService] Failed to decrypt ${modelName}.${fieldPath}:`,
269
+ JSON.stringify(errorContext)
270
+ );
271
+
272
+ // Throw error to fail fast - don't silently corrupt data
273
+ throw new Error(`Decryption failed for ${modelName}.${fieldPath}: ${error.message}`);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Check if a value is in encrypted format.
279
+ *
280
+ * Encrypted format: "keyId:iv:cipher:encKey" (envelope encryption)
281
+ * All parts are base64-encoded strings.
282
+ *
283
+ * @private
284
+ * @param {any} value - Value to check
285
+ * @returns {boolean} - True if value is encrypted
286
+ *
287
+ * @example
288
+ * _isEncryptedValue("plain_text") // false
289
+ * _isEncryptedValue("YWVzLWtleS0x:TXlJVkhlcmU=:QWN0dWFsQ2lwaGVy:RW5jcnlwdGVk") // true
290
+ * _isEncryptedValue(null) // false
291
+ * _isEncryptedValue({}) // false
292
+ */
293
+ _isEncryptedValue(value) {
294
+ // Must be string
295
+ if (typeof value !== 'string') {
296
+ return false;
297
+ }
298
+
299
+ // Must have exactly 4 colon-separated parts
300
+ const parts = value.split(':');
301
+ if (parts.length !== 4) {
302
+ return false;
303
+ }
304
+
305
+ // Enhanced validation: check for base64 pattern
306
+ // This prevents false positives on URLs, connection strings, etc.
307
+ const base64Pattern = /^[A-Za-z0-9+/=]+$/;
308
+
309
+ // All parts should be base64-encoded
310
+ if (!parts.every(part => base64Pattern.test(part))) {
311
+ return false;
312
+ }
313
+
314
+ // Encrypted values should be sufficiently long to be valid
315
+ // Real encrypted values from Cryptor are always >50 chars due to envelope encryption format:
316
+ // - keyId (base64): ~12 chars minimum
317
+ // - iv (base64): ~24 chars for 16-byte IV
318
+ // - ciphertext (base64): varies, minimum ~16 chars for small values
319
+ // - encryptedKey (base64): ~44 chars for 32-byte data key
320
+ // Total minimum: ~96 chars, so 50 is a safe lower bound
321
+ // This prevents false positives on non-encrypted strings that happen to have 4 colons
322
+ if (value.length < 50) {
323
+ return false;
324
+ }
325
+
326
+ return true;
327
+ }
328
+ }
329
+
330
+ module.exports = { DocumentDBEncryptionService };