@friggframework/core 2.0.0--canary.680.daae24e.0 → 2.0.0--canary.686.e94de4d.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 (156) hide show
  1. package/CHANGELOG.md +0 -11
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +2 -2
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/core/Worker.js +31 -7
  8. package/core/create-handler.js +9 -0
  9. package/credential/use-cases/credential-operations-for-user.js +110 -25
  10. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  11. package/database/config.js +48 -24
  12. package/database/documentdb-utils.js +6 -1
  13. package/database/encryption/README.md +0 -2
  14. package/database/encryption/encryption-config.js +10 -31
  15. package/database/encryption/encryption-schema-registry.js +99 -47
  16. package/database/encryption/field-encryption-service.js +5 -69
  17. package/database/organization-where.js +18 -0
  18. package/database/prisma.js +4 -2
  19. package/database/unique-violation.js +25 -0
  20. package/extensions/bootstrap-runner.js +43 -0
  21. package/extensions/extension-loader.js +187 -0
  22. package/extensions/index.js +41 -0
  23. package/extensions/initialize-app.js +123 -0
  24. package/extensions/route-mounter.js +103 -0
  25. package/extensions/schema-composer.js +188 -0
  26. package/generated/prisma-mongodb/edge.js +27 -4
  27. package/generated/prisma-mongodb/index-browser.js +24 -1
  28. package/generated/prisma-mongodb/index.d.ts +3227 -47
  29. package/generated/prisma-mongodb/index.js +27 -4
  30. package/generated/prisma-mongodb/package.json +1 -1
  31. package/generated/prisma-mongodb/schema.prisma +65 -0
  32. package/generated/prisma-mongodb/wasm.js +27 -4
  33. package/generated/prisma-postgresql/edge.js +27 -4
  34. package/generated/prisma-postgresql/index-browser.js +24 -1
  35. package/generated/prisma-postgresql/index.d.ts +3519 -67
  36. package/generated/prisma-postgresql/index.js +27 -4
  37. package/generated/prisma-postgresql/package.json +1 -1
  38. package/generated/prisma-postgresql/schema.prisma +61 -0
  39. package/generated/prisma-postgresql/wasm.js +27 -4
  40. package/handlers/app-definition-loader.js +103 -12
  41. package/handlers/routers/auth.js +40 -12
  42. package/handlers/routers/health.js +4 -2
  43. package/handlers/routers/integration-defined-routers.js +144 -92
  44. package/handlers/routers/integration-webhook-routers.js +102 -64
  45. package/index.js +63 -1
  46. package/infrastructure/scheduler/index.js +19 -6
  47. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  48. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  49. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  50. package/integrations/index.js +5 -0
  51. package/integrations/integration-router.js +115 -73
  52. package/integrations/repositories/integration-not-found.js +14 -0
  53. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  54. package/integrations/repositories/integration-repository-interface.js +17 -1
  55. package/integrations/repositories/integration-repository-mongo.js +59 -76
  56. package/integrations/repositories/integration-repository-postgres.js +62 -76
  57. package/integrations/schedule-config.js +80 -0
  58. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  59. package/integrations/use-cases/create-integration.js +55 -20
  60. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  61. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  62. package/integrations/use-cases/index.js +7 -1
  63. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  64. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  65. package/integrations/utils/map-integration-dto.js +44 -1
  66. package/management-api/management-api-config.js +71 -2
  67. package/management-api/meta-router.js +21 -2
  68. package/management-api/openapi/components.js +40 -0
  69. package/management-api/openapi/v2-operations.js +16 -4
  70. package/management-api/route-registry.js +123 -6
  71. package/management-api/v2/dependencies.js +40 -15
  72. package/management-api/v2/dto.js +20 -0
  73. package/management-api/v2/handlers/authorization.js +1 -0
  74. package/management-api/v2/handlers/credentials.js +6 -2
  75. package/management-api/v2/handlers/entities.js +10 -7
  76. package/management-api/v2/handlers/integrations.js +6 -3
  77. package/management-api/v2/request.js +32 -1
  78. package/modules/entity-scope.js +15 -0
  79. package/modules/index.js +8 -0
  80. package/modules/module-factory.js +12 -0
  81. package/modules/module.js +53 -2
  82. package/modules/repositories/entity-not-found.js +18 -0
  83. package/modules/repositories/module-repository-documentdb.js +51 -6
  84. package/modules/repositories/module-repository-interface.js +19 -2
  85. package/modules/repositories/module-repository-mongo.js +67 -74
  86. package/modules/repositories/module-repository-postgres.js +66 -74
  87. package/modules/repositories/module-repository.js +2 -1
  88. package/modules/scope-config.js +192 -0
  89. package/modules/scope-policy.js +112 -0
  90. package/modules/scope-resolution.js +288 -0
  91. package/modules/scope-visibility.js +247 -0
  92. package/modules/tests/doubles/ownership-matching.js +18 -0
  93. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  94. package/modules/use-cases/create-entity-from-credential.js +113 -0
  95. package/modules/use-cases/entity-operations-for-user.js +50 -13
  96. package/modules/use-cases/entity-types.js +31 -7
  97. package/modules/use-cases/execute-entity-proxy-request.js +50 -3
  98. package/modules/use-cases/get-authorization-requirements.js +2 -2
  99. package/modules/use-cases/get-entities-for-user.js +26 -13
  100. package/modules/use-cases/get-entity-module-for-user.js +34 -7
  101. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  102. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  103. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  104. package/modules/use-cases/get-module.js +10 -4
  105. package/modules/use-cases/manage-access.js +21 -0
  106. package/modules/use-cases/multi-step-authorization.js +40 -13
  107. package/modules/use-cases/process-authorization-callback.js +53 -19
  108. package/modules/use-cases/refresh-entity-options.js +10 -4
  109. package/modules/use-cases/test-module-auth.js +10 -4
  110. package/package.json +6 -5
  111. package/prisma-mongodb/schema.prisma +65 -0
  112. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  113. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  114. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  115. package/prisma-postgresql/schema.prisma +61 -0
  116. package/providers/index.js +23 -0
  117. package/providers/resolve-provider.js +93 -0
  118. package/providers/runtime-detection.js +124 -0
  119. package/queues/index.js +25 -0
  120. package/queues/providers/index.js +11 -0
  121. package/queues/providers/netlify-background-provider.js +150 -0
  122. package/queues/providers/qstash-queue-provider.js +219 -0
  123. package/queues/providers/sqs-queue-provider.js +70 -0
  124. package/queues/queue-client-interface.js +55 -0
  125. package/queues/queue-message-auth.js +193 -0
  126. package/queues/queue-provider-factory.js +101 -0
  127. package/queues/queue-provider.js +65 -0
  128. package/queues/sqs-queue-client.js +81 -0
  129. package/types/index.d.ts +1 -0
  130. package/types/modules/index.d.ts +139 -0
  131. package/types/user/index.d.ts +134 -0
  132. package/user/adopter-jwt-config.js +177 -0
  133. package/user/adopter-jwt-errors.js +59 -0
  134. package/user/adopter-token-verifier.js +122 -0
  135. package/user/app-user-helpers.js +97 -0
  136. package/user/create-user-authentication.js +64 -0
  137. package/user/membership.js +51 -0
  138. package/user/repositories/organization-membership-record.js +19 -0
  139. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  140. package/user/repositories/organization-membership-repository-factory.js +42 -0
  141. package/user/repositories/organization-membership-repository-interface.js +57 -0
  142. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  143. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  144. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  145. package/user/repositories/user-repository-documentdb.js +27 -2
  146. package/user/repositories/user-repository-interface.js +18 -0
  147. package/user/repositories/user-repository-mongo.js +24 -0
  148. package/user/repositories/user-repository-postgres.js +18 -0
  149. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  150. package/user/use-cases/authenticate-user.js +7 -1
  151. package/user/use-cases/ensure-organization-membership.js +92 -0
  152. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  153. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  154. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  155. package/user/user.js +64 -1
  156. package/database/encryption/test-support/fake-ciphertext.js +0 -15
