@friggframework/core 2.0.0--canary.679.a2bcc2f.0 → 2.0.0--canary.686.219e1e7.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 (143) hide show
  1. package/CLAUDE.md +28 -0
  2. package/README.md +105 -0
  3. package/application/commands/integration-commands.js +2 -2
  4. package/application/commands/scheduler-commands.js +11 -7
  5. package/application/commands/user-commands.js +17 -5
  6. package/core/Worker.js +31 -7
  7. package/core/create-handler.js +9 -0
  8. package/credential/use-cases/credential-operations-for-user.js +110 -25
  9. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  10. package/database/config.js +48 -24
  11. package/database/documentdb-utils.js +6 -1
  12. package/database/encryption/encryption-schema-registry.js +80 -12
  13. package/database/organization-where.js +18 -0
  14. package/database/prisma.js +4 -2
  15. package/database/unique-violation.js +25 -0
  16. package/extensions/bootstrap-runner.js +43 -0
  17. package/extensions/extension-loader.js +167 -0
  18. package/extensions/index.js +41 -0
  19. package/extensions/initialize-app.js +123 -0
  20. package/extensions/route-mounter.js +103 -0
  21. package/extensions/schema-composer.js +188 -0
  22. package/generated/prisma-mongodb/edge.js +27 -4
  23. package/generated/prisma-mongodb/index-browser.js +24 -1
  24. package/generated/prisma-mongodb/index.d.ts +3227 -47
  25. package/generated/prisma-mongodb/index.js +27 -4
  26. package/generated/prisma-mongodb/package.json +1 -1
  27. package/generated/prisma-mongodb/schema.prisma +65 -0
  28. package/generated/prisma-mongodb/wasm.js +27 -4
  29. package/generated/prisma-postgresql/edge.js +27 -4
  30. package/generated/prisma-postgresql/index-browser.js +24 -1
  31. package/generated/prisma-postgresql/index.d.ts +3519 -67
  32. package/generated/prisma-postgresql/index.js +27 -4
  33. package/generated/prisma-postgresql/package.json +1 -1
  34. package/generated/prisma-postgresql/schema.prisma +61 -0
  35. package/generated/prisma-postgresql/wasm.js +27 -4
  36. package/handlers/app-definition-loader.js +93 -12
  37. package/handlers/routers/auth.js +40 -12
  38. package/handlers/routers/integration-defined-routers.js +144 -92
  39. package/handlers/routers/integration-webhook-routers.js +102 -64
  40. package/index.js +58 -1
  41. package/infrastructure/scheduler/index.js +19 -6
  42. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  43. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  44. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  45. package/integrations/integration-router.js +115 -73
  46. package/integrations/repositories/integration-not-found.js +14 -0
  47. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  48. package/integrations/repositories/integration-repository-interface.js +17 -1
  49. package/integrations/repositories/integration-repository-mongo.js +59 -76
  50. package/integrations/repositories/integration-repository-postgres.js +62 -76
  51. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  52. package/integrations/use-cases/create-integration.js +55 -20
  53. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  54. package/integrations/use-cases/index.js +5 -1
  55. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  56. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  57. package/management-api/management-api-config.js +71 -2
  58. package/management-api/openapi/components.js +40 -0
  59. package/management-api/openapi/v2-operations.js +16 -4
  60. package/management-api/v2/dependencies.js +40 -15
  61. package/management-api/v2/dto.js +20 -0
  62. package/management-api/v2/handlers/authorization.js +1 -0
  63. package/management-api/v2/handlers/credentials.js +6 -2
  64. package/management-api/v2/handlers/entities.js +10 -7
  65. package/management-api/v2/handlers/integrations.js +6 -3
  66. package/management-api/v2/request.js +32 -1
  67. package/modules/entity-scope.js +15 -0
  68. package/modules/index.js +8 -0
  69. package/modules/module-factory.js +12 -0
  70. package/modules/module.js +44 -2
  71. package/modules/repositories/entity-not-found.js +18 -0
  72. package/modules/repositories/module-repository-documentdb.js +51 -6
  73. package/modules/repositories/module-repository-interface.js +19 -2
  74. package/modules/repositories/module-repository-mongo.js +67 -74
  75. package/modules/repositories/module-repository-postgres.js +66 -74
  76. package/modules/repositories/module-repository.js +2 -1
  77. package/modules/scope-config.js +192 -0
  78. package/modules/scope-policy.js +112 -0
  79. package/modules/scope-resolution.js +288 -0
  80. package/modules/scope-visibility.js +247 -0
  81. package/modules/tests/doubles/ownership-matching.js +18 -0
  82. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  83. package/modules/use-cases/create-entity-from-credential.js +113 -0
  84. package/modules/use-cases/entity-operations-for-user.js +50 -13
  85. package/modules/use-cases/entity-types.js +31 -7
  86. package/modules/use-cases/execute-entity-proxy-request.js +50 -3
  87. package/modules/use-cases/get-authorization-requirements.js +2 -2
  88. package/modules/use-cases/get-entities-for-user.js +26 -13
  89. package/modules/use-cases/get-entity-module-for-user.js +34 -7
  90. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  91. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  92. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  93. package/modules/use-cases/get-module.js +10 -4
  94. package/modules/use-cases/manage-access.js +21 -0
  95. package/modules/use-cases/multi-step-authorization.js +40 -13
  96. package/modules/use-cases/process-authorization-callback.js +53 -19
  97. package/modules/use-cases/refresh-entity-options.js +10 -4
  98. package/modules/use-cases/test-module-auth.js +10 -4
  99. package/package.json +6 -5
  100. package/prisma-mongodb/schema.prisma +65 -0
  101. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  102. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  103. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  104. package/prisma-postgresql/schema.prisma +61 -0
  105. package/providers/index.js +13 -0
  106. package/providers/resolve-provider.js +93 -0
  107. package/queues/index.js +25 -0
  108. package/queues/providers/index.js +11 -0
  109. package/queues/providers/netlify-background-provider.js +148 -0
  110. package/queues/providers/qstash-queue-provider.js +197 -0
  111. package/queues/providers/sqs-queue-provider.js +70 -0
  112. package/queues/queue-client-interface.js +55 -0
  113. package/queues/queue-message-auth.js +187 -0
  114. package/queues/queue-provider-factory.js +101 -0
  115. package/queues/queue-provider.js +62 -0
  116. package/queues/sqs-queue-client.js +81 -0
  117. package/types/index.d.ts +1 -0
  118. package/types/modules/index.d.ts +129 -0
  119. package/types/user/index.d.ts +134 -0
  120. package/user/adopter-jwt-config.js +177 -0
  121. package/user/adopter-jwt-errors.js +59 -0
  122. package/user/adopter-token-verifier.js +122 -0
  123. package/user/app-user-helpers.js +97 -0
  124. package/user/create-user-authentication.js +64 -0
  125. package/user/membership.js +51 -0
  126. package/user/repositories/organization-membership-record.js +19 -0
  127. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  128. package/user/repositories/organization-membership-repository-factory.js +42 -0
  129. package/user/repositories/organization-membership-repository-interface.js +57 -0
  130. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  131. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  132. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  133. package/user/repositories/user-repository-documentdb.js +27 -2
  134. package/user/repositories/user-repository-interface.js +18 -0
  135. package/user/repositories/user-repository-mongo.js +24 -0
  136. package/user/repositories/user-repository-postgres.js +18 -0
  137. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  138. package/user/use-cases/authenticate-user.js +7 -1
  139. package/user/use-cases/ensure-organization-membership.js +92 -0
  140. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  141. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  142. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  143. package/user/user.js +64 -1
