@friggframework/core 2.0.0--canary.686.df38e8e.0 → 2.0.0--canary.710.9045bbc.0

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 (217) hide show
  1. package/CHANGELOG.md +0 -16
  2. package/CLAUDE.md +41 -29
  3. package/README.md +0 -105
  4. package/application/commands/README.md +17 -11
  5. package/application/commands/entity-commands.js +18 -60
  6. package/application/commands/integration-commands.js +73 -20
  7. package/application/commands/scheduler-commands.js +7 -11
  8. package/application/commands/user-commands.js +5 -17
  9. package/application/index.js +2 -6
  10. package/core/Worker.js +7 -31
  11. package/core/create-handler.js +0 -9
  12. package/credential/repositories/credential-repository-documentdb.js +0 -34
  13. package/credential/repositories/credential-repository-interface.js +0 -15
  14. package/credential/repositories/credential-repository-mongo.js +0 -23
  15. package/credential/repositories/credential-repository-postgres.js +0 -28
  16. package/database/config.js +24 -48
  17. package/database/documentdb-encryption-service.js +22 -9
  18. package/database/documentdb-utils.js +0 -2
  19. package/database/encryption/README.md +17 -81
  20. package/database/encryption/documentdb-encryption-service.md +1 -78
  21. package/database/encryption/encryption-schema-registry.js +46 -123
  22. package/database/encryption/field-encryption-service.js +5 -69
  23. package/database/encryption/integration-mapping-encryption.js +1 -1
  24. package/database/prisma.js +38 -15
  25. package/database/use-cases/check-encryption-health-use-case.js +52 -43
  26. package/errors/index.js +4 -0
  27. package/errors/mapping-already-exists-error.js +14 -0
  28. package/generated/prisma-mongodb/edge.js +4 -43
  29. package/generated/prisma-mongodb/index-browser.js +1 -40
  30. package/generated/prisma-mongodb/index.d.ts +111 -4739
  31. package/generated/prisma-mongodb/index.js +4 -43
  32. package/generated/prisma-mongodb/package.json +1 -1
  33. package/generated/prisma-mongodb/schema.prisma +0 -92
  34. package/generated/prisma-mongodb/wasm.js +4 -43
  35. package/generated/prisma-postgresql/edge.js +4 -43
  36. package/generated/prisma-postgresql/index-browser.js +1 -40
  37. package/generated/prisma-postgresql/index.d.ts +33 -4966
  38. package/generated/prisma-postgresql/index.js +4 -43
  39. package/generated/prisma-postgresql/package.json +1 -1
  40. package/generated/prisma-postgresql/schema.prisma +0 -87
  41. package/generated/prisma-postgresql/wasm.js +4 -43
  42. package/handlers/WEBHOOKS.md +29 -0
  43. package/handlers/app-definition-loader.js +19 -121
  44. package/handlers/app-handler-helpers.js +3 -25
  45. package/handlers/backend-utils.js +25 -31
  46. package/handlers/middleware/admin-auth.js +1 -14
  47. package/handlers/routers/auth.js +10 -46
  48. package/handlers/routers/health.js +4 -13
  49. package/handlers/routers/integration-defined-routers.js +92 -144
  50. package/handlers/routers/integration-webhook-routers.js +64 -102
  51. package/handlers/workers/integration-defined-workers.js +2 -5
  52. package/index.js +3 -65
  53. package/infrastructure/scheduler/eventbridge-scheduler-adapter.js +67 -13
  54. package/infrastructure/scheduler/index.js +6 -19
  55. package/infrastructure/scheduler/scheduler-service-factory.js +7 -55
  56. package/integrations/index.js +0 -1
  57. package/integrations/integration-base.js +29 -0
  58. package/integrations/integration-router.js +96 -131
  59. package/integrations/repositories/integration-mapping-repository-documentdb.js +50 -0
  60. package/integrations/repositories/integration-mapping-repository-interface.js +15 -0
  61. package/integrations/repositories/integration-mapping-repository-mongo.js +29 -0
  62. package/integrations/repositories/integration-mapping-repository-postgres.js +30 -0
  63. package/integrations/repositories/integration-mapping-repository.js +29 -0
  64. package/integrations/repositories/integration-repository-documentdb.js +35 -35
  65. package/integrations/repositories/integration-repository-interface.js +31 -17
  66. package/integrations/repositories/integration-repository-mongo.js +129 -60
  67. package/integrations/repositories/integration-repository-postgres.js +134 -62
  68. package/integrations/repositories/process-repository-mongo.js +1 -1
  69. package/integrations/tests/doubles/test-integration-repository.js +1 -12
  70. package/integrations/use-cases/add-entities-to-integration.js +92 -0
  71. package/integrations/use-cases/create-integration.js +75 -72
  72. package/integrations/use-cases/get-integrations-for-user.js +40 -80
  73. package/integrations/use-cases/index.js +1 -7
  74. package/integrations/use-cases/remove-entities-from-integration.js +70 -0
  75. package/integrations/utils/assert-entity-ids-input.js +42 -0
  76. package/integrations/utils/map-integration-dto.js +14 -43
  77. package/jest-setup.js +13 -2
  78. package/jest-teardown.js +5 -2
  79. package/jest.config.js +4 -0
  80. package/jest.db.config.js +23 -0
  81. package/modules/index.js +0 -14
  82. package/modules/module-factory.js +0 -12
  83. package/modules/module.js +2 -53
  84. package/modules/repositories/module-repository-documentdb.js +6 -51
  85. package/modules/repositories/module-repository-interface.js +2 -19
  86. package/modules/repositories/module-repository-mongo.js +74 -67
  87. package/modules/repositories/module-repository-postgres.js +74 -66
  88. package/modules/repositories/module-repository.js +1 -2
  89. package/modules/requester/requester.js +2 -27
  90. package/modules/use-cases/get-entities-for-user.js +13 -26
  91. package/modules/use-cases/get-entity-options-by-id.js +4 -10
  92. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  93. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  94. package/modules/use-cases/get-module.js +4 -10
  95. package/modules/use-cases/process-authorization-callback.js +19 -53
  96. package/modules/use-cases/refresh-entity-options.js +4 -10
  97. package/modules/use-cases/test-module-auth.js +4 -10
  98. package/modules/utils/map-module-dto.js +5 -3
  99. package/modules/utils/to-credential-id.js +14 -0
  100. package/package.json +7 -6
  101. package/prisma-mongodb/schema.prisma +0 -92
  102. package/prisma-postgresql/schema.prisma +0 -87
  103. package/queues/index.js +0 -25
  104. package/queues/queuer-util.js +6 -5
  105. package/types/index.d.ts +0 -2
  106. package/user/repositories/user-repository-documentdb.js +2 -27
  107. package/user/repositories/user-repository-interface.js +0 -18
  108. package/user/repositories/user-repository-mongo.js +0 -24
  109. package/user/repositories/user-repository-postgres.js +0 -18
  110. package/user/tests/doubles/test-user-repository.js +1 -1
  111. package/user/use-cases/authenticate-user.js +1 -7
  112. package/user/use-cases/get-user-from-adopter-jwt.js +124 -95
  113. package/user/use-cases/get-user-from-bearer-token.js +7 -2
  114. package/user/use-cases/get-user-from-x-frigg-headers.js +80 -83
  115. package/user/user.js +1 -64
  116. package/credential/use-cases/credential-operations-for-user.js +0 -202
  117. package/credential/use-cases/reauthorize-credential-for-user.js +0 -73
  118. package/database/encryption/encryption-config.js +0 -219
  119. package/database/encryption/test-support/fake-ciphertext.js +0 -15
  120. package/database/organization-where.js +0 -18
  121. package/database/unique-violation.js +0 -25
  122. package/extensions/bootstrap-runner.js +0 -43
  123. package/extensions/extension-loader.js +0 -187
  124. package/extensions/index.js +0 -41
  125. package/extensions/initialize-app.js +0 -123
  126. package/extensions/route-mounter.js +0 -103
  127. package/extensions/schema-composer.js +0 -188
  128. package/infrastructure/scheduler/netlify-scheduler-adapter.js +0 -220
  129. package/infrastructure/scheduler/scheduled-job-repository.js +0 -118
  130. package/integrations/repositories/integration-not-found.js +0 -14
  131. package/integrations/schedule-config.js +0 -80
  132. package/integrations/use-cases/dispatch-ongoing-syncs.js +0 -100
  133. package/integrations/use-cases/integration-operations-for-user.js +0 -256
  134. package/integrations/use-cases/resolve-integration-ownership.js +0 -177
  135. package/management-api/create-management-api-router.js +0 -49
  136. package/management-api/error-response.js +0 -116
  137. package/management-api/management-api-config.js +0 -151
  138. package/management-api/meta-router.js +0 -76
  139. package/management-api/openapi/build-openapi.js +0 -144
  140. package/management-api/openapi/components.js +0 -408
  141. package/management-api/openapi/v1-operations.js +0 -83
  142. package/management-api/openapi/v2-operations.js +0 -264
  143. package/management-api/route-registry.js +0 -411
  144. package/management-api/test-support/http-client.js +0 -39
  145. package/management-api/use-cases/get-management-api-meta.js +0 -57
  146. package/management-api/v1-deprecation.js +0 -120
  147. package/management-api/v2/create-v2-router.js +0 -85
  148. package/management-api/v2/dependencies.js +0 -281
  149. package/management-api/v2/dto.js +0 -143
  150. package/management-api/v2/handlers/authorization.js +0 -94
  151. package/management-api/v2/handlers/credentials.js +0 -33
  152. package/management-api/v2/handlers/entities.js +0 -77
  153. package/management-api/v2/handlers/integrations.js +0 -128
  154. package/management-api/v2/handlers/proxy.js +0 -34
  155. package/management-api/v2/request.js +0 -85
  156. package/management-api/v2/test-support/fake-dependencies.js +0 -103
  157. package/modules/domain/authorization-session.js +0 -144
  158. package/modules/entity-scope.js +0 -15
  159. package/modules/entity-type-registry.js +0 -311
  160. package/modules/proxy/proxy-errors.js +0 -20
  161. package/modules/proxy/proxy-path.js +0 -179
  162. package/modules/proxy/proxy-policy.js +0 -248
  163. package/modules/repositories/authorization-session-repository-documentdb.js +0 -112
  164. package/modules/repositories/authorization-session-repository-factory.js +0 -31
  165. package/modules/repositories/authorization-session-repository-interface.js +0 -47
  166. package/modules/repositories/authorization-session-repository-prisma.js +0 -87
  167. package/modules/repositories/entity-not-found.js +0 -18
  168. package/modules/requester/proxy-request.js +0 -244
  169. package/modules/scope-config.js +0 -192
  170. package/modules/scope-policy.js +0 -112
  171. package/modules/scope-resolution.js +0 -288
  172. package/modules/scope-visibility.js +0 -247
  173. package/modules/tests/doubles/in-memory-authorization-session-repository.js +0 -45
  174. package/modules/tests/doubles/ownership-matching.js +0 -18
  175. package/modules/use-cases/authorize-entity-for-user.js +0 -61
  176. package/modules/use-cases/create-entity-from-credential.js +0 -113
  177. package/modules/use-cases/entity-operations-for-user.js +0 -162
  178. package/modules/use-cases/entity-types.js +0 -139
  179. package/modules/use-cases/execute-entity-proxy-request.js +0 -286
  180. package/modules/use-cases/get-authorization-requirements.js +0 -100
  181. package/modules/use-cases/get-entity-module-for-user.js +0 -113
  182. package/modules/use-cases/manage-access.js +0 -21
  183. package/modules/use-cases/multi-step-authorization.js +0 -329
  184. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +0 -28
  185. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +0 -26
  186. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +0 -25
  187. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +0 -28
  188. package/providers/index.js +0 -23
  189. package/providers/resolve-provider.js +0 -93
  190. package/providers/runtime-detection.js +0 -124
  191. package/queues/providers/index.js +0 -11
  192. package/queues/providers/netlify-background-provider.js +0 -187
  193. package/queues/providers/qstash-queue-provider.js +0 -219
  194. package/queues/providers/sqs-queue-provider.js +0 -70
  195. package/queues/queue-client-interface.js +0 -55
  196. package/queues/queue-message-auth.js +0 -193
  197. package/queues/queue-provider-factory.js +0 -101
  198. package/queues/queue-provider.js +0 -65
  199. package/queues/sqs-queue-client.js +0 -81
  200. package/types/modules/index.d.ts +0 -197
  201. package/types/user/index.d.ts +0 -134
  202. package/user/adopter-jwt-config.js +0 -177
  203. package/user/adopter-jwt-errors.js +0 -59
  204. package/user/adopter-token-verifier.js +0 -122
  205. package/user/app-user-helpers.js +0 -97
  206. package/user/create-user-authentication.js +0 -64
  207. package/user/membership.js +0 -51
  208. package/user/repositories/organization-membership-record.js +0 -19
  209. package/user/repositories/organization-membership-repository-documentdb.js +0 -106
  210. package/user/repositories/organization-membership-repository-factory.js +0 -42
  211. package/user/repositories/organization-membership-repository-interface.js +0 -57
  212. package/user/repositories/organization-membership-repository-mongo.js +0 -13
  213. package/user/repositories/organization-membership-repository-postgres.js +0 -29
  214. package/user/repositories/organization-membership-repository-prisma.js +0 -81
  215. package/user/tests/doubles/adopter-jwt-fixtures.js +0 -220
  216. package/user/use-cases/ensure-organization-membership.js +0 -92
  217. package/user/use-cases/find-individual-users-by-organization.js +0 -54
