@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
@@ -50,8 +50,16 @@ const RESERVED_V2_SEGMENTS = Object.freeze(['reports']);
50
50
  const V1_MIGRATION_DOCS_URL =
51
51
  'https://docs.friggframework.org/api/migrate-v1-v2';
52
52
 
53
- /** Lambda functions (devtools names) that serve Management API routes. */
54
- const FUNCTIONS = Object.freeze({ AUTH: 'auth', HEALTH: 'health' });
53
+ /**
54
+ * Functions (devtools names) that serve registered routes. `queueWorker` is
55
+ * the HTTP queue worker of hosts that deliver queue messages over HTTP
56
+ * (provider-netlify's worker-background); AWS delivers through SQS.
57
+ */
58
+ const FUNCTIONS = Object.freeze({
59
+ AUTH: 'auth',
60
+ HEALTH: 'health',
61
+ QUEUE_WORKER: 'queueWorker',
62
+ });
55
63
 
56
64
  const route = (version, fn) => (definition) =>
57
65
  Object.freeze({ version, function: fn, ...definition });
@@ -180,6 +188,91 @@ const AUTH_EXTRA_ROUTES = Object.freeze(
180
188
  ].map(route(null, FUNCTIONS.AUTH))
181
189
  );
182
190
 
191
+ /**
192
+ * Unversioned routes the DB-free health Lambda serves next to /api/meta.
193
+ * GET /api/docs redirects to the current OpenAPI document; the docs
194
+ * themselves live under /api/meta (ADR-053).
195
+ */
196
+ const DOCS_ROUTES = Object.freeze(
197
+ [
198
+ {
199
+ operationId: 'redirectToOpenApi',
200
+ method: 'GET',
201
+ path: '/api/docs',
202
+ },
203
+ ].map(route(null, FUNCTIONS.HEALTH))
204
+ );
205
+
206
+ /**
207
+ * Admin routes live under /admin (as core's /admin/db-migrate and the
208
+ * admin-scripts /admin/scripts do), never under the public /api prefix. A
209
+ * core extension (appDefinition.coreExtensions[].routes.path) mounts its admin
210
+ * API at /admin/<name>, served by the auth function behind the admin API key;
211
+ * these segments belong to other functions and cannot be taken.
212
+ */
213
+ const ADMIN_ROUTE_PREFIX = '/admin/';
214
+ const RESERVED_ADMIN_SEGMENTS = Object.freeze(['scripts', 'db-migrate', 'queue']);
215
+
216
+ /**
217
+ * The queue delivery endpoint of hosts that deliver queue messages over HTTP
218
+ * (QStash posts here on Netlify). Every message must be signed: the worker
219
+ * verifies it before parsing anything. Not part of the public /api surface.
220
+ */
221
+ const HTTP_QUEUE_ROUTES = Object.freeze(
222
+ [
223
+ {
224
+ operationId: 'deliverQueueMessage',
225
+ method: 'POST',
226
+ path: '/admin/queue',
227
+ },
228
+ ].map(route(null, FUNCTIONS.QUEUE_WORKER))
229
+ );
230
+
231
+ /**
232
+ * Why a core extension route path is not acceptable, or null when it is:
233
+ * `/admin/<segment>` (letters, digits, `-` and `_`, one or more segments), not
234
+ * a reserved segment.
235
+ *
236
+ * @param {*} path
237
+ * @returns {string|null}
238
+ */
239
+ function coreExtensionRoutePathError(path) {
240
+ if (typeof path !== 'string' || !path.startsWith(ADMIN_ROUTE_PREFIX)) {
241
+ return `must start with ${ADMIN_ROUTE_PREFIX} (admin routes are not served under /api)`;
242
+ }
243
+ if (!/^\/admin(\/[A-Za-z0-9_-]+)+$/.test(path)) {
244
+ return 'must be /admin/<segment>[/<segment>...] using letters, digits, - and _';
245
+ }
246
+ const segment = path.split('/')[2];
247
+ if (RESERVED_ADMIN_SEGMENTS.includes(segment)) {
248
+ return `/admin/${segment} is reserved (${RESERVED_ADMIN_SEGMENTS.map((s) => `/admin/${s}`).join(', ')})`;
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Route records for the core extensions' admin routes: each path and
255
+ * everything under it, on the auth function (core's auth router mounts the
256
+ * extension routes behind the admin API key).
257
+ *
258
+ * @param {Array<Object>} [coreExtensions] - appDefinition.coreExtensions (plain data;
259
+ * only `routes.path` is read)
260
+ * @returns {Array<Object>}
261
+ */
262
+ function listCoreExtensionRoutes(coreExtensions = []) {
263
+ if (!Array.isArray(coreExtensions)) return [];
264
+ const out = [];
265
+ for (const ext of coreExtensions) {
266
+ const path = ext?.routes?.path;
267
+ if (typeof path !== 'string' || coreExtensionRoutePathError(path)) continue;
268
+ out.push(
269
+ Object.freeze({ version: null, function: FUNCTIONS.AUTH, method: 'ANY', path, extension: ext.name }),
270
+ Object.freeze({ version: null, function: FUNCTIONS.AUTH, method: 'ANY', path: `${path}/{proxy+}`, extension: ext.name })
271
+ );
272
+ }
273
+ return out;
274
+ }
275
+
183
276
  /**
184
277
  * The v2 routes an app serves. Routes tied to an opt-in capability (the entity
185
278
  * proxy, ADR-052) are left out unless the app enables it.
@@ -206,6 +299,7 @@ function listRoutes({ v1 = true, proxy = false } = {}) {
206
299
  ...listV2Routes({ proxy }),
207
300
  ...META_ROUTES,
208
301
  ...AUTH_EXTRA_ROUTES,
302
+ ...DOCS_ROUTES,
209
303
  ];
210
304
  }
211
305
 
@@ -225,18 +319,25 @@ function resourceRoot(record) {
225
319
  }
226
320
 
227
321
  /**
228
- * API Gateway HTTP API routes for the Management API, derived from the
229
- * registry. Each resource root gets an exact route when the root itself is
322
+ * Gateway routes derived from the registry: the Management API, its
323
+ * discovery and docs routes, the core extensions' admin routes and, for
324
+ * hosts that deliver queue messages over HTTP, the queue endpoint. Each
325
+ * Management API resource root gets an exact route when the root itself is
230
326
  * registered and a greedy `{proxy+}` route when deeper paths are. The auth
231
327
  * function uses `ANY` (as it always has, so CORS preflights reach Express);
232
328
  * the health function only serves GET.
233
329
  *
330
+ * devtools turns these into API Gateway HTTP API events; provider-netlify
331
+ * into netlify.toml redirects.
332
+ *
234
333
  * @param {Object} [options]
235
334
  * @param {boolean} [options.v1=true]
236
335
  * @param {boolean} [options.proxy=false]
336
+ * @param {Array<Object>} [options.coreExtensions=[]] - appDefinition.coreExtensions
337
+ * @param {boolean} [options.httpQueue=false] - include the HTTP queue route
237
338
  * @returns {Array<{function: string, method: string, path: string}>}
238
339
  */
239
- function getGatewayRoutes({ v1 = true, proxy = false } = {}) {
340
+ function getGatewayRoutes({ v1 = true, proxy = false, coreExtensions = [], httpQueue = false } = {}) {
240
341
  const out = [];
241
342
  const seen = new Set();
242
343
  const add = (fn, method, path) => {
@@ -265,7 +366,7 @@ function getGatewayRoutes({ v1 = true, proxy = false } = {}) {
265
366
 
266
367
  // Extra routes get an exact route unless a greedy route on the same
267
368
  // function already covers them.
268
- for (const record of AUTH_EXTRA_ROUTES) {
369
+ for (const record of [...AUTH_EXTRA_ROUTES, ...DOCS_ROUTES]) {
269
370
  const covered = greedyRoots.has(
270
371
  `${record.function} ${resourceRoot(record)}`
271
372
  );
@@ -274,6 +375,16 @@ function getGatewayRoutes({ v1 = true, proxy = false } = {}) {
274
375
  }
275
376
  }
276
377
 
378
+ for (const record of listCoreExtensionRoutes(coreExtensions)) {
379
+ add(record.function, record.method, record.path);
380
+ }
381
+
382
+ if (httpQueue) {
383
+ for (const record of HTTP_QUEUE_ROUTES) {
384
+ add(record.function, record.method, record.path);
385
+ }
386
+ }
387
+
277
388
  return out;
278
389
  }
279
390
 
@@ -287,6 +398,12 @@ module.exports = {
287
398
  V2_ROUTES,
288
399
  META_ROUTES,
289
400
  AUTH_EXTRA_ROUTES,
401
+ DOCS_ROUTES,
402
+ HTTP_QUEUE_ROUTES,
403
+ ADMIN_ROUTE_PREFIX,
404
+ RESERVED_ADMIN_SEGMENTS,
405
+ coreExtensionRoutePathError,
406
+ listCoreExtensionRoutes,
290
407
  listRoutes,
291
408
  listV2Routes,
292
409
  getGatewayRoutes,
@@ -29,13 +29,18 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
29
29
  getEntityTypeRegistry,
30
30
  getEntityTypeDefinitions,
31
31
  } = require('../../modules/entity-type-registry');
32
- const { GetUserFromBearerToken } = require('../../user/use-cases/get-user-from-bearer-token');
33
- const { GetUserFromXFriggHeaders } = require('../../user/use-cases/get-user-from-x-frigg-headers');
34
- const { GetUserFromAdopterJwt } = require('../../user/use-cases/get-user-from-adopter-jwt');
32
+ const { createScopePolicy } = require('../../modules/scope-policy');
33
+ const {
34
+ AuthorizeEntityForUser,
35
+ } = require('../../modules/use-cases/authorize-entity-for-user');
36
+ const {
37
+ ResolveIntegrationOwnership,
38
+ } = require('../../integrations/use-cases/resolve-integration-ownership');
35
39
  const {
36
40
  AuthenticateWithSharedSecret,
37
41
  } = require('../../user/use-cases/authenticate-with-shared-secret');
38
42
  const { AuthenticateUser } = require('../../user/use-cases/authenticate-user');
43
+ const { buildUserAuthentication } = require('../../user/create-user-authentication');
39
44
  const { CreateIntegration } = require('../../integrations/use-cases/create-integration');
40
45
  const { UpdateIntegration } = require('../../integrations/use-cases/update-integration');
41
46
  const {
@@ -87,6 +92,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
87
92
  appDefinition.entityTypeRegistry ??
88
93
  getEntityTypeRegistry(appDefinition);
89
94
  const moduleDefinitions = getEntityTypeDefinitions(registry);
95
+ // Ownership by scope (ADR-056 §3): one object every use case asks.
96
+ const scopePolicy = createScopePolicy({
97
+ ownership: appDefinition.ownership,
98
+ entityTypeRegistry: registry,
99
+ });
90
100
 
91
101
  const moduleRepository = createModuleRepository();
92
102
  const integrationRepository = createIntegrationRepository();
@@ -95,9 +105,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
95
105
  const authorizationSessionRepository = createAuthorizationSessionRepository();
96
106
 
97
107
  const authenticateUser = new AuthenticateUser({
98
- getUserFromBearerToken: new GetUserFromBearerToken({ userRepository, userConfig }),
99
- getUserFromXFriggHeaders: new GetUserFromXFriggHeaders({ userRepository, userConfig }),
100
- getUserFromAdopterJwt: new GetUserFromAdopterJwt({ userRepository, userConfig }),
108
+ ...buildUserAuthentication({ userConfig, userRepository }),
101
109
  authenticateWithSharedSecret: new AuthenticateWithSharedSecret(),
102
110
  userConfig,
103
111
  });
@@ -111,10 +119,12 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
111
119
  const getEntityModuleForUser = new GetEntityModuleForUser({
112
120
  moduleRepository,
113
121
  moduleDefinitions,
122
+ scopePolicy,
114
123
  });
115
124
  const getCredentialForUser = new credentialOps.GetCredentialForUserV2({
116
125
  credentialRepository,
117
126
  moduleRepository,
127
+ scopePolicy,
118
128
  });
119
129
  const getAuthorizationRequirements = new GetAuthorizationRequirements({
120
130
  moduleDefinitions,
@@ -128,17 +138,22 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
128
138
  moduleDefinitions,
129
139
  getAuthorizationRequirements,
130
140
  authorizationSessionRepository,
131
- processAuthorizationCallback: new ProcessAuthorizationCallback({
132
- moduleRepository,
133
- credentialRepository,
134
- integrationRepository,
135
- moduleDefinitions,
141
+ authorizeEntityForUser: new AuthorizeEntityForUser({
142
+ scopePolicy,
143
+ processAuthorizationCallback: new ProcessAuthorizationCallback({
144
+ moduleRepository,
145
+ credentialRepository,
146
+ integrationRepository,
147
+ moduleDefinitions,
148
+ }),
136
149
  }),
137
150
  moduleRepository,
138
151
  credentialRepository,
152
+ scopePolicy,
139
153
  });
140
154
  const getOwnedIntegrationRecord = new integrationOps.GetOwnedIntegrationRecord({
141
155
  integrationRepository,
156
+ scopePolicy,
142
157
  });
143
158
  const getIntegrationInstanceForUser = new integrationOps.GetIntegrationInstanceForUser({
144
159
  getOwnedIntegrationRecord,
@@ -148,6 +163,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
148
163
  return {
149
164
  config,
150
165
  moduleDefinitions,
166
+ scopePolicy,
151
167
  repositories: {
152
168
  moduleRepository,
153
169
  integrationRepository,
@@ -163,6 +179,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
163
179
  integrationClasses,
164
180
  moduleFactory,
165
181
  moduleRepository,
182
+ scopePolicy,
166
183
  }),
167
184
  getPossibleIntegrations: new GetPossibleIntegrations({ integrationClasses }),
168
185
  createIntegrationForUser: new integrationOps.CreateIntegrationForUser({
@@ -171,7 +188,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
171
188
  integrationClasses,
172
189
  moduleFactory,
173
190
  }),
174
- getEntityModuleForUser,
191
+ resolveIntegrationOwnership: new ResolveIntegrationOwnership({
192
+ moduleRepository,
193
+ integrationClasses,
194
+ scopePolicy,
195
+ }),
175
196
  integrationClasses,
176
197
  }),
177
198
  getIntegrationInstanceForUser,
@@ -203,7 +224,10 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
203
224
 
204
225
  // Entities
205
226
  getEntityModuleForUser,
206
- listEntitiesForUser: new entityOps.ListEntitiesForUser({ moduleRepository }),
227
+ listEntitiesForUser: new entityOps.ListEntitiesForUser({
228
+ moduleRepository,
229
+ scopePolicy,
230
+ }),
207
231
  getEntityForUser: new entityOps.GetEntityForUser({ getEntityModuleForUser }),
208
232
  deleteEntityForUser: new entityOps.DeleteEntityForUser({
209
233
  getEntityModuleForUser,
@@ -215,14 +239,15 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
215
239
  refreshEntityOptionsForUser: new entityOps.RefreshEntityOptionsForUser({
216
240
  getEntityModuleForUser,
217
241
  }),
218
- listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry }),
219
- getEntityType: new GetEntityType({ entityTypeRegistry: registry }),
242
+ listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry, scopePolicy }),
243
+ getEntityType: new GetEntityType({ entityTypeRegistry: registry, scopePolicy }),
220
244
  getAuthorizationRequirements,
221
245
 
222
246
  // Credentials
223
247
  listCredentialsForUser: new credentialOps.ListCredentialsForUser({
224
248
  credentialRepository,
225
249
  moduleRepository,
250
+ scopePolicy,
226
251
  }),
227
252
  getCredentialForUser,
228
253
  deleteCredentialForUser: new credentialOps.DeleteCredentialForUser({
@@ -13,6 +13,20 @@ const idOf = (value) => {
13
13
  return String(value);
14
14
  };
15
15
 
16
+ /**
17
+ * `{ scope, owner, organizationId, access }` (ADR-056 §3.4): whose a record is
18
+ * and what the caller may do with it. Empty when the use case did not say.
19
+ */
20
+ function tenancyFields(tenancy) {
21
+ if (!tenancy) return {};
22
+ return {
23
+ scope: tenancy.scope,
24
+ owner: tenancy.owner,
25
+ organizationId: tenancy.organizationId ?? null,
26
+ access: tenancy.access,
27
+ };
28
+ }
29
+
16
30
  /** An entity (connected account). */
17
31
  function toEntityDto(entity) {
18
32
  const credential =
@@ -30,6 +44,7 @@ function toEntityDto(entity) {
30
44
  typeof credential?.authIsValid === 'boolean'
31
45
  ? credential.authIsValid
32
46
  : null,
47
+ ...tenancyFields(entity.tenancy),
33
48
  };
34
49
  }
35
50
 
@@ -42,6 +57,7 @@ function toIntegrationDto(integration) {
42
57
  config: integration.config ?? {},
43
58
  version: integration.version ?? null,
44
59
  messages: integration.messages ?? {},
60
+ ...tenancyFields(integration.tenancy),
45
61
  };
46
62
  }
47
63
 
@@ -59,6 +75,8 @@ const CREDENTIAL_METADATA = new Set([
59
75
  'type',
60
76
  'entityIds',
61
77
  'status',
78
+ // Added by the credential use cases (ADR-056 §3.4); never provider data.
79
+ 'tenancy',
62
80
  ]);
63
81
 
64
82
  /**
@@ -110,11 +128,13 @@ function maskCredential(credential) {
110
128
  entityCount: entityIds.length,
111
129
  createdAt: toIso(credential.createdAt),
112
130
  updatedAt: toIso(credential.updatedAt),
131
+ ...tenancyFields(credential.tenancy),
113
132
  data,
114
133
  };
115
134
  }
116
135
 
117
136
  module.exports = {
137
+ tenancyFields,
118
138
  toEntityDto,
119
139
  toIntegrationDto,
120
140
  maskCredential,
@@ -63,6 +63,7 @@ function createAuthorizationHandlers(useCases) {
63
63
  data: body.data,
64
64
  step,
65
65
  sessionId,
66
+ scope: optionalString(body.scope, 'scope'),
66
67
  });
67
68
  res.json(toAuthorizationResponse(result));
68
69
  },
@@ -1,4 +1,5 @@
1
1
  const { maskCredential } = require('../dto');
2
+ const { parseListFilters } = require('../request');
2
3
 
3
4
  /** Management API v2 credential handlers, keyed by operationId. */
4
5
  function createCredentialHandlers(useCases) {
@@ -6,8 +7,11 @@ function createCredentialHandlers(useCases) {
6
7
  useCases;
7
8
 
8
9
  return {
9
- async listCredentials(_req, res, user) {
10
- const credentials = await listCredentialsForUser.execute(user);
10
+ async listCredentials(req, res, user) {
11
+ const credentials = await listCredentialsForUser.execute(
12
+ user,
13
+ parseListFilters(req.query)
14
+ );
11
15
  res.json({ credentials: credentials.map(maskCredential) });
12
16
  },
13
17
 
@@ -1,5 +1,5 @@
1
1
  const { toEntityDto } = require('../dto');
2
- const { objectBody, parseStep } = require('../request');
2
+ const { objectBody, parseStep, parseListFilters } = require('../request');
3
3
 
4
4
  /** Management API v2 entity and entity-type handlers, keyed by operationId. */
5
5
  function createEntityHandlers(useCases) {
@@ -16,17 +16,20 @@ function createEntityHandlers(useCases) {
16
16
  } = useCases;
17
17
 
18
18
  return {
19
- async listEntities(_req, res, user) {
20
- const entities = await listEntitiesForUser.execute(user);
19
+ async listEntities(req, res, user) {
20
+ const entities = await listEntitiesForUser.execute(
21
+ user,
22
+ parseListFilters(req.query)
23
+ );
21
24
  res.json({ entities: entities.map(toEntityDto) });
22
25
  },
23
26
 
24
- async listEntityTypes(_req, res) {
25
- res.json({ types: await listEntityTypes.execute() });
27
+ async listEntityTypes(_req, res, user) {
28
+ res.json({ types: await listEntityTypes.execute({ user }) });
26
29
  },
27
30
 
28
- async getEntityType(req, res) {
29
- res.json(await getEntityType.execute(req.params.entityType));
31
+ async getEntityType(req, res, user) {
32
+ res.json(await getEntityType.execute(req.params.entityType, { user }));
30
33
  },
31
34
 
32
35
  // Describes a step's requirements without starting an authorization
@@ -1,5 +1,5 @@
1
1
  const { toIntegrationDto } = require('../dto');
2
- const { objectBody, optionalString } = require('../request');
2
+ const { objectBody, optionalString, parseListFilters } = require('../request');
3
3
 
4
4
  /**
5
5
  * Management API v2 integration handlers, keyed by registry operationId.
@@ -27,8 +27,11 @@ function createIntegrationHandlers(useCases) {
27
27
  );
28
28
 
29
29
  return {
30
- async listIntegrations(_req, res, user) {
31
- const integrations = await getIntegrationsForUser.execute(user.getId());
30
+ async listIntegrations(req, res, user) {
31
+ const integrations = await getIntegrationsForUser.execute(
32
+ user,
33
+ parseListFilters(req.query)
34
+ );
32
35
  res.json({ integrations: integrations.map(toIntegrationDto) });
33
36
  },
34
37
 
@@ -1,4 +1,5 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { LIST_OWNERS } = require('../../modules/scope-visibility');
2
3
 
3
4
  /** The JSON body as an object, or a 400 VALIDATION_ERROR. */
4
5
  function objectBody(req) {
@@ -51,4 +52,34 @@ function optionalString(value, name) {
51
52
  return value;
52
53
  }
53
54
 
54
- module.exports = { objectBody, requireFields, parseStep, optionalString };
55
+ const LIST_SCOPES = ['individual', 'organization'];
56
+
57
+ /**
58
+ * `?scope=individual|organization` and `?owner=me|org` on a list (ADR-056
59
+ * §3.4). Anything else is a 400.
60
+ */
61
+ function parseListFilters(query = {}) {
62
+ const scope = optionalString(query.scope, 'scope');
63
+ const owner = optionalString(query.owner, 'owner');
64
+ if (scope !== undefined && !LIST_SCOPES.includes(scope)) {
65
+ throw Boom.badRequest(`scope must be one of ${LIST_SCOPES.join(', ')}`, {
66
+ code: 'VALIDATION_ERROR',
67
+ details: { field: 'scope' },
68
+ });
69
+ }
70
+ if (owner !== undefined && !Object.hasOwn(LIST_OWNERS, owner)) {
71
+ throw Boom.badRequest('owner must be me or org', {
72
+ code: 'VALIDATION_ERROR',
73
+ details: { field: 'owner' },
74
+ });
75
+ }
76
+ return { scope, owner };
77
+ }
78
+
79
+ module.exports = {
80
+ objectBody,
81
+ requireFields,
82
+ parseStep,
83
+ optionalString,
84
+ parseListFilters,
85
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Entity scopes and ownership (ADR-056 §3), re-exported from the four files it
3
+ * is split into:
4
+ *
5
+ * scope-resolution constants, scope resolution, ownership on create
6
+ * scope-config reading and validating app-definition settings
7
+ * scope-visibility who may see which stored row
8
+ * scope-policy the object a use case asks
9
+ */
10
+ module.exports = {
11
+ ...require('./scope-resolution'),
12
+ ...require('./scope-config'),
13
+ ...require('./scope-visibility'),
14
+ ...require('./scope-policy'),
15
+ };
package/modules/index.js CHANGED
@@ -8,6 +8,11 @@ const {
8
8
  getEntityTypeRegistry,
9
9
  EntityRegistryError,
10
10
  } = require('./entity-type-registry');
11
+ const {
12
+ resolveScope,
13
+ listBindingScopes,
14
+ OwnershipConfigError,
15
+ } = require('./entity-scope');
11
16
 
12
17
  module.exports = {
13
18
  ApiKeyRequester,
@@ -18,4 +23,7 @@ module.exports = {
18
23
  ModuleFactory,
19
24
  getEntityTypeRegistry,
20
25
  EntityRegistryError,
26
+ resolveScope,
27
+ listBindingScopes,
28
+ OwnershipConfigError,
21
29
  };
@@ -45,6 +45,18 @@ class ModuleFactory {
45
45
  );
46
46
  }
47
47
 
48
+ // definition.env may be an async function (e.g. the db-credentials
49
+ // extension's bootstrap loads OAuth app credentials from the
50
+ // database); resolve it first. A static env keeps the plain
51
+ // constructor, exactly as before.
52
+ if (typeof moduleDefinition.env === 'function') {
53
+ return await Module.create({
54
+ userId,
55
+ entity,
56
+ definition: moduleDefinition,
57
+ });
58
+ }
59
+
48
60
  return new Module({
49
61
  userId,
50
62
  entity,
package/modules/module.js CHANGED
@@ -21,8 +21,17 @@ class Module extends Delegate {
21
21
  * @param {string} params.userId The user id
22
22
  * @param {Object} params.entity The entity record from the database
23
23
  * @param {string} [params.state] Optional OAuth state value forwarded to the API client (round-trips through the OAuth provider).
24
+ * @param {Object} [params.resolvedEnv] definition.env as Module.create() resolved it (may be undefined).
25
+ * @param {boolean} [params.envResolved=false] true when resolvedEnv carries the resolved env, even an undefined one; otherwise the static definition.env is used.
24
26
  */
25
- constructor({ definition, userId = null, entity: entityObj = null, state = null }) {
27
+ constructor({
28
+ definition,
29
+ userId = null,
30
+ entity: entityObj = null,
31
+ state = null,
32
+ resolvedEnv,
33
+ envResolved = false,
34
+ }) {
26
35
  super({ definition, userId, entity: entityObj });
27
36
 
28
37
  this.validateDefinition(definition);
@@ -54,8 +63,23 @@ class Module extends Delegate {
54
63
 
55
64
  Object.assign(this, this.definition.requiredAuthMethods);
56
65
 
66
+ // A pre-resolved env (Module.create()) supports definition.env as an
67
+ // async function, e.g. OAuth app credentials loaded from the database
68
+ // by the db-credentials extension; otherwise the static object.
69
+ // "Resolved" is an explicit flag, not a non-null value: a function env
70
+ // may legitimately resolve to undefined (no static env, no DB row),
71
+ // and falling back to definition.env would then read the function.
72
+ const envValues = envResolved ? resolvedEnv : this.definition.env;
73
+ if (typeof envValues === 'function') {
74
+ // Spreading a function yields no keys, so the API client would
75
+ // silently get no client_id / client_secret.
76
+ throw new TypeError(
77
+ `Module ${this.name}: definition.env is a function; build the module with await Module.create(...)`
78
+ );
79
+ }
80
+
57
81
  const apiParams = {
58
- ...this.definition.env,
82
+ ...envValues,
59
83
  delegate: this,
60
84
  logger: this.logger,
61
85
  ...(state ? { state } : {}),
@@ -67,6 +91,33 @@ class Module extends Delegate {
67
91
  this.api = new this.apiClass(apiParams);
68
92
  }
69
93
 
94
+ /**
95
+ * Resolves definition.env, supporting both a static object and a
96
+ * (possibly async) function.
97
+ *
98
+ * @param {Object} definition - Module definition
99
+ * @returns {Promise<Object|undefined>} Resolved env values
100
+ */
101
+ static async resolveEnv(definition) {
102
+ if (typeof definition.env === 'function') {
103
+ return await definition.env();
104
+ }
105
+ return definition.env;
106
+ }
107
+
108
+ /**
109
+ * Async factory that resolves definition.env first, so a module whose
110
+ * env is a function gets its values. Prefer it over `new Module(...)`
111
+ * wherever the call site can await.
112
+ *
113
+ * @param {Object} params - same as the constructor, minus resolvedEnv
114
+ * @returns {Promise<Module>}
115
+ */
116
+ static async create({ definition, userId = null, entity = null, state = null }) {
117
+ const resolvedEnv = await Module.resolveEnv(definition);
118
+ return new Module({ definition, userId, entity, state, resolvedEnv, envResolved: true });
119
+ }
120
+
70
121
  getName() {
71
122
  return this.name;
72
123
  }
@@ -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 };