@@ -1,8 +1,10 @@
1
1
  const { prisma } = require('../../database/prisma');
2
+ const { organizationWhere } = require('../../database/organization-where');
2
3
  const {
3
4
  IntegrationRepositoryInterface,
4
5
  } = require('./integration-repository-interface');
5
6
  const { validateConfigPatch } = require('./config-patch-shared');
7
+ const { integrationNotFoundError } = require('./integration-not-found');
6
8
 
7
9
  /**
8
10
  * MongoDB Integration Repository Adapter
@@ -25,6 +27,25 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
25
27
  this.prisma = prisma;
26
28
  }
27
29
 
30
+ /**
31
+ * The one place a integration integration row becomes the record the
32
+ * application sees (ids of its entities, its organization).
33
+ * @private
34
+ */
35
+ _mapIntegration(integration) {
36
+ return {
37
+ id: integration.id,
38
+ entitiesIds: integration.entities.map((e) => e.id),
39
+ userId: integration.userId,
40
+ organizationId: integration.organizationId ?? null,
41
+ config: integration.config,
42
+ version: integration.version,
43
+ status: integration.status,
44
+ messages: integration.messages,
45
+ createdAt: integration.createdAt,
46
+ };
47
+ }
48
+
28
49
  /**
29
50
  * Find all integrations for a user
30
51
  * Replaces: IntegrationModel.find({ user: userId }).populate('entities')
@@ -41,16 +62,32 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
41
62
  });
42
63
 
43
64
  // Map to domain objects (maintains same API)
44
- return integrations.map((integration) => ({
45
- id: integration.id,
46
- entitiesIds: integration.entities.map((e) => e.id),
47
- userId: integration.userId,
48
- config: integration.config,
49
- version: integration.version,
50
- status: integration.status,
51
- messages: integration.messages,
52
- createdAt: integration.createdAt,
53
- }));
65
+ return integrations.map((integration) => (this._mapIntegration(integration)));
66
+ }
67
+
68
+ /**
69
+ * Find integrations matching any of the owner/organization pairs, in one
70
+ * query. `organizationId: null` matches rows with no organization. The
71
+ * caller (a use case) decides which pairs; this only runs them.
72
+ *
73
+ * @param {Array<{userId: string, organizationId: (string|null)}>} clauses
74
+ * @returns {Promise<Array>} Array of integration objects
75
+ */
76
+ async findIntegrationsByOwnership(clauses) {
77
+ if (!Array.isArray(clauses) || clauses.length === 0) return [];
78
+ const integrations = await this.prisma.integration.findMany({
79
+ where: {
80
+ OR: clauses.map(({ userId, organizationId }) => ({
81
+ userId,
82
+ ...organizationWhere(organizationId),
83
+ })),
84
+ },
85
+ include: {
86
+ entities: true,
87
+ },
88
+ });
89
+
90
+ return integrations.map((integration) => (this._mapIntegration(integration)));
54
91
  }
