@friggframework/core 2.0.0--canary.663.8de669c.0 → 2.0.0--canary.686.821f8a3.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 (188) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +20 -10
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/application/index.js +6 -2
  8. package/core/Worker.js +31 -7
  9. package/core/create-handler.js +9 -0
  10. package/credential/repositories/credential-repository-documentdb.js +34 -0
  11. package/credential/repositories/credential-repository-interface.js +15 -0
  12. package/credential/repositories/credential-repository-mongo.js +23 -0
  13. package/credential/repositories/credential-repository-postgres.js +28 -0
  14. package/credential/use-cases/credential-operations-for-user.js +202 -0
  15. package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
  16. package/database/config.js +48 -24
  17. package/database/documentdb-utils.js +6 -1
  18. package/database/encryption/README.md +2 -0
  19. package/database/encryption/encryption-config.js +10 -31
  20. package/database/encryption/encryption-schema-registry.js +121 -24
  21. package/database/encryption/field-encryption-service.js +69 -5
  22. package/database/encryption/test-support/fake-ciphertext.js +15 -0
  23. package/database/organization-where.js +18 -0
  24. package/database/prisma.js +4 -2
  25. package/database/unique-violation.js +25 -0
  26. package/extensions/bootstrap-runner.js +43 -0
  27. package/extensions/extension-loader.js +187 -0
  28. package/extensions/index.js +41 -0
  29. package/extensions/initialize-app.js +123 -0
  30. package/extensions/route-mounter.js +103 -0
  31. package/extensions/schema-composer.js +188 -0
  32. package/generated/prisma-mongodb/edge.js +43 -4
  33. package/generated/prisma-mongodb/index-browser.js +40 -1
  34. package/generated/prisma-mongodb/index.d.ts +4761 -133
  35. package/generated/prisma-mongodb/index.js +43 -4
  36. package/generated/prisma-mongodb/package.json +1 -1
  37. package/generated/prisma-mongodb/schema.prisma +92 -0
  38. package/generated/prisma-mongodb/wasm.js +43 -4
  39. package/generated/prisma-postgresql/edge.js +43 -4
  40. package/generated/prisma-postgresql/index-browser.js +40 -1
  41. package/generated/prisma-postgresql/index.d.ts +4961 -28
  42. package/generated/prisma-postgresql/index.js +43 -4
  43. package/generated/prisma-postgresql/package.json +1 -1
  44. package/generated/prisma-postgresql/schema.prisma +87 -0
  45. package/generated/prisma-postgresql/wasm.js +43 -4
  46. package/handlers/app-definition-loader.js +121 -19
  47. package/handlers/app-handler-helpers.js +25 -3
  48. package/handlers/backend-utils.js +31 -25
  49. package/handlers/middleware/admin-auth.js +14 -1
  50. package/handlers/routers/auth.js +46 -10
  51. package/handlers/routers/health.js +13 -4
  52. package/handlers/routers/integration-defined-routers.js +144 -92
  53. package/handlers/routers/integration-webhook-routers.js +102 -64
  54. package/handlers/workers/integration-defined-workers.js +5 -2
  55. package/index.js +65 -1
  56. package/infrastructure/scheduler/index.js +19 -6
  57. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  58. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  59. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  60. package/integrations/index.js +1 -0
  61. package/integrations/integration-router.js +131 -96
  62. package/integrations/repositories/integration-not-found.js +14 -0
  63. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  64. package/integrations/repositories/integration-repository-interface.js +17 -1
  65. package/integrations/repositories/integration-repository-mongo.js +59 -76
  66. package/integrations/repositories/integration-repository-postgres.js +62 -76
  67. package/integrations/schedule-config.js +80 -0
  68. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  69. package/integrations/use-cases/create-integration.js +55 -20
  70. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  71. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  72. package/integrations/use-cases/index.js +7 -1
  73. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  74. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  75. package/integrations/utils/map-integration-dto.js +43 -14
  76. package/management-api/create-management-api-router.js +49 -0
  77. package/management-api/error-response.js +116 -0
  78. package/management-api/management-api-config.js +151 -0
  79. package/management-api/meta-router.js +76 -0
  80. package/management-api/openapi/build-openapi.js +144 -0
  81. package/management-api/openapi/components.js +408 -0
  82. package/management-api/openapi/v1-operations.js +83 -0
  83. package/management-api/openapi/v2-operations.js +264 -0
  84. package/management-api/route-registry.js +411 -0
  85. package/management-api/test-support/http-client.js +39 -0
  86. package/management-api/use-cases/get-management-api-meta.js +57 -0
  87. package/management-api/v1-deprecation.js +120 -0
  88. package/management-api/v2/create-v2-router.js +85 -0
  89. package/management-api/v2/dependencies.js +281 -0
  90. package/management-api/v2/dto.js +143 -0
  91. package/management-api/v2/handlers/authorization.js +94 -0
  92. package/management-api/v2/handlers/credentials.js +33 -0
  93. package/management-api/v2/handlers/entities.js +77 -0
  94. package/management-api/v2/handlers/integrations.js +128 -0
  95. package/management-api/v2/handlers/proxy.js +34 -0
  96. package/management-api/v2/request.js +85 -0
  97. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  98. package/modules/domain/authorization-session.js +144 -0
  99. package/modules/entity-scope.js +15 -0
  100. package/modules/entity-type-registry.js +311 -0
  101. package/modules/index.js +14 -0
  102. package/modules/module-factory.js +12 -0
  103. package/modules/module.js +53 -2
  104. package/modules/proxy/proxy-errors.js +20 -0
  105. package/modules/proxy/proxy-path.js +179 -0
  106. package/modules/proxy/proxy-policy.js +248 -0
  107. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  108. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  109. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  110. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  111. package/modules/repositories/entity-not-found.js +18 -0
  112. package/modules/repositories/module-repository-documentdb.js +51 -6
  113. package/modules/repositories/module-repository-interface.js +19 -2
  114. package/modules/repositories/module-repository-mongo.js +67 -74
  115. package/modules/repositories/module-repository-postgres.js +66 -74
  116. package/modules/repositories/module-repository.js +2 -1
  117. package/modules/requester/proxy-request.js +244 -0
  118. package/modules/requester/requester.js +27 -2
  119. package/modules/scope-config.js +192 -0
  120. package/modules/scope-policy.js +112 -0
  121. package/modules/scope-resolution.js +288 -0
  122. package/modules/scope-visibility.js +247 -0
  123. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  124. package/modules/tests/doubles/ownership-matching.js +18 -0
  125. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  126. package/modules/use-cases/create-entity-from-credential.js +113 -0
  127. package/modules/use-cases/entity-operations-for-user.js +162 -0
  128. package/modules/use-cases/entity-types.js +139 -0
  129. package/modules/use-cases/execute-entity-proxy-request.js +286 -0
  130. package/modules/use-cases/get-authorization-requirements.js +100 -0
  131. package/modules/use-cases/get-entities-for-user.js +26 -13
  132. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  133. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  134. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  135. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  136. package/modules/use-cases/get-module.js +10 -4
  137. package/modules/use-cases/manage-access.js +21 -0
  138. package/modules/use-cases/multi-step-authorization.js +329 -0
  139. package/modules/use-cases/process-authorization-callback.js +53 -19
  140. package/modules/use-cases/refresh-entity-options.js +10 -4
  141. package/modules/use-cases/test-module-auth.js +10 -4
  142. package/package.json +6 -5
  143. package/prisma-mongodb/schema.prisma +92 -0
  144. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  145. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  146. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  147. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  148. package/prisma-postgresql/schema.prisma +87 -0
  149. package/providers/index.js +23 -0
  150. package/providers/resolve-provider.js +93 -0
  151. package/providers/runtime-detection.js +124 -0
  152. package/queues/index.js +25 -0
  153. package/queues/providers/index.js +11 -0
  154. package/queues/providers/netlify-background-provider.js +150 -0
  155. package/queues/providers/qstash-queue-provider.js +219 -0
  156. package/queues/providers/sqs-queue-provider.js +70 -0
  157. package/queues/queue-client-interface.js +55 -0
  158. package/queues/queue-message-auth.js +193 -0
  159. package/queues/queue-provider-factory.js +101 -0
  160. package/queues/queue-provider.js +65 -0
  161. package/queues/sqs-queue-client.js +81 -0
  162. package/types/index.d.ts +2 -0
  163. package/types/modules/index.d.ts +197 -0
  164. package/types/user/index.d.ts +134 -0
  165. package/user/adopter-jwt-config.js +177 -0
  166. package/user/adopter-jwt-errors.js +59 -0
  167. package/user/adopter-token-verifier.js +122 -0
  168. package/user/app-user-helpers.js +97 -0
  169. package/user/create-user-authentication.js +64 -0
  170. package/user/membership.js +51 -0
  171. package/user/repositories/organization-membership-record.js +19 -0
  172. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  173. package/user/repositories/organization-membership-repository-factory.js +42 -0
  174. package/user/repositories/organization-membership-repository-interface.js +57 -0
  175. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  176. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  177. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  178. package/user/repositories/user-repository-documentdb.js +27 -2
  179. package/user/repositories/user-repository-interface.js +18 -0
  180. package/user/repositories/user-repository-mongo.js +24 -0
  181. package/user/repositories/user-repository-postgres.js +18 -0
  182. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  183. package/user/use-cases/authenticate-user.js +7 -1
  184. package/user/use-cases/ensure-organization-membership.js +92 -0
  185. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  186. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  187. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  188. package/user/user.js +64 -1
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Port for persisting AuthorizationSession records (Management API v2
3
+ * multi-step authorization). Adapters return AuthorizationSession domain
4
+ * objects and never expired sessions from lookups.
5
+ */
6
+ class AuthorizationSessionRepositoryInterface {
7
+ /**
8
+ * @param {import('../domain/authorization-session').AuthorizationSession} session
9
+ * @returns {Promise<import('../domain/authorization-session').AuthorizationSession>}
10
+ */
11
+ async create(session) {
12
+ throw new Error('Method create must be implemented by subclass');
13
+ }
14
+
15
+ /**
16
+ * @param {string} sessionId
17
+ * @returns {Promise<import('../domain/authorization-session').AuthorizationSession|null>}
18
+ * null when missing or expired.
19
+ */
20
+ async findBySessionId(sessionId) {
21
+ throw new Error('Method findBySessionId must be implemented by subclass');
22
+ }
23
+
24
+ /**
25
+ * Saves currentStep, stepData and completed.
26
+ * @param {import('../domain/authorization-session').AuthorizationSession} session
27
+ */
28
+ async update(session) {
29
+ throw new Error('Method update must be implemented by subclass');
30
+ }
31
+
32
+ /** @param {string} sessionId */
33
+ async deleteBySessionId(sessionId) {
34
+ throw new Error('Method deleteBySessionId must be implemented by subclass');
35
+ }
36
+
37
+ /**
38
+ * Removes sessions that expired before `now`.
39
+ * @param {Date} [now]
40
+ * @returns {Promise<number>} how many were removed
41
+ */
42
+ async deleteExpired(now) {
43
+ throw new Error('Method deleteExpired must be implemented by subclass');
44
+ }
45
+ }
46
+
47
+ module.exports = { AuthorizationSessionRepositoryInterface };
@@ -0,0 +1,87 @@
1
+ const { prisma } = require('../../database/prisma');
2
+ const { AuthorizationSession } = require('../domain/authorization-session');
3
+ const {
4
+ AuthorizationSessionRepositoryInterface,
5
+ } = require('./authorization-session-repository-interface');
6
+
7
+ /**
8
+ * AuthorizationSession persistence through the Prisma client, for PostgreSQL
9
+ * and MongoDB (both schemas declare the same model; the session id, not the
10
+ * row id, is the key, so no id conversion is needed). `stepData` is
11
+ * encrypted by the Prisma encryption extension (encryption-schema-registry).
12
+ */
13
+ class AuthorizationSessionRepositoryPrisma extends AuthorizationSessionRepositoryInterface {
14
+ constructor(prismaClient = prisma) {
15
+ super();
16
+ this.prisma = prismaClient;
17
+ }
18
+
19
+ async create(session) {
20
+ const record = await this.prisma.authorizationSession.create({
21
+ data: {
22
+ sessionId: session.sessionId,
23
+ userId: session.userId,
24
+ entityType: session.entityType,
25
+ credentialId: session.credentialId,
26
+ currentStep: session.currentStep,
27
+ maxSteps: session.maxSteps,
28
+ stepData: session.stepData,
29
+ expiresAt: session.expiresAt,
30
+ completed: session.completed,
31
+ },
32
+ });
33
+ return toDomain(record);
34
+ }
35
+
36
+ async findBySessionId(sessionId) {
37
+ if (typeof sessionId !== 'string' || !sessionId) return null;
38
+ const record = await this.prisma.authorizationSession.findFirst({
39
+ where: { sessionId, expiresAt: { gt: new Date() } },
40
+ });
41
+ return record ? toDomain(record) : null;
42
+ }
43
+
44
+ async update(session) {
45
+ const record = await this.prisma.authorizationSession.update({
46
+ where: { sessionId: session.sessionId },
47
+ data: {
48
+ currentStep: session.currentStep,
49
+ stepData: session.stepData,
50
+ completed: session.completed,
51
+ },
52
+ });
53
+ return toDomain(record);
54
+ }
55
+
56
+ async deleteBySessionId(sessionId) {
57
+ const result = await this.prisma.authorizationSession.deleteMany({
58
+ where: { sessionId },
59
+ });
60
+ return result.count;
61
+ }
62
+
63
+ async deleteExpired(now = new Date()) {
64
+ const result = await this.prisma.authorizationSession.deleteMany({
65
+ where: { expiresAt: { lte: now } },
66
+ });
67
+ return result.count;
68
+ }
69
+ }
70
+
71
+ function toDomain(record) {
72
+ return new AuthorizationSession({
73
+ sessionId: record.sessionId,
74
+ userId: record.userId,
75
+ entityType: record.entityType,
76
+ credentialId: record.credentialId ?? null,
77
+ currentStep: record.currentStep,
78
+ maxSteps: record.maxSteps,
79
+ stepData: record.stepData || {},
80
+ expiresAt: record.expiresAt,
81
+ completed: record.completed,
82
+ createdAt: record.createdAt,
83
+ updatedAt: record.updatedAt,
84
+ });
85
+ }
86
+
87
+ module.exports = { AuthorizationSessionRepositoryPrisma, toDomain };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The error `findEntityById` throws for an id with no row, and the one
3
+ * predicate that recognises it (as integration-not-found.js does for
4
+ * integrations). The adapters build the error here and use cases match it
5
+ * here, so a missing entity can be told from any other failure without
6
+ * widening a regex.
7
+ *
8
+ * Why it matters: a use case that answers 404 for another tenant's entity must
9
+ * answer the same 404 for an id that does not exist. Otherwise the status code
10
+ * says which ids exist (ADR-052 §3, ADR-056 §3.6).
11
+ */
12
+ const MESSAGE = /^Entity .+ not found$/;
13
+
14
+ const entityNotFoundError = (id) => new Error(`Entity ${id} not found`);
15
+
16
+ const isEntityNotFound = (error) => MESSAGE.test(error?.message || '');
17
+
18
+ module.exports = { entityNotFoundError, isEntityNotFound };
@@ -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
  }