@friggframework/core 2.0.0-next.40 → 2.0.0-next.42

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 (196) hide show
  1. package/CLAUDE.md +693 -0
  2. package/README.md +931 -50
  3. package/application/commands/README.md +421 -0
  4. package/application/commands/credential-commands.js +224 -0
  5. package/application/commands/entity-commands.js +315 -0
  6. package/application/commands/integration-commands.js +160 -0
  7. package/application/commands/integration-commands.test.js +123 -0
  8. package/application/commands/user-commands.js +213 -0
  9. package/application/index.js +69 -0
  10. package/core/CLAUDE.md +690 -0
  11. package/core/create-handler.js +0 -6
  12. package/credential/repositories/credential-repository-factory.js +47 -0
  13. package/credential/repositories/credential-repository-interface.js +98 -0
  14. package/credential/repositories/credential-repository-mongo.js +301 -0
  15. package/credential/repositories/credential-repository-postgres.js +307 -0
  16. package/credential/repositories/credential-repository.js +307 -0
  17. package/credential/use-cases/get-credential-for-user.js +21 -0
  18. package/credential/use-cases/update-authentication-status.js +15 -0
  19. package/database/config.js +117 -0
  20. package/database/encryption/README.md +683 -0
  21. package/database/encryption/encryption-integration.test.js +553 -0
  22. package/database/encryption/encryption-schema-registry.js +141 -0
  23. package/database/encryption/encryption-schema-registry.test.js +392 -0
  24. package/database/encryption/field-encryption-service.js +226 -0
  25. package/database/encryption/field-encryption-service.test.js +525 -0
  26. package/database/encryption/logger.js +79 -0
  27. package/database/encryption/mongo-decryption-fix-verification.test.js +348 -0
  28. package/database/encryption/postgres-decryption-fix-verification.test.js +371 -0
  29. package/database/encryption/postgres-relation-decryption.test.js +245 -0
  30. package/database/encryption/prisma-encryption-extension.js +222 -0
  31. package/database/encryption/prisma-encryption-extension.test.js +439 -0
  32. package/database/index.js +25 -12
  33. package/database/models/readme.md +1 -0
  34. package/database/prisma.js +162 -0
  35. package/database/repositories/health-check-repository-factory.js +38 -0
  36. package/database/repositories/health-check-repository-interface.js +86 -0
  37. package/database/repositories/health-check-repository-mongodb.js +72 -0
  38. package/database/repositories/health-check-repository-postgres.js +75 -0
  39. package/database/repositories/health-check-repository.js +108 -0
  40. package/database/use-cases/check-database-health-use-case.js +34 -0
  41. package/database/use-cases/check-encryption-health-use-case.js +82 -0
  42. package/database/use-cases/test-encryption-use-case.js +252 -0
  43. package/encrypt/Cryptor.js +20 -152
  44. package/encrypt/index.js +1 -2
  45. package/encrypt/test-encrypt.js +0 -2
  46. package/handlers/app-definition-loader.js +38 -0
  47. package/handlers/app-handler-helpers.js +0 -3
  48. package/handlers/auth-flow.integration.test.js +147 -0
  49. package/handlers/backend-utils.js +25 -45
  50. package/handlers/integration-event-dispatcher.js +54 -0
  51. package/handlers/integration-event-dispatcher.test.js +141 -0
  52. package/handlers/routers/HEALTHCHECK.md +103 -1
  53. package/handlers/routers/auth.js +3 -14
  54. package/handlers/routers/health.js +63 -424
  55. package/handlers/routers/health.test.js +7 -0
  56. package/handlers/routers/integration-defined-routers.js +8 -5
  57. package/handlers/routers/user.js +25 -5
  58. package/handlers/routers/websocket.js +5 -3
  59. package/handlers/use-cases/check-external-apis-health-use-case.js +81 -0
  60. package/handlers/use-cases/check-integrations-health-use-case.js +32 -0
  61. package/handlers/workers/integration-defined-workers.js +6 -3
  62. package/index.js +45 -22
  63. package/integrations/index.js +12 -10
  64. package/integrations/integration-base.js +224 -53
  65. package/integrations/integration-router.js +386 -178
  66. package/integrations/options.js +1 -1
  67. package/integrations/repositories/integration-mapping-repository-factory.js +50 -0
  68. package/integrations/repositories/integration-mapping-repository-interface.js +106 -0
  69. package/integrations/repositories/integration-mapping-repository-mongo.js +161 -0
  70. package/integrations/repositories/integration-mapping-repository-postgres.js +227 -0
  71. package/integrations/repositories/integration-mapping-repository.js +156 -0
  72. package/integrations/repositories/integration-repository-factory.js +44 -0
  73. package/integrations/repositories/integration-repository-interface.js +115 -0
  74. package/integrations/repositories/integration-repository-mongo.js +271 -0
  75. package/integrations/repositories/integration-repository-postgres.js +319 -0
  76. package/integrations/tests/doubles/dummy-integration-class.js +90 -0
  77. package/integrations/tests/doubles/test-integration-repository.js +99 -0
  78. package/integrations/tests/use-cases/create-integration.test.js +131 -0
  79. package/integrations/tests/use-cases/delete-integration-for-user.test.js +150 -0
  80. package/integrations/tests/use-cases/find-integration-context-by-external-entity-id.test.js +92 -0
  81. package/integrations/tests/use-cases/get-integration-for-user.test.js +150 -0
  82. package/integrations/tests/use-cases/get-integration-instance.test.js +176 -0
  83. package/integrations/tests/use-cases/get-integrations-for-user.test.js +176 -0
  84. package/integrations/tests/use-cases/get-possible-integrations.test.js +188 -0
  85. package/integrations/tests/use-cases/update-integration-messages.test.js +142 -0
  86. package/integrations/tests/use-cases/update-integration-status.test.js +103 -0
  87. package/integrations/tests/use-cases/update-integration.test.js +141 -0
  88. package/integrations/use-cases/create-integration.js +83 -0
  89. package/integrations/use-cases/delete-integration-for-user.js +73 -0
  90. package/integrations/use-cases/find-integration-context-by-external-entity-id.js +72 -0
  91. package/integrations/use-cases/get-integration-for-user.js +78 -0
  92. package/integrations/use-cases/get-integration-instance-by-definition.js +67 -0
  93. package/integrations/use-cases/get-integration-instance.js +83 -0
  94. package/integrations/use-cases/get-integrations-for-user.js +87 -0
  95. package/integrations/use-cases/get-possible-integrations.js +27 -0
  96. package/integrations/use-cases/index.js +11 -0
  97. package/integrations/use-cases/load-integration-context-full.test.js +329 -0
  98. package/integrations/use-cases/load-integration-context.js +71 -0
  99. package/integrations/use-cases/load-integration-context.test.js +114 -0
  100. package/integrations/use-cases/update-integration-messages.js +44 -0
  101. package/integrations/use-cases/update-integration-status.js +32 -0
  102. package/integrations/use-cases/update-integration.js +93 -0
  103. package/integrations/utils/map-integration-dto.js +36 -0
  104. package/jest-global-setup-noop.js +3 -0
  105. package/jest-global-teardown-noop.js +3 -0
  106. package/{module-plugin → modules}/entity.js +1 -0
  107. package/{module-plugin → modules}/index.js +0 -8
  108. package/modules/module-factory.js +56 -0
  109. package/modules/module-hydration.test.js +205 -0
  110. package/modules/module.js +221 -0
  111. package/modules/repositories/module-repository-factory.js +33 -0
  112. package/modules/repositories/module-repository-interface.js +129 -0
  113. package/modules/repositories/module-repository-mongo.js +386 -0
  114. package/modules/repositories/module-repository-postgres.js +437 -0
  115. package/modules/repositories/module-repository.js +327 -0
  116. package/{module-plugin → modules}/test/mock-api/api.js +8 -3
  117. package/{module-plugin → modules}/test/mock-api/definition.js +12 -8
  118. package/modules/tests/doubles/test-module-factory.js +16 -0
  119. package/modules/tests/doubles/test-module-repository.js +39 -0
  120. package/modules/use-cases/get-entities-for-user.js +32 -0
  121. package/modules/use-cases/get-entity-options-by-id.js +59 -0
  122. package/modules/use-cases/get-entity-options-by-type.js +34 -0
  123. package/modules/use-cases/get-module-instance-from-type.js +31 -0
  124. package/modules/use-cases/get-module.js +56 -0
  125. package/modules/use-cases/process-authorization-callback.js +121 -0
  126. package/modules/use-cases/refresh-entity-options.js +59 -0
  127. package/modules/use-cases/test-module-auth.js +55 -0
  128. package/modules/utils/map-module-dto.js +18 -0
  129. package/package.json +14 -6
  130. package/prisma-mongodb/schema.prisma +321 -0
  131. package/prisma-postgresql/migrations/20250930193005_init/migration.sql +315 -0
  132. package/prisma-postgresql/migrations/20251006135218_init/migration.sql +9 -0
  133. package/prisma-postgresql/migrations/migration_lock.toml +3 -0
  134. package/prisma-postgresql/schema.prisma +303 -0
  135. package/syncs/manager.js +468 -443
  136. package/syncs/repositories/sync-repository-factory.js +38 -0
  137. package/syncs/repositories/sync-repository-interface.js +109 -0
  138. package/syncs/repositories/sync-repository-mongo.js +239 -0
  139. package/syncs/repositories/sync-repository-postgres.js +319 -0
  140. package/syncs/sync.js +0 -1
  141. package/token/repositories/token-repository-factory.js +33 -0
  142. package/token/repositories/token-repository-interface.js +131 -0
  143. package/token/repositories/token-repository-mongo.js +212 -0
  144. package/token/repositories/token-repository-postgres.js +257 -0
  145. package/token/repositories/token-repository.js +219 -0
  146. package/types/integrations/index.d.ts +2 -6
  147. package/types/module-plugin/index.d.ts +5 -57
  148. package/types/syncs/index.d.ts +0 -2
  149. package/user/repositories/user-repository-factory.js +46 -0
  150. package/user/repositories/user-repository-interface.js +198 -0
  151. package/user/repositories/user-repository-mongo.js +250 -0
  152. package/user/repositories/user-repository-postgres.js +311 -0
  153. package/user/tests/doubles/test-user-repository.js +72 -0
  154. package/user/tests/use-cases/create-individual-user.test.js +24 -0
  155. package/user/tests/use-cases/create-organization-user.test.js +28 -0
  156. package/user/tests/use-cases/create-token-for-user-id.test.js +19 -0
  157. package/user/tests/use-cases/get-user-from-bearer-token.test.js +64 -0
  158. package/user/tests/use-cases/login-user.test.js +140 -0
  159. package/user/use-cases/create-individual-user.js +61 -0
  160. package/user/use-cases/create-organization-user.js +47 -0
  161. package/user/use-cases/create-token-for-user-id.js +30 -0
  162. package/user/use-cases/get-user-from-bearer-token.js +77 -0
  163. package/user/use-cases/login-user.js +122 -0
  164. package/user/user.js +77 -0
  165. package/websocket/repositories/websocket-connection-repository-factory.js +37 -0
  166. package/websocket/repositories/websocket-connection-repository-interface.js +106 -0
  167. package/websocket/repositories/websocket-connection-repository-mongo.js +155 -0
  168. package/websocket/repositories/websocket-connection-repository-postgres.js +195 -0
  169. package/websocket/repositories/websocket-connection-repository.js +160 -0
  170. package/database/models/State.js +0 -9
  171. package/database/models/Token.js +0 -70
  172. package/database/mongo.js +0 -171
  173. package/encrypt/Cryptor.test.js +0 -32
  174. package/encrypt/encrypt.js +0 -104
  175. package/encrypt/encrypt.test.js +0 -1069
  176. package/handlers/routers/middleware/loadUser.js +0 -15
  177. package/handlers/routers/middleware/requireLoggedInUser.js +0 -12
  178. package/integrations/create-frigg-backend.js +0 -31
  179. package/integrations/integration-factory.js +0 -251
  180. package/integrations/integration-mapping.js +0 -43
  181. package/integrations/integration-model.js +0 -46
  182. package/integrations/integration-user.js +0 -144
  183. package/integrations/test/integration-base.test.js +0 -144
  184. package/module-plugin/auther.js +0 -393
  185. package/module-plugin/credential.js +0 -22
  186. package/module-plugin/entity-manager.js +0 -70
  187. package/module-plugin/manager.js +0 -169
  188. package/module-plugin/module-factory.js +0 -61
  189. package/module-plugin/test/auther.test.js +0 -97
  190. /package/{module-plugin → modules}/ModuleConstants.js +0 -0
  191. /package/{module-plugin → modules}/requester/api-key.js +0 -0
  192. /package/{module-plugin → modules}/requester/basic.js +0 -0
  193. /package/{module-plugin → modules}/requester/oauth-2.js +0 -0
  194. /package/{module-plugin → modules}/requester/requester.js +0 -0
  195. /package/{module-plugin → modules}/requester/requester.test.js +0 -0
  196. /package/{module-plugin → modules}/test/mock-api/mocks/hubspot.js +0 -0