@@ -3,12 +3,14 @@ const {
3
3
  toObjectId,
4
4
  fromObjectId,
5
5
  findMany,
6
+ findManyDrained,
6
7
  findOne,
7
8
  insertOne,
8
9
  updateOne,
9
10
  deleteOne,
10
11
  } = require('../../database/documentdb-utils');
11
12
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
13
+ const { entityNotFoundError } = require('./entity-not-found');
12
14
  const { DocumentDBEncryptionService } = require('../../database/documentdb-encryption-service');
13
15
 
14
16
  /**
@@ -33,11 +35,11 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
33
35
  async findEntityById(entityId) {
34
36
  const objectId = toObjectId(entityId);
35
37
  if (!objectId) {
36
- throw new Error(`Entity ${entityId} not found`);
38
+ throw entityNotFoundError(entityId);
37
39
  }
38
40
  const doc = await findOne(this.prisma, 'Entity', { _id: objectId });
39
41
  if (!doc) {
40
- throw new Error(`Entity ${entityId} not found`);
42
+ throw entityNotFoundError(entityId);
41
43
  }
42
44
  const credential = await this._fetchCredential(doc.credentialId);
43
45
  return this._mapEntity(doc, credential);
@@ -76,6 +78,29 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
76
78
  return docs.map((doc) => this._mapEntity(doc, credentialMap.get(fromObjectId(doc.credentialId)) || null));
77
79
  }
78
80
 
81
+ /**
82
+ * Find entities matching any of the owner/organization pairs, in one query.
83
+ * `organizationId: null` matches rows with no organization (a raw MongoDB
84
+ * `null` also matches a missing field). The caller (a use case) decides
85
+ * which pairs; this only runs them.
86
+ *
87
+ * @param {Array<{userId: string, organizationId: (string|null)}>} clauses
88
+ * @returns {Promise<Array>}
89
+ */
90
+ async findEntitiesByOwnership(clauses) {
91
+ if (!Array.isArray(clauses) || clauses.length === 0) return [];
92
+ // Drained: a list can hold more than the first cursor batch (~101).
93
+ const docs = await findManyDrained(this.prisma, 'Entity', {
94
+ $or: clauses.map(({ userId, organizationId }) => ({
95
+ userId: toObjectId(userId),
96
+ organizationId:
97
+ organizationId === null ? null : toObjectId(organizationId),
98
+ })),
99
+ });
100
+ const credentialMap = await this._fetchCredentialsBulk(docs.map((doc) => doc.credentialId));
101
+ return docs.map((doc) => this._mapEntity(doc, credentialMap.get(fromObjectId(doc.credentialId)) || null));
102
+ }
103
+
79
104
  async unsetCredential(entityId) {
80
105
  const objectId = toObjectId(entityId);
81
106
  if (!objectId) return false;
@@ -115,7 +140,14 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
115
140
  );
