@friggframework/core 2.0.0-next.6 → 2.0.0-next.61

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 (286) hide show
  1. package/CLAUDE.md +694 -0
  2. package/README.md +959 -50
  3. package/application/commands/README.md +451 -0
  4. package/application/commands/credential-commands.js +245 -0
  5. package/application/commands/entity-commands.js +336 -0
  6. package/application/commands/integration-commands.js +210 -0
  7. package/application/commands/user-commands.js +283 -0
  8. package/application/index.js +69 -0
  9. package/core/CLAUDE.md +690 -0
  10. package/core/Worker.js +8 -21
  11. package/core/create-handler.js +14 -7
  12. package/credential/repositories/credential-repository-documentdb.js +304 -0
  13. package/credential/repositories/credential-repository-factory.js +54 -0
  14. package/credential/repositories/credential-repository-interface.js +98 -0
  15. package/credential/repositories/credential-repository-mongo.js +269 -0
  16. package/credential/repositories/credential-repository-postgres.js +291 -0
  17. package/credential/repositories/credential-repository.js +302 -0
  18. package/credential/use-cases/get-credential-for-user.js +25 -0
  19. package/credential/use-cases/update-authentication-status.js +15 -0
  20. package/database/MONGODB_TRANSACTION_FIX.md +198 -0
  21. package/database/adapters/lambda-invoker.js +97 -0
  22. package/database/config.js +154 -0
  23. package/database/documentdb-encryption-service.js +330 -0
  24. package/database/documentdb-utils.js +136 -0
  25. package/database/encryption/README.md +839 -0
  26. package/database/encryption/documentdb-encryption-service.md +3575 -0
  27. package/database/encryption/encryption-schema-registry.js +268 -0
  28. package/database/encryption/field-encryption-service.js +226 -0
  29. package/database/encryption/logger.js +79 -0
  30. package/database/encryption/prisma-encryption-extension.js +222 -0
  31. package/database/index.js +61 -21
  32. package/database/models/WebsocketConnection.js +16 -10
  33. package/database/models/readme.md +1 -0
  34. package/database/prisma.js +182 -0
  35. package/database/repositories/health-check-repository-documentdb.js +134 -0
  36. package/database/repositories/health-check-repository-factory.js +48 -0
  37. package/database/repositories/health-check-repository-interface.js +82 -0
  38. package/database/repositories/health-check-repository-mongodb.js +89 -0
  39. package/database/repositories/health-check-repository-postgres.js +82 -0
  40. package/database/repositories/health-check-repository.js +108 -0
  41. package/database/repositories/migration-status-repository-s3.js +137 -0
  42. package/database/use-cases/check-database-health-use-case.js +29 -0
  43. package/database/use-cases/check-database-state-use-case.js +81 -0
  44. package/database/use-cases/check-encryption-health-use-case.js +83 -0
  45. package/database/use-cases/get-database-state-via-worker-use-case.js +61 -0
  46. package/database/use-cases/get-migration-status-use-case.js +93 -0
  47. package/database/use-cases/run-database-migration-use-case.js +139 -0
  48. package/database/use-cases/test-encryption-use-case.js +253 -0
  49. package/database/use-cases/trigger-database-migration-use-case.js +157 -0
  50. package/database/utils/mongodb-collection-utils.js +91 -0
  51. package/database/utils/mongodb-schema-init.js +106 -0
  52. package/database/utils/prisma-runner.js +477 -0
  53. package/database/utils/prisma-schema-parser.js +182 -0
  54. package/docs/PROCESS_MANAGEMENT_QUEUE_SPEC.md +517 -0
  55. package/encrypt/Cryptor.js +34 -168
  56. package/encrypt/index.js +1 -2
  57. package/encrypt/test-encrypt.js +0 -2
  58. package/errors/client-safe-error.js +26 -0
  59. package/errors/fetch-error.js +2 -1
  60. package/errors/index.js +2 -0
  61. package/generated/prisma-mongodb/client.d.ts +1 -0
  62. package/generated/prisma-mongodb/client.js +4 -0
  63. package/generated/prisma-mongodb/default.d.ts +1 -0
  64. package/generated/prisma-mongodb/default.js +4 -0
  65. package/generated/prisma-mongodb/edge.d.ts +1 -0
  66. package/generated/prisma-mongodb/edge.js +334 -0
  67. package/generated/prisma-mongodb/index-browser.js +316 -0
  68. package/generated/prisma-mongodb/index.d.ts +22903 -0
  69. package/generated/prisma-mongodb/index.js +359 -0
  70. package/generated/prisma-mongodb/package.json +183 -0
  71. package/generated/prisma-mongodb/query-engine-debian-openssl-3.0.x +0 -0
  72. package/generated/prisma-mongodb/query-engine-rhel-openssl-3.0.x +0 -0
  73. package/generated/prisma-mongodb/runtime/binary.d.ts +1 -0
  74. package/generated/prisma-mongodb/runtime/binary.js +289 -0
  75. package/generated/prisma-mongodb/runtime/edge-esm.js +34 -0
  76. package/generated/prisma-mongodb/runtime/edge.js +34 -0
  77. package/generated/prisma-mongodb/runtime/index-browser.d.ts +370 -0
  78. package/generated/prisma-mongodb/runtime/index-browser.js +16 -0
  79. package/generated/prisma-mongodb/runtime/library.d.ts +3977 -0
  80. package/generated/prisma-mongodb/runtime/react-native.js +83 -0
  81. package/generated/prisma-mongodb/runtime/wasm-compiler-edge.js +84 -0
  82. package/generated/prisma-mongodb/runtime/wasm-engine-edge.js +36 -0
  83. package/generated/prisma-mongodb/schema.prisma +360 -0
  84. package/generated/prisma-mongodb/wasm-edge-light-loader.mjs +4 -0
  85. package/generated/prisma-mongodb/wasm-worker-loader.mjs +4 -0
  86. package/generated/prisma-mongodb/wasm.d.ts +1 -0
  87. package/generated/prisma-mongodb/wasm.js +341 -0
  88. package/generated/prisma-postgresql/client.d.ts +1 -0
  89. package/generated/prisma-postgresql/client.js +4 -0
  90. package/generated/prisma-postgresql/default.d.ts +1 -0
  91. package/generated/prisma-postgresql/default.js +4 -0
  92. package/generated/prisma-postgresql/edge.d.ts +1 -0
  93. package/generated/prisma-postgresql/edge.js +356 -0
  94. package/generated/prisma-postgresql/index-browser.js +338 -0
  95. package/generated/prisma-postgresql/index.d.ts +25077 -0
  96. package/generated/prisma-postgresql/index.js +381 -0
  97. package/generated/prisma-postgresql/package.json +183 -0
  98. package/generated/prisma-postgresql/query-engine-debian-openssl-3.0.x +0 -0
  99. package/generated/prisma-postgresql/query-engine-rhel-openssl-3.0.x +0 -0
  100. package/generated/prisma-postgresql/query_engine_bg.js +2 -0
  101. package/generated/prisma-postgresql/query_engine_bg.wasm +0 -0
  102. package/generated/prisma-postgresql/runtime/binary.d.ts +1 -0
  103. package/generated/prisma-postgresql/runtime/binary.js +289 -0
  104. package/generated/prisma-postgresql/runtime/edge-esm.js +34 -0
  105. package/generated/prisma-postgresql/runtime/edge.js +34 -0
  106. package/generated/prisma-postgresql/runtime/index-browser.d.ts +370 -0
  107. package/generated/prisma-postgresql/runtime/index-browser.js +16 -0
  108. package/generated/prisma-postgresql/runtime/library.d.ts +3977 -0
  109. package/generated/prisma-postgresql/runtime/react-native.js +83 -0
  110. package/generated/prisma-postgresql/runtime/wasm-compiler-edge.js +84 -0
  111. package/generated/prisma-postgresql/runtime/wasm-engine-edge.js +36 -0
  112. package/generated/prisma-postgresql/schema.prisma +343 -0
  113. package/generated/prisma-postgresql/wasm-edge-light-loader.mjs +4 -0
  114. package/generated/prisma-postgresql/wasm-worker-loader.mjs +4 -0
  115. package/generated/prisma-postgresql/wasm.d.ts +1 -0
  116. package/generated/prisma-postgresql/wasm.js +363 -0
  117. package/handlers/WEBHOOKS.md +653 -0
  118. package/handlers/app-definition-loader.js +38 -0
  119. package/handlers/app-handler-helpers.js +56 -0
  120. package/handlers/backend-utils.js +186 -0
  121. package/handlers/database-migration-handler.js +227 -0
  122. package/handlers/integration-event-dispatcher.js +54 -0
  123. package/handlers/routers/HEALTHCHECK.md +342 -0
  124. package/handlers/routers/auth.js +15 -0
  125. package/handlers/routers/db-migration.handler.js +29 -0
  126. package/handlers/routers/db-migration.js +326 -0
  127. package/handlers/routers/health.js +516 -0
  128. package/handlers/routers/integration-defined-routers.js +45 -0
  129. package/handlers/routers/integration-webhook-routers.js +67 -0
  130. package/handlers/routers/user.js +63 -0
  131. package/handlers/routers/websocket.js +57 -0
  132. package/handlers/use-cases/check-external-apis-health-use-case.js +81 -0
  133. package/handlers/use-cases/check-integrations-health-use-case.js +44 -0
  134. package/handlers/workers/db-migration.js +352 -0
  135. package/handlers/workers/integration-defined-workers.js +27 -0
  136. package/index.js +77 -22
  137. package/integrations/WEBHOOK-QUICKSTART.md +151 -0
  138. package/integrations/index.js +12 -10
  139. package/integrations/integration-base.js +326 -55
  140. package/integrations/integration-router.js +374 -179
  141. package/integrations/options.js +1 -1
  142. package/integrations/repositories/integration-mapping-repository-documentdb.js +280 -0
  143. package/integrations/repositories/integration-mapping-repository-factory.js +57 -0
  144. package/integrations/repositories/integration-mapping-repository-interface.js +106 -0
  145. package/integrations/repositories/integration-mapping-repository-mongo.js +161 -0
  146. package/integrations/repositories/integration-mapping-repository-postgres.js +227 -0
  147. package/integrations/repositories/integration-mapping-repository.js +156 -0
  148. package/integrations/repositories/integration-repository-documentdb.js +210 -0
  149. package/integrations/repositories/integration-repository-factory.js +51 -0
  150. package/integrations/repositories/integration-repository-interface.js +127 -0
  151. package/integrations/repositories/integration-repository-mongo.js +303 -0
  152. package/integrations/repositories/integration-repository-postgres.js +352 -0
  153. package/integrations/repositories/process-repository-documentdb.js +243 -0
  154. package/integrations/repositories/process-repository-factory.js +53 -0
  155. package/integrations/repositories/process-repository-interface.js +90 -0
  156. package/integrations/repositories/process-repository-mongo.js +190 -0
  157. package/integrations/repositories/process-repository-postgres.js +217 -0
  158. package/integrations/tests/doubles/config-capturing-integration.js +81 -0
  159. package/integrations/tests/doubles/dummy-integration-class.js +105 -0
  160. package/integrations/tests/doubles/test-integration-repository.js +99 -0
  161. package/integrations/use-cases/create-integration.js +83 -0
  162. package/integrations/use-cases/create-process.js +128 -0
  163. package/integrations/use-cases/delete-integration-for-user.js +101 -0
  164. package/integrations/use-cases/find-integration-context-by-external-entity-id.js +72 -0
  165. package/integrations/use-cases/get-integration-for-user.js +78 -0
  166. package/integrations/use-cases/get-integration-instance-by-definition.js +67 -0
  167. package/integrations/use-cases/get-integration-instance.js +83 -0
  168. package/integrations/use-cases/get-integrations-for-user.js +88 -0
  169. package/integrations/use-cases/get-possible-integrations.js +27 -0
  170. package/integrations/use-cases/get-process.js +87 -0
  171. package/integrations/use-cases/index.js +19 -0
  172. package/integrations/use-cases/load-integration-context.js +71 -0
  173. package/integrations/use-cases/update-integration-messages.js +44 -0
  174. package/integrations/use-cases/update-integration-status.js +32 -0
  175. package/integrations/use-cases/update-integration.js +92 -0
  176. package/integrations/use-cases/update-process-metrics.js +201 -0
  177. package/integrations/use-cases/update-process-state.js +119 -0
  178. package/integrations/utils/map-integration-dto.js +37 -0
  179. package/jest-global-setup-noop.js +3 -0
  180. package/jest-global-teardown-noop.js +3 -0
  181. package/logs/logger.js +0 -4
  182. package/{module-plugin → modules}/entity.js +1 -1
  183. package/{module-plugin → modules}/index.js +0 -8
  184. package/modules/module-factory.js +56 -0
  185. package/modules/module.js +221 -0
  186. package/modules/repositories/module-repository-documentdb.js +307 -0
  187. package/modules/repositories/module-repository-factory.js +40 -0
  188. package/modules/repositories/module-repository-interface.js +129 -0
  189. package/modules/repositories/module-repository-mongo.js +377 -0
  190. package/modules/repositories/module-repository-postgres.js +426 -0
  191. package/modules/repositories/module-repository.js +316 -0
  192. package/modules/requester/api-key.js +52 -0
  193. package/{module-plugin → modules}/requester/requester.js +1 -0
  194. package/{module-plugin → modules}/test/mock-api/api.js +8 -3
  195. package/{module-plugin → modules}/test/mock-api/definition.js +12 -8
  196. package/modules/tests/doubles/test-module-factory.js +16 -0
  197. package/modules/tests/doubles/test-module-repository.js +39 -0
  198. package/modules/use-cases/get-entities-for-user.js +32 -0
  199. package/modules/use-cases/get-entity-options-by-id.js +71 -0
  200. package/modules/use-cases/get-entity-options-by-type.js +34 -0
  201. package/modules/use-cases/get-module-instance-from-type.js +31 -0
  202. package/modules/use-cases/get-module.js +74 -0
  203. package/modules/use-cases/process-authorization-callback.js +133 -0
  204. package/modules/use-cases/refresh-entity-options.js +72 -0
  205. package/modules/use-cases/test-module-auth.js +72 -0
  206. package/modules/utils/map-module-dto.js +18 -0
  207. package/package.json +82 -50
  208. package/prisma-mongodb/schema.prisma +360 -0
  209. package/prisma-postgresql/migrations/20250930193005_init/migration.sql +315 -0
  210. package/prisma-postgresql/migrations/20251006135218_init/migration.sql +9 -0
  211. package/prisma-postgresql/migrations/20251010000000_remove_unused_entity_reference_map/migration.sql +3 -0
  212. package/prisma-postgresql/migrations/20251112195422_update_user_unique_constraints/migration.sql +25 -0
  213. package/prisma-postgresql/migrations/migration_lock.toml +3 -0
  214. package/prisma-postgresql/schema.prisma +343 -0
  215. package/queues/queuer-util.js +27 -22
  216. package/syncs/manager.js +468 -443
  217. package/syncs/repositories/sync-repository-documentdb.js +240 -0
  218. package/syncs/repositories/sync-repository-factory.js +43 -0
  219. package/syncs/repositories/sync-repository-interface.js +109 -0
  220. package/syncs/repositories/sync-repository-mongo.js +239 -0
  221. package/syncs/repositories/sync-repository-postgres.js +319 -0
  222. package/syncs/sync.js +0 -1
  223. package/token/repositories/token-repository-documentdb.js +137 -0
  224. package/token/repositories/token-repository-factory.js +40 -0
  225. package/token/repositories/token-repository-interface.js +131 -0
  226. package/token/repositories/token-repository-mongo.js +219 -0
  227. package/token/repositories/token-repository-postgres.js +264 -0
  228. package/token/repositories/token-repository.js +219 -0
  229. package/types/core/index.d.ts +2 -2
  230. package/types/integrations/index.d.ts +2 -6
  231. package/types/module-plugin/index.d.ts +5 -59
  232. package/types/syncs/index.d.ts +0 -2
  233. package/user/repositories/user-repository-documentdb.js +441 -0
  234. package/user/repositories/user-repository-factory.js +52 -0
  235. package/user/repositories/user-repository-interface.js +201 -0
  236. package/user/repositories/user-repository-mongo.js +308 -0
  237. package/user/repositories/user-repository-postgres.js +360 -0
  238. package/user/tests/doubles/test-user-repository.js +72 -0
  239. package/user/use-cases/authenticate-user.js +127 -0
  240. package/user/use-cases/authenticate-with-shared-secret.js +48 -0
  241. package/user/use-cases/create-individual-user.js +61 -0
  242. package/user/use-cases/create-organization-user.js +47 -0
  243. package/user/use-cases/create-token-for-user-id.js +30 -0
  244. package/user/use-cases/get-user-from-adopter-jwt.js +149 -0
  245. package/user/use-cases/get-user-from-bearer-token.js +77 -0
  246. package/user/use-cases/get-user-from-x-frigg-headers.js +132 -0
  247. package/user/use-cases/login-user.js +122 -0
  248. package/user/user.js +125 -0
  249. package/utils/backend-path.js +38 -0
  250. package/utils/index.js +6 -0
  251. package/websocket/repositories/websocket-connection-repository-documentdb.js +119 -0
  252. package/websocket/repositories/websocket-connection-repository-factory.js +44 -0
  253. package/websocket/repositories/websocket-connection-repository-interface.js +106 -0
  254. package/websocket/repositories/websocket-connection-repository-mongo.js +156 -0
  255. package/websocket/repositories/websocket-connection-repository-postgres.js +196 -0
  256. package/websocket/repositories/websocket-connection-repository.js +161 -0
  257. package/database/models/State.js +0 -9
  258. package/database/models/Token.js +0 -70
  259. package/database/mongo.js +0 -45
  260. package/encrypt/Cryptor.test.js +0 -32
  261. package/encrypt/encrypt.js +0 -132
  262. package/encrypt/encrypt.test.js +0 -1069
  263. package/errors/base-error.test.js +0 -32
  264. package/errors/fetch-error.test.js +0 -79
  265. package/errors/halt-error.test.js +0 -11
  266. package/errors/validation-errors.test.js +0 -120
  267. package/integrations/create-frigg-backend.js +0 -31
  268. package/integrations/integration-factory.js +0 -251
  269. package/integrations/integration-mapping.js +0 -43
  270. package/integrations/integration-model.js +0 -46
  271. package/integrations/integration-user.js +0 -144
  272. package/integrations/test/integration-base.test.js +0 -144
  273. package/lambda/TimeoutCatcher.test.js +0 -68
  274. package/logs/logger.test.js +0 -76
  275. package/module-plugin/auther.js +0 -393
  276. package/module-plugin/credential.js +0 -22
  277. package/module-plugin/entity-manager.js +0 -70
  278. package/module-plugin/manager.js +0 -169
  279. package/module-plugin/module-factory.js +0 -61
  280. package/module-plugin/requester/api-key.js +0 -36
  281. package/module-plugin/requester/requester.test.js +0 -28
  282. package/module-plugin/test/auther.test.js +0 -97
  283. /package/{module-plugin → modules}/ModuleConstants.js +0 -0
  284. /package/{module-plugin → modules}/requester/basic.js +0 -0
  285. /package/{module-plugin → modules}/requester/oauth-2.js +0 -0
  286. /package/{module-plugin → modules}/test/mock-api/mocks/hubspot.js +0 -0
