@friggframework/core 2.0.0-next.56 → 2.0.0-next.57

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.
@@ -106,7 +106,7 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
106
106
  const updateDocument = {
107
107
  userId: existing.userId,
108
108
  externalId: existing.externalId,
109
- authIsValid: authIsValid,
109
+ authIsValid: authIsValid !== undefined ? authIsValid : existing.authIsValid,
110
110
  data: mergedData,
111
111
  updatedAt: now,
112
112
  };
@@ -143,7 +143,7 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
143
143
  }
144
144
 
145
145
  const plainDocument = {
146
- userId: identifiers.userId,
146
+ userId: toObjectId(identifiers.userId),
147
147
  externalId: identifiers.externalId,
148
148
  authIsValid: details.authIsValid,
149
149
  data: { ...oauthData },
@@ -245,7 +245,7 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
245
245
  if (idObj) filter._id = idObj;
246
246
  }
247
247
  if (identifiers.userId) {
248
- filter.userId = identifiers.userId;
248
+ filter.userId = toObjectId(identifiers.userId);
249
249
  }
250
250
  if (identifiers.externalId !== undefined) {
251
251
  filter.externalId = identifiers.externalId;
@@ -121,7 +121,7 @@ class CredentialRepositoryMongo extends CredentialRepositoryInterface {
121
121
  data: {
122
122
  userId: existing.userId,
123
123
  externalId: existing.externalId,
124
- authIsValid: authIsValid,
124
+ authIsValid: authIsValid !== undefined ? authIsValid : existing.authIsValid,
125
125
  data: mergedData,
126
126
  },
127
127
  });
@@ -111,7 +111,8 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
111
111
  if (!identifiers)
112
112
  throw new Error('identifiers required to upsert credential');
113
113
 
114
- if (!identifiers.userId) {
114
+ // Support both userId (preferred) and user (legacy) for backward compatibility
115
+ if (!identifiers.userId && !identifiers.user) {
115
116
  throw new Error('userId required in identifiers');
116
117
  }
117
118
  if (!identifiers.externalId) {
@@ -138,7 +139,7 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
138
139
  data: {
139
140
  userId: this._convertId(existing.userId),
140
141
  externalId: existing.externalId,
141
- authIsValid: authIsValid,
142
+ authIsValid: authIsValid !== undefined ? authIsValid : existing.authIsValid,
142
143
  data: mergedData,
143
144
  },
144
145
  });
@@ -154,7 +155,8 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
154
155
 