116
141
  }
117
142
 
118
- async createEntity(entityData) {
143
+ /**
144
+ * @param {Object} entityData
145
+ * @param {{organizationId?: (string|null)}} [tenancy] - the organization the entity
146
+ * belongs to (ADR-056 §3.6). A separate argument, not a field of `entityData`,
147
+ * because `entityData` carries provider fields and one named `organizationId`
148
+ * must not be taken for ours.
149
+ */
150
+ async createEntity(entityData, tenancy = {}) {
119
151
  const {
120
152
  user,
121
153
  userId,
@@ -133,6 +165,7 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
133
165
  name: name ?? null,
134
166
  moduleName: moduleName ?? null,
135
167
  externalId: externalId ?? null,
168
+ organizationId: toObjectId(tenancy.organizationId) || null,
136
169
  data: dynamicData,
137
170
  };
138
171
  const insertedId = await insertOne(this.prisma, 'Entity', document);
@@ -247,8 +280,9 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
247
280
  const objectIds = ids.map(id => toObjectId(id)).filter(Boolean);
248
281
  if (objectIds.length === 0) return new Map();
249
282
 
250
- // Use raw findMany to bypass Prisma encryption extension
251
- const rawCredentials = await findMany(this.prisma, 'Credential', {
283
+ // Raw read to bypass the Prisma encryption extension. Drained:
284
+ // more ids than the first cursor batch must all come back.
285
+ const rawCredentials = await findManyDrained(this.prisma, 'Credential', {
252
286
  _id: { $in: objectIds }
253
287
  });
254
288
 
@@ -329,19 +363,30 @@ class ModuleRepositoryDocumentDB extends ModuleRepositoryInterface {
329
363
  if (filter.name) query.name = filter.name;
330
364
  if (filter.moduleName) query.moduleName = filter.moduleName;
331
365
  if (filter.externalId) query.externalId = filter.externalId;
366
+ // `null` means "no organization"; absent means "any". A raw MongoDB
367
+ // `{ field: null }` matches both a null value and a missing field.
368
+ if (filter.organizationId !== undefined) {
369
+ query.organizationId =
370
+ filter.organizationId === null
371
+ ? null
372
+ : toObjectId(filter.organizationId);
373
+ }
332
374
  return query;
333
375
  }
334
376
 
335
377
  _mapEntity(doc, credential) {
336
378
  const dynamicData = doc?.data || {};
337
379
  return {
380
+ // First, so no stored provider field can replace a column below
381
+ // (identity, owner or organization decide who sees the entity).
382
+ ...dynamicData,
338
383
  id: fromObjectId(doc?._id),
339
384
  credential,
340
385
  userId: fromObjectId(doc?.userId),
341
386
  name: doc?.name ?? null,
342
387
  externalId: doc?.externalId ?? null,
343
388
  moduleName: doc?.moduleName ?? null,
344
- ...dynamicData,
389
+ organizationId: fromObjectId(doc?.organizationId) ?? null,
345
390
  };
346
391
  }
347
392
  }
@@ -67,6 +67,21 @@ class ModuleRepositoryInterface {
67
67
  );
68
68
  }
69
69
 
70
+ /**
71
+ * Find entities matching any of the owner/organization pairs, in one query
72
+ * (ADR-056 §3.6). `organizationId: null` matches rows with no organization.
73
+ * Which pairs is decided by the caller (modules/entity-scope.js), not here.
74
+ *
75
+ * @param {Array<{userId: string|number, organizationId: (string|number|null)}>} clauses
76
+ * @returns {Promise<Array>} Array of entity objects
77
+ * @abstract
78
+ */
79
+ async findEntitiesByOwnership(clauses) {
80
+ throw new Error(
81
+ 'Method findEntitiesByOwnership must be implemented by subclass'
82
+ );
83
+ }
84
+
70
85
  /**
71
86
  * Unset credential from entity
72
87
  *
@@ -110,11 +125,13 @@ class ModuleRepositoryInterface {
110
125
  /**
111
126
  * Create a new entity
112
127
  *
113
- * @param {Object} entityData - Entity data
128
+ * @param {Object} entityData - Entity data (provider fields included)
129
+ * @param {{organizationId?: (string|number|null)}} [tenancy] - the organization
130
+ * the entity belongs to (ADR-056 §3.6), kept apart from `entityData`
114
131
  * @returns {Promise<Object>} Created entity object
115
132
  * @abstract
116
133
  */
117
- async createEntity(entityData) {
134
+ async createEntity(entityData, tenancy) {
118
135
  throw new Error('Method createEntity must be implemented by subclass');
119
136
  }
120
137
 
@@ -1,5 +1,7 @@
1
1
  const { prisma } = require('../../database/prisma');
2
+ const { organizationWhere } = require('../../database/organization-where');
2
3
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
4
+ const { entityNotFoundError } = require('./entity-not-found');
3
5
 
4
6
  /**
5
7
  * MongoDB Module Repository Adapter
@@ -76,6 +78,26 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
76
78
  return credentialMap;
77
79
  }
78
80
 
81
+ /**
82
+ * The one place a stored entity row becomes the entity the application sees.
83
+ * The provider's `data` is spread FIRST and every column is pinned after it:
84
+ * a stored `id`, `userId` or `organizationId` in `data` must not decide who
85
+ * owns the entity or who sees it.
86
+ * @private
87
+ */
88
+ _mapEntity(row, credential) {
89
+ return {
90
+ ...(row.data || {}),
91
+ id: row.id,
92
+ credential,
93
+ userId: row.userId,
94
+ name: row.name,
95
+ externalId: row.externalId,
96
+ moduleName: row.moduleName,
97
+ organizationId: row.organizationId ?? null,
98
+ };
99
+ }
100
+
79
101
  /**
80
102
  * Find entity by ID with credential
81
103
  * Replaces: Entity.findById(entityId).populate('credential')
@@ -90,20 +112,12 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
90
112
  });
91
113
 
92
114
  if (!entity) {
93
- throw new Error(`Entity ${entityId} not found`);
115
+ throw entityNotFoundError(entityId);
94
116
  }
95
117
 
96
118
  const credential = await this._fetchCredential(entity.credentialId);
97
119
 
98
- return {
99
- id: entity.id,
100
- credential,
101
- userId: entity.userId,
102
- name: entity.name,
103
- externalId: entity.externalId,
104
- moduleName: entity.moduleName,
105
- ...(entity.data || {}),
106
- };
120
+ return this._mapEntity(entity, credential);
107
121
  }
108
122
 
109
123
  /**
@@ -121,15 +135,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
121
135
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
122
136
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
123
137
 
124
- return entities.map((e) => ({
125
- id: e.id,
126
- credential: credentialMap.get(e.credentialId) || null,
127
- userId: e.userId,
128
- name: e.name,
129
- externalId: e.externalId,
130
- moduleName: e.moduleName,
131
- ...(e.data || {}),
132
- }));
138
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
133
139
  }
134
140
 
135
141
  /**
@@ -147,15 +153,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
147
153
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
148
154
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
149
155
 
150
- return entities.map((e) => ({
151
- id: e.id,
152
- credential: credentialMap.get(e.credentialId) || null,
153
- userId: e.userId,
154
- name: e.name,
155
- externalId: e.externalId,
156
- moduleName: e.moduleName,
157
- ...(e.data || {}),
158
- }));
156
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
159
157
  }
160
158
 
161
159
  /**
@@ -177,15 +175,32 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
177
175
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
178
176
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
179
177
 
180
- return entities.map((e) => ({
181
- id: e.id,
182
- credential: credentialMap.get(e.credentialId) || null,
183
- userId: e.userId,
184
- name: e.name,
185
- externalId: e.externalId,
186
- moduleName: e.moduleName,
187
- ...(e.data || {}),
188
- }));
178
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
179
+ }
180
+
181
+ /**
182
+ * Find entities matching any of the owner/organization pairs, in one query.
183
+ * `organizationId: null` matches rows with no organization. The caller
184
+ * (a use case) decides which pairs; this only runs them.
185
+ *
186
+ * @param {Array<{userId: string, organizationId: (string|null)}>} clauses
187
+ * @returns {Promise<Array>} Array of entity objects with string IDs
188
+ */
189
+ async findEntitiesByOwnership(clauses) {
190
+ if (!Array.isArray(clauses) || clauses.length === 0) return [];
191
+ const entities = await this.prisma.entity.findMany({
192
+ where: {
193
+ OR: clauses.map(({ userId, organizationId }) => ({
194
+ userId,
195
+ ...organizationWhere(organizationId),
196
+ })),
197
+ },
198
+ });
199
+
200
+ const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
201
+ const credentialMap = await this._fetchCredentialsBulk(credentialIds);
202
+
203
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
189
204
  }
190
205
 
191
206
  /**
@@ -223,15 +238,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
223
238
 
224
239
  const credential = await this._fetchCredential(entity.credentialId);
225
240
 
226
- return {
227
- id: entity.id,
228
- credential,
229
- userId: entity.userId,
230
- name: entity.name,
231
- externalId: entity.externalId,
232
- moduleName: entity.moduleName,
233
- ...(entity.data || {}),
234
- };
241
+ return this._mapEntity(entity, credential);
235
242
  }
236
243
 
237
244
  /**
@@ -251,15 +258,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
251
258
  .filter(Boolean);
252
259
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
253
260
 
254
- return entities.map((e) => ({
255
- id: e.id,
256
- credential: credentialMap.get(e.credentialId) || null,
257
- userId: e.userId,
258
- name: e.name,
259
- externalId: e.externalId,
260
- moduleName: e.moduleName,
261
- ...(e.data || {}),
262
- }));
261
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
263
262
  }
264
263
 
265
264
  /**
@@ -267,9 +266,13 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
267
266
  * Replaces: Entity.create(entityData)
268
267
  *
269
268
  * @param {Object} entityData - Entity data
269
+ * @param {{organizationId?: (string|null)}} [tenancy] - the organization the entity
270
+ * belongs to (ADR-056 §3.6). A separate argument, not a field of `entityData`,
271
+ * because `entityData` carries provider fields and one named `organizationId`
272
+ * must not be taken for ours.
270
273
  * @returns {Promise<Object>} Created entity object with string IDs
271
274
  */
272
- async createEntity(entityData) {
275
+ async createEntity(entityData, tenancy = {}) {
273
276
  const {
274
277
  user,
275
278
  userId,
@@ -287,6 +290,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
287
290
  name,
288
291
  moduleName,
289
292
  externalId,
293
+ organizationId: tenancy.organizationId ?? null,
290
294
  data: dynamicData,
291
295
  };
292
296
 
@@ -296,15 +300,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
296
300
 
297
301
  const credentialObj = await this._fetchCredential(entity.credentialId);
298
302
 
299
- return {
300
- id: entity.id,
301
- credential: credentialObj,
302
- userId: entity.userId,
303
- name: entity.name,
304
- externalId: entity.externalId,
305
- moduleName: entity.moduleName,
306
- ...(entity.data || {}),
307
- };
303
+ return this._mapEntity(entity, credentialObj);
308
304
  }
309
305
 
310
306
  /**
@@ -358,15 +354,7 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
358
354
 
359
355
  const credentialObj = await this._fetchCredential(entity.credentialId);
360
356
 
361
- return {
362
- id: entity.id,
363
- credential: credentialObj,
364
- userId: entity.userId,
365
- name: entity.name,
366
- externalId: entity.externalId,
367
- moduleName: entity.moduleName,
368
- ...(entity.data || {}),
369
- };
357
+ return this._mapEntity(entity, credentialObj);
370
358
  } catch (error) {
371
359
  if (error.code === 'P2025') {
372
360
  return null;
@@ -429,6 +417,11 @@ class ModuleRepositoryMongo extends ModuleRepositoryInterface {
429
417
  if (filter.moduleName) where.moduleName = filter.moduleName;
430
418
  if (filter.externalId) where.externalId = this._toString(filter.externalId);
431
419
 
420
+ // `null` means "no organization"; absent means "any".
421
+ if (filter.organizationId !== undefined) {
422
+ Object.assign(where, organizationWhere(filter.organizationId));
423
+ }
424
+
432
425
  return where;
433
426
  }
434
427
  }
@@ -1,5 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
3
+ const { entityNotFoundError } = require('./entity-not-found');
3
4
 
4
5
  /**
5
6
  * PostgreSQL Module Repository Adapter
@@ -108,6 +109,26 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
108
109
  return credentialMap;
109
110
  }
110
111
 
112
+ /**
113
+ * The one place a stored entity row becomes the entity the application sees.
114
+ * The provider's `data` is spread FIRST and every column is pinned after it:
115
+ * a stored `id`, `userId` or `organizationId` in `data` must not decide who
116
+ * owns the entity or who sees it.
117
+ * @private
118
+ */
119
+ _mapEntity(row, credential) {
120
+ return {
121
+ ...(row.data || {}),
122
+ id: row.id.toString(),
123
+ credential,
124
+ userId: row.userId?.toString(),
125
+ name: row.name,
126
+ externalId: row.externalId,
127
+ moduleName: row.moduleName,
128
+ organizationId: row.organizationId?.toString() ?? null,
129
+ };
130
+ }
131
+
111
132
  /**
112
133
  * Find entity by ID with credential
113
134
  * Replaces: Entity.findById(entityId).populate('credential')
@@ -124,20 +145,12 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
124
145
  });
125
146
 
126
147
  if (!entity) {
127
- throw new Error(`Entity ${entityId} not found`);
148
+ throw entityNotFoundError(entityId);
128
149
  }
129
150
 
130
151
  const credential = await this._fetchCredential(entity.credentialId);
131
152
 
132
- return {
133
- id: entity.id.toString(),
134
- credential,
135
- userId: entity.userId?.toString(),
136
- name: entity.name,
137
- externalId: entity.externalId,
138
- moduleName: entity.moduleName,
139
- ...(entity.data || {}),
140
- };
153
+ return this._mapEntity(entity, credential);
141
154
  }
142
155
 
143
156
  /**
@@ -157,15 +170,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
157
170
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
158
171
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
159
172
 
160
- return entities.map((e) => ({
161
- id: e.id.toString(),
162
- credential: credentialMap.get(e.credentialId) || null,
163
- userId: e.userId?.toString(),
164
- name: e.name,
165
- externalId: e.externalId,
166
- moduleName: e.moduleName,
167
- ...(e.data || {}),
168
- }));
173
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
169
174
  }
170
175
 
171
176
  /**
@@ -185,15 +190,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
185
190
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
186
191
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
187
192
 
188
- return entities.map((e) => ({
189
- id: e.id.toString(),
190
- credential: credentialMap.get(e.credentialId) || null,
191
- userId: e.userId?.toString(),
192
- name: e.name,
193
- externalId: e.externalId,
194
- moduleName: e.moduleName,
195
- ...(e.data || {}),
196
- }));
193
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
197
194
  }
198
195
 
199
196
  /**
@@ -217,15 +214,32 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
217
214
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
218
215
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
219
216
 
220
- return entities.map((e) => ({
221
- id: e.id.toString(),
222
- credential: credentialMap.get(e.credentialId) || null,
223
- userId: e.userId?.toString(),
224
- name: e.name,
225
- externalId: e.externalId,
226
- moduleName: e.moduleName,
227
- ...(e.data || {}),
228
- }));
217
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
218
+ }
219
+
220
+ /**
221
+ * Find entities matching any of the owner/organization pairs, in one query.
222
+ * `organizationId: null` matches rows with no organization. The caller
223
+ * (a use case) decides which pairs; this only runs them.
224
+ *
225
+ * @param {Array<{userId: string, organizationId: (string|null)}>} clauses
226
+ * @returns {Promise<Array>} Array of entity objects with string IDs
227
+ */
228
+ async findEntitiesByOwnership(clauses) {
229
+ if (!Array.isArray(clauses) || clauses.length === 0) return [];
230
+ const entities = await this.prisma.entity.findMany({
231
+ where: {
232
+ OR: clauses.map(({ userId, organizationId }) => ({
233
+ userId: this._convertId(userId),
234
+ organizationId: this._convertId(organizationId),
235
+ })),
236
+ },
237
+ });
238
+
239
+ const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
240
+ const credentialMap = await this._fetchCredentialsBulk(credentialIds);
241
+
242
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
229
243
  }
230
244
 
231
245
  /**
@@ -264,15 +278,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
264
278
 
265
279
  const credential = await this._fetchCredential(entity.credentialId);
266
280
 
267
- return {
268
- id: entity.id.toString(),
269
- credential,
270
- userId: entity.userId?.toString(),
271
- name: entity.name,
272
- externalId: entity.externalId,
273
- moduleName: entity.moduleName,
274
- ...(entity.data || {}),
275
- };
281
+ return this._mapEntity(entity, credential);
276
282
  }
277
283
 
278
284
  /**
@@ -292,15 +298,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
292
298
  .filter(Boolean);
293
299
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
294
300
 
295
- return entities.map((e) => ({
296
- id: e.id.toString(),
297
- credential: credentialMap.get(e.credentialId) || null,
298
- userId: e.userId?.toString(),
299
- name: e.name,
300
- externalId: e.externalId,
301
- moduleName: e.moduleName,
302
- ...(e.data || {}),
303
- }));
301
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
304
302
  }
305
303
 
306
304
  /**
@@ -308,9 +306,13 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
308
306
  * Replaces: Entity.create(entityData)
309
307
  *
310
308
  * @param {Object} entityData - Entity data (with string IDs from application layer)
309
+ * @param {{organizationId?: (string|null)}} [tenancy] - the organization the entity
310
+ * belongs to (ADR-056 §3.6). A separate argument, not a field of `entityData`,
311
+ * because `entityData` carries provider fields and one named `organizationId`
312
+ * must not be taken for ours.
311
313
  * @returns {Promise<Object>} Created entity object with string IDs
312
314
  */
313
- async createEntity(entityData) {
315
+ async createEntity(entityData, tenancy = {}) {
314
316
  const {
315
317
  user,
316
318
  userId,
@@ -328,6 +330,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
328
330
  name,
329
331
  moduleName,
330
332
  externalId,
333
+ organizationId: this._convertId(tenancy.organizationId ?? null),
331
334
  data: dynamicData,
332
335
  };
333
336
 
@@ -337,15 +340,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
337
340
 
338
341
  const credentialObj = await this._fetchCredential(entity.credentialId);
339
342
 
340
- return {
341
- id: entity.id.toString(),
342
- credential: credentialObj,
343
- userId: entity.userId?.toString(),
344
- name: entity.name,
345
- externalId: entity.externalId,
346
- moduleName: entity.moduleName,
347
- ...(entity.data || {}),
348
- };
343
+ return this._mapEntity(entity, credentialObj);
349
344
  }
350
345
 
351
346
  /**
@@ -401,15 +396,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
401
396
 
402
397
  const credentialObj = await this._fetchCredential(entity.credentialId);
403
398
 
404
- return {
405
- id: entity.id.toString(),
406
- credential: credentialObj,
407
- userId: entity.userId?.toString(),
408
- name: entity.name,
409
- externalId: entity.externalId,
410
- moduleName: entity.moduleName,
411
- ...(entity.data || {}),
412
- };
399
+ return this._mapEntity(entity, credentialObj);
413
400
  } catch (error) {
414
401
  if (error.code === 'P2025') {
415
402
  return null;
@@ -474,6 +461,11 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
474
461
  if (filter.moduleName) where.moduleName = filter.moduleName;
475
462
  if (filter.externalId) where.externalId = this._toString(filter.externalId);
476
463
 
464
+ // `null` means "no organization"; absent means "any".
465
+ if (filter.organizationId !== undefined) {
466
+ where.organizationId = this._convertId(filter.organizationId);
467
+ }
468
+
477
469
  return where;
478
470
  }
479
471
  }
@@ -1,5 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
3
+ const { entityNotFoundError } = require('./entity-not-found');
3
4
 
4
5
  /**
5
6
  * Prisma-based Module Repository
@@ -37,7 +38,7 @@ class ModuleRepository extends ModuleRepositoryInterface {
37
38
  });
38
39
 
39
40
  if (!entity) {
40
- throw new Error(`Entity ${entityId} not found`);
41
+ throw entityNotFoundError(entityId);
41
42
  }
42
43
 
43
44
  return {