@@ -0,0 +1,3575 @@
1
+ # DocumentDB Encryption Service Implementation Guide
2
+
3
+ **Status**: 🔴 **CRITICAL** - Security Vulnerability
4
+ **Priority**: P0 - Immediate Action Required
5
+ **Created**: 2025-01-13
6
+ **Last Updated**: 2025-01-13
7
+
8
+ ---
9
+
10
+ ## Table of Contents
11
+
12
+ 1. [Executive Summary](#executive-summary)
13
+ 2. [Problem Statement](#problem-statement)
14
+ 3. [Architecture & Design](#architecture--design)
15
+ 4. [Technical Specification](#technical-specification)
16
+ 5. [Implementation Plan](#implementation-plan)
17
+ 6. [Code Examples](#code-examples)
18
+ 7. [Testing Strategy](#testing-strategy)
19
+ 8. [Migration Guide](#migration-guide)
20
+ 9. [Security Considerations](#security-considerations)
21
+ 10. [Maintenance & Future Work](#maintenance--future-work)
22
+ 11. [References](#references)
23
+
24
+ ---
25
+
26
+ ## Executive Summary
27
+
28
+ ### The Problem
29
+
30
+ DocumentDB repositories use `$runCommandRaw()` for MongoDB protocol compatibility, which **bypasses Prisma Client Extensions**, including the encryption extension. This results in a **critical security vulnerability** where:
31
+
32
+ - ✅ **MongoDB/PostgreSQL**: Automatic encryption via Prisma Extension
33
+ - ❌ **DocumentDB**: OAuth credentials stored in **plain text**
34
+
35
+ ### The Solution
36
+
37
+ Create `DocumentDBEncryptionService` - a centralized encryption service specifically designed for DocumentDB repositories that:
38
+
39
+ - Provides document-level encryption/decryption
40
+ - Handles nested field paths (e.g., `data.access_token`)
41
+ - Uses the same Cryptor and schema registry as Prisma Extension
42
+ - Maintains consistency with existing encryption architecture
43
+
44
+ ### Impact
45
+
46
+ - **Security**: OAuth credentials encrypted at rest in DocumentDB
47
+ - **Architecture**: DRY principle - single source of encryption logic
48
+ - **Consistency**: All DocumentDB repos use same encryption pattern
49
+ - **Compliance**: Meets production encryption requirements
50
+
51
+ ---
52
+
53
+ ## Problem Statement
54
+
55
+ ### Current Architecture (MongoDB/PostgreSQL)
56
+
57
+ ```
58
+ Application Code (Use Cases)
59
+ ↓ works with plain data
60
+ Repositories
61
+ ↓ uses Prisma queries
62
+ Prisma Client + Extension (AUTOMATIC ENCRYPTION)
63
+ ↓ intercepts all queries
64
+ FieldEncryptionService
65
+ ↓ encrypts/decrypts per field
66
+ Cryptor (KMS or AES)
67
+
68
+ Database (encrypted storage)
69
+ ```
70
+
71
+ **How it works**:
72
+
73
+ ```javascript
74
+ // MongoDB Repository - Automatic encryption
75
+ await prisma.credential.create({
76
+ data: {
77
+ access_token: 'plain_secret', // ← Plain text in
78
+ },
79
+ });
80
+ // → Prisma Extension intercepts
81
+ // → FieldEncryptionService.encryptField() called
82
+ // → Stored as "keyId:iv:cipher:encKey" in database
83
+
84
+ const cred = await prisma.credential.findFirst({ where: { id } });
85
+ // ← Database returns "keyId:iv:cipher:encKey"
86
+ // ← Prisma Extension intercepts
87
+ // ← FieldEncryptionService.decryptField() called
88
+ // ← Application receives { access_token: "plain_secret" }
89
+ ```
90
+
91
+ ### DocumentDB Architecture (Current - BROKEN)
92
+
93
+ ```
94
+ Application Code (Use Cases)
95
+ ↓ works with plain data
96
+ DocumentDB Repositories
97
+ ↓ uses $runCommandRaw
98
+ Prisma Client (NO EXTENSION INTERCEPTION)
99
+ ↓ raw command bypasses all extensions
100
+ Database (PLAIN TEXT STORAGE) ⚠️ SECURITY VULNERABILITY
101
+ ```
102
+
103
+ **Why it's broken**:
104
+
105
+ ```javascript
106
+ // DocumentDB Repository - NO encryption
107
+ const oauthData = {
108
+ access_token: 'ya29.actual_google_token', // Plain text!
109
+ refresh_token: '1//0secret_refresh_token', // Plain text!
110
+ };
111
+
112
+ await prisma.$runCommandRaw({
113
+ insert: 'Credential',
114
+ documents: [{ data: oauthData }],
115
+ });
116
+ // ❌ Prisma Extension NEVER sees this command
117
+ // ❌ FieldEncryptionService NEVER invoked
118
+ // ❌ Stored in database as PLAIN TEXT
119
+ ```
120
+
121
+ ### Root Cause
122
+
123
+ From Prisma documentation:
124
+
125
+ > "$runCommandRaw is a low-level database access method. Prisma Client extensions do not apply to raw database access."
126
+
127
+ **Why DocumentDB needs raw commands**:
128
+
129
+ - DocumentDB has MongoDB compatibility limitations
130
+ - Certain Prisma features don't work (transactions, some aggregations)
131
+ - Raw commands provide direct MongoDB protocol access
132
+
133
+ ### Current Repository Status
134
+
135
+ | Repository | Encryption Status | Security Risk |
136
+ | ----------------------------------- | ------------------------------------------------------ | -------------------------------------------- |
137
+ | **UserRepositoryDocumentDB** | ✅ Has manual encryption for `hashword` | Low - passwords protected |
138
+ | **ModuleRepositoryDocumentDB** | ⚠️ Has manual decryption for reads only | Medium - assumes credentials encrypted |
139
+ | **CredentialRepositoryDocumentDB** | ❌ **NO encryption on writes, NO decryption on reads** | 🔴 **CRITICAL - OAuth tokens in plain text** |
140
+ | **IntegrationRepositoryDocumentDB** | ✅ No encrypted fields, OK | None |
141
+
142
+ ---
143
+
144
+ ## Architecture & Design
145
+
146
+ ### Comparison: FieldEncryptionService vs DocumentDBEncryptionService
147
+
148
+ | Aspect | FieldEncryptionService | DocumentDBEncryptionService |
149
+ | -------------------- | ---------------------------------------------- | ---------------------------------------- |
150
+ | **Purpose** | Encrypt individual fields for Prisma Extension | Encrypt entire documents for raw queries |
151
+ | **Invocation** | Automatic (Prisma intercepts queries) | Manual (repository calls explicitly) |
152
+ | **Scope** | Single field at a time | Entire document with multiple fields |
153
+ | **Nested Fields** | Handled by Prisma Extension traversal | Must manually traverse field paths |
154
+ | **Integration** | Via Prisma Client Extension | Direct import in repositories |
155
+ | **Query Types** | `create()`, `update()`, `findFirst()`, etc. | `$runCommandRaw()`, via documentdb-utils |
156
+ | **Database Support** | MongoDB, PostgreSQL (via Prisma) | DocumentDB (raw MongoDB protocol) |
157
+ | **Schema Registry** | Used by Prisma Extension | Directly queries registry |
158
+ | **Error Handling** | Prisma transaction rollback | Must handle in repository |
159
+ | **Testing** | Integration tests with Prisma | Unit tests + repository tests |
160
+
161
+ ### Proposed Architecture (DocumentDB - FIXED)
162
+
163
+ ```
164
+ Application Code (Use Cases)
165
+ ↓ works with plain data
166
+ DocumentDB Repositories
167
+ ↓ MANUALLY calls encryptFields()/decryptFields()
168
+ DocumentDBEncryptionService
169
+ ↓ traverses field paths based on schema registry
170
+ ↓ encrypts/decrypts each field
171
+ Cryptor (KMS or AES)
172
+
173
+ Database (ENCRYPTED STORAGE) ✅ SECURE
174
+ ```
175
+
176
+ ### Architecture Flow Diagram
177
+
178
+ ```
179
+ ┌─────────────────────────────────────────────────────────────────┐
180
+ │ Application Layer (Use Cases) │
181
+ │ - Works with plain text data │
182
+ │ - Never sees encrypted values │
183
+ └──────────────────┬──────────────────────────────────────────────┘
184
+
185
+ ┌────────────┴──────────────┐
186
+ │ │
187
+ ▼ MongoDB/PostgreSQL ▼ DocumentDB
188
+ ┌─────────────────────┐ ┌──────────────────────────┐
189
+ │ Repository │ │ Repository │
190
+ │ (plain text) │ │ (plain text) │
191
+ └──────┬──────────────┘ └───┬──────────────────────┘
192
+ │ │ Manually calls
193
+ │ Uses Prisma queries │ encryptFields()/
194
+ ▼ │ decryptFields()
195
+ ┌─────────────────────┐ ▼
196
+ │ Prisma Client │ ┌──────────────────────────────┐
197
+ │ + Extension │ │ DocumentDBEncryptionService │
198
+ │ (automatic) │ │ - Traverses field paths │
199
+ └──────┬──────────────┘ │ - Calls Cryptor per field │
200
+ │ Intercepts └───┬──────────────────────────┘
201
+ │ queries │
202
+ ▼ │
203
+ ┌─────────────────────┐ │
204
+ │ FieldEncryptionSvc │◄───────┘ Both use Cryptor
205
+ │ - Per-field logic │
206
+ └──────┬──────────────┘
207
+
208
+
209
+ ┌─────────────────────────────────────┐
210
+ │ Cryptor (AWS KMS or AES) │
211
+ │ - Envelope encryption │
212
+ │ - Returns: "keyId:iv:cipher:encKey"│
213
+ └──────┬──────────────────────────────┘
214
+
215
+
216
+ ┌─────────────────────────────────────┐
217
+ │ Database (MongoDB/PostgreSQL/ │
218
+ │ DocumentDB) │
219
+ │ - Stores encrypted strings │
220
+ └─────────────────────────────────────┘
221
+ ```
222
+
223
+ ### Design Principles
224
+
225
+ 1. **Consistency**: Same encryption format and Cryptor as Prisma Extension
226
+ 2. **Reusability**: Single service used by all DocumentDB repositories
227
+ 3. **Schema-Driven**: Uses `encryption-schema-registry.js` (same as Prisma)
228
+ 4. **Environment-Aware**: Respects STAGE-based bypass (dev/test/local)
229
+ 5. **Error-Tolerant**: Graceful handling of decryption failures
230
+ 6. **Testable**: Can be unit tested independently of repositories
231
+
232
+ ---
233
+
234
+ ## Technical Specification
235
+
236
+ ### Class Design
237
+
238
+ ```javascript
239
+ /**
240
+ * Encryption service specifically for DocumentDB repositories
241
+ * that use $runCommandRaw and bypass Prisma Extensions.
242
+ *
243
+ * Provides document-level encryption/decryption,
244
+ * handling nested fields according to the encryption schema registry.
245
+ */
246
+ class DocumentDBEncryptionService {
247
+ constructor()
248
+ _initializeCryptor()
249
+ async encryptFields(modelName, document)
250
+ async decryptFields(modelName, document)
251
+ async _encryptFieldPath(document, fieldPath, modelName)
252
+ async _decryptFieldPath(document, fieldPath, modelName)
253
+ _isEncryptedValue(value)
254
+ }
255
+ ```
256
+
257
+ ### Method Specifications
258
+
259
+ #### `constructor()`
260
+
261
+ **Purpose**: Initialize the service and configure Cryptor
262
+
263
+ **Behavior**:
264
+
265
+ - Calls `_initializeCryptor()` immediately
266
+ - Sets up `this.cryptor` and `this.enabled` properties
267
+
268
+ **No parameters**
269
+
270
+ ---
271
+
272
+ #### `_initializeCryptor()`
273
+
274
+ **Purpose**: Initialize Cryptor with environment-based configuration
275
+
276
+ **Logic**:
277
+
278
+ ```javascript
279
+ 1. Get STAGE from environment (default: 'development')
280
+ 2. If STAGE in ['dev', 'test', 'local']:
281
+ - Set this.cryptor = null
282
+ - Set this.enabled = false
283
+ - Return (bypass encryption)
284
+ 3. Check for KMS_KEY_ARN environment variable
285
+ 4. Check for AES_KEY_ID environment variable
286
+ 5. If neither present:
287
+ - Warn "No encryption keys configured"
288
+ - Set this.cryptor = null
289
+ - Set this.enabled = false
290
+ - Return
291
+ 6. Create Cryptor({ shouldUseAws: hasKMS })
292
+ 7. Set this.enabled = true
293
+ ```
294
+
295
+ **Environment Variables Used**:
296
+
297
+ - `STAGE` or `NODE_ENV`: Determines bypass behavior
298
+ - `KMS_KEY_ARN`: AWS KMS key ARN (enables KMS encryption)
299
+ - `AES_KEY_ID`: AES key identifier (enables AES encryption)
300
+ - `AES_KEY`: AES encryption key (required if AES_KEY_ID present)
301
+
302
+ **Matches**: Logic from `packages/core/database/prisma.js` lines 76-96
303
+
304
+ ---
305
+
306
+ #### `async encryptFields(modelName, document)`
307
+
308
+ **Purpose**: Encrypt fields in a document before storing to DocumentDB
309
+
310
+ **Parameters**:
311
+
312
+ - `modelName` (string): Model name from schema registry (e.g., 'User', 'Credential')
313
+ - `document` (Object): Document to encrypt
314
+
315
+ **Returns**: `Promise<Object>` - Document with encrypted fields
316
+
317
+ **Algorithm**:
318
+
319
+ ```javascript
320
+ 1. If !this.enabled or !this.cryptor:
321
+ - Return document unchanged (bypass)
322
+ 2. If !document or typeof document !== 'object':
323
+ - Return document unchanged (invalid input)
324
+ 3. Get encrypted fields config from registry:
325
+ - encryptedFieldsConfig = getEncryptedFields(modelName)
326
+ 4. If no config or no fields defined:
327
+ - Return document unchanged (no encryption needed)
328
+ 5. Create shallow copy: result = { ...document }
329
+ 6. For each fieldPath in encryptedFieldsConfig.fields:
330
+ - await this._encryptFieldPath(result, fieldPath, modelName)
331
+ 7. Return result
332
+ ```
333
+
334
+ **Error Handling**:
335
+
336
+ - Invalid inputs: Return unchanged
337
+ - Encryption errors: Propagate to caller (repository must handle)
338
+
339
+ **Example**:
340
+
341
+ ```javascript
342
+ const plainDoc = {
343
+ userId: '123',
344
+ data: {
345
+ access_token: 'plain_secret',
346
+ refresh_token: 'plain_refresh',
347
+ },
348
+ };
349
+
350
+ const encrypted = await service.encryptFields('Credential', plainDoc);
351
+ // encrypted.data.access_token = "aes-key-1:iv:cipher:enckey"
352
+ // encrypted.data.refresh_token = "aes-key-1:iv:cipher:enckey"
353
+ ```
354
+
355
+ ---
356
+
357
+ #### `async decryptFields(modelName, document)`
358
+
359
+ **Purpose**: Decrypt fields in a document after reading from DocumentDB
360
+
361
+ **Parameters**:
362
+
363
+ - `modelName` (string): Model name from schema registry
364
+ - `document` (Object): Document to decrypt
365
+
366
+ **Returns**: `Promise<Object>` - Document with decrypted fields
367
+
368
+ **Algorithm**:
369
+
370
+ ```javascript
371
+ 1. If !this.enabled or !this.cryptor:
372
+ - Return document unchanged (bypass)
373
+ 2. If !document or typeof document !== 'object':
374
+ - Return document unchanged (invalid input)
375
+ 3. Get encrypted fields config from registry:
376
+ - encryptedFieldsConfig = getEncryptedFields(modelName)
377
+ 4. If no config or no fields defined:
378
+ - Return document unchanged (no decryption needed)
379
+ 5. Create shallow copy: result = { ...document }
380
+ 6. For each fieldPath in encryptedFieldsConfig.fields:
381
+ - await this._decryptFieldPath(result, fieldPath, modelName)
382
+ 7. Return result
383
+ ```
384
+
385
+ **Error Handling**:
386
+
387
+ - Decryption failures: Set field to null (don't expose encrypted data)
388
+ - Log error with context
389
+
390
+ **Example**:
391
+
392
+ ```javascript
393
+ const encryptedDoc = {
394
+ userId: '123',
395
+ data: {
396
+ access_token: 'aes-key-1:iv:cipher:enckey',
397
+ refresh_token: 'aes-key-1:iv:cipher:enckey',
398
+ },
399
+ };
400
+
401
+ const decrypted = await service.decryptFields('Credential', encryptedDoc);
402
+ // decrypted.data.access_token = "plain_secret"
403
+ // decrypted.data.refresh_token = "plain_refresh"
404
+ ```
405
+
406
+ ---
407
+
408
+ #### `async _encryptFieldPath(document, fieldPath, modelName)`
409
+
410
+ **Purpose**: Encrypt a specific field path in a document (handles nested fields)
411
+
412
+ **Parameters**:
413
+
414
+ - `document` (Object): Document to modify (mutated in place)
415
+ - `fieldPath` (string): Field path from schema registry (e.g., 'data.access_token')
416
+ - `modelName` (string): For error logging context
417
+
418
+ **Algorithm**:
419
+
420
+ ```javascript
421
+ 1. Split fieldPath by '.': parts = fieldPath.split('.')
422
+ 2. Navigate to parent object:
423
+ - current = document
424
+ - For i from 0 to parts.length - 2:
425
+ - If !current[parts[i]]: return (path doesn't exist)
426
+ - current = current[parts[i]]
427
+ 3. Get field name: fieldName = parts[parts.length - 1]
428
+ 4. Get value: value = current[fieldName]
429
+ 5. Skip if already encrypted or empty:
430
+ - If !value or this._isEncryptedValue(value): return
431
+ 6. Convert to string if needed:
432
+ - stringValue = (typeof value === 'string') ? value : JSON.stringify(value)
433
+ 7. Encrypt using Cryptor:
434
+ - current[fieldName] = await this.cryptor.encrypt(stringValue)
435
+ 8. Catch errors:
436
+ - Log: "Failed to encrypt {modelName}.{fieldPath}: {error}"
437
+ - Throw error (repository must handle)
438
+ ```
439
+
440
+ **Example Field Paths**:
441
+
442
+ - `hashword` → Encrypts `document.hashword`
443
+ - `data.access_token` → Encrypts `document.data.access_token`
444
+ - `data.refresh_token` → Encrypts `document.data.refresh_token`
445
+
446
+ ---
447
+
448
+ #### `async _decryptFieldPath(document, fieldPath, modelName)`
449
+
450
+ **Purpose**: Decrypt a specific field path in a document
451
+
452
+ **Parameters**:
453
+
454
+ - `document` (Object): Document to modify (mutated in place)
455
+ - `fieldPath` (string): Field path from schema registry
456
+ - `modelName` (string): For error logging context
457
+
458
+ **Algorithm**:
459
+
460
+ ```javascript
461
+ 1. Split fieldPath by '.': parts = fieldPath.split('.')
462
+ 2. Navigate to parent object:
463
+ - current = document
464
+ - For i from 0 to parts.length - 2:
465
+ - If !current[parts[i]]: return (path doesn't exist)
466
+ - current = current[parts[i]]
467
+ 3. Get field name: fieldName = parts[parts.length - 1]
468
+ 4. Get encrypted value: encryptedValue = current[fieldName]
469
+ 5. Skip if not encrypted format:
470
+ - If !encryptedValue or !this._isEncryptedValue(encryptedValue): return
471
+ 6. Decrypt using Cryptor:
472
+ - decryptedString = await this.cryptor.decrypt(encryptedValue)
473
+ 7. Try to parse as JSON:
474
+ - Try: current[fieldName] = JSON.parse(decryptedString)
475
+ - Catch: current[fieldName] = decryptedString (not JSON, return as string)
476
+ 8. Catch decryption errors:
477
+ - Log: "Failed to decrypt {modelName}.{fieldPath}: {error}"
478
+ - Set current[fieldName] = null (don't expose potentially corrupted data)
479
+ ```
480
+
481
+ **Error Tolerance**:
482
+
483
+ - If decryption fails, set field to `null` instead of throwing
484
+ - Prevents exposing encrypted strings to application
485
+ - Logs error for debugging
486
+
487
+ ---
488
+
489
+ #### `_isEncryptedValue(value)`
490
+
491
+ **Purpose**: Check if a value is in encrypted format
492
+
493
+ **Parameters**:
494
+
495
+ - `value` (any): Value to check
496
+
497
+ **Returns**: `boolean` - True if value is encrypted
498
+
499
+ **Logic**:
500
+
501
+ ```javascript
502
+ 1. If typeof value !== 'string': return false
503
+ 2. Split by ':': parts = value.split(':')
504
+ 3. Return parts.length >= 4
505
+ ```
506
+
507
+ **Encrypted Format**: `"keyId:iv:cipher:encKey"` (envelope encryption)
508
+
509
+ **Examples**:
510
+
511
+ ```javascript
512
+ _isEncryptedValue('plain_text'); // false
513
+ _isEncryptedValue('aes-key-1:iv123:cipher456:enckey789'); // true
514
+ _isEncryptedValue(null); // false
515
+ _isEncryptedValue({}); // false
516
+ ```
517
+
518
+ ---
519
+
520
+ ### Dependencies
521
+
522
+ ```javascript
523
+ const { Cryptor } = require('../encrypt/Cryptor');
524
+ const {
525
+ getEncryptedFields,
526
+ } = require('./encryption/encryption-schema-registry');
527
+ ```
528
+
529
+ **Cryptor**: Handles actual encryption/decryption (KMS or AES)
530
+ **getEncryptedFields**: Returns encrypted field paths for a model
531
+
532
+ ---
533
+
534
+ ### Encrypted Fields (from Schema Registry)
535
+
536
+ ```javascript
537
+ // From packages/core/database/encryption/encryption-schema-registry.js
538
+
539
+ const ENCRYPTED_FIELDS = {
540
+ User: ['hashword'],
541
+ Credential: [
542
+ 'data.access_token',
543
+ 'data.refresh_token',
544
+ 'data.id_token',
545
+ 'data.domain',
546
+ ],
547
+ IntegrationMapping: ['mapping'],
548
+ Token: ['token'],
549
+ };
550
+ ```
551
+
552
+ **DocumentDBEncryptionService** will automatically encrypt/decrypt these fields when `encryptFields()`/`decryptFields()` is called with the corresponding model name.
553
+
554
+ ---
555
+
556
+ ## Implementation Plan
557
+
558
+ ### Phase 1: Create DocumentDBEncryptionService (New File)
559
+
560
+ **Files to Create**:
561
+
562
+ 1. `packages/core/database/documentdb-encryption-service.js`
563
+ 2. `packages/core/database/__tests__/documentdb-encryption-service.test.js`
564
+
565
+ **Implementation Checklist**:
566
+
567
+ #### 1.1 Service Class (`documentdb-encryption-service.js`)
568
+
569
+ - [ ] Create file with standard file header comment
570
+ - [ ] Import dependencies: `Cryptor`, `getEncryptedFields`
571
+ - [ ] Create `DocumentDBEncryptionService` class
572
+ - [ ] Implement `constructor()` - calls `_initializeCryptor()`
573
+ - [ ] Implement `_initializeCryptor()` - matches `prisma.js` logic
574
+ - [ ] Check STAGE environment variable
575
+ - [ ] Implement bypass for dev/test/local
576
+ - [ ] Check for KMS_KEY_ARN
577
+ - [ ] Check for AES_KEY_ID
578
+ - [ ] Create Cryptor with shouldUseAws flag
579
+ - [ ] Set this.enabled flag
580
+ - [ ] Implement `encryptFields(modelName, document)`
581
+ - [ ] Early returns for disabled/invalid input
582
+ - [ ] Get encrypted fields from registry
583
+ - [ ] Loop through field paths
584
+ - [ ] Call `_encryptFieldPath()` for each
585
+ - [ ] Implement `decryptFields(modelName, document)`
586
+ - [ ] Early returns for disabled/invalid input
587
+ - [ ] Get encrypted fields from registry
588
+ - [ ] Loop through field paths
589
+ - [ ] Call `_decryptFieldPath()` for each
590
+ - [ ] Implement `_encryptFieldPath(document, fieldPath, modelName)`
591
+ - [ ] Parse field path (split by '.')
592
+ - [ ] Navigate to parent object
593
+ - [ ] Check if already encrypted
594
+ - [ ] Convert to string if needed
595
+ - [ ] Call `this.cryptor.encrypt()`
596
+ - [ ] Error handling with context
597
+ - [ ] Implement `_decryptFieldPath(document, fieldPath, modelName)`
598
+ - [ ] Parse field path
599
+ - [ ] Navigate to parent object
600
+ - [ ] Check if encrypted format
601
+ - [ ] Call `this.cryptor.decrypt()`
602
+ - [ ] Try to parse as JSON
603
+ - [ ] Error handling (set to null on failure)
604
+ - [ ] Implement `_isEncryptedValue(value)`
605
+ - [ ] Type check (must be string)
606
+ - [ ] Split by ':'
607
+ - [ ] Check for 4+ parts
608
+ - [ ] Add JSDoc comments for all public methods
609
+ - [ ] Export: `module.exports = { DocumentDBEncryptionService };`
610
+
611
+ #### 1.2 Service Tests (`__tests__/documentdb-encryption-service.test.js`)
612
+
613
+ - [ ] Create test file with describe block
614
+ - [ ] Mock dependencies: `Cryptor`, `getEncryptedFields`
615
+ - [ ] **Test Group: Initialization**
616
+ - [ ] Test bypass in dev stage
617
+ - [ ] Test bypass in test stage
618
+ - [ ] Test bypass in local stage
619
+ - [ ] Test enabled with KMS_KEY_ARN in production
620
+ - [ ] Test enabled with AES_KEY_ID in production
621
+ - [ ] Test disabled with no keys in production
622
+ - [ ] Test KMS takes precedence over AES
623
+ - [ ] **Test Group: encryptFields()**
624
+ - [ ] Test returns unchanged when disabled (dev stage)
625
+ - [ ] Test returns unchanged for null document
626
+ - [ ] Test returns unchanged for non-object document
627
+ - [ ] Test returns unchanged when no encrypted fields in registry
628
+ - [ ] Test encrypts User.hashword
629
+ - [ ] Test encrypts Credential.data.access_token
630
+ - [ ] Test encrypts Credential.data.refresh_token
631
+ - [ ] Test encrypts multiple nested fields
632
+ - [ ] Test skips already encrypted values
633
+ - [ ] Test skips null values
634
+ - [ ] Test skips non-existent paths
635
+ - [ ] Test encrypts objects (JSON.stringify)
636
+ - [ ] Test error handling (propagates error)
637
+ - [ ] **Test Group: decryptFields()**
638
+ - [ ] Test returns unchanged when disabled
639
+ - [ ] Test returns unchanged for null document
640
+ - [ ] Test returns unchanged for non-object document
641
+ - [ ] Test returns unchanged when no encrypted fields in registry
642
+ - [ ] Test decrypts User.hashword
643
+ - [ ] Test decrypts Credential.data.access_token
644
+ - [ ] Test decrypts multiple nested fields
645
+ - [ ] Test skips plain text values
646
+ - [ ] Test skips null values
647
+ - [ ] Test skips non-existent paths
648
+ - [ ] Test parses JSON objects after decryption
649
+ - [ ] Test handles non-JSON strings
650
+ - [ ] Test error handling (sets field to null)
651
+ - [ ] **Test Group: \_isEncryptedValue()**
652
+ - [ ] Test returns false for plain text
653
+ - [ ] Test returns false for null
654
+ - [ ] Test returns false for numbers
655
+ - [ ] Test returns false for objects
656
+ - [ ] Test returns false for short strings (< 4 parts)
657
+ - [ ] Test returns true for encrypted format (4+ parts with colons)
658
+ - [ ] **Test Coverage Target**: >90% line coverage
659
+
660
+ **Estimated Time**: 2-3 hours
661
+
662
+ ---
663
+
664
+ ### Phase 1.5: Fix Critical Issues from Code Review
665
+
666
+ **Status**: ⚠️ CRITICAL - Must complete before Phase 2
667
+
668
+ **Context**: After Phase 1 implementation and code review, three critical issues were identified that must be fixed before integrating the service into repositories. These issues address data corruption, silent failures, and testability concerns.
669
+
670
+ **Code Review Summary**: Overall assessment 6/10 → 8/10 after fixes
671
+
672
+ ---
673
+
674
+ #### Critical Issue #1: JSON.parse Corrupts Date Objects
675
+
676
+ **Problem**:
677
+
678
+ ```javascript
679
+ // Current implementation (lines 101, 147)
680
+ const result = JSON.parse(JSON.stringify(document));
681
+ ```
682
+
683
+ **Why it's critical**:
684
+
685
+ - `JSON.stringify()` converts Date objects to ISO strings
686
+ - `JSON.parse()` does NOT convert them back to Date objects
687
+ - OAuth tokens often have `expires_at` as Date objects
688
+ - This causes **silent data corruption** in production
689
+
690
+ **Example of corruption**:
691
+
692
+ ```javascript
693
+ const credential = {
694
+ data: { access_token: 'secret' },
695
+ expires_at: new Date('2025-12-31'), // Date object
696
+ };
697
+
698
+ const encrypted = await service.encryptFields('Credential', credential);
699
+ // encrypted.expires_at is now "2025-12-31T00:00:00.000Z" (STRING, not Date)
700
+ // This breaks any code expecting Date.getTime(), Date.toISOString(), etc.
701
+ ```
702
+
703
+ **Fix**:
704
+
705
+ ```javascript
706
+ // Use structuredClone (Node.js 17+)
707
+ const result = structuredClone(document);
708
+ ```
709
+
710
+ **Benefits of structuredClone**:
711
+
712
+ - ✅ Preserves Date objects
713
+ - ✅ Preserves RegExp objects
714
+ - ✅ Preserves Buffer objects
715
+ - ✅ Handles circular references
716
+ - ✅ Native Node.js function (no dependencies)
717
+
718
+ **Files to Update**:
719
+
720
+ - `documentdb-encryption-service.js` lines 101, 147
721
+
722
+ **Checklist**:
723
+
724
+ - [ ] Replace `JSON.parse(JSON.stringify(document))` in `encryptFields()` (line 101)
725
+ - [ ] Replace `JSON.parse(JSON.stringify(document))` in `decryptFields()` (line 147)
726
+ - [ ] Add test case: `it('preserves Date objects in documents')`
727
+ - [ ] Verify Node.js version supports structuredClone (>=17)
728
+
729
+ **Estimated Time**: 5 minutes
730
+
731
+ ---
732
+
733
+ #### Critical Issue #2: Decryption Failures Set to Null
734
+
735
+ **Problem**:
736
+
737
+ ```javascript
738
+ // Current implementation (_decryptFieldPath, line 258)
739
+ catch (error) {
740
+ console.error('[DocumentDBEncryptionService] Failed to decrypt...', errorContext);
741
+ current[fieldName] = null; // ❌ Silent data loss
742
+ }
743
+ ```
744
+
745
+ **Why it's critical**:
746
+
747
+ - **Silent credential loss** - Application continues with null tokens
748
+ - **Hard to debug** - Error logged but not propagated
749
+ - **Security risk** - Could mask key rotation issues or corrupted data
750
+ - **Cascade failures** - Null propagates until crash elsewhere
751
+
752
+ **Real-world scenario**:
753
+
754
+ ```javascript
755
+ // Encrypted credential in database (key rotated or corrupted)
756
+ const credential = await findCredential(userId);
757
+ // Decryption silently fails, field set to null
758
+
759
+ // Application continues
760
+ const api = new AsanaAPI({ token: credential.access_token });
761
+ // ❌ Later crashes with "Cannot use null as token" far from root cause
762
+ ```
763
+
764
+ **Why this is wrong**:
765
+
766
+ - Violates fail-fast principle (errors should be discovered immediately)
767
+ - Inconsistent with `encryptFields()` which throws errors
768
+ - Repository can't distinguish null data from decryption failure
769
+
770
+ **Fix**:
771
+
772
+ ```javascript
773
+ // Throw error immediately (fail fast)
774
+ catch (error) {
775
+ console.error('[DocumentDBEncryptionService] Failed to decrypt...', errorContext);
776
+ throw new Error(`Decryption failed for ${modelName}.${fieldPath}: ${error.message}`);
777
+ }
778
+ ```
779
+
780
+ **Files to Update**:
781
+
782
+ - `documentdb-encryption-service.js` line 258
783
+ - `documentdb-encryption-service.test.js` update test "sets field to null on decryption error"
784
+
785
+ **Checklist**:
786
+
787
+ - [ ] Remove `current[fieldName] = null;` from `_decryptFieldPath()` (line 258)
788
+ - [ ] Add `throw new Error(...)` with context
789
+ - [ ] Update test: change from `expect(result.hashword).toBeNull()` to `expect(...).rejects.toThrow()`
790
+ - [ ] Update test name: "throws error on decryption failure" (not "sets field to null")
791
+ - [ ] Verify all 56+ tests still pass
792
+
793
+ **Estimated Time**: 10 minutes
794
+
795
+ ---
796
+
797
+ #### Critical Issue #3: No Cryptor Dependency Injection
798
+
799
+ **Problem**:
800
+
801
+ ```javascript
802
+ // Current implementation (constructor, lines 24-26)
803
+ constructor() {
804
+ this._initializeCryptor(); // ❌ Creates Cryptor internally
805
+ }
806
+
807
+ _initializeCryptor() {
808
+ this.cryptor = new Cryptor({ shouldUseAws }); // ❌ Hard-coded
809
+ }
810
+ ```
811
+
812
+ **Why it's critical**:
813
+
814
+ - **Repository tests break** - Can't mock encryption in Phase 2-4
815
+ - **Requires real keys** - Tests need AWS credentials or AES keys
816
+ - **Slower tests** - Real encryption is slower than mocks
817
+ - **Can't test error scenarios** - Can't simulate Cryptor failures
818
+
819
+ **Impact on Phase 2 (UserRepositoryDocumentDB tests)**:
820
+
821
+ ```javascript
822
+ describe('UserRepositoryDocumentDB', () => {
823
+ it('encrypts hashword before saving', async () => {
824
+ // ❌ PROBLEM: Can't mock DocumentDBEncryptionService's Cryptor
825
+ const service = new DocumentDBEncryptionService();
826
+ // Tries to create real Cryptor - tests fail without keys
827
+
828
+ const repo = new UserRepositoryDocumentDB({
829
+ encryptionService: service,
830
+ });
831
+ await repo.createUser({ hashword: 'password' });
832
+ // ❌ Real KMS/AES encryption happens in tests
833
+ });
834
+ });
835
+ ```
836
+
837
+ **Fix**:
838
+
839
+ ```javascript
840
+ class DocumentDBEncryptionService {
841
+ constructor({ cryptor = null } = {}) {
842
+ if (cryptor) {
843
+ // Dependency injection - use provided Cryptor (for testing)
844
+ this.cryptor = cryptor;
845
+ this.enabled = true;
846
+ } else {
847
+ // Default behavior - create Cryptor from environment
848
+ this._initializeCryptor();
849
+ }
850
+ }
851
+ }
852
+ ```
853
+
854
+ **Usage**:
855
+
856
+ ```javascript
857
+ // In tests (with mock)
858
+ const mockCryptor = {
859
+ encrypt: jest.fn().mockResolvedValue('encrypted'),
860
+ decrypt: jest.fn().mockResolvedValue('decrypted'),
861
+ };
862
+ const service = new DocumentDBEncryptionService({ cryptor: mockCryptor });
863
+
864
+ // In production (uses environment config)
865
+ const service = new DocumentDBEncryptionService();
866
+ ```
867
+
868
+ **Files to Update**:
869
+
870
+ - `documentdb-encryption-service.js` constructor
871
+ - `documentdb-encryption-service.test.js` add dependency injection test
872
+
873
+ **Checklist**:
874
+
875
+ - [ ] Change constructor signature: `constructor({ cryptor = null } = {})`
876
+ - [ ] Add conditional logic: if cryptor provided, use it; else call `_initializeCryptor()`
877
+ - [ ] Set `this.enabled = true` when cryptor injected
878
+ - [ ] Add test: `it('accepts injected Cryptor for testing')`
879
+ - [ ] Verify injection test passes
880
+ - [ ] Verify all existing tests still pass
881
+
882
+ **Estimated Time**: 15 minutes
883
+
884
+ ---
885
+
886
+ #### Phase 1.5 Summary
887
+
888
+ **Total Changes**:
889
+
890
+ - 3 files modified
891
+ - 5 lines of code changed (service implementation)
892
+ - 3 new/updated test cases
893
+ - 0 breaking changes (backward compatible)
894
+
895
+ **Total Time**: ~30 minutes
896
+
897
+ **Success Criteria**:
898
+
899
+ - ✅ All 56+ tests pass
900
+ - ✅ Date objects preserved in documents
901
+ - ✅ Decryption failures throw errors
902
+ - ✅ Cryptor can be injected for testing
903
+ - ✅ 100% code coverage maintained
904
+ - ✅ Code review assessment improves from 6/10 to 8/10
905
+
906
+ **Validation**:
907
+
908
+ ```javascript
909
+ // Test 1: Date preservation
910
+ const doc = { data: { token: 'secret' }, createdAt: new Date() };
911
+ const encrypted = await service.encryptFields('Model', doc);
912
+ expect(encrypted.createdAt).toBeInstanceOf(Date); // ✅ Must pass
913
+
914
+ // Test 2: Decryption error throws
915
+ const corrupted = { data: { token: 'corrupted_encrypted_value' } };
916
+ await expect(service.decryptFields('Model', corrupted)).rejects.toThrow(
917
+ 'Decryption failed'
918
+ ); // ✅ Must pass
919
+
920
+ // Test 3: Dependency injection
921
+ const mockCryptor = { encrypt: jest.fn(), decrypt: jest.fn() };
922
+ const service = new DocumentDBEncryptionService({ cryptor: mockCryptor });
923
+ expect(service.cryptor).toBe(mockCryptor); // ✅ Must pass
924
+ ```
925
+
926
+ **Next Step**: After Phase 1.5 completion, proceed to Phase 2 (Refactor UserRepositoryDocumentDB)
927
+
928
+ ---
929
+
930
+ ### Phase 2: Refactor UserRepositoryDocumentDB
931
+
932
+ **File**: `packages/core/user/repositories/user-repository-documentdb.js`
933
+
934
+ **Changes Checklist**:
935
+
936
+ - [ ] Import DocumentDBEncryptionService at top of file
937
+ - [ ] **Remove existing encryption methods** (lines 24-148):
938
+ - [ ] Remove `_initializeCryptor()` method
939
+ - [ ] Remove `_encryptField()` method
940
+ - [ ] Remove `_decryptField()` method
941
+ - [ ] Remove `_isEncryptedValue()` method
942
+ - [ ] Remove `_encryptHashword()` method
943
+ - [ ] Remove `_decryptHashword()` method
944
+ - [ ] **Update constructor**:
945
+ - [ ] Add: `this.encryptionService = new DocumentDBEncryptionService();`
946
+ - [ ] Remove: `this._initializeCryptor();`
947
+ - [ ] **Update `createIndividualUser()` method** (around line 183):
948
+ - [ ] After building document, before insertOne():
949
+ ```javascript
950
+ const encryptedDocument = await this.encryptionService.encryptFields(
951
+ 'User',
952
+ document
953
+ );
954
+ const insertedId = await insertOne(
955
+ this.prisma,
956
+ 'User',
957
+ encryptedDocument
958
+ );
959
+ ```
960
+ - [ ] After findOne(), before \_mapUser():
961
+ ```javascript
962
+ const decryptedUser = await this.encryptionService.decryptFields(
963
+ 'User',
964
+ created
965
+ );
966
+ return this._mapUser(decryptedUser);
967
+ ```
968
+ - [ ] **Update `createOrganizationUser()` method**:
969
+ - [ ] No changes needed (no encrypted fields for organization users)
970
+ - [ ] **Update `findIndividualUserById()` method** (around line 165):
971
+ - [ ] After findOne():
972
+ ```javascript
973
+ const decryptedUser = await this.encryptionService.decryptFields(
974
+ 'User',
975
+ doc
976
+ );
977
+ return this._mapUser(decryptedUser);
978
+ ```
979
+ - [ ] **Update `findIndividualUserByUsername()` method**:
980
+ - [ ] Same pattern: decrypt after findOne()
981
+ - [ ] **Update `findIndividualUserByEmail()` method**:
982
+ - [ ] Same pattern: decrypt after findOne()
983
+ - [ ] **Update `findIndividualUserByAppUserId()` method**:
984
+ - [ ] Same pattern: decrypt after findOne()
985
+ - [ ] **Update `findIndividualUserById()` method**:
986
+ - [ ] Same pattern: decrypt after findOne()
987
+ - [ ] **Update `updateIndividualUser()` method** (around line 303):
988
+ - [ ] After preparing update payload, encrypt before updateOne():
989
+ ```javascript
990
+ const encryptedPayload = await this.encryptionService.encryptFields(
991
+ 'User',
992
+ payload
993
+ );
994
+ await updateOne(
995
+ this.prisma,
996
+ 'User',
997
+ { _id: objectId, type: 'INDIVIDUAL' },
998
+ { $set: encryptedPayload }
999
+ );
1000
+ ```
1001
+ - [ ] After findOne(), decrypt before \_mapUser():
1002
+ ```javascript
1003
+ const decryptedUser = await this.encryptionService.decryptFields(
1004
+ 'User',
1005
+ updated
1006
+ );
1007
+ return this._mapUser(decryptedUser);
1008
+ ```
1009
+ - [ ] **Update `updateOrganizationUser()` method**:
1010
+ - [ ] No changes needed (no encrypted fields)
1011
+ - [ ] Verify no references to old encryption methods remain
1012
+ - [ ] Run linter to check for issues
1013
+ - [ ] Test locally
1014
+
1015
+ **Estimated Time**: 1 hour
1016
+
1017
+ ---
1018
+
1019
+ ### Phase 3: Refactor ModuleRepositoryDocumentDB
1020
+
1021
+ **File**: `packages/core/modules/repositories/module-repository-documentdb.js`
1022
+
1023
+ **Changes Checklist**:
1024
+
1025
+ - [ ] Import DocumentDBEncryptionService at top of file
1026
+ - [ ] **Remove existing encryption methods** (lines 22-117):
1027
+ - [ ] Remove `_initializeCryptor()` method
1028
+ - [ ] Remove `_encryptField()` method
1029
+ - [ ] Remove `_decryptField()` method
1030
+ - [ ] Remove `_isEncryptedValue()` method
1031
+ - [ ] Remove `_decryptCredentialData()` method
1032
+ - [ ] **Update constructor**:
1033
+ - [ ] Add: `this.encryptionService = new DocumentDBEncryptionService();`
1034
+ - [ ] Remove: `this._initializeCryptor();`
1035
+ - [ ] **Update `_fetchCredential()` method** (around line 241):
1036
+ - [ ] After findOne(), before returning:
1037
+ ```javascript
1038
+ const decryptedCredential = await this.encryptionService.decryptFields(
1039
+ 'Credential',
1040
+ rawCredential
1041
+ );
1042
+ return {
1043
+ id: fromObjectId(decryptedCredential._id),
1044
+ userId: fromObjectId(decryptedCredential.userId),
1045
+ externalId: decryptedCredential.externalId ?? null,
1046
+ authIsValid: decryptedCredential.authIsValid ?? null,
1047
+ createdAt: decryptedCredential.createdAt,
1048
+ updatedAt: decryptedCredential.updatedAt,
1049
+ data: decryptedCredential.data,
1050
+ };
1051
+ ```
1052
+ - [ ] **Update `_fetchCredentialsBulk()` method** (around line 280):
1053
+ - [ ] Inside the map function for each credential:
1054
+ ```javascript
1055
+ const decryptedCredential = await this.encryptionService.decryptFields(
1056
+ 'Credential',
1057
+ rawCredential
1058
+ );
1059
+ return this._convertCredentialIds({
1060
+ id: fromObjectId(decryptedCredential._id),
1061
+ // ... rest of mapping
1062
+ data: decryptedCredential.data,
1063
+ });
1064
+ ```
1065
+ - [ ] Verify no references to old encryption methods remain
1066
+ - [ ] Run linter to check for issues
1067
+ - [ ] Test locally
1068
+
1069
+ **Note**: ModuleRepository doesn't create/update credentials, only reads them. It relies on CredentialRepository for writes.
1070
+
1071
+ **Estimated Time**: 1 hour
1072
+
1073
+ ---
1074
+
1075
+ ### Phase 4: Fix CredentialRepositoryDocumentDB (CRITICAL)
1076
+
1077
+ **File**: `packages/core/credential/repositories/credential-repository-documentdb.js`
1078
+
1079
+ **Critical Priority**: This is the security vulnerability fix
1080
+
1081
+ **Changes Checklist**:
1082
+
1083
+ - [ ] Import DocumentDBEncryptionService at top of file
1084
+ - [ ] **Update constructor**:
1085
+ - [ ] Add: `this.encryptionService = new DocumentDBEncryptionService();`
1086
+ - [ ] **Fix `upsertCredential()` method** (around line 50):
1087
+
1088
+ - [ ] **Current problematic code**:
1089
+
1090
+ ```javascript
1091
+ const { user, userId, authIsValid, externalId, ...oauthData } =
1092
+ details || {};
1093
+ // oauthData contains PLAIN TEXT: access_token, refresh_token, etc.
1094
+
1095
+ const document = {
1096
+ data: oauthData, // ❌ STORED AS PLAIN TEXT
1097
+ };
1098
+ await insertOne(this.prisma, 'Credential', document);
1099
+ ```
1100
+
1101
+ - [ ] **Replace with ENCRYPTED version**:
1102
+
1103
+ ```javascript
1104
+ const { user, userId, authIsValid, externalId, ...oauthData } =
1105
+ details || {};
1106
+
1107
+ // Build plain text document
1108
+ const plainDocument = {
1109
+ userId: toObjectId(userId || user),
1110
+ externalId: externalId ?? null,
1111
+ authIsValid: authIsValid ?? true,
1112
+ data: oauthData, // Still plain text at this point
1113
+ createdAt: now,
1114
+ updatedAt: now,
1115
+ };
1116
+
1117
+ // ✅ ENCRYPT before storing
1118
+ const encryptedDocument = await this.encryptionService.encryptFields(
1119
+ 'Credential',
1120
+ plainDocument
1121
+ );
1122
+
1123
+ const insertedId = await insertOne(
1124
+ this.prisma,
1125
+ 'Credential',
1126
+ encryptedDocument
1127
+ );
1128
+
1129
+ // Read back and decrypt
1130
+ const created = await findOne(this.prisma, 'Credential', {
1131
+ _id: insertedId,
1132
+ });
1133
+ const decryptedCredential = await this.encryptionService.decryptFields(
1134
+ 'Credential',
1135
+ created
1136
+ );
1137
+
1138
+ return this._mapCredential(decryptedCredential);
1139
+ ```
1140
+
1141
+ - [ ] **For UPDATE case** (when credential exists):
1142
+
1143
+ ```javascript
1144
+ // Merge existing data with new data
1145
+ const existingData = existing.data || {};
1146
+ const mergedData = { ...existingData, ...oauthData };
1147
+
1148
+ // Build update document
1149
+ const updateDocument = {
1150
+ data: mergedData,
1151
+ authIsValid: authIsValid ?? existing.authIsValid,
1152
+ updatedAt: now,
1153
+ };
1154
+
1155
+ // ✅ ENCRYPT before storing
1156
+ const encryptedUpdate = await this.encryptionService.encryptFields(
1157
+ 'Credential',
1158
+ { data: updateDocument.data } // Only encrypt the data field
1159
+ );
1160
+
1161
+ await updateOne(
1162
+ this.prisma,
1163
+ 'Credential',
1164
+ { _id: existing._id },
1165
+ {
1166
+ $set: {
1167
+ data: encryptedUpdate.data,
1168
+ authIsValid: updateDocument.authIsValid,
1169
+ updatedAt: updateDocument.updatedAt,
1170
+ },
1171
+ }
1172
+ );
1173
+
1174
+ // Read back and decrypt
1175
+ const updated = await findOne(this.prisma, 'Credential', {
1176
+ _id: existing._id,
1177
+ });
1178
+ const decryptedCredential = await this.encryptionService.decryptFields(
1179
+ 'Credential',
1180
+ updated
1181
+ );
1182
+
1183
+ return this._mapCredential(decryptedCredential);
1184
+ ```
1185
+
1186
+ - [ ] **Fix `_mapCredential()` method** (around line 192):
1187
+ - [ ] **Current problematic code**:
1188
+ ```javascript
1189
+ _mapCredential(doc) {
1190
+ const data = doc?.data || {};
1191
+ return {
1192
+ id: fromObjectId(doc._id),
1193
+ userId: fromObjectId(doc.userId),
1194
+ externalId: doc.externalId ?? null,
1195
+ authIsValid: doc.authIsValid ?? null,
1196
+ ...data // ❌ Could be encrypted strings
1197
+ };
1198
+ }
1199
+ ```
1200
+ - [ ] **Note**: If we decrypt in `upsertCredential()` before calling `_mapCredential()`, this method doesn't need changes. But for safety:
1201
+ ```javascript
1202
+ _mapCredential(doc) {
1203
+ // Assume doc is already decrypted by caller
1204
+ // (upsertCredential, findCredential should decrypt before calling this)
1205
+ const data = doc?.data || {};
1206
+ return {
1207
+ id: fromObjectId(doc._id),
1208
+ userId: fromObjectId(doc.userId),
1209
+ externalId: doc.externalId ?? null,
1210
+ authIsValid: doc.authIsValid ?? null,
1211
+ ...data // Already decrypted
1212
+ };
1213
+ }
1214
+ ```
1215
+ - [ ] **Fix `findCredential()` method** (if exists):
1216
+
1217
+ - [ ] After findOne(), decrypt:
1218
+
1219
+ ```javascript
1220
+ const doc = await findOne(this.prisma, 'Credential', filter);
1221
+ if (!doc) return null;
1222
+
1223
+ const decryptedDoc = await this.encryptionService.decryptFields(
1224
+ 'Credential',
1225
+ doc
1226
+ );
1227
+ return this._mapCredential(decryptedDoc);
1228
+ ```
1229
+
1230
+ - [ ] **Fix `findManyCredentials()` method** (if exists):
1231
+
1232
+ - [ ] After findMany(), decrypt each:
1233
+
1234
+ ```javascript
1235
+ const docs = await findMany(this.prisma, 'Credential', filter);
1236
+
1237
+ const decryptedDocs = await Promise.all(
1238
+ docs.map((doc) =>
1239
+ this.encryptionService.decryptFields('Credential', doc)
1240
+ )
1241
+ );
1242
+
1243
+ return decryptedDocs.map((doc) => this._mapCredential(doc));
1244
+ ```
1245
+
1246
+ - [ ] Add JSDoc comments explaining encryption
1247
+ - [ ] Verify all credential read/write operations are covered
1248
+ - [ ] Run linter
1249
+ - [ ] Test locally with real OAuth flow
1250
+
1251
+ **Security Verification**:
1252
+
1253
+ - [ ] Create test credential with `access_token: "test_secret"`
1254
+ - [ ] Query database directly (bypass repository)
1255
+ - [ ] Verify stored value is encrypted format: `"keyId:iv:cipher:encKey"`
1256
+ - [ ] Verify repository returns decrypted value: `"test_secret"`
1257
+
1258
+ **Estimated Time**: 1.5 hours
1259
+
1260
+ ---
1261
+
1262
+ ### Phase 5: Add Comprehensive Tests
1263
+
1264
+ #### 5.1 User Repository Encryption Tests
1265
+
1266
+ **File**: `packages/core/user/repositories/__tests__/user-repository-documentdb-encryption.test.js`
1267
+
1268
+ **Test Coverage Checklist**:
1269
+
1270
+ - [ ] Create test file with describe block
1271
+ - [ ] Mock DocumentDBEncryptionService
1272
+ - [ ] **Test Group: Encryption on Write**
1273
+ - [ ] Test `createIndividualUser()` encrypts hashword before insert
1274
+ - [ ] Test `updateIndividualUser()` encrypts hashword before update
1275
+ - [ ] Verify encrypted format in database (use direct query)
1276
+ - [ ] Verify plain text never stored
1277
+ - [ ] **Test Group: Decryption on Read**
1278
+ - [ ] Test `findIndividualUserById()` returns decrypted hashword
1279
+ - [ ] Test `findIndividualUserByUsername()` returns decrypted hashword
1280
+ - [ ] Test `findIndividualUserByEmail()` returns decrypted hashword
1281
+ - [ ] Verify application receives plain text
1282
+ - [ ] **Test Group: Stage-Based Bypass**
1283
+ - [ ] Test encryption bypassed in dev stage
1284
+ - [ ] Test encryption bypassed in test stage
1285
+ - [ ] Test encryption bypassed in local stage
1286
+ - [ ] Test encryption enabled in production stage
1287
+ - [ ] **Test Group: Edge Cases**
1288
+ - [ ] Test null hashword handling
1289
+ - [ ] Test undefined hashword handling
1290
+ - [ ] Test empty string hashword
1291
+ - [ ] Test already encrypted hashword (idempotent)
1292
+ - [ ] **Test Group: Error Handling**
1293
+ - [ ] Test encryption service throws error
1294
+ - [ ] Test decryption service throws error
1295
+ - [ ] Verify error propagation to use case
1296
+ - [ ] Run tests: `npm test user-repository-documentdb-encryption.test.js`
1297
+
1298
+ **Estimated Time**: 1.5 hours
1299
+
1300
+ ---
1301
+
1302
+ #### 5.2 Module Repository Encryption Tests
1303
+
1304
+ **File**: `packages/core/modules/repositories/__tests__/module-repository-documentdb-encryption.test.js`
1305
+
1306
+ **Test Coverage Checklist**:
1307
+
1308
+ - [ ] Create test file with describe block
1309
+ - [ ] Mock DocumentDBEncryptionService
1310
+ - [ ] Mock credential data in database (pre-encrypted)
1311
+ - [ ] **Test Group: Credential Decryption**
1312
+ - [ ] Test `_fetchCredential()` decrypts credential data
1313
+ - [ ] Test `_fetchCredentialsBulk()` decrypts multiple credentials
1314
+ - [ ] Verify nested field decryption (data.access_token)
1315
+ - [ ] Verify multiple field decryption (access_token, refresh_token, id_token)
1316
+ - [ ] **Test Group: Integration with Entities**
1317
+ - [ ] Test `findEntityById()` returns entity with decrypted credential
1318
+ - [ ] Test `findEntitiesByUserId()` returns entities with decrypted credentials
1319
+ - [ ] Test `findEntitiesByUserIdAndModuleName()` decrypts credentials
1320
+ - [ ] **Test Group: Error Handling**
1321
+ - [ ] Test corrupted encrypted data (decryption fails)
1322
+ - [ ] Test missing credential (null credential)
1323
+ - [ ] Verify graceful degradation
1324
+ - [ ] **Test Group: Performance**
1325
+ - [ ] Test bulk decryption of 10 credentials
1326
+ - [ ] Verify parallel decryption (not sequential)
1327
+ - [ ] Run tests: `npm test module-repository-documentdb-encryption.test.js`
1328
+
1329
+ **Estimated Time**: 1.5 hours
1330
+
1331
+ ---
1332
+
1333
+ #### 5.3 Credential Repository Encryption Tests (NEW - CRITICAL)
1334
+
1335
+ **File**: `packages/core/credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js`
1336
+
1337
+ **Test Coverage Checklist**:
1338
+
1339
+ - [ ] Create test file with describe block
1340
+ - [ ] Mock DocumentDBEncryptionService
1341
+ - [ ] Setup DocumentDB test database
1342
+ - [ ] **Test Group: Encryption on Upsert (INSERT)**
1343
+
1344
+ - [ ] Test encrypts access_token before insert
1345
+ - [ ] Test encrypts refresh_token before insert
1346
+ - [ ] Test encrypts id_token before insert
1347
+ - [ ] Test encrypts domain before insert
1348
+ - [ ] **Verify encrypted format in database**:
1349
+
1350
+ ```javascript
1351
+ // Direct database query (bypass repository)
1352
+ const rawDoc = await prisma.$runCommandRaw({
1353
+ find: 'Credential',
1354
+ filter: { userId: toObjectId(userId) },
1355
+ });
1356
+ const storedToken = rawDoc.cursor.firstBatch[0].data.access_token;
1357
+
1358
+ // Must match encrypted format
1359
+ expect(storedToken).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/);
1360
+ expect(storedToken).not.toBe('plain_secret');
1361
+ ```
1362
+
1363
+ - [ ] **Test Group: Encryption on Upsert (UPDATE)**
1364
+ - [ ] Test existing credential update encrypts new tokens
1365
+ - [ ] Test merges existing encrypted data with new encrypted data
1366
+ - [ ] Test updates preserve other credential fields
1367
+ - [ ] **Test Group: Decryption on Read**
1368
+
1369
+ - [ ] Test `upsertCredential()` returns decrypted credential
1370
+ - [ ] Test `findCredential()` returns decrypted credential (if exists)
1371
+ - [ ] Test `_mapCredential()` receives decrypted data
1372
+ - [ ] **Verify plain text returned to application**:
1373
+
1374
+ ```javascript
1375
+ const credential = await repository.upsertCredential({
1376
+ userId,
1377
+ externalId,
1378
+ access_token: 'plain_secret',
1379
+ refresh_token: 'plain_refresh',
1380
+ });
1381
+
1382
+ expect(credential.access_token).toBe('plain_secret');
1383
+ expect(credential.refresh_token).toBe('plain_refresh');
1384
+ ```
1385
+
1386
+ - [ ] **Test Group: Integration Flow**
1387
+ - [ ] Test full flow: insert → read → verify
1388
+ - [ ] Test full flow: insert → update → read → verify
1389
+ - [ ] Test multiple credentials per user
1390
+ - [ ] Test credential retrieval by externalId
1391
+ - [ ] **Test Group: Security Validation**
1392
+ - [ ] Test KMS encryption in production stage
1393
+ - [ ] Test AES encryption when KMS unavailable
1394
+ - [ ] Test bypass in dev/test/local stages
1395
+ - [ ] Test plain text never exposed in logs
1396
+ - [ ] **Test Group: Error Handling**
1397
+ - [ ] Test encryption service throws error on insert
1398
+ - [ ] Test decryption service throws error on read
1399
+ - [ ] Test partial credential data (missing fields)
1400
+ - [ ] Test null values for optional fields
1401
+ - [ ] **Test Group: Edge Cases**
1402
+ - [ ] Test empty oauth data
1403
+ - [ ] Test very large token values (>1KB)
1404
+ - [ ] Test special characters in tokens
1405
+ - [ ] Test unicode in tokens
1406
+ - [ ] Run tests: `npm test credential-repository-documentdb-encryption.test.js`
1407
+
1408
+ **Security Test Example**:
1409
+
1410
+ ```javascript
1411
+ describe('Security - Encryption Verification', () => {
1412
+ it('stores access_token in encrypted format in database', async () => {
1413
+ const userId = new ObjectId();
1414
+ const externalId = 'test-external-123';
1415
+ const plainToken = 'ya29.actual_google_token_here';
1416
+
1417
+ // Create credential via repository
1418
+ await credentialRepo.upsertCredential({
1419
+ userId: fromObjectId(userId),
1420
+ externalId,
1421
+ access_token: plainToken,
1422
+ });
1423
+
1424
+ // Query database directly (bypass repository and encryption)
1425
+ const rawResult = await prisma.$runCommandRaw({
1426
+ find: 'Credential',
1427
+ filter: { userId, externalId },
1428
+ });
1429
+
1430
+ const storedCredential = rawResult.cursor.firstBatch[0];
1431
+ const storedToken = storedCredential.data.access_token;
1432
+
1433
+ // CRITICAL: Verify encrypted format
1434
+ expect(storedToken).not.toBe(plainToken); // Must not be plain text
1435
+ expect(storedToken).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/); // Must be "keyId:iv:cipher:encKey"
1436
+
1437
+ // Verify repository returns decrypted value
1438
+ const retrieved = await credentialRepo.findCredential({
1439
+ userId,
1440
+ externalId,
1441
+ });
1442
+ expect(retrieved.access_token).toBe(plainToken); // Must be decrypted
1443
+ });
1444
+ });
1445
+ ```
1446
+
1447
+ **Estimated Time**: 2 hours
1448
+
1449
+ ---
1450
+
1451
+ ### Phase 6: Apply to Both Locations
1452
+
1453
+ **Dual Location Rule**: All changes must be applied to BOTH:
1454
+
1455
+ 1. **Development**: `/Users/danielklotz/projects/lefthook/frontify--frigg/tmp/frigg/packages/core/`
1456
+ 2. **Runtime**: `/Users/danielklotz/projects/lefthook/frontify--frigg/backend/node_modules/@friggframework/core/`
1457
+
1458
+ **Files to Update in Both Locations**:
1459
+
1460
+ - [ ] `database/documentdb-encryption-service.js` (NEW)
1461
+ - [ ] `database/__tests__/documentdb-encryption-service.test.js` (NEW)
1462
+ - [ ] `user/repositories/user-repository-documentdb.js`
1463
+ - [ ] `user/repositories/__tests__/user-repository-documentdb-encryption.test.js` (NEW)
1464
+ - [ ] `modules/repositories/module-repository-documentdb.js`
1465
+ - [ ] `modules/repositories/__tests__/module-repository-documentdb-encryption.test.js` (NEW)
1466
+ - [ ] `credential/repositories/credential-repository-documentdb.js`
1467
+ - [ ] `credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js` (NEW)
1468
+
1469
+ **Verification Steps**:
1470
+
1471
+ For each file:
1472
+
1473
+ - [ ] Copy from `/tmp/frigg/` to `/backend/node_modules/@friggframework/`
1474
+ - [ ] Verify file checksums match
1475
+ - [ ] Run `diff` to confirm identical content
1476
+ - [ ] Check file permissions
1477
+
1478
+ **Script to Automate** (optional):
1479
+
1480
+ ```bash
1481
+ #!/bin/bash
1482
+ # sync-documentdb-encryption.sh
1483
+
1484
+ SOURCE="/Users/danielklotz/projects/lefthook/frontify--frigg/tmp/frigg/packages/core"
1485
+ DEST="/Users/danielklotz/projects/lefthook/frontify--frigg/backend/node_modules/@friggframework/core"
1486
+
1487
+ FILES=(
1488
+ "database/documentdb-encryption-service.js"
1489
+ "database/__tests__/documentdb-encryption-service.test.js"
1490
+ "user/repositories/user-repository-documentdb.js"
1491
+ "user/repositories/__tests__/user-repository-documentdb-encryption.test.js"
1492
+ "modules/repositories/module-repository-documentdb.js"
1493
+ "modules/repositories/__tests__/module-repository-documentdb-encryption.test.js"
1494
+ "credential/repositories/credential-repository-documentdb.js"
1495
+ "credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js"
1496
+ )
1497
+
1498
+ for file in "${FILES[@]}"; do
1499
+ cp "$SOURCE/$file" "$DEST/$file"
1500
+ echo "✅ Synced: $file"
1501
+ done
1502
+
1503
+ echo "🎉 All files synced successfully"
1504
+ ```
1505
+
1506
+ **Estimated Time**: 30 minutes
1507
+
1508
+ ---
1509
+
1510
+ ### Phase 7: Validation & Testing
1511
+
1512
+ #### 7.1 Run Test Suites
1513
+
1514
+ **Test Execution Checklist**:
1515
+
1516
+ - [ ] **Run DocumentDB encryption service tests**:
1517
+
1518
+ ```bash
1519
+ cd /Users/danielklotz/projects/lefthook/frontify--frigg/tmp/frigg
1520
+ npm test packages/core/database/__tests__/documentdb-encryption-service.test.js
1521
+ ```
1522
+
1523
+ - [ ] Verify all tests pass
1524
+ - [ ] Check coverage >90%
1525
+
1526
+ - [ ] **Run User repository encryption tests**:
1527
+
1528
+ ```bash
1529
+ npm test packages/core/user/repositories/__tests__/user-repository-documentdb-encryption.test.js
1530
+ ```
1531
+
1532
+ - [ ] Verify all tests pass
1533
+
1534
+ - [ ] **Run Module repository encryption tests**:
1535
+
1536
+ ```bash
1537
+ npm test packages/core/modules/repositories/__tests__/module-repository-documentdb-encryption.test.js
1538
+ ```
1539
+
1540
+ - [ ] Verify all tests pass
1541
+
1542
+ - [ ] **Run Credential repository encryption tests** (CRITICAL):
1543
+
1544
+ ```bash
1545
+ npm test packages/core/credential/repositories/__tests__/credential-repository-documentdb-encryption.test.js
1546
+ ```
1547
+
1548
+ - [ ] Verify all tests pass
1549
+ - [ ] Verify security test passes (encrypted format verification)
1550
+
1551
+ - [ ] **Run all repository tests**:
1552
+
1553
+ ```bash
1554
+ npm test -- --testPathPattern=documentdb
1555
+ ```
1556
+
1557
+ - [ ] Verify no regressions
1558
+
1559
+ - [ ] **Run full test suite**:
1560
+ ```bash
1561
+ npm test
1562
+ ```
1563
+ - [ ] Verify all tests pass
1564
+ - [ ] Check for no unexpected failures
1565
+
1566
+ ---
1567
+
1568
+ #### 7.2 Manual Verification
1569
+
1570
+ **Local Environment Setup**:
1571
+
1572
+ - [ ] Start MongoDB (DocumentDB simulation):
1573
+
1574
+ ```bash
1575
+ cd /Users/danielklotz/projects/lefthook/frontify--frigg/backend
1576
+ npm run docker:start
1577
+ ```
1578
+
1579
+ - [ ] Verify MongoDB is running:
1580
+
1581
+ ```bash
1582
+ docker ps | grep mongo
1583
+ ```
1584
+
1585
+ - [ ] Set environment variables for encryption:
1586
+
1587
+ ```bash
1588
+ export STAGE=production
1589
+ export AES_KEY_ID=local-test-key
1590
+ export AES_KEY=01234567890123456789012345678901 # 32 chars
1591
+ ```
1592
+
1593
+ - [ ] Start backend:
1594
+ ```bash
1595
+ cd /Users/danielklotz/projects/lefthook/frontify--frigg/backend
1596
+ npm run frigg:start
1597
+ ```
1598
+
1599
+ **Manual Test: Credential Creation**
1600
+
1601
+ - [ ] Create user and get token:
1602
+
1603
+ ```bash
1604
+ curl -X POST http://localhost:3000/user/create \
1605
+ -H "Content-Type: application/json" \
1606
+ -d '{"username":"test@test.com","password":"test"}' \
1607
+ -o /tmp/token.json
1608
+
1609
+ TOKEN=$(jq -r '.token' /tmp/token.json)
1610
+ echo "Token: $TOKEN"
1611
+ ```
1612
+
1613
+ - [ ] Create OAuth credential (if endpoint exists, else use Asana OAuth flow):
1614
+ ```bash
1615
+ # Trigger OAuth flow through application
1616
+ # Then verify credential was created encrypted
1617
+ ```
1618
+
1619
+ **Manual Test: Database Verification**
1620
+
1621
+ - [ ] Connect to MongoDB:
1622
+
1623
+ ```bash
1624
+ docker exec -it $(docker ps -q -f name=mongo) mongosh
1625
+ ```
1626
+
1627
+ - [ ] Query credential:
1628
+
1629
+ ```javascript
1630
+ use frigg
1631
+ db.Credential.findOne()
1632
+ ```
1633
+
1634
+ - [ ] **CRITICAL VERIFICATION**:
1635
+
1636
+ ```javascript
1637
+ // Check data.access_token format
1638
+ const cred = db.Credential.findOne({ externalId: 'google-user-123' });
1639
+ print('access_token:', cred.data.access_token);
1640
+
1641
+ // Expected format: "keyId:iv:cipher:encKey"
1642
+ // Example: "aes-key-1:1234567890abcdef:a1b2c3d4e5f6...:9876543210fedcba"
1643
+
1644
+ // MUST NOT be plain text like "ya29.a0AfH6SMCX..."
1645
+ ```
1646
+
1647
+ - [ ] Verify encrypted format:
1648
+ ```javascript
1649
+ // Should have 4+ colon-separated parts
1650
+ const parts = cred.data.access_token.split(':');
1651
+ print('Parts count:', parts.length); // Should be >= 4
1652
+ ```
1653
+
1654
+ **Manual Test: API Usage**
1655
+
1656
+ - [ ] Use credential through API:
1657
+
1658
+ ```bash
1659
+ # Make API request that uses the credential
1660
+ # Example: Fetch Asana user info
1661
+ curl -X GET http://localhost:3000/api/asana/me \
1662
+ -H "Authorization: Bearer $TOKEN"
1663
+ ```
1664
+
1665
+ - [ ] Verify API call succeeds (credential was decrypted correctly)
1666
+
1667
+ **Manual Test: Stage Bypass**
1668
+
1669
+ - [ ] Stop backend
1670
+
1671
+ - [ ] Change to dev stage:
1672
+
1673
+ ```bash
1674
+ export STAGE=dev
1675
+ unset AES_KEY_ID
1676
+ unset AES_KEY
1677
+ ```
1678
+
1679
+ - [ ] Start backend
1680
+
1681
+ - [ ] Create credential
1682
+
1683
+ - [ ] Verify credential stored as plain text (bypass worked):
1684
+ ```javascript
1685
+ // In mongosh:
1686
+ const devCred = db.Credential.findOne({ userId: ObjectId('...') });
1687
+ print('access_token:', devCred.data.access_token);
1688
+ // Should be plain text (not encrypted) in dev stage
1689
+ ```
1690
+
1691
+ ---
1692
+
1693
+ #### 7.3 Integration Testing
1694
+
1695
+ **OAuth Flow Testing**:
1696
+
1697
+ - [ ] **Asana OAuth Flow**:
1698
+
1699
+ - [ ] Start OAuth flow via Asana integration
1700
+ - [ ] Complete OAuth authorization
1701
+ - [ ] Verify credential created in database
1702
+ - [ ] Check credential is encrypted in database
1703
+ - [ ] Verify Asana API calls work (credential decrypted)
1704
+
1705
+ - [ ] **Frontify OAuth Flow**:
1706
+ - [ ] Start OAuth flow via Frontify integration
1707
+ - [ ] Complete OAuth authorization
1708
+ - [ ] Verify credential created in database
1709
+ - [ ] Check credential is encrypted in database
1710
+ - [ ] Verify Frontify API calls work
1711
+
1712
+ **Credential Refresh Testing**:
1713
+
1714
+ - [ ] Trigger token refresh (if implemented)
1715
+ - [ ] Verify new tokens are encrypted
1716
+ - [ ] Verify old tokens are overwritten (not duplicated)
1717
+ - [ ] Verify refresh token itself is encrypted
1718
+
1719
+ **Multi-User Testing**:
1720
+
1721
+ - [ ] Create credentials for 3 different users
1722
+ - [ ] Verify each credential is independently encrypted
1723
+ - [ ] Verify users can only access their own credentials
1724
+ - [ ] Check for no credential leakage between users
1725
+
1726
+ ---
1727
+
1728
+ #### 7.4 Performance Testing
1729
+
1730
+ **Encryption Performance**:
1731
+
1732
+ - [ ] Measure encryption time for single credential:
1733
+
1734
+ ```javascript
1735
+ const start = Date.now();
1736
+ const encrypted = await service.encryptFields('Credential', credential);
1737
+ const encryptTime = Date.now() - start;
1738
+ console.log(`Encryption time: ${encryptTime}ms`);
1739
+ // Should be < 50ms for KMS, < 10ms for AES
1740
+ ```
1741
+
1742
+ - [ ] Measure decryption time for single credential
1743
+
1744
+ **Bulk Operations**:
1745
+
1746
+ - [ ] Test bulk credential retrieval (10 credentials):
1747
+
1748
+ ```javascript
1749
+ const start = Date.now();
1750
+ const entities = await moduleRepo.findEntitiesByUserId(userId);
1751
+ const bulkTime = Date.now() - start;
1752
+ console.log(`Bulk retrieval time: ${bulkTime}ms`);
1753
+ // Should be reasonable (< 500ms for 10 credentials)
1754
+ ```
1755
+
1756
+ - [ ] Verify parallel decryption is used (not sequential)
1757
+
1758
+ ---
1759
+
1760
+ #### 7.5 Security Validation
1761
+
1762
+ **Encryption Format Verification**:
1763
+
1764
+ - [ ] Create credential with known value
1765
+ - [ ] Query database directly
1766
+ - [ ] Verify format matches: `keyId:iv:cipher:encKey`
1767
+ - [ ] Verify at least 4 colon-separated parts
1768
+ - [ ] Verify base64-like characters in each part
1769
+
1770
+ **Decryption Verification**:
1771
+
1772
+ - [ ] Create credential with known value
1773
+ - [ ] Retrieve via repository
1774
+ - [ ] Verify decrypted value matches original
1775
+ - [ ] Verify no corruption or truncation
1776
+
1777
+ **Negative Tests**:
1778
+
1779
+ - [ ] Manually corrupt encrypted value in database
1780
+ - [ ] Attempt to retrieve credential
1781
+ - [ ] Verify graceful handling (field set to null, logged error)
1782
+ - [ ] Verify application doesn't crash
1783
+
1784
+ **Key Rotation Simulation** (if time permits):
1785
+
1786
+ - [ ] Create credential with key1
1787
+ - [ ] Rotate to key2 (change AES_KEY_ID)
1788
+ - [ ] Verify old credentials still decrypt (backward compatible)
1789
+ - [ ] Verify new credentials use key2
1790
+
1791
+ **Estimated Time**: 1.5 hours
1792
+
1793
+ ---
1794
+
1795
+ ### Phase 8: Documentation Updates
1796
+
1797
+ #### 8.1 Update Main Encryption README
1798
+
1799
+ **File**: `packages/core/database/encryption/README.md`
1800
+
1801
+ **Sections to Add**:
1802
+
1803
+ - [ ] **Add "DocumentDB Encryption" section** (after "How It Works"):
1804
+
1805
+ ```markdown
1806
+ ## DocumentDB Encryption
1807
+
1808
+ ### Why DocumentDB Needs Manual Encryption
1809
+
1810
+ DocumentDB repositories use `$runCommandRaw()` for MongoDB protocol compatibility,
1811
+ which bypasses Prisma Client Extensions. This means the automatic encryption
1812
+ extension does not apply.
1813
+
1814
+ ### DocumentDBEncryptionService
1815
+
1816
+ For DocumentDB repositories, use `DocumentDBEncryptionService` to manually
1817
+ encrypt/decrypt documents before/after database operations.
1818
+
1819
+ #### Usage Example
1820
+
1821
+ \`\`\`javascript
1822
+ const { DocumentDBEncryptionService } = require('../documentdb-encryption-service');
1823
+ const { insertOne, findOne } = require('../documentdb-utils');
1824
+
1825
+ class MyRepositoryDocumentDB {
1826
+ constructor() {
1827
+ this.encryptionService = new DocumentDBEncryptionService();
1828
+ }
1829
+
1830
+ async create(data) {
1831
+ // Encrypt before write
1832
+ const encrypted = await this.encryptionService.encryptFields('ModelName', data);
1833
+ const id = await insertOne(this.prisma, 'CollectionName', encrypted);
1834
+
1835
+ // Decrypt after read
1836
+ const doc = await findOne(this.prisma, 'CollectionName', { _id: id });
1837
+ const decrypted = await this.encryptionService.decryptFields('ModelName', doc);
1838
+
1839
+ return decrypted;
1840
+ }
1841
+
1842
+ }
1843
+ \`\`\`
1844
+
1845
+ #### Configuration
1846
+
1847
+ Uses the same environment variables and Cryptor as the Prisma Extension:
1848
+
1849
+ - `STAGE`: Bypasses encryption for dev/test/local
1850
+ - `KMS_KEY_ARN`: AWS KMS encryption (production)
1851
+ - `AES_KEY_ID` + `AES_KEY`: AES encryption (fallback)
1852
+
1853
+ #### Implementation Details
1854
+
1855
+ See: [documentdb-encryption-service.md](./documentdb-encryption-service.md)
1856
+ ```
1857
+
1858
+ - [ ] **Update "Adding Encrypted Fields" section**:
1859
+
1860
+ ```markdown
1861
+ After adding fields to `encryption-schema-registry.js`:
1862
+
1863
+ 1. **For MongoDB/PostgreSQL**: No code changes needed (automatic)
1864
+ 2. **For DocumentDB**: Encryption is automatic via DocumentDBEncryptionService
1865
+ (service reads from same registry)
1866
+ ```
1867
+
1868
+ ---
1869
+
1870
+ #### 8.2 Repository JSDoc Comments
1871
+
1872
+ **UserRepositoryDocumentDB**:
1873
+
1874
+ - [ ] Add class-level JSDoc:
1875
+ ```javascript
1876
+ /**
1877
+ * User repository for DocumentDB.
1878
+ * Uses DocumentDBEncryptionService for field-level encryption.
1879
+ *
1880
+ * Encrypted fields: User.hashword
1881
+ *
1882
+ * @see DocumentDBEncryptionService
1883
+ * @see encryption-schema-registry.js
1884
+ */
1885
+ class UserRepositoryDocumentDB extends UserRepositoryInterface {
1886
+ ```
1887
+
1888
+ **ModuleRepositoryDocumentDB**:
1889
+
1890
+ - [ ] Add class-level JSDoc:
1891
+ ```javascript
1892
+ /**
1893
+ * Module/Entity repository for DocumentDB.
1894
+ * Uses DocumentDBEncryptionService for credential decryption.
1895
+ *
1896
+ * Encrypted fields: Credential.data.*
1897
+ *
1898
+ * Note: This repository only reads credentials. CredentialRepository
1899
+ * handles credential creation/updates with encryption.
1900
+ *
1901
+ * @see DocumentDBEncryptionService
1902
+ * @see CredentialRepositoryDocumentDB
1903
+ */
1904
+ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
1905
+ ```
1906
+
1907
+ **CredentialRepositoryDocumentDB**:
1908
+
1909
+ - [ ] Add class-level JSDoc:
1910
+ ```javascript
1911
+ /**
1912
+ * Credential repository for DocumentDB.
1913
+ * Uses DocumentDBEncryptionService for field-level encryption.
1914
+ *
1915
+ * Encrypted fields:
1916
+ * - Credential.data.access_token
1917
+ * - Credential.data.refresh_token
1918
+ * - Credential.data.id_token
1919
+ * - Credential.data.domain
1920
+ *
1921
+ * SECURITY CRITICAL: All OAuth credentials must be encrypted at rest.
1922
+ *
1923
+ * @see DocumentDBEncryptionService
1924
+ * @see encryption-schema-registry.js
1925
+ */
1926
+ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
1927
+ ```
1928
+
1929
+ **Estimated Time**: 30 minutes
1930
+
1931
+ ---
1932
+
1933
+ ## Total Implementation Time Estimate
1934
+
1935
+ | Phase | Description | Time |
1936
+ | --------- | ------------------------------------------ | ------------- |
1937
+ | Phase 1 | Create DocumentDBEncryptionService + tests | 2-3 hours |
1938
+ | Phase 2 | Refactor UserRepositoryDocumentDB | 1 hour |
1939
+ | Phase 3 | Refactor ModuleRepositoryDocumentDB | 1 hour |
1940
+ | Phase 4 | Fix CredentialRepositoryDocumentDB | 1.5 hours |
1941
+ | Phase 5 | Add comprehensive tests (3 repos) | 5 hours |
1942
+ | Phase 6 | Apply to both locations | 30 minutes |
1943
+ | Phase 7 | Validation and integration testing | 1.5 hours |
1944
+ | Phase 8 | Documentation updates | 30 minutes |
1945
+ | **Total** | | **~13 hours** |
1946
+
1947
+ ---
1948
+
1949
+ ## Code Examples
1950
+
1951
+ ### Example 1: Before & After - CredentialRepositoryDocumentDB
1952
+
1953
+ **BEFORE (Vulnerable - Plain Text Storage)**:
1954
+
1955
+ ```javascript
1956
+ class CredentialRepositoryDocumentDB {
1957
+ constructor() {
1958
+ this.prisma = prisma;
1959
+ // ❌ No encryption service
1960
+ }
1961
+
1962
+ async upsertCredential(credentialDetails) {
1963
+ const { identifiers, details } = credentialDetails;
1964
+ const { user, userId, authIsValid, externalId, ...oauthData } =
1965
+ details || {};
1966
+
1967
+ // ❌ oauthData contains PLAIN TEXT tokens
1968
+ const document = {
1969
+ userId: toObjectId(userId || user),
1970
+ externalId,
1971
+ data: oauthData, // ❌ { access_token: "plain_secret", ... }
1972
+ createdAt: new Date(),
1973
+ updatedAt: new Date(),
1974
+ };
1975
+
1976
+ // ❌ STORED AS PLAIN TEXT
1977
+ const insertedId = await insertOne(this.prisma, 'Credential', document);
1978
+
1979
+ const created = await findOne(this.prisma, 'Credential', {
1980
+ _id: insertedId,
1981
+ });
1982
+ // ❌ Returns encrypted string (if previously encrypted) or plain text
1983
+ return this._mapCredential(created);
1984
+ }
1985
+ }
1986
+ ```
1987
+
1988
+ **AFTER (Secure - Encrypted Storage)**:
1989
+
1990
+ ```javascript
1991
+ const {
1992
+ DocumentDBEncryptionService,
1993
+ } = require('../database/documentdb-encryption-service');
1994
+
1995
+ class CredentialRepositoryDocumentDB {
1996
+ constructor() {
1997
+ this.prisma = prisma;
1998
+ // ✅ Initialize encryption service
1999
+ this.encryptionService = new DocumentDBEncryptionService();
2000
+ }
2001
+
2002
+ async upsertCredential(credentialDetails) {
2003
+ const { identifiers, details } = credentialDetails;
2004
+ const { user, userId, authIsValid, externalId, ...oauthData } =
2005
+ details || {};
2006
+
2007
+ // Build plain text document
2008
+ const plainDocument = {
2009
+ userId: toObjectId(userId || user),
2010
+ externalId,
2011
+ data: oauthData, // Still plain text: { access_token: "plain_secret", ... }
2012
+ createdAt: new Date(),
2013
+ updatedAt: new Date(),
2014
+ };
2015
+
2016
+ // ✅ ENCRYPT before storing
2017
+ const encryptedDocument = await this.encryptionService.encryptFields(
2018
+ 'Credential',
2019
+ plainDocument
2020
+ );
2021
+ // encryptedDocument.data = { access_token: "keyId:iv:cipher:encKey", ... }
2022
+
2023
+ // ✅ STORED AS ENCRYPTED
2024
+ const insertedId = await insertOne(
2025
+ this.prisma,
2026
+ 'Credential',
2027
+ encryptedDocument
2028
+ );
2029
+
2030
+ const created = await findOne(this.prisma, 'Credential', {
2031
+ _id: insertedId,
2032
+ });
2033
+
2034
+ // ✅ DECRYPT before returning
2035
+ const decryptedCredential = await this.encryptionService.decryptFields(
2036
+ 'Credential',
2037
+ created
2038
+ );
2039
+ // decryptedCredential.data = { access_token: "plain_secret", ... }
2040
+
2041
+ return this._mapCredential(decryptedCredential);
2042
+ }
2043
+ }
2044
+ ```
2045
+
2046
+ ---
2047
+
2048
+ ### Example 2: DocumentDBEncryptionService Usage Patterns
2049
+
2050
+ **Pattern 1: Single Field Encryption (User.hashword)**:
2051
+
2052
+ ```javascript
2053
+ class UserRepositoryDocumentDB {
2054
+ async createIndividualUser(params) {
2055
+ const document = {
2056
+ type: 'INDIVIDUAL',
2057
+ username: params.username,
2058
+ hashword: await bcrypt.hash(params.hashword, 10), // Bcrypt hash
2059
+ createdAt: new Date(),
2060
+ };
2061
+
2062
+ // Encrypt bcrypt hash before storage
2063
+ const encrypted = await this.encryptionService.encryptFields(
2064
+ 'User',
2065
+ document
2066
+ );
2067
+ // encrypted.hashword = "keyId:iv:cipher:encKey"
2068
+
2069
+ const id = await insertOne(this.prisma, 'User', encrypted);
2070
+ const created = await findOne(this.prisma, 'User', { _id: id });
2071
+
2072
+ // Decrypt before returning
2073
+ const decrypted = await this.encryptionService.decryptFields(
2074
+ 'User',
2075
+ created
2076
+ );
2077
+ // decrypted.hashword = "$2b$10$..." (bcrypt hash)
2078
+
2079
+ return this._mapUser(decrypted);
2080
+ }
2081
+ }
2082
+ ```
2083
+
2084
+ **Pattern 2: Nested Fields Encryption (Credential.data.\*)**:
2085
+
2086
+ ```javascript
2087
+ class CredentialRepositoryDocumentDB {
2088
+ async upsertCredential(details) {
2089
+ const document = {
2090
+ data: {
2091
+ access_token: 'ya29.actual_token',
2092
+ refresh_token: '1//0refresh',
2093
+ id_token: 'eyJhbGci...',
2094
+ expires_at: 1234567890, // Not encrypted (not in registry)
2095
+ scope: 'openid profile', // Not encrypted
2096
+ },
2097
+ };
2098
+
2099
+ // Encrypts only fields defined in encryption-schema-registry.js
2100
+ const encrypted = await this.encryptionService.encryptFields(
2101
+ 'Credential',
2102
+ document
2103
+ );
2104
+ // encrypted.data = {
2105
+ // access_token: "keyId:iv:cipher:encKey", ← ENCRYPTED
2106
+ // refresh_token: "keyId:iv:cipher:encKey", ← ENCRYPTED
2107
+ // id_token: "keyId:iv:cipher:encKey", ← ENCRYPTED
2108
+ // expires_at: 1234567890, ← PLAIN (not in registry)
2109
+ // scope: "openid profile" ← PLAIN (not in registry)
2110
+ // }
2111
+ }
2112
+ }
2113
+ ```
2114
+
2115
+ **Pattern 3: Bulk Decryption (Multiple Credentials)**:
2116
+
2117
+ ```javascript
2118
+ class ModuleRepositoryDocumentDB {
2119
+ async _fetchCredentialsBulk(credentialIds) {
2120
+ const objectIds = credentialIds
2121
+ .map((id) => toObjectId(id))
2122
+ .filter(Boolean);
2123
+
2124
+ // Fetch all credentials (encrypted)
2125
+ const rawCredentials = await findMany(this.prisma, 'Credential', {
2126
+ _id: { $in: objectIds },
2127
+ });
2128
+
2129
+ // Decrypt in parallel
2130
+ const decryptionPromises = rawCredentials.map(async (rawCredential) => {
2131
+ const decrypted = await this.encryptionService.decryptFields(
2132
+ 'Credential',
2133
+ rawCredential
2134
+ );
2135
+ return this._mapCredential(decrypted);
2136
+ });
2137
+
2138
+ return await Promise.all(decryptionPromises);
2139
+ }
2140
+ }
2141
+ ```
2142
+
2143
+ ---
2144
+
2145
+ ### Example 3: Complete Flow - OAuth Credential Creation
2146
+
2147
+ ```javascript
2148
+ // 1. User completes OAuth flow, application receives tokens
2149
+ const oauthTokens = {
2150
+ access_token: 'ya29.a0AfH6SMCXyz...',
2151
+ refresh_token: '1//0gFz6TRvwUm...',
2152
+ id_token: 'eyJhbGciOiJSUzI1...',
2153
+ expires_in: 3600,
2154
+ token_type: 'Bearer',
2155
+ };
2156
+
2157
+ // 2. Use case calls repository
2158
+ const credential = await credentialRepository.upsertCredential({
2159
+ identifiers: { userId: 'user123', externalId: 'google-user-456' },
2160
+ details: oauthTokens,
2161
+ });
2162
+
2163
+ // 3. Inside repository: Build plain document
2164
+ const plainDocument = {
2165
+ userId: toObjectId('user123'),
2166
+ externalId: 'google-user-456',
2167
+ data: {
2168
+ access_token: 'ya29.a0AfH6SMCXyz...',
2169
+ refresh_token: '1//0gFz6TRvwUm...',
2170
+ id_token: 'eyJhbGciOiJSUzI1...',
2171
+ expires_in: 3600,
2172
+ token_type: 'Bearer',
2173
+ },
2174
+ };
2175
+
2176
+ // 4. DocumentDBEncryptionService encrypts sensitive fields
2177
+ const encryptedDocument = await this.encryptionService.encryptFields(
2178
+ 'Credential',
2179
+ plainDocument
2180
+ );
2181
+ // Result:
2182
+ // {
2183
+ // userId: ObjectId("..."),
2184
+ // externalId: "google-user-456",
2185
+ // data: {
2186
+ // access_token: "aes-key-1:a1b2c3:d4e5f6:g7h8i9", ← ENCRYPTED
2187
+ // refresh_token: "aes-key-1:j1k2l3:m4n5o6:p7q8r9", ← ENCRYPTED
2188
+ // id_token: "aes-key-1:s1t2u3:v4w5x6:y7z8a9", ← ENCRYPTED
2189
+ // expires_in: 3600, ← PLAIN (not in registry)
2190
+ // token_type: "Bearer" ← PLAIN (not in registry)
2191
+ // }
2192
+ // }
2193
+
2194
+ // 5. Store in DocumentDB
2195
+ await insertOne(this.prisma, 'Credential', encryptedDocument);
2196
+
2197
+ // 6. Read back from DocumentDB
2198
+ const rawDocument = await findOne(this.prisma, 'Credential', {
2199
+ userId: objectId,
2200
+ });
2201
+ // Returns encrypted data as stored
2202
+
2203
+ // 7. DocumentDBEncryptionService decrypts sensitive fields
2204
+ const decryptedDocument = await this.encryptionService.decryptFields(
2205
+ 'Credential',
2206
+ rawDocument
2207
+ );
2208
+ // Result:
2209
+ // {
2210
+ // data: {
2211
+ // access_token: "ya29.a0AfH6SMCXyz...", ← DECRYPTED
2212
+ // refresh_token: "1//0gFz6TRvwUm...", ← DECRYPTED
2213
+ // id_token: "eyJhbGciOiJSUzI1...", ← DECRYPTED
2214
+ // expires_in: 3600,
2215
+ // token_type: "Bearer"
2216
+ // }
2217
+ // }
2218
+
2219
+ // 8. Use case receives plain text credential
2220
+ return credential; // { access_token: "ya29...", refresh_token: "1//0...", ... }
2221
+
2222
+ // 9. Application makes API call
2223
+ await fetch('https://www.googleapis.com/oauth2/v1/userinfo', {
2224
+ headers: { Authorization: `Bearer ${credential.access_token}` },
2225
+ });
2226
+ // ✅ Works! Token is usable
2227
+ ```
2228
+
2229
+ ---
2230
+
2231
+ ## Testing Strategy
2232
+
2233
+ ### Unit Tests: DocumentDBEncryptionService
2234
+
2235
+ **Coverage Goals**:
2236
+
2237
+ - 100% line coverage
2238
+ - All branches covered
2239
+ - All error paths tested
2240
+
2241
+ **Key Test Cases**:
2242
+
2243
+ ```javascript
2244
+ describe('DocumentDBEncryptionService', () => {
2245
+ describe('Initialization', () => {
2246
+ it('bypasses encryption in dev stage', () => {
2247
+ process.env.STAGE = 'dev';
2248
+ const service = new DocumentDBEncryptionService();
2249
+ expect(service.enabled).toBe(false);
2250
+ expect(service.cryptor).toBeNull();
2251
+ });
2252
+
2253
+ it('enables KMS encryption in production with KMS_KEY_ARN', () => {
2254
+ process.env.STAGE = 'production';
2255
+ process.env.KMS_KEY_ARN =
2256
+ 'arn:aws:kms:us-east-1:123456789012:key/abc123';
2257
+ const service = new DocumentDBEncryptionService();
2258
+ expect(service.enabled).toBe(true);
2259
+ expect(service.cryptor.shouldUseAws).toBe(true);
2260
+ });
2261
+
2262
+ it('enables AES encryption in production with AES_KEY_ID', () => {
2263
+ process.env.STAGE = 'production';
2264
+ process.env.AES_KEY_ID = 'local-key';
2265
+ process.env.AES_KEY = '01234567890123456789012345678901';
2266
+ const service = new DocumentDBEncryptionService();
2267
+ expect(service.enabled).toBe(true);
2268
+ expect(service.cryptor.shouldUseAws).toBe(false);
2269
+ });
2270
+ });
2271
+
2272
+ describe('encryptFields()', () => {
2273
+ it('encrypts User.hashword', async () => {
2274
+ const document = {
2275
+ username: 'test@example.com',
2276
+ hashword: '$2b$10$plain_bcrypt_hash',
2277
+ };
2278
+
2279
+ const encrypted = await service.encryptFields('User', document);
2280
+
2281
+ expect(encrypted.username).toBe('test@example.com'); // Not encrypted
2282
+ expect(encrypted.hashword).not.toBe('$2b$10$plain_bcrypt_hash'); // Encrypted
2283
+ expect(encrypted.hashword).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/); // Format check
2284
+ });
2285
+
2286
+ it('encrypts Credential.data.access_token', async () => {
2287
+ const document = {
2288
+ userId: '123',
2289
+ data: {
2290
+ access_token: 'ya29.token_here',
2291
+ scope: 'openid profile', // Not in registry
2292
+ },
2293
+ };
2294
+
2295
+ const encrypted = await service.encryptFields(
2296
+ 'Credential',
2297
+ document
2298
+ );
2299
+
2300
+ expect(encrypted.data.access_token).not.toBe('ya29.token_here');
2301
+ expect(encrypted.data.access_token).toMatch(
2302
+ /^[^:]+:[^:]+:[^:]+:[^:]+$/
2303
+ );
2304
+ expect(encrypted.data.scope).toBe('openid profile'); // Not encrypted
2305
+ });
2306
+
2307
+ it('skips already encrypted values', async () => {
2308
+ const alreadyEncrypted = 'keyId:iv123:cipher456:enckey789';
2309
+ const document = { hashword: alreadyEncrypted };
2310
+
2311
+ const result = await service.encryptFields('User', document);
2312
+
2313
+ expect(result.hashword).toBe(alreadyEncrypted); // Unchanged
2314
+ });
2315
+
2316
+ it('returns unchanged for unknown model', async () => {
2317
+ const document = { field: 'value' };
2318
+ const result = await service.encryptFields(
2319
+ 'UnknownModel',
2320
+ document
2321
+ );
2322
+ expect(result).toEqual(document);
2323
+ });
2324
+ });
2325
+
2326
+ describe('decryptFields()', () => {
2327
+ it('decrypts User.hashword', async () => {
2328
+ const encryptedDoc = {
2329
+ username: 'test@example.com',
2330
+ hashword: 'keyId:iv:cipher:enckey', // Mock encrypted
2331
+ };
2332
+
2333
+ // Mock Cryptor to return known value
2334
+ mockCryptor.decrypt.mockResolvedValue('$2b$10$plain_bcrypt_hash');
2335
+
2336
+ const decrypted = await service.decryptFields('User', encryptedDoc);
2337
+
2338
+ expect(decrypted.hashword).toBe('$2b$10$plain_bcrypt_hash');
2339
+ expect(mockCryptor.decrypt).toHaveBeenCalledWith(
2340
+ 'keyId:iv:cipher:enckey'
2341
+ );
2342
+ });
2343
+
2344
+ it('handles decryption failures gracefully', async () => {
2345
+ const encryptedDoc = { hashword: 'corrupted:data:here:error' };
2346
+ mockCryptor.decrypt.mockRejectedValue(
2347
+ new Error('Decryption failed')
2348
+ );
2349
+
2350
+ const result = await service.decryptFields('User', encryptedDoc);
2351
+
2352
+ expect(result.hashword).toBeNull(); // Set to null on error
2353
+ });
2354
+
2355
+ it('parses JSON objects after decryption', async () => {
2356
+ const encryptedDoc = { data: { config: 'keyId:iv:cipher:enckey' } };
2357
+ const jsonObject = { nested: 'value', array: [1, 2, 3] };
2358
+ mockCryptor.decrypt.mockResolvedValue(JSON.stringify(jsonObject));
2359
+
2360
+ const result = await service.decryptFields(
2361
+ 'CustomModel',
2362
+ encryptedDoc
2363
+ );
2364
+
2365
+ expect(result.data.config).toEqual(jsonObject); // Parsed as object
2366
+ });
2367
+ });
2368
+ });
2369
+ ```
2370
+
2371
+ ---
2372
+
2373
+ ### Integration Tests: Repository Level
2374
+
2375
+ **CredentialRepositoryDocumentDB Security Tests**:
2376
+
2377
+ ```javascript
2378
+ describe('CredentialRepositoryDocumentDB - Security', () => {
2379
+ let repository;
2380
+ let prisma;
2381
+
2382
+ beforeAll(async () => {
2383
+ // Setup DocumentDB test database
2384
+ process.env.STAGE = 'production';
2385
+ process.env.AES_KEY_ID = 'test-key';
2386
+ process.env.AES_KEY = '01234567890123456789012345678901';
2387
+
2388
+ prisma = await connectPrisma();
2389
+ repository = new CredentialRepositoryDocumentDB({ prisma });
2390
+ });
2391
+
2392
+ afterAll(async () => {
2393
+ await disconnectPrisma();
2394
+ });
2395
+
2396
+ describe('CRITICAL: OAuth Token Encryption', () => {
2397
+ it('stores access_token encrypted in database', async () => {
2398
+ const userId = new ObjectId();
2399
+ const externalId = 'google-user-123';
2400
+ const plainToken = 'ya29.actual_google_token_here';
2401
+
2402
+ // Create credential via repository
2403
+ await repository.upsertCredential({
2404
+ identifiers: { userId: fromObjectId(userId), externalId },
2405
+ details: { access_token: plainToken, token_type: 'Bearer' },
2406
+ });
2407
+
2408
+ // Query database directly (bypass repository)
2409
+ const rawResult = await prisma.$runCommandRaw({
2410
+ find: 'Credential',
2411
+ filter: { userId, externalId },
2412
+ });
2413
+
2414
+ const storedCredential = rawResult.cursor.firstBatch[0];
2415
+ const storedToken = storedCredential.data.access_token;
2416
+
2417
+ // CRITICAL ASSERTIONS
2418
+ expect(storedToken).not.toBe(plainToken); // NOT plain text
2419
+ expect(storedToken).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/); // Encrypted format
2420
+ expect(storedToken.split(':').length).toBeGreaterThanOrEqual(4); // 4+ parts
2421
+
2422
+ // Verify repository returns decrypted
2423
+ const retrieved = await repository.findCredential({
2424
+ userId: fromObjectId(userId),
2425
+ externalId,
2426
+ });
2427
+ expect(retrieved.access_token).toBe(plainToken); // Decrypted
2428
+ });
2429
+
2430
+ it('encrypts refresh_token', async () => {
2431
+ const userId = new ObjectId();
2432
+ const plainRefresh = '1//0secret_refresh_token';
2433
+
2434
+ await repository.upsertCredential({
2435
+ identifiers: {
2436
+ userId: fromObjectId(userId),
2437
+ externalId: 'test-456',
2438
+ },
2439
+ details: { refresh_token: plainRefresh },
2440
+ });
2441
+
2442
+ const rawResult = await prisma.$runCommandRaw({
2443
+ find: 'Credential',
2444
+ filter: { userId },
2445
+ });
2446
+
2447
+ const stored = rawResult.cursor.firstBatch[0].data.refresh_token;
2448
+ expect(stored).not.toBe(plainRefresh);
2449
+ expect(stored).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/);
2450
+ });
2451
+
2452
+ it('encrypts id_token', async () => {
2453
+ const userId = new ObjectId();
2454
+ const plainIdToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...';
2455
+
2456
+ await repository.upsertCredential({
2457
+ identifiers: {
2458
+ userId: fromObjectId(userId),
2459
+ externalId: 'test-789',
2460
+ },
2461
+ details: { id_token: plainIdToken },
2462
+ });
2463
+
2464
+ const rawResult = await prisma.$runCommandRaw({
2465
+ find: 'Credential',
2466
+ filter: { userId },
2467
+ });
2468
+
2469
+ const stored = rawResult.cursor.firstBatch[0].data.id_token;
2470
+ expect(stored).not.toBe(plainIdToken);
2471
+ expect(stored).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/);
2472
+ });
2473
+
2474
+ it('does NOT encrypt non-sensitive fields', async () => {
2475
+ const userId = new ObjectId();
2476
+
2477
+ await repository.upsertCredential({
2478
+ identifiers: {
2479
+ userId: fromObjectId(userId),
2480
+ externalId: 'test-000',
2481
+ },
2482
+ details: {
2483
+ access_token: 'token123',
2484
+ expires_in: 3600, // Not in encrypted fields registry
2485
+ token_type: 'Bearer', // Not in registry
2486
+ scope: 'openid profile', // Not in registry
2487
+ },
2488
+ });
2489
+
2490
+ const rawResult = await prisma.$runCommandRaw({
2491
+ find: 'Credential',
2492
+ filter: { userId },
2493
+ });
2494
+
2495
+ const stored = rawResult.cursor.firstBatch[0].data;
2496
+
2497
+ // These should NOT be encrypted
2498
+ expect(stored.expires_in).toBe(3600);
2499
+ expect(stored.token_type).toBe('Bearer');
2500
+ expect(stored.scope).toBe('openid profile');
2501
+
2502
+ // But access_token should be encrypted
2503
+ expect(stored.access_token).toMatch(/^[^:]+:[^:]+:[^:]+:[^:]+$/);
2504
+ });
2505
+ });
2506
+
2507
+ describe('Full Integration Flow', () => {
2508
+ it('encrypts on insert, decrypts on read', async () => {
2509
+ const userId = new ObjectId();
2510
+ const plainData = {
2511
+ access_token: 'test_access_123',
2512
+ refresh_token: 'test_refresh_456',
2513
+ expires_in: 7200,
2514
+ };
2515
+
2516
+ // Insert
2517
+ const created = await repository.upsertCredential({
2518
+ identifiers: {
2519
+ userId: fromObjectId(userId),
2520
+ externalId: 'flow-test',
2521
+ },
2522
+ details: plainData,
2523
+ });
2524
+
2525
+ // Verify returned data is plain text
2526
+ expect(created.access_token).toBe('test_access_123');
2527
+ expect(created.refresh_token).toBe('test_refresh_456');
2528
+
2529
+ // Read via repository
2530
+ const retrieved = await repository.findCredential({
2531
+ userId: fromObjectId(userId),
2532
+ externalId: 'flow-test',
2533
+ });
2534
+
2535
+ // Verify decrypted correctly
2536
+ expect(retrieved.access_token).toBe('test_access_123');
2537
+ expect(retrieved.refresh_token).toBe('test_refresh_456');
2538
+
2539
+ // Verify database has encrypted values
2540
+ const rawResult = await prisma.$runCommandRaw({
2541
+ find: 'Credential',
2542
+ filter: { userId },
2543
+ });
2544
+ const stored = rawResult.cursor.firstBatch[0].data;
2545
+ expect(stored.access_token).not.toBe('test_access_123');
2546
+ expect(stored.refresh_token).not.toBe('test_refresh_456');
2547
+ });
2548
+ });
2549
+
2550
+ describe('Stage-Based Bypass', () => {
2551
+ it('bypasses encryption in dev stage', async () => {
2552
+ // Re-initialize with dev stage
2553
+ process.env.STAGE = 'dev';
2554
+ const devRepo = new CredentialRepositoryDocumentDB({ prisma });
2555
+
2556
+ const userId = new ObjectId();
2557
+ const plainToken = 'dev_token_plain';
2558
+
2559
+ await devRepo.upsertCredential({
2560
+ identifiers: {
2561
+ userId: fromObjectId(userId),
2562
+ externalId: 'dev-test',
2563
+ },
2564
+ details: { access_token: plainToken },
2565
+ });
2566
+
2567
+ // In dev, should be stored as plain text
2568
+ const rawResult = await prisma.$runCommandRaw({
2569
+ find: 'Credential',
2570
+ filter: { userId },
2571
+ });
2572
+ const stored = rawResult.cursor.firstBatch[0].data.access_token;
2573
+ expect(stored).toBe(plainToken); // Plain text in dev!
2574
+
2575
+ // Reset to production
2576
+ process.env.STAGE = 'production';
2577
+ });
2578
+ });
2579
+ });
2580
+ ```
2581
+
2582
+ ---
2583
+
2584
+ ### Manual Test Script
2585
+
2586
+ ```bash
2587
+ #!/bin/bash
2588
+ # manual-encryption-test.sh
2589
+ # Tests DocumentDB encryption manually
2590
+
2591
+ set -e
2592
+
2593
+ echo "🔐 DocumentDB Encryption Manual Test"
2594
+ echo "===================================="
2595
+
2596
+ # Setup
2597
+ export STAGE=production
2598
+ export AES_KEY_ID=test-manual-key
2599
+ export AES_KEY=01234567890123456789012345678901
2600
+
2601
+ echo "✅ Environment configured (production, AES encryption)"
2602
+
2603
+ # Start MongoDB
2604
+ echo "📦 Starting MongoDB..."
2605
+ docker-compose up -d mongo
2606
+ sleep 5
2607
+
2608
+ # Start backend
2609
+ echo "🚀 Starting backend..."
2610
+ cd backend
2611
+ npm run frigg:start &
2612
+ BACKEND_PID=$!
2613
+ sleep 10
2614
+
2615
+ # Create user
2616
+ echo "👤 Creating test user..."
2617
+ TOKEN=$(curl -s -X POST http://localhost:3000/user/create \
2618
+ -H "Content-Type: application/json" \
2619
+ -d '{"username":"test@encryption.com","password":"testpass"}' \
2620
+ | jq -r '.token')
2621
+
2622
+ echo "✅ User created, token: ${TOKEN:0:20}..."
2623
+
2624
+ # Trigger OAuth flow (simulated)
2625
+ echo "🔑 Simulating OAuth credential creation..."
2626
+ # Note: This would normally be done through OAuth flow
2627
+ # For testing, we can directly call credential creation endpoint if it exists
2628
+
2629
+ # Verify encryption in database
2630
+ echo "🔍 Verifying encryption in database..."
2631
+ docker exec -it $(docker ps -q -f name=mongo) mongosh --eval "
2632
+ use frigg;
2633
+ var cred = db.Credential.findOne();
2634
+ if (cred) {
2635
+ print('Found credential:');
2636
+ print(' ID: ' + cred._id);
2637
+ print(' access_token format: ' + cred.data.access_token);
2638
+
2639
+ var parts = cred.data.access_token.split(':');
2640
+ if (parts.length >= 4) {
2641
+ print(' ✅ ENCRYPTED (4+ parts)');
2642
+ } else {
2643
+ print(' ❌ NOT ENCRYPTED (plain text)');
2644
+ quit(1);
2645
+ }
2646
+ } else {
2647
+ print('⚠️ No credentials found');
2648
+ }
2649
+ "
2650
+
2651
+ echo "✅ Manual test complete"
2652
+
2653
+ # Cleanup
2654
+ kill $BACKEND_PID
2655
+ docker-compose down
2656
+ ```
2657
+
2658
+ ---
2659
+
2660
+ ## Migration Guide
2661
+
2662
+ ### For Existing Deployments with Plain Text Credentials
2663
+
2664
+ **⚠️ WARNING**: If DocumentDB repositories are already deployed and storing plain text credentials, follow this migration plan.
2665
+
2666
+ ---
2667
+
2668
+ ### Step 1: Assess the Damage
2669
+
2670
+ **Query Database for Plain Text Credentials**:
2671
+
2672
+ ```javascript
2673
+ // Run in mongosh on DocumentDB
2674
+
2675
+ use frigg;
2676
+
2677
+ // Check total credentials
2678
+ var totalCreds = db.Credential.countDocuments();
2679
+ print('Total credentials:', totalCreds);
2680
+
2681
+ // Sample credentials to check format
2682
+ var sampleCreds = db.Credential.find().limit(10).toArray();
2683
+
2684
+ sampleCreds.forEach(function(cred) {
2685
+ var token = cred.data?.access_token;
2686
+ if (!token) {
2687
+ print('Credential', cred._id, ': No access_token');
2688
+ return;
2689
+ }
2690
+
2691
+ var parts = token.split(':');
2692
+ if (parts.length >= 4) {
2693
+ print('Credential', cred._id, ': ENCRYPTED ✅');
2694
+ } else {
2695
+ print('Credential', cred._id, ': PLAIN TEXT ❌', token.substring(0, 20) + '...');
2696
+ }
2697
+ });
2698
+ ```
2699
+
2700
+ **Estimate Impact**:
2701
+
2702
+ - Number of affected credentials
2703
+ - Number of affected users
2704
+ - Third-party services (Asana, Frontify, etc.)
2705
+
2706
+ ---
2707
+
2708
+ ### Step 2: Immediate Security Response
2709
+
2710
+ **Priority Actions**:
2711
+
2712
+ 1. **Deploy Fix Immediately**:
2713
+
2714
+ ```bash
2715
+ # Deploy encryption fix to stop new plain text storage
2716
+ cd backend
2717
+ npm install @friggframework/core@latest # With encryption fix
2718
+ npm run deploy -- --stage production
2719
+ ```
2720
+
2721
+ 2. **Rotate All Affected Tokens**:
2722
+
2723
+ - Force OAuth re-authentication for all users
2724
+ - Revoke old tokens on third-party services
2725
+ - Generate new encrypted tokens
2726
+
2727
+ 3. **Audit Access**:
2728
+ - Review database access logs
2729
+ - Identify who had access to plain text credentials
2730
+ - Check for unauthorized API usage
2731
+
2732
+ ---
2733
+
2734
+ ### Step 3: Data Migration
2735
+
2736
+ **Migration Script** (`migrate-encrypt-credentials.js`):
2737
+
2738
+ ```javascript
2739
+ const {
2740
+ prisma,
2741
+ connectPrisma,
2742
+ disconnectPrisma,
2743
+ } = require('@friggframework/core/database/prisma');
2744
+ const {
2745
+ DocumentDBEncryptionService,
2746
+ } = require('@friggframework/core/database/documentdb-encryption-service');
2747
+ const {
2748
+ toObjectId,
2749
+ fromObjectId,
2750
+ } = require('@friggframework/core/database/documentdb-utils');
2751
+
2752
+ /**
2753
+ * Migrate plain text credentials to encrypted format.
2754
+ *
2755
+ * This script:
2756
+ * 1. Identifies plain text credentials
2757
+ * 2. Encrypts them using DocumentDBEncryptionService
2758
+ * 3. Updates database with encrypted values
2759
+ * 4. Verifies encryption
2760
+ */
2761
+ async function migrateCredentials() {
2762
+ console.log('🔐 Starting credential encryption migration...');
2763
+
2764
+ // Initialize
2765
+ await connectPrisma();
2766
+ const encryptionService = new DocumentDBEncryptionService();
2767
+
2768
+ if (!encryptionService.enabled) {
2769
+ console.error(
2770
+ '❌ Encryption not enabled! Check environment variables.'
2771
+ );
2772
+ process.exit(1);
2773
+ }
2774
+
2775
+ // Fetch all credentials
2776
+ const result = await prisma.$runCommandRaw({
2777
+ find: 'Credential',
2778
+ filter: {},
2779
+ });
2780
+
2781
+ const credentials = result.cursor.firstBatch;
2782
+ console.log(`📊 Found ${credentials.length} credentials`);
2783
+
2784
+ let encryptedCount = 0;
2785
+ let alreadyEncryptedCount = 0;
2786
+ let errorCount = 0;
2787
+
2788
+ for (const cred of credentials) {
2789
+ const credId = fromObjectId(cred._id);
2790
+
2791
+ try {
2792
+ // Check if already encrypted
2793
+ const token = cred.data?.access_token;
2794
+ if (!token) {
2795
+ console.log(
2796
+ `⏭️ Skipping credential ${credId} (no access_token)`
2797
+ );
2798
+ continue;
2799
+ }
2800
+
2801
+ const parts = token.split(':');
2802
+ if (parts.length >= 4) {
2803
+ console.log(`✅ Credential ${credId} already encrypted`);
2804
+ alreadyEncryptedCount++;
2805
+ continue;
2806
+ }
2807
+
2808
+ // Encrypt credential data
2809
+ console.log(`🔐 Encrypting credential ${credId}...`);
2810
+ const encryptedData = await encryptionService.encryptFields(
2811
+ 'Credential',
2812
+ {
2813
+ data: cred.data,
2814
+ }
2815
+ );
2816
+
2817
+ // Update database
2818
+ await prisma.$runCommandRaw({
2819
+ update: 'Credential',
2820
+ updates: [
2821
+ {
2822
+ q: { _id: cred._id },
2823
+ u: {
2824
+ $set: {
2825
+ data: encryptedData.data,
2826
+ updatedAt: new Date(),
2827
+ },
2828
+ },
2829
+ },
2830
+ ],
2831
+ });
2832
+
2833
+ console.log(`✅ Encrypted credential ${credId}`);
2834
+ encryptedCount++;
2835
+ } catch (error) {
2836
+ console.error(
2837
+ `❌ Failed to encrypt credential ${credId}:`,
2838
+ error.message
2839
+ );
2840
+ errorCount++;
2841
+ }
2842
+ }
2843
+
2844
+ console.log('\n📊 Migration Summary:');
2845
+ console.log(` Total credentials: ${credentials.length}`);
2846
+ console.log(` Encrypted: ${encryptedCount}`);
2847
+ console.log(` Already encrypted: ${alreadyEncryptedCount}`);
2848
+ console.log(` Errors: ${errorCount}`);
2849
+
2850
+ await disconnectPrisma();
2851
+ console.log('✅ Migration complete');
2852
+ }
2853
+
2854
+ // Run migration
2855
+ migrateCredentials().catch((error) => {
2856
+ console.error('💥 Migration failed:', error);
2857
+ process.exit(1);
2858
+ });
2859
+ ```
2860
+
2861
+ **Run Migration**:
2862
+
2863
+ ```bash
2864
+ # Set production environment variables
2865
+ export STAGE=production
2866
+ export KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/abc123
2867
+
2868
+ # Run migration
2869
+ node migrate-encrypt-credentials.js
2870
+
2871
+ # Verify
2872
+ node verify-encryption.js # See verification script below
2873
+ ```
2874
+
2875
+ ---
2876
+
2877
+ ### Step 4: Verification
2878
+
2879
+ **Verification Script** (`verify-encryption.js`):
2880
+
2881
+ ```javascript
2882
+ const {
2883
+ prisma,
2884
+ connectPrisma,
2885
+ disconnectPrisma,
2886
+ } = require('@friggframework/core/database/prisma');
2887
+
2888
+ async function verifyEncryption() {
2889
+ console.log('🔍 Verifying credential encryption...');
2890
+
2891
+ await connectPrisma();
2892
+
2893
+ const result = await prisma.$runCommandRaw({
2894
+ find: 'Credential',
2895
+ filter: {},
2896
+ });
2897
+
2898
+ const credentials = result.cursor.firstBatch;
2899
+ let passCount = 0;
2900
+ let failCount = 0;
2901
+
2902
+ for (const cred of credentials) {
2903
+ const token = cred.data?.access_token;
2904
+ if (!token) continue;
2905
+
2906
+ const parts = token.split(':');
2907
+ if (parts.length >= 4) {
2908
+ passCount++;
2909
+ } else {
2910
+ console.error(`❌ Plain text found in credential ${cred._id}`);
2911
+ failCount++;
2912
+ }
2913
+ }
2914
+
2915
+ await disconnectPrisma();
2916
+
2917
+ console.log('\n📊 Verification Results:');
2918
+ console.log(` Encrypted: ${passCount}`);
2919
+ console.log(` Plain text: ${failCount}`);
2920
+
2921
+ if (failCount > 0) {
2922
+ console.error(
2923
+ '\n❌ Verification failed! Plain text credentials still exist.'
2924
+ );
2925
+ process.exit(1);
2926
+ } else {
2927
+ console.log('\n✅ Verification passed! All credentials encrypted.');
2928
+ }
2929
+ }
2930
+
2931
+ verifyEncryption().catch((error) => {
2932
+ console.error('💥 Verification failed:', error);
2933
+ process.exit(1);
2934
+ });
2935
+ ```
2936
+
2937
+ ---
2938
+
2939
+ ### Step 5: Post-Migration Cleanup
2940
+
2941
+ 1. **Delete Migration Scripts**:
2942
+
2943
+ ```bash
2944
+ rm migrate-encrypt-credentials.js
2945
+ rm verify-encryption.js
2946
+ ```
2947
+
2948
+ 2. **Update Documentation**:
2949
+
2950
+ - Document the incident
2951
+ - Document lessons learned
2952
+ - Update security procedures
2953
+
2954
+ 3. **Monitor**:
2955
+ - Set up alerts for plain text detection
2956
+ - Monitor API error rates (in case decryption fails)
2957
+ - Watch for OAuth re-authentication requests
2958
+
2959
+ ---
2960
+
2961
+ ### Rollback Procedures
2962
+
2963
+ **If Migration Fails**:
2964
+
2965
+ 1. **Stop the migration script**
2966
+
2967
+ 2. **Restore from backup**:
2968
+
2969
+ ```bash
2970
+ # Restore MongoDB backup from before migration
2971
+ mongorestore --uri="mongodb://..." --archive=backup-before-migration.archive
2972
+ ```
2973
+
2974
+ 3. **Revert code deployment**:
2975
+
2976
+ ```bash
2977
+ # Rollback to previous version
2978
+ cd backend
2979
+ npm install @friggframework/core@<previous-version>
2980
+ npm run deploy -- --stage production
2981
+ ```
2982
+
2983
+ 4. **Investigate and fix issues**
2984
+
2985
+ 5. **Re-attempt migration with fixes**
2986
+
2987
+ ---
2988
+
2989
+ ### Zero-Downtime Migration Strategy
2990
+
2991
+ For large deployments:
2992
+
2993
+ 1. **Phase 1: Deploy encryption fix** (don't migrate yet)
2994
+
2995
+ - New credentials will be encrypted
2996
+ - Old credentials remain as-is
2997
+ - Application handles both encrypted and plain text
2998
+
2999
+ 2. **Phase 2: Migrate in batches**
3000
+
3001
+ ```javascript
3002
+ // Migrate 100 credentials at a time
3003
+ const batchSize = 100;
3004
+ for (let skip = 0; skip < totalCredentials; skip += batchSize) {
3005
+ await migrateBatch(skip, batchSize);
3006
+ await sleep(1000); // 1 second between batches
3007
+ }
3008
+ ```
3009
+
3010
+ 3. **Phase 3: Verify**
3011
+
3012
+ - Check random samples
3013
+ - Monitor error rates
3014
+ - Verify API calls still work
3015
+
3016
+ 4. **Phase 4: Complete**
3017
+ - Remove backward compatibility code
3018
+ - Update monitoring alerts
3019
+
3020
+ ---
3021
+
3022
+ ## Security Considerations
3023
+
3024
+ ### Encryption Format
3025
+
3026
+ **Envelope Encryption Pattern**:
3027
+
3028
+ ```
3029
+ keyId:iv:cipher:encKey
3030
+ ```
3031
+
3032
+ **Components**:
3033
+
3034
+ - `keyId`: Identifier for the encryption key (e.g., "aes-key-1", KMS key ID)
3035
+ - `iv`: Initialization vector (base64-encoded)
3036
+ - `cipher`: Encrypted data (base64-encoded)
3037
+ - `encKey`: Encrypted data encryption key (base64-encoded)
3038
+
3039
+ **Example**:
3040
+
3041
+ ```
3042
+ aes-key-1:MTIzNDU2Nzg5MGFiY2RlZg==:ZW5jcnlwdGVkX2RhdGFfaGVyZQ==:ZGVrX2VuY3J5cHRlZA==
3043
+ ```
3044
+
3045
+ ---
3046
+
3047
+ ### Key Management
3048
+
3049
+ **Production (KMS - Recommended)**:
3050
+
3051
+ ```bash
3052
+ # AWS KMS key is auto-discovered by Frigg infrastructure
3053
+ # Or set explicitly:
3054
+ export KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/abc-123-def-456
3055
+
3056
+ # Stage must be production
3057
+ export STAGE=production
3058
+ ```
3059
+
3060
+ **Benefits**:
3061
+
3062
+ - ✅ AWS-managed key rotation
3063
+ - ✅ Audit trail via CloudTrail
3064
+ - ✅ Fine-grained IAM permissions
3065
+ - ✅ Hardware security module (HSM) backed
3066
+ - ✅ Compliance-ready (HIPAA, PCI-DSS, etc.)
3067
+
3068
+ **Alternative (AES - Any Environment)**:
3069
+
3070
+ ```bash
3071
+ # Generate a 32-character key
3072
+ export AES_KEY_ID=my-app-key-v1
3073
+ export AES_KEY=$(openssl rand -hex 16) # 32 hex chars = 16 bytes
3074
+
3075
+ # Can be used in production
3076
+ export STAGE=production
3077
+ ```
3078
+
3079
+ **Benefits**:
3080
+
3081
+ - ✅ Works in any environment (no AWS required)
3082
+ - ✅ Faster than KMS (no network calls)
3083
+ - ✅ No AWS costs
3084
+
3085
+ **Drawbacks**:
3086
+
3087
+ - ⚠️ Must securely manage key yourself
3088
+ - ⚠️ No automatic key rotation
3089
+ - ⚠️ Key stored in environment/config
3090
+
3091
+ ---
3092
+
3093
+ ### Stage-Based Bypass
3094
+
3095
+ **Purpose**: Skip encryption in local development for easier debugging
3096
+
3097
+ **Bypassed Stages**:
3098
+
3099
+ - `dev`
3100
+ - `test`
3101
+ - `local`
3102
+
3103
+ **Production Stages** (encryption enabled):
3104
+
3105
+ - `production`
3106
+ - `prod`
3107
+ - `staging`
3108
+ - `stage`
3109
+ - Any other value
3110
+
3111
+ **Configuration**:
3112
+
3113
+ ```bash
3114
+ # Bypass encryption (dev)
3115
+ export STAGE=dev
3116
+ # DocumentDBEncryptionService.enabled = false
3117
+ # Data stored as plain text
3118
+
3119
+ # Enable encryption (production)
3120
+ export STAGE=production
3121
+ export KMS_KEY_ARN=...
3122
+ # DocumentDBEncryptionService.enabled = true
3123
+ # Data stored encrypted
3124
+ ```
3125
+
3126
+ **Security Note**: Never use `STAGE=dev` in production environments!
3127
+
3128
+ ---
3129
+
3130
+ ### Encrypted Fields Registry
3131
+
3132
+ **Location**: `packages/core/database/encryption/encryption-schema-registry.js`
3133
+
3134
+ **Current Encrypted Fields**:
3135
+
3136
+ ```javascript
3137
+ const ENCRYPTED_FIELDS = {
3138
+ User: ['hashword'],
3139
+ Credential: [
3140
+ 'data.access_token',
3141
+ 'data.refresh_token',
3142
+ 'data.id_token',
3143
+ 'data.domain',
3144
+ ],
3145
+ IntegrationMapping: ['mapping'],
3146
+ Token: ['token'],
3147
+ };
3148
+ ```
3149
+
3150
+ **Adding New Encrypted Fields**:
3151
+
3152
+ 1. Open `encryption-schema-registry.js`
3153
+ 2. Add field path to appropriate model:
3154
+ ```javascript
3155
+ Credential: [
3156
+ 'data.access_token',
3157
+ 'data.refresh_token',
3158
+ 'data.id_token',
3159
+ 'data.domain',
3160
+ 'data.client_secret', // ← NEW
3161
+ ];
3162
+ ```
3163
+ 3. Deploy - encryption applied automatically (no code changes needed)
3164
+
3165
+ **Field Path Examples**:
3166
+
3167
+ - Top-level: `hashword` → encrypts `document.hashword`
3168
+ - Nested: `data.access_token` → encrypts `document.data.access_token`
3169
+ - Deep nesting supported: `config.secrets.apiKey`
3170
+
3171
+ ---
3172
+
3173
+ ### Compliance & Best Practices
3174
+
3175
+ **GDPR Compliance**:
3176
+
3177
+ - ✅ Data encrypted at rest
3178
+ - ✅ Encryption keys managed securely
3179
+ - ✅ User data can be deleted (right to erasure)
3180
+
3181
+ **PCI-DSS Compliance** (if storing payment data):
3182
+
3183
+ - ✅ Encryption of cardholder data
3184
+ - ✅ Key management procedures
3185
+ - ✅ Audit logging (via CloudTrail with KMS)
3186
+
3187
+ **HIPAA Compliance** (if storing health data):
3188
+
3189
+ - ✅ Encryption at rest (required)
3190
+ - ✅ Access controls (AWS KMS IAM)
3191
+ - ✅ Audit trail (CloudTrail)
3192
+
3193
+ **Best Practices**:
3194
+
3195
+ 1. **Use KMS in production** - Better security, compliance, key rotation
3196
+ 2. **Rotate keys periodically** - Even with KMS, review and rotate annually
3197
+ 3. **Monitor decryption failures** - Alert on >1% failure rate
3198
+ 4. **Test encryption in CI/CD** - Automated tests verify encryption works
3199
+ 5. **Secure key storage** - Never commit keys to version control
3200
+ 6. **Least privilege access** - Limit who can decrypt data
3201
+
3202
+ ---
3203
+
3204
+ ### Security Audit Checklist
3205
+
3206
+ Before going to production:
3207
+
3208
+ - [ ] Verify `STAGE=production` in environment
3209
+ - [ ] Verify encryption keys configured (`KMS_KEY_ARN` or `AES_KEY_ID`)
3210
+ - [ ] Run security tests (verify encrypted format in database)
3211
+ - [ ] Test credential creation and retrieval end-to-end
3212
+ - [ ] Verify OAuth flows work (tokens decrypted correctly)
3213
+ - [ ] Check logs for decryption errors
3214
+ - [ ] Review IAM permissions (if using KMS)
3215
+ - [ ] Test key rotation procedure (if using KMS)
3216
+ - [ ] Document encryption architecture for auditors
3217
+ - [ ] Set up monitoring alerts (decryption failures, plain text detection)
3218
+
3219
+ ---
3220
+
3221
+ ## Maintenance & Future Work
3222
+
3223
+ ### Adding New DocumentDB Repositories
3224
+
3225
+ When creating a new DocumentDB repository that handles encrypted data:
3226
+
3227
+ 1. **Import DocumentDBEncryptionService**:
3228
+
3229
+ ```javascript
3230
+ const {
3231
+ DocumentDBEncryptionService,
3232
+ } = require('../database/documentdb-encryption-service');
3233
+ ```
3234
+
3235
+ 2. **Initialize in constructor**:
3236
+
3237
+ ```javascript
3238
+ constructor() {
3239
+ this.prisma = prisma;
3240
+ this.encryptionService = new DocumentDBEncryptionService();
3241
+ }
3242
+ ```
3243
+
3244
+ 3. **Encrypt before writes**:
3245
+
3246
+ ```javascript
3247
+ async create(data) {
3248
+ const encrypted = await this.encryptionService.encryptFields('ModelName', data);
3249
+ const id = await insertOne(this.prisma, 'CollectionName', encrypted);
3250
+ // ...
3251
+ }
3252
+ ```
3253
+
3254
+ 4. **Decrypt after reads**:
3255
+
3256
+ ```javascript
3257
+ async findById(id) {
3258
+ const doc = await findOne(this.prisma, 'CollectionName', { _id: toObjectId(id) });
3259
+ const decrypted = await this.encryptionService.decryptFields('ModelName', doc);
3260
+ return this._mapModel(decrypted);
3261
+ }
3262
+ ```
3263
+
3264
+ 5. **Add encrypted fields to registry** (if new model):
3265
+
3266
+ ```javascript
3267
+ // packages/core/database/encryption/encryption-schema-registry.js
3268
+ const ENCRYPTED_FIELDS = {
3269
+ // ... existing models
3270
+ NewModel: ['sensitiveField1', 'nested.field2'],
3271
+ };
3272
+ ```
3273
+
3274
+ 6. **Add tests** (see Phase 5 for test patterns)
3275
+
3276
+ ---
3277
+
3278
+ ### Adding New Encrypted Fields
3279
+
3280
+ To encrypt a new field in an existing model:
3281
+
3282
+ 1. **Update encryption-schema-registry.js**:
3283
+
3284
+ ```javascript
3285
+ const ENCRYPTED_FIELDS = {
3286
+ Credential: [
3287
+ 'data.access_token',
3288
+ 'data.refresh_token',
3289
+ 'data.id_token',
3290
+ 'data.domain',
3291
+ 'data.client_secret', // ← NEW FIELD
3292
+ ],
3293
+ };
3294
+ ```
3295
+
3296
+ 2. **No code changes needed** - DocumentDBEncryptionService reads from registry
3297
+
3298
+ 3. **Deploy** - new field will be encrypted automatically
3299
+
3300
+ 4. **Migrate existing data** (if field already has plain text values):
3301
+ ```javascript
3302
+ // Run migration script to encrypt existing plain text values
3303
+ // Similar to credential migration script
3304
+ ```
3305
+
3306
+ ---
3307
+
3308
+ ### Known Limitations
3309
+
3310
+ 1. **Performance**: Encryption/decryption adds latency
3311
+
3312
+ - KMS: ~50ms per field (network call to AWS)
3313
+ - AES: ~5-10ms per field (local crypto)
3314
+ - **Mitigation**: Use bulk operations, consider caching decrypted values
3315
+
3316
+ 2. **DocumentDB-specific**: Only needed for DocumentDB
3317
+
3318
+ - MongoDB/PostgreSQL use automatic Prisma Extension
3319
+ - Duplicate logic unavoidable (Prisma raw queries bypass extensions)
3320
+
3321
+ 3. **Manual encryption required**: Developers must remember to call service
3322
+
3323
+ - **Mitigation**: Code reviews, tests, linting rules
3324
+
3325
+ 4. **No transactional encryption**: Encryption happens outside transactions
3326
+
3327
+ - **Risk**: If encryption fails mid-operation, could leave inconsistent state
3328
+ - **Mitigation**: Encrypt before transaction starts, handle errors
3329
+
3330
+ 5. **Field-level only**: Doesn't encrypt entire documents or collections
3331
+ - **Alternative**: Use database-level encryption (AWS DocumentDB encryption at rest)
3332
+
3333
+ ---
3334
+
3335
+ ### Future Improvements
3336
+
3337
+ 1. **Automatic Repository Decorator**:
3338
+
3339
+ ```javascript
3340
+ // Potential future API
3341
+ @encryptDocumentDB(['User', 'Credential'])
3342
+ class MyRepositoryDocumentDB {
3343
+ // Encryption applied automatically by decorator
3344
+ }
3345
+ ```
3346
+
3347
+ 2. **Encryption Caching**:
3348
+
3349
+ - Cache decrypted values for frequently accessed credentials
3350
+ - Invalidate cache on credential update
3351
+ - Reduce KMS API calls
3352
+
3353
+ 3. **Field Compression**:
3354
+
3355
+ - Compress large fields before encryption
3356
+ - Reduce storage and transfer costs
3357
+ - Especially useful for `IntegrationMapping.mapping`
3358
+
3359
+ 4. **Key Versioning**:
3360
+
3361
+ - Support multiple active keys
3362
+ - Gradual key rotation without migration
3363
+ - Store key version with encrypted data
3364
+
3365
+ 5. **Encryption Metrics**:
3366
+
3367
+ - Track encryption/decryption performance
3368
+ - Monitor failure rates
3369
+ - Alert on anomalies
3370
+
3371
+ 6. **Integration with Prisma Extension**:
3372
+ - Potential future Prisma feature: Extension support for raw queries
3373
+ - Would eliminate need for DocumentDBEncryptionService
3374
+ - Track: https://github.com/prisma/prisma/issues/...
3375
+
3376
+ ---
3377
+
3378
+ ### Monitoring & Alerts
3379
+
3380
+ **Recommended Metrics**:
3381
+
3382
+ 1. **Encryption Failures**:
3383
+
3384
+ ```javascript
3385
+ // Log when encryption fails
3386
+ console.error('Encryption failed', { modelName, fieldPath, error });
3387
+ // Alert if >1% of operations fail
3388
+ ```
3389
+
3390
+ 2. **Decryption Failures**:
3391
+
3392
+ ```javascript
3393
+ // Log when decryption fails
3394
+ console.error('Decryption failed', { modelName, fieldPath, error });
3395
+ // Alert immediately (could indicate data corruption)
3396
+ ```
3397
+
3398
+ 3. **Plain Text Detection**:
3399
+
3400
+ ```javascript
3401
+ // Periodic scan of database
3402
+ // Alert if any plain text credentials found
3403
+ ```
3404
+
3405
+ 4. **Performance Metrics**:
3406
+ ```javascript
3407
+ // Track encryption/decryption time
3408
+ const start = Date.now();
3409
+ await service.encryptFields(...);
3410
+ const duration = Date.now() - start;
3411
+ metrics.histogram('encryption_duration_ms', duration);
3412
+ ```
3413
+
3414
+ **CloudWatch Dashboards** (for AWS deployments):
3415
+
3416
+ - Encryption operation count
3417
+ - Average encryption duration
3418
+ - Decryption failure rate
3419
+ - KMS API call count (if using KMS)
3420
+
3421
+ ---
3422
+
3423
+ ### Support & Troubleshooting
3424
+
3425
+ **Common Issues**:
3426
+
3427
+ 1. **"No encryption keys configured"**
3428
+
3429
+ - **Cause**: Missing `KMS_KEY_ARN` or `AES_KEY_ID` in production
3430
+ - **Fix**: Set environment variables, restart application
3431
+
3432
+ 2. **"Decryption failed"**
3433
+
3434
+ - **Cause**: Wrong key, corrupted data, or key rotation
3435
+ - **Fix**: Check key configuration, verify data integrity, check key version
3436
+
3437
+ 3. **"Cannot read property 'access_token' of undefined"**
3438
+
3439
+ - **Cause**: Credential data is null or decryption returned null
3440
+ - **Fix**: Check if credential exists, verify encryption didn't fail on write
3441
+
3442
+ 4. **"Encryption too slow"**
3443
+
3444
+ - **Cause**: Using KMS with high latency
3445
+ - **Fix**: Switch to AES for non-production, optimize KMS calls (batching)
3446
+
3447
+ 5. **"Credentials not encrypted after deployment"**
3448
+ - **Cause**: `STAGE=dev` in production, or missing encryption keys
3449
+ - **Fix**: Set `STAGE=production`, configure keys, redeploy
3450
+
3451
+ **Getting Help**:
3452
+
3453
+ - Check logs for error details
3454
+ - Review encryption-schema-registry.js configuration
3455
+ - Verify environment variables
3456
+ - Run health check: `curl http://localhost:3000/health/detailed`
3457
+ - Check encryption status in health response
3458
+
3459
+ ---
3460
+
3461
+ ## References
3462
+
3463
+ ### Related Files
3464
+
3465
+ **Core Encryption**:
3466
+
3467
+ - `packages/core/database/encryption/README.md` - Main encryption documentation
3468
+ - `packages/core/database/encryption/encryption-schema-registry.js` - Encrypted fields definition
3469
+ - `packages/core/database/encryption/field-encryption-service.js` - Field-level encryption (Prisma Extension)
3470
+ - `packages/core/database/encryption/prisma-encryption-extension.js` - Prisma Client Extension
3471
+ - `packages/core/encrypt/Cryptor.js` - Encryption adapter (KMS/AES)
3472
+
3473
+ **DocumentDB**:
3474
+
3475
+ - `packages/core/database/documentdb-utils.js` - Raw query utilities
3476
+ - `packages/core/database/prisma.js` - Prisma client initialization
3477
+
3478
+ **Repositories**:
3479
+
3480
+ - `packages/core/user/repositories/user-repository-documentdb.js` - User repository
3481
+ - `packages/core/modules/repositories/module-repository-documentdb.js` - Module/Entity repository
3482
+ - `packages/core/credential/repositories/credential-repository-documentdb.js` - Credential repository
3483
+ - `packages/core/integrations/repositories/integration-repository-documentdb.js` - Integration repository
3484
+
3485
+ **Tests**:
3486
+
3487
+ - `packages/core/database/encryption/*.test.js` - Encryption unit tests
3488
+ - `packages/core/**/repositories/__tests__/*.test.js` - Repository tests
3489
+
3490
+ ---
3491
+
3492
+ ### External Documentation
3493
+
3494
+ **Prisma**:
3495
+
3496
+ - [Prisma Client Extensions](https://www.prisma.io/docs/concepts/components/prisma-client/client-extensions)
3497
+ - [Raw Database Access](https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access)
3498
+ - [MongoDB Support](https://www.prisma.io/docs/concepts/database-connectors/mongodb)
3499
+
3500
+ **AWS DocumentDB**:
3501
+
3502
+ - [AWS DocumentDB Documentation](https://docs.aws.amazon.com/documentdb/)
3503
+ - [MongoDB Compatibility](https://docs.aws.amazon.com/documentdb/latest/developerguide/functional-differences.html)
3504
+
3505
+ **AWS KMS**:
3506
+
3507
+ - [AWS KMS Developer Guide](https://docs.aws.amazon.com/kms/latest/developerguide/)
3508
+ - [Envelope Encryption](https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#enveloping)
3509
+
3510
+ **Encryption Best Practices**:
3511
+
3512
+ - [OWASP Cryptographic Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html)
3513
+ - [NIST Encryption Standards](https://csrc.nist.gov/projects/cryptographic-standards-and-guidelines)
3514
+
3515
+ ---
3516
+
3517
+ ### Frigg Framework
3518
+
3519
+ **Core Documentation**:
3520
+
3521
+ - [Frigg Framework Docs](https://docs.friggframework.org)
3522
+ - [GitHub Repository](https://github.com/friggframework/frigg)
3523
+ - [Community Slack](https://friggframework.org/#contact)
3524
+
3525
+ **Related Issues**:
3526
+
3527
+ - GitHub Issue: DocumentDB encryption support [#TBD]
3528
+ - GitHub PR: Implement DocumentDBEncryptionService [#TBD]
3529
+
3530
+ ---
3531
+
3532
+ ## Appendix
3533
+
3534
+ ### Glossary
3535
+
3536
+ **Terms**:
3537
+
3538
+ - **DocumentDB**: AWS DocumentDB, a MongoDB-compatible database service
3539
+ - **Prisma Extension**: Prisma feature that intercepts and modifies queries
3540
+ - **Raw Query**: Low-level database command that bypasses Prisma ORM
3541
+ - **Envelope Encryption**: Encryption pattern using data keys encrypted by master keys
3542
+ - **KMS**: AWS Key Management Service
3543
+ - **AES**: Advanced Encryption Standard (symmetric encryption)
3544
+ - **Field-Level Encryption**: Encrypting individual fields within documents
3545
+
3546
+ **Acronyms**:
3547
+
3548
+ - **DRY**: Don't Repeat Yourself
3549
+ - **IAM**: Identity and Access Management
3550
+ - **HSM**: Hardware Security Module
3551
+ - **GDPR**: General Data Protection Regulation
3552
+ - **PCI-DSS**: Payment Card Industry Data Security Standard
3553
+ - **HIPAA**: Health Insurance Portability and Accountability Act
3554
+
3555
+ ---
3556
+
3557
+ ### Changelog
3558
+
3559
+ | Version | Date | Author | Changes |
3560
+ | ------- | ---------- | ------ | --------------------- |
3561
+ | 1.0 | 2025-01-13 | System | Initial documentation |
3562
+
3563
+ ---
3564
+
3565
+ ## Conclusion
3566
+
3567
+ This document provides a complete specification and implementation guide for the DocumentDBEncryptionService. Follow the phases sequentially, run all tests, and verify encryption at each step.
3568
+
3569
+ **Remember**: This is a **CRITICAL SECURITY** implementation. OAuth credentials MUST be encrypted at rest. Take the time to implement correctly and test thoroughly.
3570
+
3571
+ For questions or support, contact the Frigg team via GitHub issues or community Slack.
3572
+
3573
+ ---
3574
+
3575
+ **Document Status**: ✅ Ready for Implementation