@@ -3,7 +3,6 @@ const {
3
3
  IntegrationRepositoryInterface,
4
4
  } = require('./integration-repository-interface');
5
5
  const { validateConfigPatch } = require('./config-patch-shared');
6
- const { integrationNotFoundError } = require('./integration-not-found');
7
6
  const { strictIntId } = require('./report-id');
8
7
 
9
8
  /**
@@ -52,7 +51,6 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
52
51
  ...integration,
53
52
  id: integration.id?.toString(),
54
53
  userId: integration.userId?.toString(),
55
- organizationId: integration.organizationId?.toString() ?? null,
56
54
  entities: integration.entities?.map(e => ({
57
55
  ...e,
58
56
  id: e.id?.toString(),
@@ -62,25 +60,6 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
62
60
  };
63
61
  }
64
62
 
65
- /**
66
- * The one place a converted integration row becomes the record the
67
- * application sees (string ids, ids of its entities, its organization).
68
- * @private
69
- */
70
- _mapIntegration(converted) {
71
- return {
72
- id: converted.id,
73
- entitiesIds: converted.entities.map((e) => e.id),
74
- userId: converted.userId,
75
- organizationId: converted.organizationId,
76
- config: converted.config,
77
- version: converted.version,
78
- status: converted.status,
79
- messages: converted.messages,
80
- createdAt: converted.createdAt,
81
- };
82
- }
83
-
84
63
  /**
85
64
  * Find all integrations for a user
86
65
  *
@@ -99,35 +78,16 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
99
78
  // Map to domain objects with string IDs
100
79
  return integrations.map((integration) => {
101
80
  const converted = this._convertIntegrationIds(integration);
102
- return this._mapIntegration(converted);
103
- });
104
- }
105
-
106
- /**
107
- * Find integrations matching any of the owner/organization pairs, in one
108
- * query. `organizationId: null` matches rows with no organization. The
109
- * caller (a use case) decides which pairs; this only runs them.
110
- *
111
- * @param {Array<{userId: string, organizationId: (string|null)}>} clauses
112
- * @returns {Promise<Array>} Array of integration objects with string IDs
113
- */
114
- async findIntegrationsByOwnership(clauses) {
115
- if (!Array.isArray(clauses) || clauses.length === 0) return [];
116
- const integrations = await this.prisma.integration.findMany({
117
- where: {
118
- OR: clauses.map(({ userId, organizationId }) => ({
119
- userId: this._convertId(userId),
120
- organizationId: this._convertId(organizationId),
121
- })),
122
- },
123
- include: {
124
- entities: true,
125
- },
126
- });
127
-
128
- return integrations.map((integration) => {
129
- const converted = this._convertIntegrationIds(integration);
130
- return this._mapIntegration(converted);
81
+ return {
82
+ id: converted.id,
83
+ entitiesIds: converted.entities.map((e) => e.id),
84
+ userId: converted.userId,
85
+ config: converted.config,
86
+ version: converted.version,
87
+ status: converted.status,
88
+ messages: converted.messages,
89
+ createdAt: converted.createdAt,
90
+ };
131
91
  });
132
92
  }
