@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
@@ -7,16 +7,22 @@
7
7
  * Environment Detection:
8
8
  * - SCHEDULER_PROVIDER=eventbridge -> Use AWS EventBridge Scheduler
9
9
  * - SCHEDULER_PROVIDER=mock -> Use in-memory mock scheduler
10
+ * - SCHEDULER_PROVIDER=netlify -> Netlify poll-and-dispatch scheduler
11
+ * (friggframework/frigg#545; needs `repository`, usually `queueProvider`)
12
+ * - Default on Netlify (NETLIFY=true, set by the Netlify runtime and build;
13
+ * provider-netlify's generated functions also set SCHEDULER_PROVIDER) -> Netlify
10
14
  * - Default in dev/test/local stages -> Mock scheduler
11
15
  * - Default in other stages -> EventBridge scheduler
16
+ * - Any other name -> require('@friggframework/provider-<name>').SchedulerAdapter
17
+ *
18
+ * Adapters are required inside their branch so building one never loads
19
+ * another's SDK (@aws-sdk/client-scheduler is not a core dependency).
12
20
  */
13
21
 
14
- const { EventBridgeSchedulerAdapter } = require('./eventbridge-scheduler-adapter');
15
- const { MockSchedulerAdapter } = require('./mock-scheduler-adapter');
16
-
17
22
  const SCHEDULER_PROVIDERS = {
18
23
  EVENTBRIDGE: 'eventbridge',
19
24
  MOCK: 'mock',
25
+ NETLIFY: 'netlify',
20
26
  };
21
27
 
22
28
  const LOCAL_STAGES = ['dev', 'test', 'local'];
@@ -32,6 +38,12 @@ function determineProvider() {
32
38
  return explicitProvider;
33
39
  }
34
40
 
41
+ // Checked before the stage: a Netlify site with no STAGE (or STAGE=dev)
42
+ // must still persist schedules where scheduled-sync reads them.
43
+ if (process.env.NETLIFY === 'true') {
44
+ return SCHEDULER_PROVIDERS.NETLIFY;
45
+ }
46
+
35
47
  const stage = process.env.STAGE || 'dev';
36
48
  if (LOCAL_STAGES.includes(stage)) {
37
49
  return SCHEDULER_PROVIDERS.MOCK;
@@ -40,6 +52,13 @@ function determineProvider() {
40
52
  return SCHEDULER_PROVIDERS.EVENTBRIDGE;
41
53
  }
42
54
 
55
+ function createDefaultScheduledJobRepository() {
56
+ const { ScheduledJobRepository } = require('./scheduled-job-repository');
57
+ // The prisma export is a lazy proxy: nothing connects until a query runs.
58
+ const { prisma } = require('../../database/prisma');
59
+ return new ScheduledJobRepository({ prismaClient: prisma });
60
+ }
61
+
43
62
  /**
44
63
  * Create a scheduler service instance
45
64
  *
@@ -47,22 +66,51 @@ function determineProvider() {
47
66
  * @param {string} options.provider - Scheduler provider ('eventbridge' or 'mock')
48
67
  * @param {string} options.region - AWS region (for EventBridge)
49
68
  * @param {boolean} options.verbose - Verbose logging (for Mock)
69
+ * @param {Object} [options.repository] - Schedule repository (for Netlify: persists schedules)
70
+ * @param {Object} [options.queueProvider] - Queue provider (for Netlify: dispatches due jobs)
50
71
  * @returns {SchedulerServiceInterface} Implementation of scheduler interface
51
72
  */
52
73
  function createSchedulerService(options = {}) {
53
74
  const provider = options.provider || determineProvider();
54
75
 
55
76
  switch (provider) {
56
- case SCHEDULER_PROVIDERS.EVENTBRIDGE:
77
+ case SCHEDULER_PROVIDERS.EVENTBRIDGE: {
78
+ const { EventBridgeSchedulerAdapter } = require('./eventbridge-scheduler-adapter');
57
79
  return new EventBridgeSchedulerAdapter({
58
80
  region: options.region,
59
81
  });
60
- case SCHEDULER_PROVIDERS.MOCK:
82
+ }
83
+ case SCHEDULER_PROVIDERS.MOCK: {
84
+ const { MockSchedulerAdapter } = require('./mock-scheduler-adapter');
61
85
  return new MockSchedulerAdapter({
62
86
  verbose: options.verbose,
63
87
  });
64
- default:
65
- throw new Error(`Unknown scheduler provider: ${provider}`);
88
+ }
89
+ case SCHEDULER_PROVIDERS.NETLIFY: {
90
+ const { NetlifySchedulerAdapter } = require('./netlify-scheduler-adapter');
91
+ return new NetlifySchedulerAdapter({
92
+ // Default: the Prisma ScheduledJob rows that the Netlify
93
+ // scheduled-sync function polls. Without it the adapter
94
+ // falls back to memory, and a schedule made in one function
95
+ // is never seen by the cron.
96
+ repository: options.repository || createDefaultScheduledJobRepository(),
97
+ queueProvider: options.queueProvider,
98
+ });
99
+ }
100
+ default: {
101
+ let plugin;
102
+ try {
103
+ plugin = require('../../providers/resolve-provider').resolveProvider(null, {
104
+ provider,
105
+ });
106
+ } catch (error) {
107
+ throw new Error(`Unknown scheduler provider: ${provider} (${error.message})`);
108
+ }
109
+ if (!plugin.SchedulerAdapter) {
110
+ throw new Error(`Unknown scheduler provider: ${provider}`);
111
+ }
112
+ return new plugin.SchedulerAdapter(options);
113
+ }
66
114
  }
67
115
  }
68
116
 
@@ -21,6 +21,7 @@ module.exports = {
21
21
  Options,
22
22
  createIntegrationRouter,
23
23
  checkRequiredParams,
24
+ // Deprecated (ADR-059): a wrapper over getEntityTypeRegistry.
24
25
  getModulesDefinitionFromIntegrationClasses,
25
26
  LoadIntegrationContextUseCase,
26
27
  validateExtensionBinding,
@@ -31,9 +31,6 @@ const {
31
31
  GetIntegrationInstance,
32
32
  } = require('./use-cases/get-integration-instance');
33
33
  const { UpdateIntegration } = require('./use-cases/update-integration');
34
- const {
35
- getModulesDefinitionFromIntegrationClasses,
36
- } = require('./utils/map-integration-dto');
37
34
  const {
38
35
  GetModuleInstanceFromType,
39
36
  } = require('../modules/use-cases/get-module-instance-from-type');
@@ -54,47 +51,51 @@ const {
54
51
  const {
55
52
  createUserRepository,
56
53
  } = require('../user/repositories/user-repository-factory');
57
- const {
58
- GetUserFromBearerToken,
59
- } = require('../user/use-cases/get-user-from-bearer-token');
60
- const {
61
- GetUserFromXFriggHeaders,
62
- } = require('../user/use-cases/get-user-from-x-frigg-headers');
63
- const {
64
- GetUserFromAdopterJwt,
65
- } = require('../user/use-cases/get-user-from-adopter-jwt');
66
54
  const {
67
55
  AuthenticateWithSharedSecret,
68
56
  } = require('../user/use-cases/authenticate-with-shared-secret');
69
57
  const { AuthenticateUser } = require('../user/use-cases/authenticate-user');
58
+ const {
59
+ buildUserAuthentication,
60
+ } = require('../user/create-user-authentication');
70
61
  const {
71
62
  ProcessAuthorizationCallback,
72
63
  } = require('../modules/use-cases/process-authorization-callback');
64
+ const { createScopePolicy } = require('../modules/scope-policy');
65
+ const {
66
+ AuthorizeEntityForUser,
67
+ } = require('../modules/use-cases/authorize-entity-for-user');
68
+ const {
69
+ CreateEntityFromCredential,
70
+ } = require('../modules/use-cases/create-entity-from-credential');
71
+ const {
72
+ ResolveIntegrationOwnership,
73
+ } = require('./use-cases/resolve-integration-ownership');
74
+ const { GetOwnedIntegrationRecord } = require('./use-cases/integration-operations-for-user');
73
75
 
74
76
  const log = getLogger('frigg.integrations');
75
77
 
76
78
  function createIntegrationRouter() {
77
- const { integrations: integrationClasses, userConfig } =
78
- loadAppDefinition();
79
+ // moduleDefinitions is the app registry's definitions (ADR-059), so
80
+ // /api/authorize and friends accept a type no integration binds.
81
+ const {
82
+ integrations: integrationClasses,
83
+ userConfig,
84
+ moduleDefinitions,
85
+ ownership,
86
+ entityTypeRegistry,
87
+ } = loadAppDefinition();
88
+ // Ownership by scope (ADR-056 §3): one object every use case asks.
89
+ const scopePolicy = createScopePolicy({ ownership, entityTypeRegistry });
79
90
  const moduleRepository = createModuleRepository();
80
91
  const integrationRepository = createIntegrationRepository();
81
92
  const credentialRepository = createCredentialRepository();
82
93
  const userRepository = createUserRepository();
83
-
84
- const getUserFromBearerToken = new GetUserFromBearerToken({
85
- userRepository,
86
- userConfig,
87
- });
88
-
89
- const getUserFromXFriggHeaders = new GetUserFromXFriggHeaders({
90
- userRepository,
91
- userConfig,
92
- });
93
-
94
- const getUserFromAdopterJwt = new GetUserFromAdopterJwt({
95
- userRepository,
96
- userConfig,
97
- });
94
+ const {
95
+ getUserFromBearerToken,
96
+ getUserFromXFriggHeaders,
97
+ getUserFromAdopterJwt,
98
+ } = buildUserAuthentication({ userConfig, userRepository });
98
99
 
99
100
  const authenticateWithSharedSecret = new AuthenticateWithSharedSecret();
100
101
 
@@ -108,8 +109,7 @@ function createIntegrationRouter() {
108
109
 
109
110
  const moduleFactory = new ModuleFactory({
110
111
  moduleRepository,
111
- moduleDefinitions:
112
- getModulesDefinitionFromIntegrationClasses(integrationClasses),
112
+ moduleDefinitions,
113
113
  });
114
114
  const deleteIntegrationForUser = new DeleteIntegrationForUser({
115
115
  integrationRepository,
@@ -122,6 +122,19 @@ function createIntegrationRouter() {
122
122
  integrationClasses,
123
123
  moduleFactory,
124
124
  moduleRepository,
125
+ scopePolicy,
126
+ });
127
+
128
+ // Decides who owns a new integration and which entities the caller may bind
129
+ // (the v1 create used to check neither), and who may reach an existing one.
130
+ const resolveIntegrationOwnership = new ResolveIntegrationOwnership({
131
+ moduleRepository,
132
+ integrationClasses,
133
+ scopePolicy,
134
+ });
135
+ const getOwnedIntegrationRecord = new GetOwnedIntegrationRecord({
136
+ integrationRepository,
137
+ scopePolicy,
125
138
  });
126
139
 
127
140
  const getCredentialForUser = new GetCredentialForUser({
@@ -136,8 +149,8 @@ function createIntegrationRouter() {
136
149
 
137
150
  const getEntitiesForUser = new GetEntitiesForUser({
138
151
  moduleRepository,
139
- moduleDefinitions:
140
- getModulesDefinitionFromIntegrationClasses(integrationClasses),
152
+ moduleDefinitions,
153
+ scopePolicy,
141
154
  });
142
155
 
143
156
  const getIntegrationInstance = new GetIntegrationInstance({
@@ -153,54 +166,65 @@ function createIntegrationRouter() {
153
166
  });
154
167
 
155
168
  const getModuleInstanceFromType = new GetModuleInstanceFromType({
156
- moduleDefinitions:
157
- getModulesDefinitionFromIntegrationClasses(integrationClasses),
169
+ moduleDefinitions,
158
170
  });
159
171
 
160
172
  const getEntityOptionsByType = new GetEntityOptionsByType({
161
- moduleDefinitions:
162
- getModulesDefinitionFromIntegrationClasses(integrationClasses),
173
+ moduleDefinitions,
163
174
  });
164
175
 
165
176
  const testModuleAuth = new TestModuleAuth({
166
177
  moduleRepository,
167
- moduleDefinitions:
168
- getModulesDefinitionFromIntegrationClasses(integrationClasses),
178
+ moduleDefinitions,
179
+ scopePolicy,
169
180
  });
170
181
 
171
182
  const getModule = new GetModule({
172
183
  moduleRepository,
173
- moduleDefinitions:
174
- getModulesDefinitionFromIntegrationClasses(integrationClasses),
184
+ moduleDefinitions,
185
+ scopePolicy,
175
186
  });
176
187
 
177
188
  const getEntityOptionsById = new GetEntityOptionsById({
178
189
  moduleRepository,
179
- moduleDefinitions:
180
- getModulesDefinitionFromIntegrationClasses(integrationClasses),
190
+ moduleDefinitions,
191
+ scopePolicy,
181
192
  });
182
193
 
183
194
  const refreshEntityOptions = new RefreshEntityOptions({
184
195
  moduleRepository,
185
- moduleDefinitions:
186
- getModulesDefinitionFromIntegrationClasses(integrationClasses),
196
+ moduleDefinitions,
197
+ scopePolicy,
187
198
  });
188
199
 
189
200
  const getPossibleIntegrations = new GetPossibleIntegrations({
190
201
  integrationClasses,
191
202
  });
192
203
 
204
+ // The owner of what an authorization creates follows its scope (ADR-056 §3.2).
193
205
  const processAuthorizationCallback = new ProcessAuthorizationCallback({
194
206
  moduleRepository,
195
207
  credentialRepository,
196
208
  integrationRepository,
197
- moduleDefinitions:
198
- getModulesDefinitionFromIntegrationClasses(integrationClasses),
209
+ moduleDefinitions,
210
+ });
211
+ const authorizeEntityForUser = new AuthorizeEntityForUser({
212
+ scopePolicy,
213
+ processAuthorizationCallback,
214
+ });
215
+ // v1 POST /api/entity: same owner resolution as /api/authorize.
216
+ const createEntityFromCredential = new CreateEntityFromCredential({
217
+ authorizeEntityForUser,
218
+ processAuthorizationCallback,
219
+ credentialRepository,
220
+ moduleDefinitions,
199
221
  });
200
222
 
201
223
  const router = express();
202
224
 
203
225
  setIntegrationRoutes(router, authenticateUser, {
226
+ resolveIntegrationOwnership,
227
+ getOwnedIntegrationRecord,
204
228
  createIntegration,
205
229
  deleteIntegrationForUser,
206
230
  getIntegrationsForUser,
@@ -217,7 +241,8 @@ function createIntegrationRouter() {
217
241
  getModule,
218
242
  getEntityOptionsById,
219
243
  refreshEntityOptions,
220
- processAuthorizationCallback,
244
+ authorizeEntityForUser,
245
+ createEntityFromCredential,
221
246
  });
222
247
  return router;
223
248
  }
@@ -254,6 +279,8 @@ function checkRequiredParams(params, requiredKeys) {
254
279
  */
255
280
  function setIntegrationRoutes(router, authenticateUser, useCases) {
256
281
  const {
282
+ resolveIntegrationOwnership,
283
+ getOwnedIntegrationRecord,
257
284
  createIntegration,
258
285
  deleteIntegrationForUser,
259
286
  getIntegrationsForUser,
@@ -262,15 +289,22 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
262
289
  updateIntegration,
263
290
  getPossibleIntegrations,
264
291
  } = useCases;
292
+
293
+ // The owner of an integration the caller may reach (404 otherwise), which is
294
+ // what the id-and-owner use cases below expect. `manage: true` is for
295
+ // changing or deleting: an organization's integration needs an admin (403).
296
+ const ownerOf = async (integrationId, user, options) =>
297
+ (await getOwnedIntegrationRecord.execute(integrationId, user, options))
298
+ .userId;
299
+
265
300
  router.route('/api/integrations').get(
266
301
  catchAsyncError(async (req, res) => {
267
302
  const user = await authenticateUser.execute(req);
268
- const userId = user.getId();
269
- const integrations = await getIntegrationsForUser.execute(userId);
303
+ const integrations = await getIntegrationsForUser.execute(user);
270
304
  const results = {
271
305
  entities: {
272
306
  options: await getPossibleIntegrations.execute(),
273
- authorized: await getEntitiesForUser.execute(userId),
307
+ authorized: await getEntitiesForUser.execute(user),
274
308
  },
275
309
  integrations: integrations,
276
310
  };
@@ -282,7 +316,6 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
282
316
  router.route('/api/integrations').post(
283
317
  catchAsyncError(async (req, res) => {
284
318
  const user = await authenticateUser.execute(req);
285
- const userId = user.getId();
286
319
  const params = checkRequiredParams(req.body, [
287
320
  'entities',
288
321
  'config',
@@ -290,10 +323,17 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
290
323
 
291
324
  get(params.config, 'type');
292
325
 
326
+ const { ownerId, organizationId, adoptUnscoped } =
327
+ await resolveIntegrationOwnership.execute({
328
+ entityIds: params.entities,
329
+ config: params.config,
330
+ user,
331
+ });
293
332
  const integration = await createIntegration.execute(
294
333
  params.entities,
295
- userId,
296
- params.config
334
+ ownerId,
335
+ params.config,
336
+ { organizationId, adoptUnscoped }
297
337
  );
298
338
 
299
339
  res.status(201).json(integration);
@@ -303,12 +343,11 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
303
343
  router.route('/api/integrations/:integrationId').patch(
304
344
  catchAsyncError(async (req, res) => {
305
345
  const user = await authenticateUser.execute(req);
306
- const userId = user.getId();
307
346
  const params = checkRequiredParams(req.body, ['config']);
308
347
 
309
348
  const integration = await updateIntegration.execute(
310
349
  req.params.integrationId,
311
- userId,
350
+ await ownerOf(req.params.integrationId, user, { manage: true }),
312
351
  params.config
313
352
  );
314
353
  res.json(integration);
@@ -321,7 +360,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
321
360
  const params = checkRequiredParams(req.params, ['integrationId']);
322
361
  await deleteIntegrationForUser.execute(
323
362
  params.integrationId,
324
- user.getId()
363
+ await ownerOf(params.integrationId, user, { manage: true })
325
364
  );
326
365
  res.status(204).json({});
327
366
  })
@@ -333,7 +372,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
333
372
  const params = checkRequiredParams(req.params, ['integrationId']);
334
373
  const integration = await getIntegrationInstance.execute(
335
374
  params.integrationId,
336
- user.getId()
375
+ await ownerOf(params.integrationId, user)
337
376
  );
338
377
  res.json(await integration.send('GET_CONFIG_OPTIONS'));
339
378
  })
@@ -349,7 +388,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
349
388
  ]);
350
389
  const integration = await getIntegrationInstance.execute(
351
390
  params.integrationId,
352
- user.getId()
391
+ await ownerOf(params.integrationId, user)
353
392
  );
354
393
 
355
394
  res.json(
@@ -363,7 +402,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
363
402
  const params = checkRequiredParams(req.params, ['integrationId']);
364
403
  const integration = await getIntegrationInstance.execute(
365
404
  params.integrationId,
366
- user.getId()
405
+ await ownerOf(params.integrationId, user)
367
406
  );
368
407
  res.json(await integration.send('GET_USER_ACTIONS', req.body));
369
408
  })
@@ -380,7 +419,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
380
419
  ]);
381
420
  const integration = await getIntegrationInstance.execute(
382
421
  params.integrationId,
383
- user.getId()
422
+ await ownerOf(params.integrationId, user)
384
423
  );
385
424
 
386
425
  res.json(
@@ -405,7 +444,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
405
444
  ]);
406
445
  const integration = await getIntegrationInstance.execute(
407
446
  params.integrationId,
408
- user.getId()
447
+ await ownerOf(params.integrationId, user)
409
448
  );
410
449
 
411
450
  res.json(
@@ -426,7 +465,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
426
465
  ]);
427
466
  const integration = await getIntegrationInstance.execute(
428
467
  params.integrationId,
429
- user.getId()
468
+ await ownerOf(params.integrationId, user)
430
469
  );
431
470
  res.json(await integration.send(params.actionId, req.body));
432
471
  })
@@ -443,7 +482,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
443
482
  const params = checkRequiredParams(req.params, ['integrationId']);
444
483
  const integration = await getIntegrationInstance.execute(
445
484
  params.integrationId,
446
- user.getId()
485
+ await ownerOf(params.integrationId, user)
447
486
  );
448
487
 
449
488
  // We could perhaps augment router with dynamic options? Haven't decided yet, but here may be the place
@@ -463,7 +502,7 @@ function setIntegrationRoutes(router, authenticateUser, useCases) {
463
502
  const params = checkRequiredParams(req.params, ['integrationId']);
464
503
  const instance = await getIntegrationInstance.execute(
465
504
  params.integrationId,
466
- user.getId()
505
+ await ownerOf(params.integrationId, user)
467
506
  );
468
507
 
469
508
  if (!instance) {
@@ -501,16 +540,21 @@ function setEntityRoutes(router, authenticateUser, useCases) {
501
540
  getModule,
502
541
  getEntityOptionsById,
503
542
  refreshEntityOptions,
504
- processAuthorizationCallback,
543
+ authorizeEntityForUser,
544
+ createEntityFromCredential,
505
545
  } = useCases;
506
546
 
507
547
  router.route('/api/authorize').get(
508
548
  catchAsyncError(async (req, res) => {
509
549
  const user = await authenticateUser.execute(req);
510
- const userId = user.getId();
511
550
  const params = checkRequiredParams(req.query, ['entityType']);
551
+ // The owner POST stores under, so GET and POST describe one connection.
552
+ const { ownerId } = authorizeEntityForUser.resolveOwnership({
553
+ user,
554
+ entityType: params.entityType,
555
+ });
512
556
  const module = await getModuleInstanceFromType.execute(
513
- userId,
557
+ ownerId,
514
558
  params.entityType,
515
559
  { state: req.query.state }
516
560
  );
@@ -529,11 +573,15 @@ function setEntityRoutes(router, authenticateUser, useCases) {
529
573
  router.route('/api/authorize').post(
530
574
  catchAsyncError(async (req, res) => {
531
575
  const user = await authenticateUser.execute(req);
532
- const userId = user.getId();
533
576
  const params = checkRequiredParams(req.body, [
534
577
  'entityType',
535
578
  'data',
536
579
  ]);
580
+ const ownership = authorizeEntityForUser.resolveOwnership({
581
+ user,
582
+ entityType: params.entityType,
583
+ });
584
+ const userId = ownership.ownerId;
537
585
 
538
586
  const dataKeys =
539
587
  params.data && typeof params.data === 'object'
@@ -545,11 +593,13 @@ function setEntityRoutes(router, authenticateUser, useCases) {
545
593
  dataKeys,
546
594
  });
547
595
 
548
- const entityDetails = await processAuthorizationCallback.execute(
549
- userId,
550
- params.entityType,
551
- params.data
552
- );
596
+ const { result: entityDetails } =
597
+ await authorizeEntityForUser.execute({
598
+ user,
599
+ entityType: params.entityType,
600
+ data: params.data,
601
+ ownership,
602
+ });
553
603
 
554
604
  log.info('Entity authorized', {
555
605
  eventName: 'frigg.integrations.authorized',
@@ -566,35 +616,20 @@ function setEntityRoutes(router, authenticateUser, useCases) {
566
616
  router.route('/api/entity').post(
567
617
  catchAsyncError(async (req, res) => {
568
618
  const user = await authenticateUser.execute(req);
569
- const userId = user.getId();
570
619
  const params = checkRequiredParams(req.body, [
571
620
  'entityType',
572
621
  'data',
573
622
  ]);
574
623
  checkRequiredParams(req.body.data, ['credential_id']);
575
624
 
576
- // May want to pass along the user ID as well so credential ID's can't be fished???
577
- const credential = await getCredentialForUser.execute(
578
- params.data.credential_id,
579
- userId
625
+ // The owner follows the entity type's scope, as /api/authorize.
626
+ res.json(
627
+ await createEntityFromCredential.execute({
628
+ user,
629
+ entityType: params.entityType,
630
+ credentialId: params.data.credential_id,
631
+ })
580
632
  );
581
-
582
- if (!credential) {
583
- throw Boom.badRequest('Invalid credential ID');
584
- }
585
-
586
- const module = await getModuleInstanceFromType.execute(
587
- userId,
588
- params.entityType
589
- );
590
- const entityDetails = await module.getEntityDetails(
591
- module.api,
592
- null,
593
- null,
594
- userId
595
- );
596
-
597
- res.json(await module.findOrCreateEntity(entityDetails));
598
633
  })
599
634
  );
600
635
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The error `findIntegrationById` throws for an id with no row, and the one
3
+ * predicate that recognises it. The adapters build the error here and the
4
+ * use cases match it here, so the message is written once and a missing
5
+ * integration can be told from any other failure without widening a regex.
6
+ */
7
+ const MESSAGE = /^Integration with id .+ not found$/;
8
+
9
+ const integrationNotFoundError = (id) =>
10
+ new Error(`Integration with id ${id} not found`);
11
+
12
+ const isIntegrationNotFound = (error) => MESSAGE.test(error?.message || '');
13
+
14
+ module.exports = { integrationNotFoundError, isIntegrationNotFound };
@@ -14,6 +14,7 @@ const {
14
14
  IntegrationRepositoryInterface,
15
15
  } = require('./integration-repository-interface');
16
16
  const { validateConfigPatch } = require('./config-patch-shared');
17
+ const { integrationNotFoundError } = require('./integration-not-found');
17
18
 
18
19
  class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
19
20
  constructor() {
@@ -28,6 +29,28 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
28
29
  return records.map((doc) => this._mapIntegration(doc));
29
30
  }
30
31
 
32
+ /**
33
+ * Find integrations matching any of the owner/organization pairs, in one
34
+ * query. `organizationId: null` matches rows with no organization (a raw
35
+ * MongoDB `null` also matches a missing field). The caller (a use case)
36
+ * decides which pairs; this only runs them.
37
+ *
38
+ * @param {Array<{userId: string, organizationId: (string|null)}>} clauses
39
+ * @returns {Promise<Array>}
40
+ */
41
+ async findIntegrationsByOwnership(clauses) {
42
+ if (!Array.isArray(clauses) || clauses.length === 0) return [];
43
+ // Drained: a list can hold more than the first cursor batch (~101).
44
+ const records = await findManyDrained(this.prisma, 'Integration', {
45
+ $or: clauses.map(({ userId, organizationId }) => ({
46
+ userId: toObjectId(userId),
47
+ organizationId:
48
+ organizationId === null ? null : toObjectId(organizationId),
49
+ })),
50
+ });
51
+ return records.map((doc) => this._mapIntegration(doc));
52
+ }
53
+
31
54
  async findIntegrations({ type, status } = {}) {
32
55
  const filter = {};
33
56
  if (type) {
@@ -68,11 +91,11 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
68
91
  async findIntegrationById(id) {
69
92
  const objectId = toObjectId(id);
70
93
  if (!objectId) {
71
- throw new Error(`Integration with id ${id} not found`);
94
+ throw integrationNotFoundError(id);
72
95
  }
73
96
  const doc = await findOne(this.prisma, 'Integration', { _id: objectId });
74
97
  if (!doc) {
75
- throw new Error(`Integration with id ${id} not found`);
98
+ throw integrationNotFoundError(id);
76
99
  }
77
100
  return this._mapIntegration(doc);
78
101
  }
@@ -132,10 +155,18 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
132
155
  return true;
133
156
  }
134
157
 
135
- async createIntegration(entities, userId, config) {
158
+ /**
159
+ * @param {Array<string>} entities - entity ids
160
+ * @param {string} userId - owner (the person, or the organization's user)
161
+ * @param {Object} config - Integration configuration
162
+ * @param {{organizationId?: (string|null)}} [tenancy] - the organization the
163
+ * integration is created in (ADR-056 §3.6)
164
+ */
165
+ async createIntegration(entities, userId, config, tenancy = {}) {
136
166
  const now = new Date();
137
167
  const document = {
138
168
  userId: toObjectId(userId) || null,
169
+ organizationId: toObjectId(tenancy.organizationId) || null,
139
170
  config,
140
171
  version: '0.0.0',
141
172
  status: 'IN_CREATION',
@@ -298,6 +329,7 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
298
329
  id: fromObjectId(doc?._id),
299
330
  entitiesIds: (doc?.entityIds || []).map((value) => fromObjectId(value)),
300
331
  userId: fromObjectId(doc?.userId),
332
+ organizationId: fromObjectId(doc?.organizationId) ?? null,
301
333
  config: doc?.config ?? null,
302
334
  version: doc?.version ?? null,
303
335
  status: doc?.status ?? null,