155
156
  const created = await this.prisma.credential.create({
156
157
  data: {
157
- userId: this._convertId(identifiers.userId),
158
+ // Use userId from where clause (supports both userId and user fields)
159
+ userId: where.userId,
158
160
  externalId,
159
161
  authIsValid: authIsValid,
160
162
  data: oauthData,
@@ -257,8 +259,11 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
257
259
  const where = {};
258
260
 
259
261
  if (identifiers.id) where.id = this._convertId(identifiers.id);
262
+ // Support both userId (preferred) and user (legacy) for backward compatibility
260
263
  if (identifiers.userId)
261
264
  where.userId = this._convertId(identifiers.userId);
265
+ else if (identifiers.user)
266
+ where.userId = this._convertId(identifiers.user);
262
267
  if (identifiers.externalId) where.externalId = identifiers.externalId;
263
268
 
264
269
  return where;
package/database/index.js CHANGED
@@ -12,13 +12,14 @@
12
12
  * etc.
13
13
  */
14
14
 
15
- const { mongoose } = require('./mongoose');
16
- const { IndividualUser } = require('./models/IndividualUser');
17
- const { OrganizationUser } = require('./models/OrganizationUser');
18
- const { UserModel } = require('./models/UserModel');
19
- const { WebsocketConnection } = require('./models/WebsocketConnection');
15
+ // Lazy-load mongoose to avoid importing mongodb when using PostgreSQL only
16
+ let _mongoose = null;
17
+ let _IndividualUser = null;
18
+ let _OrganizationUser = null;
19
+ let _UserModel = null;
20
+ let _WebsocketConnection = null;
20
21
 
21
- // Prisma exports
22
+ // Prisma exports (always available)
22
23
  const { prisma } = require('./prisma');
23
24
  const { TokenRepository } = require('../token/repositories/token-repository');
24
25
  const {
@@ -26,12 +27,38 @@ const {
26
27
  } = require('../websocket/repositories/websocket-connection-repository');
27
28
 
28
29
  module.exports = {
29
- mongoose,
30
- IndividualUser,
31
- OrganizationUser,
32
- UserModel,
33
- WebsocketConnection,
34
- // Prisma
30
+ // Lazy-loaded mongoose exports (only load when accessed)
31
+ get mongoose() {
32
+ if (!_mongoose) {
33
+ _mongoose = require('./mongoose').mongoose;
34
+ }
35
+ return _mongoose;
36
+ },
37
+ get IndividualUser() {
38
+ if (!_IndividualUser) {
39
+ _IndividualUser = require('./models/IndividualUser').IndividualUser;
40
+ }
41
+ return _IndividualUser;
42
+ },
43
+ get OrganizationUser() {
44
+ if (!_OrganizationUser) {
45
+ _OrganizationUser = require('./models/OrganizationUser').OrganizationUser;
46
+ }
47
+ return _OrganizationUser;
48
+ },
49
+ get UserModel() {
50
+ if (!_UserModel) {
51
+ _UserModel = require('./models/UserModel').UserModel;
52
+ }
53
+ return _UserModel;
54
+ },
55
+ get WebsocketConnection() {
56
+ if (!_WebsocketConnection) {
57
+ _WebsocketConnection = require('./models/WebsocketConnection').WebsocketConnection;
58
+ }
59
+ return _WebsocketConnection;
60
+ },
61
+ // Prisma (always available)
35
62
  prisma,
36
63
  TokenRepository,
37
64
  WebsocketConnectionRepository,
@@ -373,6 +373,76 @@ async function runPrismaDbPush(verbose = false, nonInteractive = false) {
373
373
  });
374
374
  }
375
375
 
376
+ /**
377
+ * Runs Prisma migrate resolve to mark a migration as applied or rolled back
378
+ * @param {string} migrationName - Name of the migration to resolve (e.g., '20251112195422_update_user_unique_constraints')
379
+ * @param {'applied'|'rolled-back'} action - Whether to mark as applied or rolled back
380
+ * @param {boolean} verbose - Enable verbose output
381
+ * @returns {Promise<Object>} { success: boolean, output?: string, error?: string }
382
+ */
383
+ async function runPrismaMigrateResolve(migrationName, action = 'applied', verbose = false) {
384
+ return new Promise((resolve) => {
385
+ try {
386
+ const schemaPath = getPrismaSchemaPath('postgresql');
387
+
388
+ // Get Prisma binary path (checks multiple locations)
389
+ const prismaBin = getPrismaBinaryPath();
390
+
391
+ // Determine args based on whether we're using direct binary or npx
392
+ const isDirectBinary = prismaBin !== 'npx prisma';
393
+ const args = isDirectBinary
394
+ ? ['migrate', 'resolve', `--${action}`, migrationName, '--schema', schemaPath]
395
+ : ['prisma', 'migrate', 'resolve', `--${action}`, migrationName, '--schema', schemaPath];
396
+
397
+ if (verbose) {
398
+ const displayCmd = isDirectBinary
399
+ ? `${prismaBin} ${args.join(' ')}`
400
+ : `npx ${args.join(' ')}`;
401
+ console.log(chalk.gray(`Running: ${displayCmd}`));
402
+ }
403
+
404
+ // Execute the command (prismaBin might be 'node /path/to/index.js' or 'npx prisma')
405
+ const [executable, ...executableArgs] = prismaBin.split(' ');
406
+ const fullArgs = [...executableArgs, ...args];
407
+
408
+ const proc = spawn(executable, fullArgs, {
409
+ stdio: 'inherit',
410
+ env: {
411
+ ...process.env,
412
+ PRISMA_HIDE_UPDATE_MESSAGE: '1'
413
+ }
414
+ });
415
+
416
+ proc.on('error', (error) => {
417
+ resolve({
418
+ success: false,
419
+ error: error.message
420
+ });
421
+ });
422
+
423
+ proc.on('close', (code) => {
424
+ if (code === 0) {
425
+ resolve({
426
+ success: true,
427
+ output: `Migration ${migrationName} marked as ${action}`
428
+ });
429
+ } else {
430
+ resolve({
431
+ success: false,
432
+ error: `Resolve process exited with code ${code}`
433
+ });
434
+ }
435
+ });
436
+
437
+ } catch (error) {
438
+ resolve({
439
+ success: false,
440
+ error: error.message
441
+ });
442
+ }
443
+ });
444
+ }
445
+
376
446
  /**
377
447
  * Determines migration command based on STAGE environment variable
378
448
  * @param {string} stage - Stage from CLI option or environment
@@ -401,6 +471,7 @@ module.exports = {
401
471
  runPrismaGenerate,
402
472
  checkDatabaseState,
403
473
  runPrismaMigrate,
474
+ runPrismaMigrateResolve,
404
475
  runPrismaDbPush,
405
476
  getMigrationCommand
406
477
  };
@@ -92,13 +92,16 @@ const loadIntegrationForWebhook = async (integrationId) => {
92
92
  integrationId
93
93
  );
94
94
 
95
- return await getIntegrationInstance.execute(
95
+ const instance = await getIntegrationInstance.execute(
96
96
  integrationId,
97
97
  integrationRecord.userId
98
98
  );
99
+
100
+ return instance;
99
101
  };
100
102
 
101
103
  const loadIntegrationForProcess = async (processId, integrationClass) => {
104
+
102
105
  const { processRepository, integrationRepository, moduleRepository } =
103
106
  initializeRepositories();
104
107
 
@@ -122,10 +125,12 @@ const loadIntegrationForProcess = async (processId, integrationClass) => {
122
125
  throw new Error(`Process not found: ${processId}`);
123
126
  }
124
127
 
125
- return await getIntegrationInstance.execute(
128
+ const instance = await getIntegrationInstance.execute(
126
129
  process.integrationId,
127
130
  process.userId
128
131
  );
132
+
133
+ return instance;
129
134
  };
130
135
 
131
136
  const createQueueWorker = (integrationClass) => {
@@ -133,18 +138,19 @@ const createQueueWorker = (integrationClass) => {
133
138
  async _run(params, context) {
134
139
  try {
135
140
  let integrationInstance;
136
- if (
137
- params.event === 'ON_WEBHOOK' &&
138
- params.data?.integrationId
139
- ) {
140
- integrationInstance = await loadIntegrationForWebhook(
141
- params.data.integrationId
142
- );
143
- } else if (params.data?.processId) {
141
+
142
+ // Prioritize processId first (for sync handler compatibility),
143
+ // then integrationId (for ANY event type that needs hydration),
144
+ // fallback to unhydrated instance
145
+ if (params.data?.processId) {
144
146
  integrationInstance = await loadIntegrationForProcess(
145
147
  params.data.processId,
146
148
  integrationClass
147
149
  );
150
+ } else if (params.data?.integrationId) {
151
+ integrationInstance = await loadIntegrationForWebhook(
152
+ params.data.integrationId
153
+ );
148
154
  } else {
149
155
  // Instantiates a DRY integration class without database records.
150
156
  // There will be cases where we need to use helpers that the api modules can export.
@@ -235,6 +235,77 @@ router.get(
235
235
  })
236
236
  );
237
237
 
238
+ /**
239
+ * POST /db-migrate/resolve
240
+ *
241
+ * Resolve a failed migration by marking it as applied or rolled back
242
+ *
243
+ * Request body:
244
+ * {
245
+ * migrationName: string (e.g., '20251112195422_update_user_unique_constraints'),
246
+ * action: 'applied' | 'rolled-back',
247
+ * stage: string (optional, defaults to STAGE env var or 'production')
248
+ * }
249
+ *
250
+ * Response (200 OK):
251
+ * {
252
+ * success: true,
253
+ * message: string,
254
+ * migrationName: string,
255
+ * action: string
256
+ * }
257
+ */
258
+ router.post(
259
+ '/db-migrate/resolve',
260
+ catchAsyncError(async (req, res) => {
261
+ const { migrationName, action = 'applied' } = req.body;
262
+
263
+ console.log(`Migration resolve request: migration=${migrationName}, action=${action}`);
264
+
265
+ // Validation
266
+ if (!migrationName) {
267
+ return res.status(400).json({
268
+ success: false,
269
+ error: 'migrationName is required'
270
+ });
271
+ }
272
+
273
+ if (!['applied', 'rolled-back'].includes(action)) {
274
+ return res.status(400).json({
275
+ success: false,
276
+ error: 'action must be either "applied" or "rolled-back"'
277
+ });
278
+ }
279
+
280
+ try {
281
+ // Import prismaRunner here to avoid circular dependencies
282
+ const prismaRunner = require('../../database/utils/prisma-runner');
283
+
284
+ const result = await prismaRunner.runPrismaMigrateResolve(migrationName, action, true);
285
+
286
+ if (!result.success) {
287
+ return res.status(500).json({
288
+ success: false,
289
+ error: `Failed to resolve migration: ${result.error}`
290
+ });
291
+ }
292
+
293
+ res.status(200).json({
294
+ success: true,
295
+ message: `Migration ${migrationName} marked as ${action}`,
296
+ migrationName,
297
+ action
298
+ });
299
+ } catch (error) {
300
+ console.error('Migration resolve failed:', error);
301
+ return res.status(500).json({
302
+ success: false,
303
+ error: error.message
304
+ });
305
+ }
306
+ })
307
+ );
308
+
238
309
  // Minimal Lambda handler (avoids app-handler-helpers which loads core/index.js → user/**)
239
310
  const serverlessHttp = require('serverless-http');
240
311
  const express = require('express');
@@ -244,7 +315,7 @@ const app = express();
244
315
  app.use(cors());
245
316
  app.use(express.json());
246
317
  app.use(router);
247
- app.use((err, req, res, next) => {
318
+ app.use((err, _req, res, _next) => {
248
319
  console.error('Migration Router Error:', err);
249
320
  res.status(500).json({ message: 'Internal Server Error' });
250
321
  });
@@ -203,22 +203,53 @@ class IntegrationBase {
203
203
 
204
204
  /**
205
205
  * Returns the modules as object with keys as module names.
206
+ * Uses the keys from Definition.modules to attach modules correctly.
207
+ *
208
+ * Example:
209
+ * Definition.modules = { attio: {...}, quo: { definition: { getName: () => 'quo-attio' } } }
210
+ * Module with getName()='quo-attio' gets attached as this.quo (not this['quo-attio'])
211
+ *
206
212
  * @private
207
213
  * @param {Array} integrationModules - Array of module instances
208
214
  * @returns {Object} The modules object
209
215
  */
210
216
  _appendModules(integrationModules) {
211
217
  const modules = {};
218
+
219
+ // Build reverse mapping: definition.getName() → referenceKey
220
+ // e.g., 'quo-attio' → 'quo', 'attio' → 'attio'
221
+ const moduleNameToKey = {};
222
+ if (this.constructor.Definition?.modules) {
223
+ for (const [key, moduleConfig] of Object.entries(this.constructor.Definition.modules)) {
224
+ const definition = moduleConfig.definition;
225
+ if (definition) {
226
+ // Use getName() if available, fallback to moduleName
227
+ const definitionName = typeof definition.getName === 'function'
228
+ ? definition.getName()
229
+ : definition.moduleName;
230
+ if (definitionName) {
231
+ moduleNameToKey[definitionName] = key;
232
+ }
233
+ }
234
+ }
235
+ }
236
+
212
237
  for (const module of integrationModules) {
213
- const key =
238
+ const moduleName =
214
239
  typeof module.getName === 'function'
215
240
  ? module.getName()
216
241
  : module.name;
242
+
243
+ // Use the reference key from Definition.modules if available,
244
+ // otherwise fall back to moduleName
245
+ const key = moduleNameToKey[moduleName] || moduleName;
246
+
217
247
  if (key) {
218
248
  modules[key] = module;
219
249
  this[key] = module;
220
250
  }
221
251
  }
252
+
222
253
  return modules;
223
254
  }
224
255
 
@@ -333,7 +364,6 @@ class IntegrationBase {
333
364
  return {};
334
365
  }
335
366
  async loadUserActions({ actionType } = {}) {
336
- console.log('loadUserActions called with actionType:', actionType);
337
367
  const userActions = {};
338
368
  for (const [key, event] of Object.entries(this.events)) {
339
369
  if (event.type === constantsToBeMigrated.types.USER_ACTION) {
@@ -389,7 +419,6 @@ class IntegrationBase {
389
419
 
390
420
  async onWebhook({ data }) {
391
421
  // Default: no-op, integrations override this
392
- console.log('Webhook received:', data);
393
422
  }
394
423
 
395
424
  async queueWebhook(data) {
@@ -601,11 +601,10 @@ function setEntityRoutes(router, authenticateUser, useCases) {
601
601
  router.route('/api/entities/:entityId/test-auth').get(
602
602
  catchAsyncError(async (req, res) => {
603
603
  const user = await authenticateUser.execute(req);
604
- const userId = user.getId();
605
604
  const params = checkRequiredParams(req.params, ['entityId']);
606
605
  const testAuthResponse = await testModuleAuth.execute(
607
606
  params.entityId,
608
- userId
607
+ user // Pass User object for proper validation
609
608
  );
610
609
 
611
610
  if (!testAuthResponse) {
@@ -614,7 +613,7 @@ function setEntityRoutes(router, authenticateUser, useCases) {
614
613
  errors: [
615
614
  {
616
615
  title: 'Authentication Error',
617
- message: `There was an error with your ${module.getName()} Entity. Please reconnect/re-authenticate, or reach out to Support for assistance.`,
616
+ message: `There was an error with your Entity. Please reconnect/re-authenticate, or reach out to Support for assistance.`,
618
617
  timestamp: Date.now(),
619
618
  },
620
619
  ],
@@ -628,9 +627,8 @@ function setEntityRoutes(router, authenticateUser, useCases) {
628
627
  router.route('/api/entities/:entityId').get(
629
628
  catchAsyncError(async (req, res) => {
630
629
  const user = await authenticateUser.execute(req);
631
- const userId = user.getId();
632
630
  const params = checkRequiredParams(req.params, ['entityId']);
633
- const module = await getModule.execute(params.entityId, userId);
631
+ const module = await getModule.execute(params.entityId, user); // Pass User object
634
632
 
635
633
  res.json(module);
636
634
  })
@@ -639,12 +637,11 @@ function setEntityRoutes(router, authenticateUser, useCases) {
639
637
  router.route('/api/entities/:entityId/options').post(
640
638
  catchAsyncError(async (req, res) => {
641
639
  const user = await authenticateUser.execute(req);
642
- const userId = user.getId();
643
640
  const params = checkRequiredParams(req.params, ['entityId']);
644
641
 
645
642
  const entityOptions = await getEntityOptionsById.execute(
646
643
  params.entityId,
647
- userId
644
+ user // Pass User object
648
645
  );
649
646
 
650
647
  res.json(entityOptions);
@@ -654,11 +651,10 @@ function setEntityRoutes(router, authenticateUser, useCases) {
654
651
  router.route('/api/entities/:entityId/options/refresh').post(
655
652
  catchAsyncError(async (req, res) => {
656
653
  const user = await authenticateUser.execute(req);
657
- const userId = user.getId();
658
654
  const params = checkRequiredParams(req.params, ['entityId']);
659
655
  const updatedOptions = await refreshEntityOptions.execute(
660
656
  params.entityId,
661
- userId,
657
+ user, // Pass User object
662
658
  req.body
663
659
  );
664
660
 
@@ -12,11 +12,18 @@ class GetEntityOptionsById {
12
12
  }
13
13
 
14
14
  /**
15
- * Retrieve a Module instance for a given user and entity/module type.
16
- * @param {string} userId
17
- * @param {string} entityId
15
+ * Retrieve entity options for a given entity
16
+ *
17
+ * @param {string|number} entityId - Entity ID to retrieve options for
18
+ * @param {string|number|import('../../user/user').User} userIdOrUser - User ID or User object for validation
19
+ * @returns {Promise<Object>} Entity options
18
20
  */
19
- async execute(entityId, userId) {
21
+ async execute(entityId, userIdOrUser) {
22
+ // Support both userId (backward compatible) and User object (new pattern)
23
+ const userId = typeof userIdOrUser === 'object' && userIdOrUser?.getId
24
+ ? userIdOrUser.getId()
25
+ : userIdOrUser;
26
+
20
27
  const entity = await this.moduleRepository.findEntityById(
21
28
  entityId,
22
29
  userId
@@ -26,7 +33,12 @@ class GetEntityOptionsById {
26
33
  throw new Error(`Entity ${entityId} not found`);
27
34
  }
28
35
 
29
- if (entity.userId !== userId) {
36
+ // Validate entity ownership
37
+ const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
38
+ ? userIdOrUser.ownsUserId(entity.userId)
39
+ : entity.userId?.toString() === userId?.toString();
40
+
41
+ if (!isOwned) {
30
42
  throw new Error(
31
43
  `Entity ${entityId} does not belong to user ${userId}`
32
44
  );
@@ -6,7 +6,19 @@ class GetModule {
6
6
  this.moduleDefinitions = moduleDefinitions;
7
7
  }
8
8
 
9
- async execute(entityId, userId) {
9
+ /**
10
+ * Get module instance for an entity
11
+ *
12
+ * @param {string|number} entityId - Entity ID to retrieve
13
+ * @param {string|number|import('../../user/user').User} userIdOrUser - User ID or User object for validation
14
+ * @returns {Promise<Object>} Module details
15
+ */
16
+ async execute(entityId, userIdOrUser) {
17
+ // Support both userId (backward compatible) and User object (new pattern)
18
+ const userId = typeof userIdOrUser === 'object' && userIdOrUser?.getId
19
+ ? userIdOrUser.getId()
20
+ : userIdOrUser;
21
+
10
22
  const entity = await this.moduleRepository.findEntityById(
11
23
  entityId,
12
24
  userId
@@ -16,7 +28,14 @@ class GetModule {
16
28
  throw new Error(`Entity ${entityId} not found`);
17
29
  }
18
30
 
19
- if (entity.userId !== userId) {
31
+ // Validate entity ownership
32
+ // If User object provided, use ownsUserId to check linked users
33
+ // Otherwise fall back to simple equality check
34
+ const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
35
+ ? userIdOrUser.ownsUserId(entity.userId)
36
+ : entity.userId?.toString() === userId?.toString();
37
+
38
+ if (!isOwned) {
20
39
  throw new Error(
21
40
  `Entity ${entityId} does not belong to user ${userId}`
22
41
  );
@@ -100,9 +100,20 @@ class ProcessAuthorizationCallback {
100
100
  async findOrCreateEntity(entityDetails, moduleName, credentialId) {
101
101
  const { identifiers, details } = entityDetails;
102
102
 
103
+ // Support both 'user' and 'userId' field names from module definitions
104
+ // Some modules use 'user' (legacy), others use 'userId' (newer pattern)
105
+ const userId = identifiers.user || identifiers.userId;
106
+
107
+ if (!userId) {
108
+ throw new Error(
109
+ `Module definition for ${moduleName} must return 'user' or 'userId' in identifiers from getEntityDetails(). ` +
110
+ `Without userId, entity lookup would match across all users (security issue).`
111
+ );
112
+ }
113
+
103
114
  const existingEntity = await this.moduleRepository.findEntity({
104
115
  externalId: identifiers.externalId,
105
- user: identifiers.user,
116
+ user: userId,
106
117
  moduleName: moduleName,
107
118
  });
108
119
 
@@ -12,11 +12,19 @@ class RefreshEntityOptions {
12
12
  }
13
13
 
14
14
  /**
15
- * Retrieve a Module instance for a given user and entity/module type.
16
- * @param {string} userId
17
- * @param {string} entityId
15
+ * Refresh entity options for a given entity
16
+ *
17
+ * @param {string|number} entityId - Entity ID to refresh
18
+ * @param {string|number|import('../../user/user').User} userIdOrUser - User ID or User object for validation
19
+ * @param {Object} options - Refresh options
20
+ * @returns {Promise<Object>} Updated entity options
18
21
  */
19
- async execute(entityId, userId, options) {
22
+ async execute(entityId, userIdOrUser, options) {
23
+ // Support both userId (backward compatible) and User object (new pattern)
24
+ const userId = typeof userIdOrUser === 'object' && userIdOrUser?.getId
25
+ ? userIdOrUser.getId()
26
+ : userIdOrUser;
27
+
20
28
  const entity = await this.moduleRepository.findEntityById(
21
29
  entityId,
22
30
  userId
@@ -26,7 +34,12 @@ class RefreshEntityOptions {
26
34
  throw new Error(`Entity ${entityId} not found`);
27
35
  }
28
36
 
29
- if (entity.userId !== userId) {
37
+ // Validate entity ownership
38
+ const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
39
+ ? userIdOrUser.ownsUserId(entity.userId)
40
+ : entity.userId?.toString() === userId?.toString();
41
+
42
+ if (!isOwned) {
30
43
  throw new Error(
31
44
  `Entity ${entityId} does not belong to user ${userId}`
32
45
  );
@@ -11,7 +11,19 @@ class TestModuleAuth {
11
11
  this.moduleDefinitions = moduleDefinitions;
12
12
  }
13
13
 
14
- async execute(entityId, userId) {
14
+ /**
15
+ * Test authentication for a module entity
16
+ *
17
+ * @param {string|number} entityId - Entity ID to test
18
+ * @param {string|number|import('../../user/user').User} userIdOrUser - User ID or User object for validation
19
+ * @returns {Promise<boolean>} Authentication test result
20
+ */
21
+ async execute(entityId, userIdOrUser) {
22
+ // Support both userId (backward compatible) and User object (new pattern)
23
+ const userId = typeof userIdOrUser === 'object' && userIdOrUser?.getId
24
+ ? userIdOrUser.getId()
25
+ : userIdOrUser;
26
+
15
27
  const entity = await this.moduleRepository.findEntityById(
16
28
  entityId,
17
29
  userId
@@ -21,7 +33,12 @@ class TestModuleAuth {
21
33
  throw new Error(`Entity ${entityId} not found`);
22
34
  }
23
35
 
24
- if (entity.userId !== userId) {
36
+ // Validate entity ownership
37
+ const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
38
+ ? userIdOrUser.ownsUserId(entity.userId)
39
+ : entity.userId?.toString() === userId?.toString();
40
+
41
+ if (!isOwned) {
25
42
  throw new Error(
26
43
  `Entity ${entityId} does not belong to user ${userId}`
27
44
  );
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.56",
4
+ "version": "2.0.0-next.57",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -38,9 +38,9 @@
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
- "@friggframework/eslint-config": "2.0.0-next.56",
42
- "@friggframework/prettier-config": "2.0.0-next.56",
43
- "@friggframework/test": "2.0.0-next.56",
41
+ "@friggframework/eslint-config": "2.0.0-next.57",
42
+ "@friggframework/prettier-config": "2.0.0-next.57",
43
+ "@friggframework/test": "2.0.0-next.57",
44
44
  "@prisma/client": "^6.17.0",
45
45
  "@types/lodash": "4.17.15",
46
46
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -80,5 +80,5 @@
80
80
  "publishConfig": {
81
81
  "access": "public"
82
82
  },
83
- "gitHead": "5551318d5c45913810a6b03a5ea19c72b5c4cb2f"
83
+ "gitHead": "d09ee09decef14a8cdbf657494ac0dc9bf0b4014"
84
84
  }
@@ -21,49 +21,5 @@ ALTER TABLE "Credential" DROP COLUMN "subType";
21
21
  -- AlterTable
22
22
  ALTER TABLE "Entity" DROP COLUMN "subType";
23
23
 
24
- -- CreateTable
25
- CREATE TABLE "Process" (
26
- "id" SERIAL NOT NULL,
27
- "userId" INTEGER NOT NULL,
28
- "integrationId" INTEGER NOT NULL,
29
- "name" TEXT NOT NULL,
30
- "type" TEXT NOT NULL,
31
- "state" TEXT NOT NULL,
32
- "context" JSONB NOT NULL DEFAULT '{}',
33
- "results" JSONB NOT NULL DEFAULT '{}',
34
- "parentProcessId" INTEGER,
35
- "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
36
- "updatedAt" TIMESTAMP(3) NOT NULL,
37
-
38
- CONSTRAINT "Process_pkey" PRIMARY KEY ("id")
39
- );
40
-
41
- -- CreateIndex
42
- CREATE INDEX "Process_userId_idx" ON "Process"("userId");
43
-
44
- -- CreateIndex
45
- CREATE INDEX "Process_integrationId_idx" ON "Process"("integrationId");
46
-
47
- -- CreateIndex
48
- CREATE INDEX "Process_type_idx" ON "Process"("type");
49
-
50
- -- CreateIndex
51
- CREATE INDEX "Process_state_idx" ON "Process"("state");
52
-
53
- -- CreateIndex
54
- CREATE INDEX "Process_name_idx" ON "Process"("name");
55
-
56
- -- CreateIndex
57
- CREATE INDEX "Process_parentProcessId_idx" ON "Process"("parentProcessId");
58
-
59
24
  -- CreateIndex
60
25
  CREATE UNIQUE INDEX "User_username_appUserId_key" ON "User"("username", "appUserId");
61
-
62
- -- AddForeignKey
63
- ALTER TABLE "Process" ADD CONSTRAINT "Process_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
64
-
65
- -- AddForeignKey
66
- ALTER TABLE "Process" ADD CONSTRAINT "Process_integrationId_fkey" FOREIGN KEY ("integrationId") REFERENCES "Integration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
67
-
68
- -- AddForeignKey
69
- ALTER TABLE "Process" ADD CONSTRAINT "Process_parentProcessId_fkey" FOREIGN KEY ("parentProcessId") REFERENCES "Process"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -4,7 +4,6 @@ const { SQSClient, SendMessageCommand, SendMessageBatchCommand } = require('@aws
4
4
  const awsConfigOptions = () => {
5
5
  const config = {};
6
6
  if (process.env.IS_OFFLINE) {
7
- console.log('Running in offline mode');
8
7
  config.credentials = {
9
8
  accessKeyId: 'test-aws-key',
10
9
  secretAccessKey: 'test-aws-secret',
@@ -21,7 +20,6 @@ const sqs = new SQSClient(awsConfigOptions());
21
20
 
22
21
  const QueuerUtil = {
23
22
  send: async (message, queueUrl) => {
24
- console.log(`Enqueuing message to SQS queue ${queueUrl}`);
25
23
  const command = new SendMessageCommand({
26
24
  MessageBody: JSON.stringify(message),
27
25
  QueueUrl: queueUrl,
@@ -30,9 +28,6 @@ const QueuerUtil = {
30
28
  },
31
29
 
32
30
  batchSend: async (entries = [], queueUrl) => {
33
- console.log(
34
- `Enqueuing ${entries.length} entries on SQS to queue ${queueUrl}`
35
- );
36
31
  const buffer = [];
37
32
  const batchSize = 10;
38
33
 
@@ -43,7 +38,6 @@ const QueuerUtil = {
43
38
  });
44
39
  // Sends 10, then purges the buffer
45
40
  if (buffer.length === batchSize) {
46
- console.log('Buffer at 10, sending batch');
47
41
  const command = new SendMessageBatchCommand({
48
42
  Entries: buffer,
49
43
  QueueUrl: queueUrl,
@@ -53,11 +47,9 @@ const QueuerUtil = {
53
47
  buffer.splice(0, buffer.length);
54
48
  }
55
49
  }
56
- console.log('Buffer at end, sending final batch');
57
50
 
58
51
  // If any remaining entries under 10 are left in the buffer, send and return
59
52
  if (buffer.length > 0) {
60
- console.log(buffer);
61
53
  const command = new SendMessageBatchCommand({
62
54
  Entries: buffer,
63
55
  QueueUrl: queueUrl,
@@ -427,6 +427,26 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
427
427
  const date = new Date(value);
428
428
  return isNaN(date.getTime()) ? undefined : date;
429
429
  }
430
+
431
+ /**
432
+ * Link an individual user to an organization user
433
+ * @param {string} individualUserId - Individual user ID (MongoDB ObjectId string)
434
+ * @param {string} organizationUserId - Organization user ID (MongoDB ObjectId string)
435
+ * @returns {Promise<Object>} Updated individual user object
436
+ */
437
+ async linkIndividualToOrganization(individualUserId, organizationUserId) {
438
+ const doc = await updateOne(
439
+ this.prisma,
440
+ 'User',
441
+ { _id: toObjectId(individualUserId), type: 'INDIVIDUAL' },
442
+ { $set: { organizationId: toObjectId(organizationUserId) } }
443
+ );
444
+ const decrypted = await this.encryptionService.decryptFields(
445
+ 'User',
446
+ doc
447
+ );
448
+ return this._mapUser(decrypted);
449
+ }
430
450
  }
431
451
 
432
452
  module.exports = { UserRepositoryDocumentDB };
@@ -193,6 +193,20 @@ class UserRepositoryInterface {
193
193
  async deleteUser(userId) {
194
194
  throw new Error('Method deleteUser must be implemented by subclass');
195
195
  }
196
+
197
+ /**
198
+ * Link an individual user to an organization user
199
+ *
200
+ * @param {string|number} individualUserId - Individual user ID
201
+ * @param {string|number} organizationUserId - Organization user ID
202
+ * @returns {Promise<Object>} Updated individual user object
203
+ * @abstract
204
+ */
205
+ async linkIndividualToOrganization(individualUserId, organizationUserId) {
206
+ throw new Error(
207
+ 'Method linkIndividualToOrganization must be implemented by subclass'
208
+ );
209
+ }
196
210
  }
197
211
 
198
212
  module.exports = { UserRepositoryInterface };
@@ -287,6 +287,24 @@ class UserRepositoryMongo extends UserRepositoryInterface {
287
287
  throw error;
288
288
  }
289
289
  }
290
+
291
+ /**
292
+ * Link an individual user to an organization user
293
+ * @param {string} individualUserId - Individual user ID (MongoDB ObjectId string)
294
+ * @param {string} organizationUserId - Organization user ID (MongoDB ObjectId string)
295
+ * @returns {Promise<Object>} Updated individual user object
296
+ */
297
+ async linkIndividualToOrganization(individualUserId, organizationUserId) {
298
+ return await this.prisma.user.update({
299
+ where: {
300
+ id: individualUserId,
301
+ type: 'INDIVIDUAL',
302
+ },
303
+ data: {
304
+ organizationId: organizationUserId,
305
+ },
306
+ });
307
+ }
290
308
  }
291
309
 
292
310
  module.exports = { UserRepositoryMongo };
@@ -346,6 +346,28 @@ class UserRepositoryPostgres extends UserRepositoryInterface {
346
346
  throw error;
347
347
  }
348
348
  }
349
+
350
+ /**
351
+ * Link an individual user to an organization user
352
+ * @param {string} individualUserId - Individual user ID (string from application layer)
353
+ * @param {string} organizationUserId - Organization user ID (string from application layer)
354
+ * @returns {Promise<Object>} Updated individual user with string IDs
355
+ */
356
+ async linkIndividualToOrganization(individualUserId, organizationUserId) {
357
+ const intIndividualId = this._convertId(individualUserId);
358
+ const intOrganizationId = this._convertId(organizationUserId);
359
+
360
+ const user = await this.prisma.user.update({
361
+ where: {
362
+ id: intIndividualId,
363
+ type: 'INDIVIDUAL',
364
+ },
365
+ data: {
366
+ organizationId: intOrganizationId,
367
+ },
368
+ });
369
+ return this._convertUserIds(user);
370
+ }
349
371
  }
350
372
 
351
373
  module.exports = { UserRepositoryPostgres };
@@ -53,7 +53,7 @@ class GetUserFromXFriggHeaders {
53
53
  );
54
54
  }
55
55
 
56
- // VALIDATION: If both IDs provided and both users exist, verify they match
56
+ // VALIDATION/AUTO-LINKING: If both IDs provided and both users exist, handle mismatch
57
57
  if (
58
58
  appUserId &&
59
59
  appOrgId &&
@@ -66,31 +66,57 @@ class GetUserFromXFriggHeaders {
66
66
  const expectedOrgId = organizationUserData.id?.toString();
67
67
 
68
68
  if (individualOrgId !== expectedOrgId) {
69
- throw Boom.badRequest(
70
- 'User ID mismatch: x-frigg-appUserId and x-frigg-appOrgId refer to different users. ' +
71
- 'Provide only one identifier or ensure they belong to the same user.'
69
+ // Default behavior: Auto-link disconnected users
70
+ // Opt-in strict mode: Throw error on mismatch
71
+ if (this.userConfig.strictUserValidation) {
72
+ throw Boom.badRequest(
73
+ 'User ID mismatch: x-frigg-appUserId and x-frigg-appOrgId refer to different users. ' +
74
+ 'Provide only one identifier or ensure they belong to the same user.'
75
+ );
76
+ }
77
+
78
+ // Auto-link the users
79
+ individualUserData = await this.userRepository.linkIndividualToOrganization(
80
+ individualUserData.id,
81
+ organizationUserData.id
72
82
  );
73
83
  }
74
84
  }
75
85
 
76
- // Auto-create user if not found
77
- if (!individualUserData && !organizationUserData) {
78
- if (appUserId) {
79
- individualUserData =
80
- await this.userRepository.createIndividualUser({
81
- appUserId,
82
- username: `app-user-${appUserId}`,
83
- email: `${appUserId}@app.local`,
84
- });
85
- } else {
86
- organizationUserData =
87
- await this.userRepository.createOrganizationUser({
88
- appOrgId,
89
- });
86
+ // Auto-create users independently if they don't exist and are required
87
+ if (
88
+ !individualUserData &&
89
+ appUserId &&
90
+ this.userConfig.individualUserRequired !== false
91
+ ) {
92
+ individualUserData =
93
+ await this.userRepository.createIndividualUser({
94
+ appUserId,
95
+ username: `app-user-${appUserId}`,
96
+ email: `${appUserId}@app.local`,
97
+ });
98
+ }
99
+
100
+ if (
101
+ !organizationUserData &&
102
+ appOrgId &&
103
+ this.userConfig.organizationUserRequired
104
+ ) {
105
+ organizationUserData =
106
+ await this.userRepository.createOrganizationUser({
107
+ appOrgId,
108
+ });
109
+
110
+ // Link individual user to newly created org user if individual exists
111
+ if (individualUserData && organizationUserData) {
112
+ individualUserData = await this.userRepository.linkIndividualToOrganization(
113
+ individualUserData.id,
114
+ organizationUserData.id
115
+ );
90
116
  }
91
117
  }
92
118
 
93
- return new User(
119
+ const user = new User(
94
120
  individualUserData,
95
121
  organizationUserData,
96
122
  this.userConfig.usePassword,
@@ -98,9 +124,9 @@ class GetUserFromXFriggHeaders {
98
124
  this.userConfig.individualUserRequired,
99
125
  this.userConfig.organizationUserRequired
100
126
  );
127
+
128
+ return user;
101
129
  }
102
130
  }
103
131
 
104
132
  module.exports = { GetUserFromXFriggHeaders };
105
-
106
-
package/user/user.js CHANGED
@@ -88,6 +88,38 @@ class User {
88
88
  getAppOrgId() {
89
89
  return this.organizationUser?.appOrgId || null;
90
90
  }
91
+
92
+ /**
93
+ * Checks if a given userId belongs to this user (either primary or linked).
94
+ * When primary is 'organization', entities owned by the linked individual user
95
+ * should still be accessible to the organization.
96
+ *
97
+ * @param {string|number} userId - The userId to check
98
+ * @returns {boolean} True if the userId belongs to this user or their linked user
99
+ */
100
+ ownsUserId(userId) {
101
+ const userIdStr = userId?.toString();
102
+ const primaryId = this.getPrimaryUser()?.id?.toString();
103
+ const individualId = this.individualUser?.id?.toString();
104
+ const organizationId = this.organizationUser?.id?.toString();
105
+
106
+ // Check if userId matches primary user
107
+ if (userIdStr === primaryId) {
108
+ return true;
109
+ }
110
+
111
+ // When primary is 'organization', also check linked individual user
112
+ if (this.config.primary === 'organization' && userIdStr === individualId) {
113
+ return true;
114
+ }
115
+
116
+ // When primary is 'individual', also check linked organization user if required
117
+ if (this.config.primary === 'individual' && this.config.organizationUserRequired && userIdStr === organizationId) {
118
+ return true;
119
+ }
120
+
121
+ return false;
122
+ }
91
123
  }
92
124
 
93
125
  module.exports = { User };