133
93
 
@@ -157,7 +117,16 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
157
117
 
158
118
  return integrations.map((integration) => {
159
119
  const converted = this._convertIntegrationIds(integration);
160
- return this._mapIntegration(converted);
120
+ return {
121
+ id: converted.id,
122
+ entitiesIds: converted.entities.map((e) => e.id),
123
+ userId: converted.userId,
124
+ config: converted.config,
125
+ version: converted.version,
126
+ status: converted.status,
127
+ messages: converted.messages,
128
+ createdAt: converted.createdAt,
129
+ };
161
130
  });
162
131
  }
163
132
 
@@ -240,7 +209,15 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
240
209
  }
241
210
 
242
211
  const converted = this._convertIntegrationIds(integration);
243
- return this._mapIntegration(converted);
212
+ return {
213
+ id: converted.id,
214
+ entitiesIds: converted.entities.map((e) => e.id),
215
+ userId: converted.userId,
216
+ config: converted.config,
217
+ version: converted.version,
218
+ status: converted.status,
219
+ messages: converted.messages,
220
+ };
244
221
  }
245
222
 
246
223
  /**
@@ -259,11 +236,19 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
259
236
  });
260
237
 
261
238
  if (!integration) {
262
- throw integrationNotFoundError(id);
239
+ throw new Error(`Integration with id ${id} not found`);
263
240
  }
264
241
 
265
242
  const converted = this._convertIntegrationIds(integration);
266
- return this._mapIntegration(converted);
243
+ return {
244
+ id: converted.id,
245
+ entitiesIds: converted.entities.map((e) => e.id),
246
+ userId: converted.userId,
247
+ config: converted.config,
248
+ version: converted.version,
249
+ status: converted.status,
250
+ messages: converted.messages,
251
+ };
267
252
  }
268
253
 
269
254
  /**
@@ -343,15 +328,12 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
343
328
  * @param {Array<string>} entities - Array of entity IDs (strings from application layer)
344
329
  * @param {string} userId - User ID (string from application layer)
345
330
  * @param {Object} config - Integration configuration
346
- * @param {{organizationId?: (string|null)}} [tenancy] - the organization the
347
- * integration is created in (ADR-056 §3.6)
348
331
  * @returns {Promise<Object>} Created integration object with string IDs
349
332
  */