55
92
 
56
93
  /**
@@ -77,16 +114,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
77
114
  },
78
115
  });
79
116
 
80
- return integrations.map((integration) => ({
81
- id: integration.id,
82
- entitiesIds: integration.entities.map((e) => e.id),
83
- userId: integration.userId,
84
- config: integration.config,
85
- version: integration.version,
86
- status: integration.status,
87
- messages: integration.messages,
88
- createdAt: integration.createdAt,
89
- }));
117
+ return integrations.map((integration) => (this._mapIntegration(integration)));
90
118
  }
91
119
 
92
120
  /**
@@ -165,15 +193,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
165
193
  throw new Error(`Integration with name ${name} not found`);
166
194
  }
167
195
 
168
- return {
169
- id: integration.id,
170
- entitiesIds: integration.entities.map((e) => e.id),
171
- userId: integration.userId,
172
- config: integration.config,
173
- version: integration.version,
174
- status: integration.status,
175
- messages: integration.messages,
176
- };
196
+ return this._mapIntegration(integration);
177
197
  }
178
198
 
179
199
  /**
@@ -192,18 +212,10 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
192
212
  });
193
213
 
194
214
  if (!integration) {
195
- throw new Error(`Integration with id ${id} not found`);
215
+ throw integrationNotFoundError(id);
196
216
  }
197
217
 
198
- return {
199
- id: integration.id,
200
- entitiesIds: integration.entities.map((e) => e.id),
201
- userId: integration.userId,
202
- config: integration.config,
203
- version: integration.version,
204
- status: integration.status,
205
- messages: integration.messages,
206
- };
218
+ return this._mapIntegration(integration);
207
219
  }
208
220
 
209
221
  /**
@@ -290,15 +302,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
290
302
  },
291
303
  });
292
304
 
293
- return integrations.map((integration) => ({
294
- id: integration.id,
295
- entitiesIds: integration.entities.map((e) => e.id),
296
- userId: integration.userId,
297
- config: integration.config,
298
- version: integration.version,
299
- status: integration.status,
300
- messages: integration.messages,
301
- }));
305
+ return integrations.map((integration) => (this._mapIntegration(integration)));
302
306
  }
303
307
 
304
308
  /**
@@ -310,13 +314,16 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
310
314
  * @param {Array<string>} entities - Array of entity IDs (MongoDB ObjectIds)
311
315
  * @param {string} userId - User ID (MongoDB ObjectId)
312
316
  * @param {Object} config - Integration configuration
317
+ * @param {{organizationId?: (string|null)}} [tenancy] - the organization the
318
+ * integration is created in (ADR-056 §3.6)
313
319
  * @returns {Promise<Object>} Created integration object
314
320
  */