@@ -0,0 +1,553 @@
1
+ /**
2
+ * Integration tests for field-level encryption
3
+ * Tests transparent encryption/decryption with Prisma for MongoDB and PostgreSQL
4
+ *
5
+ * These tests verify:
6
+ * - Create operations encrypt fields
7
+ * - Read operations decrypt fields
8
+ * - Update operations handle encryption
9
+ * - Upsert operations work correctly
10
+ * - FindMany operations decrypt arrays
11
+ * - Null/undefined/empty values are handled
12
+ * - Database stores encrypted data
13
+ *
14
+ * Database-Agnostic Design:
15
+ * - Uses repository pattern for raw database access (getRawCredentialById)
16
+ * - MongoDB: Uses Mongoose for raw collection access
17
+ * - PostgreSQL: Uses Prisma $queryRaw for raw SQL queries
18
+ * - Field names match Prisma schema (userId, externalId, not user_id/entity_id)
19
+ * - Uses externalId (string) for test data instead of userId (ObjectId reference)
20
+ *
21
+ * Prerequisites:
22
+ * - Database must be running and accessible
23
+ * - For MongoDB: Replica set recommended (for transactions)
24
+ * - For PostgreSQL: Database must exist
25
+ * - Database type configured in backend/index.js app definition
26
+ *
27
+ * Note: Test explicitly passes 'mongodb' to repository factory for testing purposes
28
+ */
29
+
30
+ // Set default DATABASE_URL for testing if not already set
31
+ if (!process.env.DATABASE_URL) {
32
+ process.env.DATABASE_URL = 'mongodb://localhost:27017/frigg?replicaSet=rs0';
33
+ }
34
+
35
+ // Enable encryption for testing (bypass test stage check)
36
+ process.env.STAGE = 'integration-test';
37
+ process.env.AES_KEY_ID = 'test-key-id';
38
+ process.env.AES_KEY = 'test-aes-key-32-characters-long!';
39
+
40
+ jest.mock('../config', () => ({
41
+ DB_TYPE: 'mongodb',
42
+ getDatabaseType: jest.fn(() => 'mongodb'),
43
+ PRISMA_LOG_LEVEL: 'error,warn',
44
+ PRISMA_QUERY_LOGGING: false,
45
+ }));
46
+
47
+ const { prisma, connectPrisma, disconnectPrisma } = require('../prisma');
48
+ const { createHealthCheckRepository } = require('../repositories/health-check-repository-factory');
49
+ const { mongoose } = require('../mongoose');
50
+
51
+ describe('Field-Level Encryption Integration Tests', () => {
52
+ // Use externalId for test identification (works with both MongoDB and PostgreSQL)
53
+ const testExternalId = 'test-encryption-integration-id';
54
+ let repository;
55
+
56
+ beforeAll(async () => {
57
+ await connectPrisma();
58
+ // Connect mongoose for raw database queries
59
+ if (mongoose.connection.readyState === 0) {
60
+ await mongoose.connect(process.env.DATABASE_URL);
61
+ }
62
+ // Create database-specific repository for raw access
63
+ // Pass explicit database type for testing
64
+ repository = createHealthCheckRepository('mongodb');
65
+ });
66
+
67
+ afterAll(async () => {
68
+ // Clean up test data - delete all test credentials by externalId
69
+ await prisma.credential.deleteMany({
70
+ where: { externalId: { startsWith: 'test-encryption-' } },
71
+ });
72
+ await mongoose.disconnect();
73
+ await disconnectPrisma();
74
+ });
75
+
76
+ afterEach(async () => {
77
+ // Clean up after each test
78
+ await prisma.credential.deleteMany({
79
+ where: { externalId: { startsWith: 'test-encryption-' } },
80
+ });
81
+ });
82
+
83
+ describe('Create Operations', () => {
84
+ it('should encrypt sensitive fields on create', async () => {
85
+ const credential = await prisma.credential.create({
86
+ data: {
87
+ externalId: testExternalId,
88
+ data: {
89
+ access_token: 'secret-token-123',
90
+ refresh_token: 'refresh-token-456',
91
+ domain: 'example.com',
92
+ },
93
+ },
94
+ });
95
+
96
+ // Verify decrypted values returned to application
97
+ expect(credential.data.access_token).toBe('secret-token-123');
98
+ expect(credential.data.refresh_token).toBe('refresh-token-456');
99
+ expect(credential.data.domain).toBe('example.com');
100
+
101
+ // Verify raw database has encrypted values
102
+ const rawDoc = await repository.getRawCredentialById(credential.id);
103
+
104
+ expect(rawDoc.data.access_token).not.toBe('secret-token-123');
105
+ expect(rawDoc.data.access_token).toContain(':');
106
+ expect(rawDoc.data.refresh_token).not.toBe('refresh-token-456');
107
+ expect(rawDoc.data.refresh_token).toContain(':');
108
+ // domain should NOT be encrypted (not in schema registry)
109
+ expect(rawDoc.data.domain).toBe('example.com');
110
+ });
111
+
112
+ it('should handle null and undefined values', async () => {
113
+ const credential = await prisma.credential.create({
114
+ data: {
115
+ externalId: testExternalId,
116
+ data: {
117
+ access_token: null,
118
+ domain: 'example.com',
119
+ },
120
+ },
121
+ });
122
+
123
+ expect(credential.data.access_token).toBeNull();
124
+ expect(credential.data.domain).toBe('example.com');
125
+ });
126
+
127
+ it('should handle empty strings', async () => {
128
+ const credential = await prisma.credential.create({
129
+ data: {
130
+ externalId: testExternalId,
131
+ data: {
132
+ access_token: '',
133
+ domain: 'example.com',
134
+ },
135
+ },
136
+ });
137
+
138
+ // Empty strings should not be encrypted
139
+ expect(credential.data.access_token).toBe('');
140
+ expect(credential.data.domain).toBe('example.com');
141
+ });
142
+ });
143
+
144
+ describe('Read Operations', () => {
145
+ it('should decrypt fields on findUnique', async () => {
146
+ // Create with encrypted data
147
+ const created = await prisma.credential.create({
148
+ data: {
149
+ externalId: testExternalId,
150
+ data: {
151
+ access_token: 'secret-find-unique',
152
+ domain: 'findunique.com',
153
+ },
154
+ },
155
+ });
156
+
157
+ // Read back
158
+ const found = await prisma.credential.findUnique({
159
+ where: { id: created.id },
160
+ });
161
+
162
+ expect(found.data.access_token).toBe('secret-find-unique');
163
+ expect(found.data.domain).toBe('findunique.com');
164
+ });
165
+
166
+ it('should decrypt fields on findFirst', async () => {
167
+ await prisma.credential.create({
168
+ data: {
169
+ externalId: testExternalId,
170
+ data: {
171
+ access_token: 'secret-find-first',
172
+ domain: 'findfirst.com',
173
+ },
174
+ },
175
+ });
176
+
177
+ const found = await prisma.credential.findFirst({
178
+ where: { externalId: { startsWith: 'test-encryption-' } },
179
+ });
180
+
181
+ expect(found.data.access_token).toBe('secret-find-first');
182
+ expect(found.data.domain).toBe('findfirst.com');
183
+ });
184
+
185
+ it('should decrypt array of results on findMany', async () => {
186
+ // Create multiple credentials
187
+ await prisma.credential.createMany({
188
+ data: [
189
+ {
190
+ externalId: 'test-encryption-entity-1',
191
+ data: {
192
+ access_token: 'secret-1',
193
+ domain: 'domain1.com',
194
+ },
195
+ },
196
+ {
197
+ externalId: 'test-encryption-entity-2',
198
+ data: {
199
+ access_token: 'secret-2',
200
+ domain: 'domain2.com',
201
+ },
202
+ },
203
+ {
204
+ externalId: 'test-encryption-entity-3',
205
+ data: {
206
+ access_token: 'secret-3',
207
+ domain: 'domain3.com',
208
+ },
209
+ },
210
+ ],
211
+ });
212
+
213
+ const credentials = await prisma.credential.findMany({
214
+ where: { externalId: { startsWith: 'test-encryption-' } },
215
+ });
216
+
217
+ expect(credentials).toHaveLength(3);
218
+ expect(credentials[0].data.access_token).toBe('secret-1');
219
+ expect(credentials[1].data.access_token).toBe('secret-2');
220
+ expect(credentials[2].data.access_token).toBe('secret-3');
221
+ });
222
+
223
+ it('should return null for non-existent records', async () => {
224
+ // Use a valid ObjectId format that doesn't exist in database
225
+ const { ObjectId } = require('mongodb');
226
+ const nonExistentId = new ObjectId().toString();
227
+
228
+ const found = await prisma.credential.findUnique({
229
+ where: { id: nonExistentId },
230
+ });
231
+
232
+ expect(found).toBeNull();
233
+ });
234
+
235
+ it('should return empty array for no matches', async () => {
236
+ const credentials = await prisma.credential.findMany({
237
+ where: { externalId: 'non-existent-external-id' },
238
+ });
239
+
240
+ expect(credentials).toEqual([]);
241
+ });
242
+ });
243
+
244
+ describe('Update Operations', () => {
245
+ it('should encrypt new values on update', async () => {
246
+ // Create
247
+ const created = await prisma.credential.create({
248
+ data: {
249
+ externalId: testExternalId,
250
+ data: {
251
+ access_token: 'old-token',
252
+ domain: 'old.com',
253
+ },
254
+ },
255
+ });
256
+
257
+ // Update
258
+ const updated = await prisma.credential.update({
259
+ where: { id: created.id },
260
+ data: {
261
+ data: {
262
+ access_token: 'new-token',
263
+ domain: 'new.com',
264
+ },
265
+ },
266
+ });
267
+
268
+ // Verify decrypted values
269
+ expect(updated.data.access_token).toBe('new-token');
270
+ expect(updated.data.domain).toBe('new.com');
271
+
272
+ // Verify raw database has new encrypted value
273
+ const rawDoc = await repository.getRawCredentialById(created.id);
274
+
275
+ expect(rawDoc.data.access_token).not.toBe('new-token');
276
+ expect(rawDoc.data.access_token).toContain(':');
277
+ });
278
+
279
+ it('should handle partial updates', async () => {
280
+ const created = await prisma.credential.create({
281
+ data: {
282
+ externalId: testExternalId,
283
+ data: {
284
+ access_token: 'original-token',
285
+ refresh_token: 'original-refresh',
286
+ domain: 'original.com',
287
+ },
288
+ },
289
+ });
290
+
291
+ // Update only access_token
292
+ const updated = await prisma.credential.update({
293
+ where: { id: created.id },
294
+ data: {
295
+ data: {
296
+ ...created.data,
297
+ access_token: 'updated-token',
298
+ },
299
+ },
300
+ });
301
+
302
+ expect(updated.data.access_token).toBe('updated-token');
303
+ expect(updated.data.refresh_token).toBe('original-refresh');
304
+ expect(updated.data.domain).toBe('original.com');
305
+ });
306
+ });
307
+
308
+ describe('Upsert Operations', () => {
309
+ it('should encrypt on insert path', async () => {
310
+ // Use a valid ObjectId format that doesn't exist in database
311
+ const { ObjectId } = require('mongodb');
312
+ const nonExistentId = new ObjectId().toString();
313
+
314
+ const upserted = await prisma.credential.upsert({
315
+ where: {
316
+ id: nonExistentId,
317
+ },
318
+ create: {
319
+ id: nonExistentId,
320
+ externalId: 'test-encryption-upsert-entity',
321
+ data: {
322
+ access_token: 'upsert-create-token',
323
+ domain: 'upsert-create.com',
324
+ },
325
+ },
326
+ update: {
327
+ data: {
328
+ access_token: 'upsert-update-token',
329
+ domain: 'upsert-update.com',
330
+ },
331
+ },
332
+ });
333
+
334
+ expect(upserted.data.access_token).toBe('upsert-create-token');
335
+
336
+ // Verify encryption in database
337
+ const rawDoc = await repository.getRawCredentialById(upserted.id);
338
+
339
+ expect(rawDoc.data.access_token).not.toBe('upsert-create-token');
340
+ expect(rawDoc.data.access_token).toContain(':');
341
+ });
342
+
343
+ it('should encrypt on update path', async () => {
344
+ // Create first
345
+ const created = await prisma.credential.create({
346
+ data: {
347
+ externalId: 'test-encryption-upsert-update-entity',
348
+ data: {
349
+ access_token: 'original-token',
350
+ domain: 'original.com',
351
+ },
352
+ },
353
+ });
354
+
355
+ // Upsert (should hit update path)
356
+ const upserted = await prisma.credential.upsert({
357
+ where: {
358
+ id: created.id,
359
+ },
360
+ create: {
361
+ externalId: 'test-encryption-upsert-update-entity',
362
+ data: {
363
+ access_token: 'create-path-token',
364
+ domain: 'create.com',
365
+ },
366
+ },
367
+ update: {
368
+ data: {
369
+ access_token: 'update-path-token',
370
+ domain: 'update.com',
371
+ },
372
+ },
373
+ });
374
+
375
+ expect(upserted.data.access_token).toBe('update-path-token');
376
+
377
+ // Verify encryption in database
378
+ const rawDoc = await repository.getRawCredentialById(upserted.id);
379
+
380
+ expect(rawDoc.data.access_token).not.toBe('update-path-token');
381
+ expect(rawDoc.data.access_token).toContain(':');
382
+ });
383
+ });
384
+
385
+ describe('Delete Operations', () => {
386
+ it('should decrypt deleted record', async () => {
387
+ const created = await prisma.credential.create({
388
+ data: {
389
+ externalId: testExternalId,
390
+ data: {
391
+ access_token: 'to-be-deleted',
392
+ domain: 'delete.com',
393
+ },
394
+ },
395
+ });
396
+
397
+ const deleted = await prisma.credential.delete({
398
+ where: { id: created.id },
399
+ });
400
+
401
+ expect(deleted.data.access_token).toBe('to-be-deleted');
402
+ expect(deleted.data.domain).toBe('delete.com');
403
+ });
404
+ });
405
+
406
+ describe('CreateMany Operations', () => {
407
+ it('should encrypt fields in bulk create', async () => {
408
+ const result = await prisma.credential.createMany({
409
+ data: [
410
+ {
411
+ externalId: 'test-encryption-bulk-1',
412
+ data: {
413
+ access_token: 'bulk-secret-1',
414
+ domain: 'bulk1.com',
415
+ },
416
+ },
417
+ {
418
+ externalId: 'test-encryption-bulk-2',
419
+ data: {
420
+ access_token: 'bulk-secret-2',
421
+ domain: 'bulk2.com',
422
+ },
423
+ },
424
+ ],
425
+ });
426
+
427
+ expect(result.count).toBe(2);
428
+
429
+ // Verify encryption in database by reading back with Prisma and checking one record's raw form
430
+ const credentials = await prisma.credential.findMany({
431
+ where: { externalId: { startsWith: 'test-encryption-' } },
432
+ });
433
+
434
+ // Check raw database for first credential
435
+ const rawDoc = await repository.getRawCredentialById(credentials[0].id);
436
+ expect(rawDoc.data.access_token).toContain(':');
437
+ expect(rawDoc.data.access_token).not.toMatch(/bulk-secret-/);
438
+
439
+ // Verify decryption when reading
440
+ const tokens = credentials.map((c) => c.data.access_token);
441
+ expect(tokens).toContain('bulk-secret-1');
442
+ expect(tokens).toContain('bulk-secret-2');
443
+ });
444
+ });
445
+
446
+ describe('Non-Encrypted Fields', () => {
447
+ it('should not encrypt fields not in schema registry', async () => {
448
+ const credential = await prisma.credential.create({
449
+ data: {
450
+ externalId: testExternalId,
451
+ data: {
452
+ access_token: 'secret-token',
453
+ domain: 'example.com',
454
+ custom_field: 'should-not-encrypt',
455
+ },
456
+ },
457
+ });
458
+
459
+ // Verify domain is not encrypted (not in schema)
460
+ const rawDoc = await repository.getRawCredentialById(credential.id);
461
+
462
+ expect(rawDoc.data.domain).toBe('example.com');
463
+ expect(rawDoc.data.custom_field).toBe('should-not-encrypt');
464
+
465
+ // access_token should be encrypted (in schema)
466
+ expect(rawDoc.data.access_token).not.toBe('secret-token');
467
+ });
468
+ });
469
+
470
+ describe('Error Handling', () => {
471
+ it('should handle malformed encrypted data gracefully', async () => {
472
+ let created;
473
+ try {
474
+ // Create a credential first to get a valid ID
475
+ created = await prisma.credential.create({
476
+ data: {
477
+ externalId: 'test-encryption-malformed-entity',
478
+ data: {
479
+ access_token: 'valid-token',
480
+ domain: 'malformed.com',
481
+ },
482
+ },
483
+ });
484
+
485
+ // Manually corrupt the encrypted data in the database
486
+ // Use realistic corrupted format: 4 colon-separated parts (passes _isEncrypted check)
487
+ // but contains invalid base64 that will fail during decryption
488
+ const { ObjectId } = require('mongodb');
489
+ const dbType = 'mongodb';
490
+ if (dbType === 'mongodb') {
491
+ const { mongoose } = require('../mongoose');
492
+ // Ensure mongoose is connected
493
+ if (mongoose.connection.readyState !== 1) {
494
+ await mongoose.connect(process.env.DATABASE_URL);
495
+ }
496
+ await mongoose.connection.db.collection('Credential').updateOne(
497
+ { _id: new ObjectId(created.id) },
498
+ { $set: { 'data.access_token': 'CORRUPT:INVALID:DATA:FAKE=' } }
499
+ );
500
+ } else {
501
+ // PostgreSQL - use raw query to corrupt data
502
+ await prisma.$executeRaw`
503
+ UPDATE "Credential"
504
+ SET data = jsonb_set(data, '{access_token}', '"CORRUPT:INVALID:DATA:FAKE="')
505
+ WHERE id = ${created.id}
506
+ `;
507
+ }
508
+
509
+ // Attempt to read should fail with decryption error
510
+ // Fix: Remove async wrapper - expect needs the promise directly for .rejects to work
511
+ await expect(
512
+ prisma.credential.findUnique({
513
+ where: { id: created.id },
514
+ })
515
+ ).rejects.toThrow();
516
+ } finally {
517
+ // Cleanup - ensure it runs even if test throws
518
+ // Use raw database delete to bypass Prisma encryption extension
519
+ // (the encrypted data is corrupted so Prisma delete would fail)
520
+ if (created) {
521
+ const { ObjectId } = require('mongodb');
522
+ const { mongoose } = require('../mongoose');
523
+ await mongoose.connection.db.collection('Credential').deleteOne(
524
+ { _id: new ObjectId(created.id) }
525
+ );
526
+ }
527
+ }
528
+ });
529
+ });
530
+
531
+ describe('Count and Aggregate Operations', () => {
532
+ it('should not interfere with count operations', async () => {
533
+ await prisma.credential.createMany({
534
+ data: [
535
+ {
536
+ externalId: 'test-encryption-count-1',
537
+ data: { access_token: 'token1', domain: 'count1.com' },
538
+ },
539
+ {
540
+ externalId: 'test-encryption-count-2',
541
+ data: { access_token: 'token2', domain: 'count2.com' },
542
+ },
543
+ ],
544
+ });
545
+
546
+ const count = await prisma.credential.count({
547
+ where: { externalId: { startsWith: 'test-encryption-' } },
548
+ });
549
+
550
+ expect(count).toBe(2);
551
+ });
552
+ });
553
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Encryption Schema Registry
3
+ *
4
+ * Centralized registry defining which fields require encryption for each Prisma model.
5
+ * Database-agnostic, works identically for MongoDB and PostgreSQL.
6
+ * Extensible by integration developers via appDefinition.
7
+ *
8
+ * Field path format: 'fieldName' or 'parent.child.field' for nested JSON.
9
+ */
10
+
11
+ const { logger } = require('./logger');
12
+
13
+ /**
14
+ * Core encryption schema (immutable - cannot be overridden by custom schemas)
15
+ */
16
+ const CORE_ENCRYPTION_SCHEMA = {
17
+ Credential: {
18
+ fields: [
19
+ 'data.access_token',
20
+ 'data.refresh_token',
21
+ 'data.id_token',
22
+ ],
23
+ },
24
+
25
+ IntegrationMapping: {
26
+ fields: ['mapping'],
27
+ },
28
+
29
+ User: {
30
+ fields: ['hashword'],
31
+ },
32
+
33
+ Token: {
34
+ fields: ['token'],
35
+ },
36
+ };
37
+
38
+ let customSchema = {};
39
+
40
+ /**
41
+ * Validates a custom encryption schema
42
+ * @returns {{valid: boolean, errors: string[]}}
43
+ */
44
+ function validateCustomSchema(schema) {
45
+ const errors = [];
46
+
47
+ if (!schema || typeof schema !== 'object') {
48
+ errors.push('Custom schema must be an object');
49
+ return { valid: false, errors };
50
+ }
51
+
52
+ for (const [modelName, config] of Object.entries(schema)) {
53
+ if (typeof modelName !== 'string' || !modelName) {
54
+ errors.push(`Invalid model name: ${modelName}`);
55
+ continue;
56
+ }
57
+
58
+ if (!config || typeof config !== 'object') {
59
+ errors.push(`Model "${modelName}" must have a config object`);
60
+ continue;
61
+ }
62
+
63
+ if (!Array.isArray(config.fields)) {
64
+ errors.push(`Model "${modelName}" must have a "fields" array`);
65
+ continue;
66
+ }
67
+
68
+ for (const fieldPath of config.fields) {
69
+ if (typeof fieldPath !== 'string' || !fieldPath) {
70
+ errors.push(`Model "${modelName}" has invalid field path: ${fieldPath}`);
71
+ }
72
+
73
+ // Check if trying to override core fields
74
+ const coreFields = CORE_ENCRYPTION_SCHEMA[modelName]?.fields || [];
75
+ if (coreFields.includes(fieldPath)) {
76
+ errors.push(
77
+ `Cannot override core encrypted field "${fieldPath}" in model "${modelName}"`
78
+ );
79
+ }
80
+ }
81
+ }
82
+
83
+ return {
84
+ valid: errors.length === 0,
85
+ errors,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Registers a custom encryption schema from integration developer.
91
+ * Merges with core schema, prevents overriding core fields.
92
+ * @throws {Error} If schema validation fails
93
+ */
94
+ function registerCustomSchema(schema) {
95
+ if (!schema || Object.keys(schema).length === 0) {
96
+ return; // Nothing to register
97
+ }
98
+
99
+ const validation = validateCustomSchema(schema);
100
+ if (!validation.valid) {
101
+ throw new Error(
102
+ `Invalid custom encryption schema:\n- ${validation.errors.join('\n- ')}`
103
+ );
104
+ }
105
+
106
+ customSchema = { ...schema };
107
+ logger.info(
108
+ `Registered custom encryption schema for models: ${Object.keys(customSchema).join(', ')}`
109
+ );
110
+ }
111
+
112
+ function getEncryptedFields(modelName) {
113
+ const coreFields = CORE_ENCRYPTION_SCHEMA[modelName]?.fields || [];
114
+ const customFields = customSchema[modelName]?.fields || [];
115
+ const allFields = [...coreFields, ...customFields];
116
+ return [...new Set(allFields)];
117
+ }
118
+
119
+ function hasEncryptedFields(modelName) {
120
+ return getEncryptedFields(modelName).length > 0;
121
+ }
122
+
123
+ function getEncryptedModels() {
124
+ const coreModels = Object.keys(CORE_ENCRYPTION_SCHEMA);
125
+ const customModels = Object.keys(customSchema);
126
+ return [...new Set([...coreModels, ...customModels])];
127
+ }
128
+
129
+ function resetCustomSchema() {
130
+ customSchema = {};
131
+ }
132
+
133
+ module.exports = {
134
+ CORE_ENCRYPTION_SCHEMA,
135
+ getEncryptedFields,
136
+ hasEncryptedFields,
137
+ getEncryptedModels,
138
+ registerCustomSchema,
139
+ validateCustomSchema,
140
+ resetCustomSchema, // For testing only
141
+ };