350
- async createIntegration(entities, userId, config, tenancy = {}) {
333
+ async createIntegration(entities, userId, config) {
351
334
  const data = {
352
335
  config,
353
336
  version: '0.0.0',
354
- organizationId: this._convertId(tenancy.organizationId ?? null),
355
337
  };
356
338
 
357
339
  // PostgreSQL: use nested relations with ID conversion
@@ -372,7 +354,73 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
372
354
  });
373
355
 
374
356
  const converted = this._convertIntegrationIds(integration);
375
- return this._mapIntegration(converted);
357
+ return {
358
+ id: converted.id,
359
+ entitiesIds: converted.entities.map((e) => e.id),
360
+ userId: converted.userId,
361
+ config: converted.config,
362
+ version: converted.version,
363
+ status: converted.status,
364
+ messages: converted.messages,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Attach entities to an existing integration. Entities already attached
370
+ * are left as they are.
371
+ *
372
+ * @param {string} integrationId - Integration ID (string from application layer)
373
+ * @param {Array<string>} entityIds - Entity IDs to attach
374
+ * @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
375
+ */
376
+ async addEntitiesToIntegration(integrationId, entityIds) {
377
+ const intId = this._convertId(integrationId);
378
+
379
+ try {
380
+ await this.prisma.integration.update({
381
+ where: { id: intId },
382
+ data: {
383
+ entities: {
384
+ connect: entityIds.map((id) => ({
385
+ id: this._convertId(id),
386
+ })),
387
+ },
388
+ },
389
+ });
390
+ } catch (error) {
391
+ if (error.code === 'P2025') return null;
392
+ throw error;
393
+ }
394
+ return this.findIntegrationById(integrationId);
395
+ }
396
+
397
+ /**
398
+ * Detach entities from an integration. Entities that are not attached
399
+ * are ignored. The entities themselves are not deleted.
400
+ *
401
+ * @param {string} integrationId - Integration ID (string from application layer)
402
+ * @param {Array<string>} entityIds - Entity IDs to detach
403
+ * @returns {Promise<Object|null>} Updated integration, or null when the integration does not exist
404
+ */
405
+ async removeEntitiesFromIntegration(integrationId, entityIds) {
406
+ const intId = this._convertId(integrationId);
407
+
408
+ try {
409
+ await this.prisma.integration.update({
410
+ where: { id: intId },
411
+ data: {
412
+ entities: {
413
+ disconnect: entityIds.map((id) => ({
414
+ id: this._convertId(id),
415
+ })),
416
+ },
417
+ },
418
+ });
419
+ } catch (error) {
420
+ if (error.code === 'P2025') return null;
421
+ throw error;
422
+ }
423
+ return this.findIntegrationById(integrationId);
376
424
  }
377
425
 
378
426
  /**
@@ -395,7 +443,15 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
395
443
  }
396
444
 
397
445
  const converted = this._convertIntegrationIds(integration);
398
- return this._mapIntegration(converted);
446
+ return {
447
+ id: converted.id,
448
+ entitiesIds: converted.entities.map((e) => e.id),
449
+ userId: converted.userId,
450
+ config: converted.config,
451
+ version: converted.version,
452
+ status: converted.status,
453
+ messages: converted.messages,
454
+ };
399
455
  }
400
456
 
401
457
  /**
@@ -420,7 +476,15 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
420
476
  });
421
477
 
422
478
  const converted = this._convertIntegrationIds(integration);
423
- return this._mapIntegration(converted);
479
+ return {
480
+ id: converted.id,
481
+ entitiesIds: converted.entities.map((e) => e.id),
482
+ userId: converted.userId,
483
+ config: converted.config,
484
+ version: converted.version,
485
+ status: converted.status,
486
+ messages: converted.messages,
487
+ };
424
488
  }
425
489
 
426
490
  /**
@@ -470,7 +534,15 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
470
534
 
471
535
  return integrations.map((integration) => {
472
536
  const converted = this._convertIntegrationIds(integration);
473
- return this._mapIntegration(converted);
537
+ return {
538
+ id: converted.id,
539
+ entitiesIds: converted.entities.map((e) => e.id),
540
+ userId: converted.userId,
541
+ config: converted.config,
542
+ version: converted.version,
543
+ status: converted.status,
544
+ messages: converted.messages,
545
+ };
474
546
  });
475
547
  }
476
548
  }
@@ -120,7 +120,7 @@ class ProcessRepositoryMongo extends ProcessRepositoryInterface {
120
120
  if (normalized.newState !== null) {
121
121
  $set.state = normalized.newState;
122
122
  }
123
- $set.updatedAt = new Date();
123
+ $set.updatedAt = { $date: new Date().toISOString() };
124
124
  update.$set = $set;
125
125
 
126
126
  if (Object.keys(normalized.pushSlice).length > 0) {
@@ -1,6 +1,5 @@
1
1
  const { v4: uuid } = require('uuid');
2
2
  const { validateConfigPatch } = require('../../repositories/config-patch-shared');
3
- const { matchesAnyClause } = require('../../../modules/tests/doubles/ownership-matching');
4
3
 
5
4
  class TestIntegrationRepository {
6
5
  constructor() {
@@ -8,14 +7,13 @@ class TestIntegrationRepository {
8
7
  this.operationHistory = [];
9
8
  }
10
9
 
11
- async createIntegration(entities, userId, config, tenancy = {}) {
10
+ async createIntegration(entities, userId, config) {
12
11
  const id = uuid();
13
12
  const record = {
14
13
  id,
15
14
  _id: id,
16
15
  entitiesIds: entities,
17
16
  userId: userId,
18
- organizationId: tenancy.organizationId ?? null,
19
17
  config,
20
18
  version: '0.0.0',
21
19
  status: 'IN_CREATION',
@@ -40,15 +38,6 @@ class TestIntegrationRepository {
40
38
  return results;
41
39
  }
42
40
 
43
- // Answers the way a database does for the clauses a use case hands it.
44
- async findIntegrationsByOwnership(clauses) {
45
- const results = Array.from(this.store.values()).filter((r) =>
46
- matchesAnyClause(r, clauses)
47
- );
48
- this.operationHistory.push({ operation: 'findByOwnership', clauses, count: results.length });
49
- return results;
50
- }
51
-
52
41
  async findIntegrations(filter = {}) {
53
42
  const { type, status } = filter;
54
43
  const results = Array.from(this.store.values()).filter((r) => {
@@ -0,0 +1,92 @@
1
+ const {
2
+ assertEntityIdsInput,
3
+ integrationNotFound,
4
+ loadIntegration,
5
+ } = require('../utils/assert-entity-ids-input');
6
+
7
+ /**
8
+ * Attaches entities to an existing integration, e.g. when a user grants
9
+ * access to more accounts after the integration was created. Every entity
10
+ * must exist, belong to the integration's user, and be of a module the
11
+ * integration already holds (so hydration can load it). Entities that are
12
+ * already attached are left as they are.
13
+ * @class AddEntitiesToIntegration
14
+ */
15
+ class AddEntitiesToIntegration {
16
+ /**
17
+ * @param {Object} params
18
+ * @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository
19
+ * @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
20
+ */
21
+ constructor({ integrationRepository, moduleRepository }) {
22
+ this.integrationRepository = integrationRepository;
23
+ this.moduleRepository = moduleRepository;
24
+ }
25
+
26
+ /**
27
+ * @param {string} integrationId
28
+ * @param {string[]} entityIds
29
+ * @returns {Promise<Object>} The updated integration record.
30
+ */
31
+ async execute(integrationId, entityIds) {
32
+ assertEntityIdsInput(integrationId, entityIds);
33
+
34
+ const integration = await loadIntegration(
35
+ this.integrationRepository,
36
+ integrationId
37
+ );
38
+
39
+ const entities = await this.moduleRepository.findEntitiesByIds(
40
+ entityIds
41
+ );
42
+ const foundIds = new Set(entities.map((e) => String(e.id)));
43
+ const missing = entityIds.filter((id) => !foundIds.has(String(id)));
44
+ if (missing.length > 0) {
45
+ const error = new Error(
46
+ `Entities not found: ${missing.join(', ')}`
47
+ );
48
+ error.code = 'ENTITIES_NOT_FOUND';
49
+ throw error;
50
+ }
51
+
52
+ const foreign = entities.filter(
53
+ (e) => String(e.userId) !== String(integration.userId)
54
+ );
55
+ if (foreign.length > 0) {
56
+ const error = new Error(
57
+ `Entities do not belong to the integration's user: ${foreign
58
+ .map((e) => e.id)
59
+ .join(', ')}`
60
+ );
61
+ error.code = 'ENTITY_USER_MISMATCH';
62
+ throw error;
63
+ }
64
+
65
+ const attached = await this.moduleRepository.findEntitiesByIds(
66
+ integration.entitiesIds ?? []
67
+ );
68
+ const heldModules = new Set(attached.map((e) => e.moduleName));
69
+ const otherModules = entities.filter(
70
+ (e) => !heldModules.has(e.moduleName)
71
+ );
72
+ if (otherModules.length > 0) {
73
+ const error = new Error(
74
+ `Entities belong to a module the integration does not hold: ${otherModules
75
+ .map((e) => `${e.id} (${e.moduleName})`)
76
+ .join(', ')}`
77
+ );
78
+ error.code = 'ENTITY_MODULE_MISMATCH';
79
+ throw error;
80
+ }
81
+
82
+ const updated =
83
+ await this.integrationRepository.addEntitiesToIntegration(
84
+ integrationId,
85
+ entityIds
86
+ );
87
+ if (!updated) throw integrationNotFound(integrationId);
88
+ return updated;
89
+ }
90
+ }
91
+
92
+ module.exports = { AddEntitiesToIntegration };
@@ -27,19 +27,13 @@ class CreateIntegration {
27
27
  * and entity set, instead of creating a duplicate.
28
28
  * @async
29
29
  * @param {string[]} entities - Array of entity IDs to associate with the integration.
30
- * @param {string} userId - ID of the OWNER of the integration (ADR-056 §3):
31
- * the person or the organization's user, as ResolveIntegrationOwnership decided.
30
+ * @param {string} userId - ID of the user creating the integration.
32
31
  * @param {Object} config - Configuration object for the integration.
33
32
  * @param {string} config.type - Type of integration to create.
34
- * @param {{organizationId?: (string|null), adoptUnscoped?: boolean}} [tenancy]
35
- * the organization it is created in (§3.6). A duplicate is only reused in
36
- * the same organization, so one person's integration in organization A is
37
- * not handed back as the one they asked for in B. `adoptUnscoped` also
38
- * accepts a duplicate written before scoping (no organization).
39
33
  * @returns {Promise<Object>} The created or reused integration DTO.
40
34
  * @throws {Error} When integration class is not found for the specified type.
41
35
  */
42
- async execute(entities, userId, config, tenancy = {}) {
36
+ async execute(entities, userId, config) {
43
37
  console.log(
44
38
  `[Frigg] Creating ${config?.type} integration for user ${userId} with entities [${entities}]`
45
39
  );
@@ -47,8 +41,7 @@ class CreateIntegration {
47
41
  const existing = await this._findDuplicate(
48
42
  userId,
49
43
  config?.type,
50
- entities,
51
- tenancy
44
+ entities
52
45
  );
53
46
  if (existing) {
54
47
  console.log(
@@ -57,18 +50,11 @@ class CreateIntegration {
57
50
  return this._reuseExisting(existing, config);
58
51
  }
59
52
 
60
- // Everything that can be refused is refused before a row is written: an
61
- // unknown type, or an entity the owner cannot load, would otherwise
62
- // leave an IN_CREATION integration behind that nothing moves on.
63
- const integrationClass = this._requireClass(config?.type);
64
- const modules = await this._loadModules(entities, userId);
65
-
66
53
  const integrationRecord =
67
54
  await this.integrationRepository.createIntegration(
68
55
  entities,
69
56
  userId,
70
- config,
71
- { organizationId: tenancy.organizationId ?? null }
57
+ config
72
58
  );
73
59
  console.log(
74
60
  `[Frigg] Created integration ${integrationRecord.id} for user ${userId}`
@@ -76,11 +62,10 @@ class CreateIntegration {
76
62
 
77
63
  // A concurrent request may have created a matching row between the
78
64
  // lookup above and this insert; re-check before any side effects run.
79
- const survivor = await this._findDuplicate(
65
+ const survivor = await this._findSurvivor(
80
66
  userId,
81
67
  config?.type,
82
- entities,
83
- tenancy
68
+ entities
84
69
  );
85
70
  if (survivor && String(survivor.id) !== String(integrationRecord.id)) {
86
71
  console.log(
@@ -93,8 +78,7 @@ class CreateIntegration {
93
78
  }
94
79
 
95
80
  const integrationInstance = await this._buildInstance(
96
- integrationRecord,
97
- { integrationClass, modules }
81
+ integrationRecord
98
82
  );
99
83
  console.log(
100
84
  `[Frigg] Sending ON_CREATE for integration ${integrationRecord.id}`
@@ -151,41 +135,69 @@ class CreateIntegration {
151
135
  return mapIntegrationClassToIntegrationDTO(integrationInstance);
152
136
  }
153
137
 
154
- async _findDuplicate(userId, type, entityIds, tenancy = {}) {
138
+ async _findMatches(userId, type, entityIds) {
155
139
  const candidates =
156
140
  await this.integrationRepository.findIntegrationsByUserId(userId);
157
- const target = [...(entityIds ?? [])].map(String).sort();
158
-
159
- const matches = candidates.filter((integration) => {
160
- // An IN_DELETION row is either mid-teardown or a zombie left
161
- // behind by a failed deleteIntegrationById call — never a live
162
- // integration. Treating it as a duplicate would hand a reinstall
163
- // attempt a row nothing will ever move out of IN_DELETION,
164
- // silently discarded by the queue worker forever. Let a fresh
165
- // create proceed instead; the zombie is cleaned up out-of-band,
166
- // matching how teardown failures are already handled here.
167
- if (integration.status === 'IN_DELETION') return false;
168
- if (integration.config?.type !== type) return false;
169
- if (!this._sameOrganization(integration, tenancy)) return false;
170
- const current = [...(integration.entitiesIds ?? [])]
171
- .map(String)
172
- .sort();
141
+ const target = new Set((entityIds ?? []).map(String));
142
+
143
+ const live = candidates.filter(
144
+ (integration) =>
145
+ // An IN_DELETION row is either mid-teardown or a zombie left
146
+ // behind by a failed deleteIntegrationById call — never a live
147
+ // integration. Treating it as a duplicate would hand a
148
+ // reinstall attempt a row nothing will ever move out of
149
+ // IN_DELETION, silently discarded by the queue worker forever.
150
+ // Let a fresh create proceed instead; the zombie is cleaned up
151
+ // out-of-band, matching how teardown failures are already
152
+ // handled here.
153
+ integration.status !== 'IN_DELETION' &&
154
+ integration.config?.type === type
155
+ );
156
+ const entitySet = (integration) =>
157
+ new Set((integration.entitiesIds ?? []).map(String));
158
+
159
+ const exact = live.filter((integration) => {
160
+ const current = entitySet(integration);
173
161
  return (
174
- current.length === target.length &&
175
- current.every((id, index) => id === target[index])
162
+ current.size === target.size &&
163
+ [...target].every((id) => current.has(id))
176
164
  );
177
165
  });
178
166
 
179
- return matches.length ? this._pickOldest(matches) : null;
167
+ // Entities may have been attached after create (addEntitiesToIntegration),
168
+ // so a reconnect that sends the original entities must still find the
169
+ // integration that now holds them, instead of creating a second one
170
+ // that would share those entities.
171
+ const supersets = live.filter((integration) => {
172
+ const current = entitySet(integration);
173
+ return [...target].every((id) => current.has(id));
174
+ });
175
+ return { exact, supersets: target.size ? supersets : [] };
176
+ }
177
+
178
+ async _findDuplicate(userId, type, entityIds) {
179
+ const { exact, supersets } = await this._findMatches(
180
+ userId,
181
+ type,
182
+ entityIds
183
+ );
184
+ if (exact.length) return this._pickOldest(exact);
185
+ return supersets.length ? this._pickOldest(supersets) : null;
180
186
  }
181
187
 
182
- /** Same organization, or a row from before scoping that belongs here. */
183
- _sameOrganization(integration, tenancy) {
184
- const wanted = tenancy.organizationId ?? null;
185
- const has = integration.organizationId ?? null;
186
- if (wanted === null && has === null) return true;
187
- if (wanted !== null && has !== null) return String(wanted) === String(has);
188
- return has === null && tenancy.adoptUnscoped === true;
188
+ // After an insert the new row is itself an exact match, so the survivor
189
+ // is the oldest of every exact or superset match (the new row included):
190
+ // an older integration that already holds these entities always wins.
191
+ async _findSurvivor(userId, type, entityIds) {
192
+ const { exact, supersets } = await this._findMatches(
193
+ userId,
194
+ type,
195
+ entityIds
196
+ );
197
+ // supersets includes the exact matches; it is empty only for an
198
+ // empty entity set, where exact matching alone applies.
199
+ const matches = supersets.length ? supersets : exact;
200
+ return matches.length ? this._pickOldest(matches) : null;
189
201
  }
190
202
 
191
203
  _pickOldest(records) {
@@ -199,36 +211,27 @@ class CreateIntegration {
199
211
  })[0];
200
212
  }
201
213
 
202
- _requireClass(type) {
214
+ async _buildInstance(integrationRecord) {
203
215
  const integrationClass = this.integrationClasses.find(
204
- (integrationClass) => integrationClass.Definition.name === type
216
+ (integrationClass) =>
217
+ integrationClass.Definition.name ===
218
+ integrationRecord.config.type
205
219
  );
220
+
206
221
  if (!integrationClass) {
207
- throw new Error(`No integration class found for type: ${type}`);
222
+ throw new Error(
223
+ `No integration class found for type: ${integrationRecord.config.type}`
224
+ );
208
225
  }
209
- return integrationClass;
210
- }
211
226
 
212
- async _loadModules(entityIds, userId) {
213
227
  const modules = [];
214
- for (const entityId of entityIds) {
215
- modules.push(
216
- await this.moduleFactory.getModuleInstance(entityId, userId)
228
+ for (const entityId of integrationRecord.entitiesIds) {
229
+ const moduleInstance = await this.moduleFactory.getModuleInstance(
230
+ entityId,
231
+ integrationRecord.userId
217
232
  );
233
+ modules.push(moduleInstance);
218
234
  }
219
- return modules;
220
- }
221
-
222
- async _buildInstance(integrationRecord, preloaded) {
223
- const integrationClass =
224
- preloaded?.integrationClass ??
225
- this._requireClass(integrationRecord.config.type);
226
- const modules =
227
- preloaded?.modules ??
228
- (await this._loadModules(
229
- integrationRecord.entitiesIds,
230
- integrationRecord.userId
231
- ));
232
235
 
233
236
  const integrationInstance = new integrationClass({
234
237
  id: integrationRecord.id,