315
- async createIntegration(entities, userId, config) {
321
+ async createIntegration(entities, userId, config, tenancy = {}) {
316
322
  const data = {
317
323
  config,
318
324
  version: '0.0.0',
319
325
  userId: userId,
326
+ organizationId: tenancy.organizationId ?? null,
320
327
  entityIds: entities,
321
328
  };
322
329
 
@@ -327,15 +334,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
327
334
  },
328
335
  });
329
336
 
330
- return {
331
- id: integration.id,
332
- entitiesIds: integration.entities.map((e) => e.id),
333
- userId: integration.userId,
334
- config: integration.config,
335
- version: integration.version,
336
- status: integration.status,
337
- messages: integration.messages,
338
- };
337
+ return this._mapIntegration(integration);
339
338
  }
340
339
 
341
340
  /**
@@ -357,15 +356,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
357
356
  return null;
358
357
  }
359
358
 
360
- return {
361
- id: integration.id,
362
- entitiesIds: integration.entities.map((e) => e.id),
363
- userId: integration.userId,
364
- config: integration.config,
365
- version: integration.version,
366
- status: integration.status,
367
- messages: integration.messages,
368
- };
359
+ return this._mapIntegration(integration);
369
360
  }
370
361
 
371
362
  /**
@@ -389,15 +380,7 @@ class IntegrationRepositoryMongo extends IntegrationRepositoryInterface {
389
380
  },
390
381
  });
391
382
 
392
- return {
393
- id: integration.id,
394
- entitiesIds: integration.entities.map((e) => e.id),
395
- userId: integration.userId,
396
- config: integration.config,
397
- version: integration.version,
398
- status: integration.status,
399
- messages: integration.messages,
400
- };
383
+ return this._mapIntegration(integration);
401
384
  }
402
385
 
403
386
  /**
@@ -3,6 +3,7 @@ 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');
6
7
  const { strictIntId } = require('./report-id');
7
8
 
8
9
  /**
@@ -51,6 +52,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
51
52
  ...integration,
52
53
  id: integration.id?.toString(),
53
54
  userId: integration.userId?.toString(),
55
+ organizationId: integration.organizationId?.toString() ?? null,
54
56
  entities: integration.entities?.map(e => ({
55
57
  ...e,
56
58
  id: e.id?.toString(),
@@ -60,6 +62,25 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
60
62
  };
61
63
  }
62
64
 
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
+
63
84
  /**
64
85
  * Find all integrations for a user
65
86
  *
@@ -78,16 +99,35 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
78
99
  // Map to domain objects with string IDs
79
100
  return integrations.map((integration) => {
80
101
  const converted = this._convertIntegrationIds(integration);
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
- };
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);
91
131
  });
92
132
  }
93
133
 
@@ -117,16 +157,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
117
157
 
118
158
  return integrations.map((integration) => {
119
159
  const converted = this._convertIntegrationIds(integration);
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
- };
160
+ return this._mapIntegration(converted);
130
161
  });
131
162
  }
132
163
 
@@ -209,15 +240,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
209
240
  }
210
241
 
211
242
  const converted = this._convertIntegrationIds(integration);
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
- };
243
+ return this._mapIntegration(converted);
221
244
  }
222
245
 
223
246
  /**
@@ -236,19 +259,11 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
236
259
  });
237
260
 
238
261
  if (!integration) {
239
- throw new Error(`Integration with id ${id} not found`);
262
+ throw integrationNotFoundError(id);
240
263
  }
241
264
 
242
265
  const converted = this._convertIntegrationIds(integration);
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
- };
266
+ return this._mapIntegration(converted);
252
267
  }
253
268
 
254
269
  /**
@@ -328,12 +343,15 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
328
343
  * @param {Array<string>} entities - Array of entity IDs (strings from application layer)
329
344
  * @param {string} userId - User ID (string from application layer)
330
345
  * @param {Object} config - Integration configuration
346
+ * @param {{organizationId?: (string|null)}} [tenancy] - the organization the
347
+ * integration is created in (ADR-056 §3.6)
331
348
  * @returns {Promise<Object>} Created integration object with string IDs
332
349
  */
