@friggframework/core 2.0.0-next.43 → 2.0.0-next.45

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 (38) hide show
  1. package/database/config.js +29 -1
  2. package/database/use-cases/test-encryption-use-case.js +6 -5
  3. package/docs/PROCESS_MANAGEMENT_QUEUE_SPEC.md +517 -0
  4. package/handlers/WEBHOOKS.md +653 -0
  5. package/handlers/backend-utils.js +118 -3
  6. package/handlers/integration-event-dispatcher.test.js +68 -0
  7. package/handlers/routers/integration-webhook-routers.js +67 -0
  8. package/handlers/routers/integration-webhook-routers.test.js +126 -0
  9. package/handlers/webhook-flow.integration.test.js +356 -0
  10. package/handlers/workers/integration-defined-workers.test.js +184 -0
  11. package/index.js +16 -0
  12. package/integrations/WEBHOOK-QUICKSTART.md +151 -0
  13. package/integrations/integration-base.js +74 -3
  14. package/integrations/repositories/process-repository-factory.js +46 -0
  15. package/integrations/repositories/process-repository-interface.js +90 -0
  16. package/integrations/repositories/process-repository-mongo.js +190 -0
  17. package/integrations/repositories/process-repository-postgres.js +217 -0
  18. package/integrations/tests/doubles/dummy-integration-class.js +1 -8
  19. package/integrations/use-cases/create-process.js +128 -0
  20. package/integrations/use-cases/create-process.test.js +178 -0
  21. package/integrations/use-cases/get-process.js +87 -0
  22. package/integrations/use-cases/get-process.test.js +190 -0
  23. package/integrations/use-cases/index.js +8 -0
  24. package/integrations/use-cases/update-process-metrics.js +201 -0
  25. package/integrations/use-cases/update-process-metrics.test.js +308 -0
  26. package/integrations/use-cases/update-process-state.js +119 -0
  27. package/integrations/use-cases/update-process-state.test.js +256 -0
  28. package/package.json +5 -5
  29. package/prisma-mongodb/schema.prisma +44 -0
  30. package/prisma-postgresql/schema.prisma +45 -0
  31. package/queues/queuer-util.js +10 -0
  32. package/user/repositories/user-repository-mongo.js +53 -12
  33. package/user/repositories/user-repository-postgres.js +53 -14
  34. package/user/tests/use-cases/login-user.test.js +85 -5
  35. package/user/tests/user-password-encryption-isolation.test.js +237 -0
  36. package/user/tests/user-password-hashing.test.js +235 -0
  37. package/user/use-cases/login-user.js +1 -1
  38. package/user/user.js +2 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0-next.43",
4
+ "version": "2.0.0-next.45",
5
5
  "dependencies": {
6
6
  "@hapi/boom": "^10.0.1",
7
7
  "@prisma/client": "^6.16.3",
@@ -23,9 +23,9 @@
23
23
  "uuid": "^9.0.1"
24
24
  },
25
25
  "devDependencies": {
26
- "@friggframework/eslint-config": "2.0.0-next.43",
27
- "@friggframework/prettier-config": "2.0.0-next.43",
28
- "@friggframework/test": "2.0.0-next.43",
26
+ "@friggframework/eslint-config": "2.0.0-next.45",
27
+ "@friggframework/prettier-config": "2.0.0-next.45",
28
+ "@friggframework/test": "2.0.0-next.45",
29
29
  "@types/lodash": "4.17.15",
30
30
  "@typescript-eslint/eslint-plugin": "^8.0.0",
31
31
  "chai": "^4.3.6",
@@ -64,5 +64,5 @@
64
64
  "publishConfig": {
65
65
  "access": "public"
66
66
  },
67
- "gitHead": "2619db8a4e885887ad9071fe166cf0e8dee12d91"
67
+ "gitHead": "996a15bcdfaa4252b9891a33f1b1c84548d66bbc"
68
68
  }