333
- async createIntegration(entities, userId, config) {
350
+ async createIntegration(entities, userId, config, tenancy = {}) {
334
351
  const data = {
335
352
  config,
336
353
  version: '0.0.0',
354
+ organizationId: this._convertId(tenancy.organizationId ?? null),
337
355
  };
338
356
 
339
357
  // PostgreSQL: use nested relations with ID conversion
@@ -354,15 +372,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
354
372
  });
355
373
 
356
374
  const converted = this._convertIntegrationIds(integration);
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
- };
375
+ return this._mapIntegration(converted);
366
376
  }
367
377
 
368
378
  /**
@@ -385,15 +395,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
385
395
  }
386
396
 
387
397
  const converted = this._convertIntegrationIds(integration);
388
- return {
389
- id: converted.id,
390
- entitiesIds: converted.entities.map((e) => e.id),
391
- userId: converted.userId,
392
- config: converted.config,
393
- version: converted.version,
394
- status: converted.status,
395
- messages: converted.messages,
396
- };
398
+ return this._mapIntegration(converted);
397
399
  }
398
400
 
399
401
  /**
@@ -418,15 +420,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
418
420
  });
419
421
 
420
422
  const converted = this._convertIntegrationIds(integration);
421
- return {
422
- id: converted.id,
423
- entitiesIds: converted.entities.map((e) => e.id),
424
- userId: converted.userId,
425
- config: converted.config,
426
- version: converted.version,
427
- status: converted.status,
428
- messages: converted.messages,
429
- };
423
+ return this._mapIntegration(converted);
430
424
  }
431
425
 
432
426
  /**
@@ -476,15 +470,7 @@ class IntegrationRepositoryPostgres extends IntegrationRepositoryInterface {
476
470
 
477
471
  return integrations.map((integration) => {
478
472
  const converted = this._convertIntegrationIds(integration);
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
- };
473
+ return this._mapIntegration(converted);
488
474
  });
489
475
  }
490
476
  }
@@ -1,5 +1,6 @@
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');
3
4
 
4
5
  class TestIntegrationRepository {
5
6
  constructor() {
@@ -7,13 +8,14 @@ class TestIntegrationRepository {
7
8
  this.operationHistory = [];
8
9
  }
9
10
 
10
- async createIntegration(entities, userId, config) {
11
+ async createIntegration(entities, userId, config, tenancy = {}) {
11
12
  const id = uuid();
12
13
  const record = {
13
14
  id,
14
15
  _id: id,
15
16
  entitiesIds: entities,
16
17
  userId: userId,
18
+ organizationId: tenancy.organizationId ?? null,
17
19
  config,
18
20
  version: '0.0.0',
19
21
  status: 'IN_CREATION',
@@ -38,6 +40,15 @@ class TestIntegrationRepository {
38
40
  return results;
39
41
  }
40
42
 
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
+
41
52
  async findIntegrations(filter = {}) {
42
53
  const { type, status } = filter;
43
54
  const results = Array.from(this.store.values()).filter((r) => {
@@ -27,13 +27,19 @@ 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 user creating 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.
31
32
  * @param {Object} config - Configuration object for the integration.
32
33
  * @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).
33
39
  * @returns {Promise<Object>} The created or reused integration DTO.
34
40
  * @throws {Error} When integration class is not found for the specified type.
35
41
  */