@@ -46,6 +46,7 @@ model User {
46
46
  credentials Credential[]
47
47
  entities Entity[]
48
48
  integrations Integration[]
49
+ processes Process[]
49
50
 
50
51
  @@unique([email])
51
52
  @@unique([username])
@@ -172,6 +173,7 @@ model Integration {
172
173
  associations Association[]
173
174
  syncs Sync[]
174
175
  mappings IntegrationMapping[]
176
+ processes Process[]
175
177
 
176
178
  @@index([userId])
177
179
  @@index([status])
@@ -206,6 +208,48 @@ model IntegrationMapping {
206
208
  @@map("IntegrationMapping")
207
209
  }
208
210
 
211
+ // ============================================================================
212
+ // PROCESS MODELS
213
+ // ============================================================================
214
+
215
+ /// Generic Process Model - tracks any long-running operation
216
+ /// Used for: CRM syncs, data migrations, bulk operations, etc.
217
+ model Process {
218
+ id String @id @default(auto()) @map("_id") @db.ObjectId
219
+
220
+ // Core references
221
+ userId String @db.ObjectId
222
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
223
+ integrationId String @db.ObjectId
224
+ integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)
225
+
226
+ // Process identification
227
+ name String // e.g., "zoho-crm-contact-sync", "pipedrive-lead-sync"
228
+ type String // e.g., "CRM_SYNC", "DATA_MIGRATION", "BULK_OPERATION"
229
+
230
+ // State machine
231
+ state String // Current state (integration-defined states)
232
+
233
+ // Flexible storage
234
+ context Json @default("{}") // Process-specific data (pagination, metadata, etc.)
235
+ results Json @default("{}") // Process results and metrics
236
+
237
+ // Hierarchy support
238
+ childProcesses String[] @db.ObjectId
239
+ parentProcessId String? @db.ObjectId
240
+
241
+ // Timestamps
242
+ createdAt DateTime @default(now())
243
+ updatedAt DateTime @updatedAt
244
+
245
+ @@index([userId])
246
+ @@index([integrationId])
247
+ @@index([type])
248
+ @@index([state])
249
+ @@index([name])
250
+ @@map("Process")
251
+ }
252
+
209
253
  // ============================================================================
210
254
  // SYNC MODELS
211
255
  // ============================================================================
@@ -46,6 +46,7 @@ model User {
46
46
  credentials Credential[]
47
47
  entities Entity[]
48
48
  integrations Integration[]
49
+ processes Process[]
49
50
 
50
51
  @@unique([email])
51
52
  @@unique([username])
@@ -164,6 +165,7 @@ model Integration {
164
165
  associations Association[]
165
166
  syncs Sync[]
166
167
  mappings IntegrationMapping[]
168
+ processes Process[]
167
169
 
168
170
  @@index([userId])
169
171
  @@index([status])
@@ -281,6 +283,49 @@ enum AssociationType {
281
283
  MANY_TO_ONE
282
284
  }
283
285
 
286
+ // ============================================================================
287
+ // PROCESS MODELS
288
+ // ============================================================================
289
+
290
+ /// Generic Process Model - tracks any long-running operation
291
+ /// Used for: CRM syncs, data migrations, bulk operations, etc.
292
+ model Process {
293
+ id Int @id @default(autoincrement())
294
+
295
+ // Core references
296
+ userId Int
297
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
298
+ integrationId Int
299
+ integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade)
300
+
301
+ // Process identification
302
+ name String // e.g., "zoho-crm-contact-sync", "pipedrive-lead-sync"
303
+ type String // e.g., "CRM_SYNC", "DATA_MIGRATION", "BULK_OPERATION"
304
+
305
+ // State machine
306
+ state String // Current state (integration-defined states)
307
+
308
+ // Flexible storage
309
+ context Json @default("{}") // Process-specific data (pagination, metadata, etc.)
310
+ results Json @default("{}") // Process results and metrics
311
+
312
+ // Hierarchy support - self-referential relation
313
+ parentProcessId Int?
314
+ parentProcess Process? @relation("ProcessHierarchy", fields: [parentProcessId], references: [id], onDelete: SetNull)
315
+ childProcesses Process[] @relation("ProcessHierarchy")
316
+
317
+ // Timestamps
318
+ createdAt DateTime @default(now())
319
+ updatedAt DateTime @updatedAt
320
+
321
+ @@index([userId])
322
+ @@index([integrationId])
323
+ @@index([type])
324
+ @@index([state])
325
+ @@index([name])
326
+ @@index([parentProcessId])
327
+ }
328
+
284
329
  // ============================================================================
285
330
  // UTILITY MODELS
286
331
  // ============================================================================
@@ -14,6 +14,16 @@ AWS.config.update(awsConfigOptions());
14
14
  const sqs = new AWS.SQS();
15
15
 
16
16
  const QueuerUtil = {
17
+ send: async (message, queueUrl) => {
18
+ console.log(`Enqueuing message to SQS queue ${queueUrl}`);
19
+ return sqs
20
+ .sendMessage({
21
+ MessageBody: JSON.stringify(message),
22
+ QueueUrl: queueUrl,
23
+ })
24
+ .promise();
25
+ },
26
+
17
27
  batchSend: async (entries = [], queueUrl) => {
18
28
  console.log(
19
29
  `Enqueuing ${entries.length} entries on SQS to queue ${queueUrl}`
@@ -1,4 +1,4 @@
1
- //todo: this repository is tightly coupled to the token repository.
1
+ const bcrypt = require('bcryptjs');
2
2
  const { prisma } = require('../../database/prisma');
3
3
  const {
4
4
  createTokenRepository,
@@ -95,19 +95,38 @@ class UserRepositoryMongo extends UserRepositoryInterface {
95
95
  * Replaces: IndividualUser.create(params)
96
96
  *
97
97
  * @param {Object} params - User creation parameters
98
+ * @param {string} [params.hashword] - Plain text password (will be bcrypt hashed automatically)
98
99
  * @returns {Promise<Object>} Created user object with string IDs
99
100
  */
100
101
  async createIndividualUser(params) {
101
- return await this.prisma.user.create({
102
- data: {
103
- type: 'INDIVIDUAL',
104
- email: params.email,
105
- username: params.username,
106
- hashword: params.hashword,
107
- appUserId: params.appUserId,
108
- organizationId: params.organization || params.organizationId,
109
- },
110
- });
102
+ const data = {
103
+ type: 'INDIVIDUAL',
104
+ email: params.email,
105
+ username: params.username,
106
+ appUserId: params.appUserId,
107
+ organizationId: params.organization || params.organizationId,
108
+ };
109
+
110
+ if (
111
+ params.hashword !== undefined &&
112
+ params.hashword !== null &&
113
+ params.hashword !== ''
114
+ ) {
115
+ if (typeof params.hashword !== 'string') {
116
+ throw new Error('Password must be a string');
117
+ }
118
+
119
+ // Prevent double-hashing: bcrypt hashes start with $2a$ or $2b$
120
+ if (params.hashword.startsWith('$2')) {
121
+ throw new Error(
122
+ 'Password appears to be already hashed. Pass plain text password only.'
123
+ );
124
+ }
125
+
126
+ data.hashword = await bcrypt.hash(params.hashword, 10);
127
+ }
128
+
129
+ return await this.prisma.user.create({ data });
111
130
  }
112
131
 
113
132
  /**
@@ -204,12 +223,34 @@ class UserRepositoryMongo extends UserRepositoryInterface {
204
223
  * Update individual user
205
224
  * @param {string} userId - User ID
206
225
  * @param {Object} updates - Fields to update
226
+ * @param {string} [updates.hashword] - Plain text password (will be bcrypt hashed automatically)
207
227
  * @returns {Promise<Object>} Updated user object with string IDs
208
228
  */
209
229
  async updateIndividualUser(userId, updates) {
230
+ const data = { ...updates };
231
+
232
+ if (
233
+ data.hashword !== undefined &&
234
+ data.hashword !== null &&
235
+ data.hashword !== ''
236
+ ) {
237
+ if (typeof data.hashword !== 'string') {
238
+ throw new Error('Password must be a string');
239
+ }
240
+
241
+ // Prevent double-hashing: bcrypt hashes start with $2a$ or $2b$
242
+ if (data.hashword.startsWith('$2')) {
243
+ throw new Error(
244
+ 'Password appears to be already hashed. Pass plain text password only.'
245
+ );
246
+ }
247
+
248
+ data.hashword = await bcrypt.hash(data.hashword, 10);
249
+ }
250
+
210
251
  return await this.prisma.user.update({
211
252
  where: { id: userId },
212
- data: updates,
253
+ data,
213
254
  });
214
255
  }
215
256
 
@@ -1,4 +1,4 @@
1
- //todo: this repository is tightly coupled to the token repository.
1
+ const bcrypt = require('bcryptjs');
2
2
  const { prisma } = require('../../database/prisma');
3
3
  const {
4
4
  createTokenRepository,
@@ -130,21 +130,40 @@ class UserRepositoryPostgres extends UserRepositoryInterface {
130
130
  * Replaces: IndividualUser.create(params)
131
131
  *
132
132
  * @param {Object} params - User creation parameters (with string IDs from application layer)
133
+ * @param {string} [params.hashword] - Plain text password (will be bcrypt hashed automatically)
133
134
  * @returns {Promise<Object>} Created user object with string IDs
134
135
  */
135
136
  async createIndividualUser(params) {
136
- const user = await this.prisma.user.create({
137
- data: {
138
- type: 'INDIVIDUAL',
139
- email: params.email,
140
- username: params.username,
141
- hashword: params.hashword,
142
- appUserId: params.appUserId,
143
- organizationId: this._convertId(
144
- params.organization || params.organizationId
145
- ),
146
- },
147
- });
137
+ const data = {
138
+ type: 'INDIVIDUAL',
139
+ email: params.email,
140
+ username: params.username,
141
+ appUserId: params.appUserId,
142
+ organizationId: this._convertId(
143
+ params.organization || params.organizationId
144
+ ),
145
+ };
146
+
147
+ if (
148
+ params.hashword !== undefined &&
149
+ params.hashword !== null &&
150
+ params.hashword !== ''
151
+ ) {
152
+ if (typeof params.hashword !== 'string') {
153
+ throw new Error('Password must be a string');
154
+ }
155
+
156
+ // Prevent double-hashing: bcrypt hashes start with $2a$ or $2b$
157
+ if (params.hashword.startsWith('$2')) {
158
+ throw new Error(
159
+ 'Password appears to be already hashed. Pass plain text password only.'
160
+ );
161
+ }
162
+
163
+ data.hashword = await bcrypt.hash(params.hashword, 10);
164
+ }
165
+
166
+ const user = await this.prisma.user.create({ data });
148
167
  return this._convertUserIds(user);
149
168
  }
150
169
 
@@ -249,13 +268,14 @@ class UserRepositoryPostgres extends UserRepositoryInterface {
249
268
  * Update individual user
250
269
  * @param {string} userId - User ID (string from application layer)
251
270
  * @param {Object} updates - Fields to update (with string IDs from application layer)
271
+ * @param {string} [updates.hashword] - Plain text password (will be bcrypt hashed automatically)
252
272
  * @returns {Promise<Object>} Updated user object with string IDs
253
273
  */
254
274
  async updateIndividualUser(userId, updates) {
255
275
  const intId = this._convertId(userId);
256
276
 
257
- // Convert organizationId if present in updates
258
277
  const data = { ...updates };
278
+
259
279
  if (data.organizationId !== undefined) {
260
280
  data.organizationId = this._convertId(data.organizationId);
261
281
  }
@@ -264,6 +284,25 @@ class UserRepositoryPostgres extends UserRepositoryInterface {
264
284
  delete data.organization;
265
285
  }
266
286
 
287
+ if (
288
+ data.hashword !== undefined &&
289
+ data.hashword !== null &&
290
+ data.hashword !== ''
291
+ ) {
292
+ if (typeof data.hashword !== 'string') {
293
+ throw new Error('Password must be a string');
294
+ }
295
+
296
+ // Prevent double-hashing: bcrypt hashes start with $2a$ or $2b$
297
+ if (data.hashword.startsWith('$2')) {
298
+ throw new Error(
299
+ 'Password appears to be already hashed. Pass plain text password only.'
300
+ );
301
+ }
302
+
303
+ data.hashword = await bcrypt.hash(data.hashword, 10);
304
+ }
305
+
267
306
  const user = await this.prisma.user.update({
268
307
  where: { id: intId },
269
308
  data,
@@ -3,7 +3,7 @@ const { LoginUser } = require('../../use-cases/login-user');
3
3
  const { TestUserRepository } = require('../doubles/test-user-repository');
4
4
 
5
5
  jest.mock('bcryptjs', () => ({
6
- compareSync: jest.fn(),
6
+ compare: jest.fn(),
7
7
  }));
8
8
 
9
9
  describe('LoginUser Use Case', () => {
@@ -16,7 +16,7 @@ describe('LoginUser Use Case', () => {
16
16
  userRepository = new TestUserRepository({ userConfig });
17
17
  loginUser = new LoginUser({ userRepository, userConfig });
18
18
 
19
- bcrypt.compareSync.mockClear();
19
+ bcrypt.compare.mockClear();
20
20
  });
21
21
 
22
22
  describe('With Password Authentication', () => {
@@ -28,11 +28,11 @@ describe('LoginUser Use Case', () => {
28
28
  hashword: 'hashed-password',
29
29
  });
30
30
 
31
- bcrypt.compareSync.mockReturnValue(true);
31
+ bcrypt.compare.mockResolvedValue(true);
32
32
 
33
33
  const user = await loginUser.execute({ username, password });
34
34
 
35
- expect(bcrypt.compareSync).toHaveBeenCalledWith(
35
+ expect(bcrypt.compare).toHaveBeenCalledWith(
36
36
  password,
37
37
  'hashed-password'
38
38
  );
@@ -48,7 +48,7 @@ describe('LoginUser Use Case', () => {
48
48
  hashword: 'hashed-password',
49
49
  });
50
50
 
51
- bcrypt.compareSync.mockReturnValue(false);
51
+ bcrypt.compare.mockResolvedValue(false);
52
52
 
53
53
  await expect(
54
54
  loginUser.execute({ username, password })
@@ -137,4 +137,84 @@ describe('LoginUser Use Case', () => {
137
137
  ).rejects.toThrow('user not found');
138
138
  });
139
139
  });
140
+
141
+ describe('Bcrypt Hash Verification', () => {
142
+ beforeEach(() => {
143
+ userConfig = { usePassword: true, individualUserRequired: true, organizationUserRequired: false };
144
+ userRepository = new TestUserRepository({ userConfig });
145
+ loginUser = new LoginUser({ userRepository, userConfig });
146
+ });
147
+
148
+ it('should verify bcrypt.compare is called with plain password and hash', async () => {
149
+ const username = 'bcrypt-test-user';
150
+ const plainPassword = 'MyPlainPassword123';
151
+ const bcryptHash = '$2b$10$abcdefghijklmnopqrstuv';
152
+
153
+ await userRepository.createIndividualUser({
154
+ username,
155
+ hashword: bcryptHash,
156
+ });
157
+
158
+ bcrypt.compare.mockResolvedValue(true);
159
+
160
+ await loginUser.execute({ username, password: plainPassword });
161
+
162
+ expect(bcrypt.compare).toHaveBeenCalledTimes(1);
163
+ expect(bcrypt.compare).toHaveBeenCalledWith(plainPassword, bcryptHash);
164
+
165
+ const [firstArg, secondArg] = bcrypt.compare.mock.calls[0];
166
+ expect(firstArg).toBe(plainPassword);
167
+ expect(secondArg).toBe(bcryptHash);
168
+ });
169
+
170
+ it('should verify stored password has bcrypt hash format', async () => {
171
+ const username = 'format-test-user';
172
+ const bcryptHash = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
173
+
174
+ await userRepository.createIndividualUser({
175
+ username,
176
+ hashword: bcryptHash,
177
+ });
178
+
179
+ const user = await userRepository.findIndividualUserByUsername(username);
180
+
181
+ expect(user.hashword).toMatch(/^\$2[ab]\$/);
182
+ expect(user.hashword.length).toBeGreaterThan(50);
183
+ expect(user.hashword).not.toContain(':');
184
+ });
185
+
186
+ it('should reject passwords that look encrypted (have colon separators)', async () => {
187
+ const username = 'encrypted-format-user';
188
+ const encryptedLookingValue = 'kms:us-east-1:key:ciphertext';
189
+
190
+ await userRepository.createIndividualUser({
191
+ username,
192
+ hashword: encryptedLookingValue,
193
+ });
194
+
195
+ bcrypt.compare.mockResolvedValue(false);
196
+
197
+ await expect(
198
+ loginUser.execute({ username, password: 'any-password' })
199
+ ).rejects.toThrow('Incorrect username or password');
200
+ });
201
+
202
+ it('should verify bcrypt.compare returns false for mismatched passwords', async () => {
203
+ const username = 'mismatch-test-user';
204
+ const correctHash = '$2b$10$correcthash';
205
+
206
+ await userRepository.createIndividualUser({
207
+ username,
208
+ hashword: correctHash,
209
+ });
210
+
211
+ bcrypt.compare.mockResolvedValue(false);
212
+
213
+ await expect(
214
+ loginUser.execute({ username, password: 'wrong-password' })
215
+ ).rejects.toThrow('Incorrect username or password');
216
+
217
+ expect(bcrypt.compare).toHaveBeenCalledWith('wrong-password', correctHash);
218
+ });
219
+ });
140
220
  });