36
- async execute(entities, userId, config) {
42
+ async execute(entities, userId, config, tenancy = {}) {
37
43
  console.log(
38
44
  `[Frigg] Creating ${config?.type} integration for user ${userId} with entities [${entities}]`
39
45
  );
@@ -41,7 +47,8 @@ class CreateIntegration {
41
47
  const existing = await this._findDuplicate(
42
48
  userId,
43
49
  config?.type,
44
- entities
50
+ entities,
51
+ tenancy
45
52
  );
46
53
  if (existing) {
47
54
  console.log(
@@ -50,11 +57,18 @@ class CreateIntegration {
50
57
  return this._reuseExisting(existing, config);
51
58
  }
52
59
 
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
+
53
66
  const integrationRecord =
54
67
  await this.integrationRepository.createIntegration(
55
68
  entities,
56
69
  userId,
57
- config
70
+ config,
71
+ { organizationId: tenancy.organizationId ?? null }
58
72
  );
59
73
  console.log(
60
74
  `[Frigg] Created integration ${integrationRecord.id} for user ${userId}`
@@ -65,7 +79,8 @@ class CreateIntegration {
65
79
  const survivor = await this._findDuplicate(
66
80
  userId,
67
81
  config?.type,
68
- entities
82
+ entities,
83
+ tenancy
69
84
  );
70
85
  if (survivor && String(survivor.id) !== String(integrationRecord.id)) {
71
86
  console.log(
@@ -78,7 +93,8 @@ class CreateIntegration {
78
93
  }
79
94
 
80
95
  const integrationInstance = await this._buildInstance(
81
- integrationRecord
96
+ integrationRecord,
97
+ { integrationClass, modules }
82
98
  );
83
99
  console.log(
84
100
  `[Frigg] Sending ON_CREATE for integration ${integrationRecord.id}`
@@ -135,7 +151,7 @@ class CreateIntegration {
135
151
  return mapIntegrationClassToIntegrationDTO(integrationInstance);
136
152
  }
137
153
 
138
- async _findDuplicate(userId, type, entityIds) {
154
+ async _findDuplicate(userId, type, entityIds, tenancy = {}) {
139
155
  const candidates =
140
156
  await this.integrationRepository.findIntegrationsByUserId(userId);
141
157
  const target = [...(entityIds ?? [])].map(String).sort();
@@ -150,6 +166,7 @@ class CreateIntegration {
150
166
  // matching how teardown failures are already handled here.
151
167
  if (integration.status === 'IN_DELETION') return false;
152
168
  if (integration.config?.type !== type) return false;
169
+ if (!this._sameOrganization(integration, tenancy)) return false;
153
170
  const current = [...(integration.entitiesIds ?? [])]
154
171
  .map(String)
155
172
  .sort();
@@ -162,6 +179,15 @@ class CreateIntegration {
162
179
  return matches.length ? this._pickOldest(matches) : null;
163
180
  }
164
181
 
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;
189
+ }
190
+
165
191
  _pickOldest(records) {
166
192
  return [...records].sort((a, b) => {
167
193
  const diff =
@@ -173,27 +199,36 @@ class CreateIntegration {
173
199
  })[0];
174
200
  }
175
201
 
176
- async _buildInstance(integrationRecord) {
202
+ _requireClass(type) {
177
203
  const integrationClass = this.integrationClasses.find(
178
- (integrationClass) =>
179
- integrationClass.Definition.name ===
180
- integrationRecord.config.type
204
+ (integrationClass) => integrationClass.Definition.name === type
181
205
  );
182
-
183
206
  if (!integrationClass) {
184
- throw new Error(
185
- `No integration class found for type: ${integrationRecord.config.type}`
186
- );
207
+ throw new Error(`No integration class found for type: ${type}`);
187
208
  }
209
+ return integrationClass;
210
+ }
188
211
 
212
+ async _loadModules(entityIds, userId) {
189
213
  const modules = [];
190
- for (const entityId of integrationRecord.entitiesIds) {
191
- const moduleInstance = await this.moduleFactory.getModuleInstance(
192
- entityId,
193
- integrationRecord.userId
214
+ for (const entityId of entityIds) {
215
+ modules.push(
216
+ await this.moduleFactory.getModuleInstance(entityId, userId)
194
217
  );
195
- modules.push(moduleInstance);
196
218
  }
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
+ ));
197
232
 
198
233
  const integrationInstance = new integrationClass({
199
234
  id: integrationRecord.id,