@friggframework/core 2.0.0--canary.685.022c3c3.0 → 2.0.0--canary.683.8a84ab1.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 (38) hide show
  1. package/CLAUDE.md +0 -23
  2. package/README.md +1 -1
  3. package/credential/use-cases/credential-operations-for-user.js +33 -110
  4. package/credential/use-cases/reauthorize-credential-for-user.js +13 -10
  5. package/handlers/app-definition-loader.js +0 -9
  6. package/index.js +3 -4
  7. package/integrations/index.js +5 -0
  8. package/integrations/integration-router.js +26 -28
  9. package/integrations/repositories/integration-repository-documentdb.js +2 -1
  10. package/integrations/utils/map-integration-dto.js +44 -1
  11. package/management-api/management-api-config.js +4 -70
  12. package/management-api/openapi/components.js +0 -1
  13. package/management-api/openapi/v2-operations.js +3 -6
  14. package/management-api/v2/dependencies.js +2 -2
  15. package/management-api/v2/dto.js +0 -3
  16. package/management-api/v2/handlers/credentials.js +2 -6
  17. package/modules/repositories/module-repository-documentdb.js +8 -6
  18. package/modules/repositories/module-repository-mongo.js +1 -2
  19. package/modules/repositories/module-repository-postgres.js +1 -2
  20. package/modules/repositories/module-repository.js +1 -2
  21. package/modules/scope-config.js +16 -1
  22. package/modules/scope-policy.js +3 -17
  23. package/modules/scope-visibility.js +37 -18
  24. package/modules/use-cases/authorize-entity-for-user.js +2 -4
  25. package/modules/use-cases/create-entity-from-credential.js +113 -0
  26. package/modules/use-cases/execute-entity-proxy-request.js +18 -50
  27. package/modules/use-cases/get-entity-module-for-user.js +2 -9
  28. package/modules/use-cases/get-entity-options-by-id.js +4 -1
  29. package/modules/use-cases/get-module.js +4 -1
  30. package/modules/use-cases/multi-step-authorization.js +1 -4
  31. package/modules/use-cases/refresh-entity-options.js +4 -1
  32. package/modules/use-cases/test-module-auth.js +4 -1
  33. package/package.json +5 -5
  34. package/types/modules/index.d.ts +9 -41
  35. package/types/user/index.d.ts +14 -4
  36. package/user/use-cases/get-user-from-adopter-jwt.js +17 -7
  37. package/user/user.js +17 -4
  38. package/modules/repositories/entity-not-found.js +0 -18
package/CLAUDE.md CHANGED
@@ -502,29 +502,6 @@ The single source of the max receive count is
502
502
  devtools integration builder uses it for the queue's `RedrivePolicy` and sets
503
503
  it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
504
504
 
505
- ### Management API v2 notes (`/management-api`)
506
-
507
- - Handlers in `management-api/v2/handlers/` call use cases only; dependencies are wired in
508
- `management-api/v2/dependencies.js`. Errors are `{ error: { code, message, details? } }`.
509
- - `appDefinition.managementApi` is resolved by `resolveManagementApiConfig`, which `loadAppDefinition`
510
- also calls so a bad value stops the app at load. Numeric proxy limits fall back to their defaults;
511
- `proxy.scopes` (ADR-056 §3.5, default `['individual']`) never does: an unknown scope, `'global'`, a
512
- non-list, an empty list or a repeated scope throws `ManagementApiConfigError`
513
- (`MANAGEMENT_API_CONFIG_INVALID`).
514
- - **Entity proxy** (`ExecuteEntityProxyRequest`): visibility in the active organization comes from
515
- `GetEntityModuleForUser` (404), then `assertProxyable` reads the same `{ scope, access }` description
516
- the v2 lists return: `grantsUse(access)` (`use` or `manage`, from `modules/scope-visibility.js`) or 403
517
- `PROXY_NOT_ALLOWED` `reason: 'access'`, and the scope must be in `proxy.scopes` or 403 `reason: 'scope'`.
518
- Do not add a second ownership rule for the proxy; change the description instead.
519
- - **Not visible is 404, and so is missing.** Module repositories throw `entityNotFoundError(id)`
520
- (`modules/repositories/entity-not-found.js`) for a missing row and use cases match it with
521
- `isEntityNotFound`. A 500 for a missing id next to a 404 for another tenant's id tells a caller which
522
- ids exist. A test double that resolves `findEntityById` to `null` hides this: no adapter does that.
523
- - **Tenant isolation on real PostgreSQL**: `management-api/v2/tenant-isolation.postgres.test.js`, run with
524
- `FRIGG_TEST_POSTGRES_URL=<a database it may wipe>`. It applies the migrations, truncates every table
525
- before each test, and drives real adopter-JWT auth through the real router and Prisma repositories.
526
- Without the variable it is skipped (and says so), not passed. See `docs/guides/entity-scopes.md`.
527
-
528
505
  ### 8. Error Handling (`/errors`)
529
506
 
530
507
  **Purpose**: Standardized error types with proper HTTP semantics.
package/README.md CHANGED
@@ -393,7 +393,7 @@ value that is not a three-part JWT is not offered to the verifier at all; it fal
393
393
  | `clockToleranceSec` | `30` | Skew allowed on `exp` and `nbf`. |
394
394
  | `userIdClaim` | `sub` | App user id (required in the token). |
395
395
  | `orgIdClaim` | `org_id` | App organization id (optional). |
396
- | `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). A token with no role is a member for permissions too: it cannot create or change organization-level entities or integrations; `user.orgAdminRoles` (default `['ADMIN']`) names the roles that can. |
396
+ | `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). For permissions the token's role wins; a token with none uses the role stored on the person's membership of the active organization, and is a member when there is none: it cannot create or change organization-level entities or integrations. `user.orgAdminRoles` (default `['ADMIN']`) names the roles that can. Frigg bearer tokens are not gated unless `user.orgAdminGateBearerTokens: true`. |
397
397
 
398
398
  Rules worth knowing:
399
399
 
@@ -1,28 +1,12 @@
1
1
  const Boom = require('@hapi/boom');
2
- const {
3
- createScopePolicy,
4
- describeRecord,
5
- visibilityClauses,
6
- matchesListFilters,
7
- SCOPES,
8
- } = require('../../modules/entity-scope');
2
+ const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
3
+ const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
9
4
  const { assertCanManage } = require('../../modules/use-cases/manage-access');
10
5
 
11
6
  /**
12
- * Management API v2 credential operations (ADR-056 §3.4, §3.6). A credential
13
- * has no module name and no organization of its own:
14
- *
15
- * - its `type` and `entityIds` come from the entities that use it that the
16
- * caller may see, so the same person's account connected in two
17
- * organizations (one credential, two entities) lists in each organization
18
- * with that organization's entity only;
19
- * - who it belongs to is its OWNER (`credential.userId`), never the entities:
20
- * a credential an individual entity and an organization entity both point at
21
- * (possible only through legacy data) is described by whoever owns the
22
- * credential, so the answer cannot change with the order the entities load;
23
- * - a credential whose entities are all out of sight (another organization's)
24
- * is a 404, and one with no entities at all (its entity was deleted) is
25
- * visible to its owner only, as it was before scoping.
7
+ * Management API v2 credential operations. A credential has no module name
8
+ * of its own; its `type` and the entities that use it come from the
9
+ * caller's entities. A missing or foreign credential is a 404.
26
10
  */
27
11
 
28
12
  const credentialIdOf = (entity) => {
@@ -55,82 +39,31 @@ function withEntityUsage(credentials, entities) {
55
39
  });
56
40
  }
57
41
 
58
- /**
59
- * `{scope, owner, organizationId, access}` of a credential for this caller, or
60
- * null when its owner is neither the caller nor the active organization. Built
61
- * by `describeRecord` over the row a connection by this owner has: the
62
- * organization of a visible entity, else the one a new entity would get.
63
- */
64
- function credentialTenancy(credential, visibleEntities, ctx) {
65
- const entityOrg = visibleEntities.find(
66
- (entity) => entity.organizationId !== undefined && entity.organizationId !== null
67
- )?.organizationId;
68
- const organizationId =
69
- entityOrg ?? (ctx.individualAcross === 'all' ? null : ctx.activeOrgId);
70
- return describeRecord({ userId: credential.userId, organizationId }, ctx, 'entity');
71
- }
72
-
73
- /** The entities of `entities` the caller may see, each with its `tenancy`. */
74
- function visibleOf(entities, ctx) {
75
- const visible = [];
76
- for (const entity of entities || []) {
77
- const tenancy = describeRecord(entity, ctx, 'entity');
78
- if (tenancy) visible.push({ ...entity, tenancy });
79
- }
80
- return visible;
81
- }
82
-
83
- /**
84
- * The caller's credentials with their type, the entities of them they may see
85
- * and `tenancy`. Credentials with no entity at all are listed too, for the
86
- * person and the active organization that own them.
87
- */
42
+ /** The caller's credentials with their type and entity usage. */
88
43
  class ListCredentialsForUser {
89
- constructor({ credentialRepository, moduleRepository, scopePolicy }) {
44
+ constructor({ credentialRepository, moduleRepository }) {
90
45
  this.credentialRepository = credentialRepository;
91
46
  this.moduleRepository = moduleRepository;
92
- this.scopePolicy = scopePolicy || createScopePolicy();
93
47
  }
94
48
 
95
- /** @param {{scope?: string, owner?: string}} [filters] */
96
- async execute(user, filters = {}) {
97
- const ctx = this.scopePolicy.accessContext(user);
98
- const owners = [...new Set([ctx.callerId, ctx.activeOrgId].filter(Boolean))];
99
- const [found, perOwner, owned] = await Promise.all([
100
- this.moduleRepository.findEntitiesByOwnership(visibilityClauses(ctx, 'entity')),
101
- Promise.all(owners.map((id) => this.credentialRepository.findCredentialsByUserId(id))),
102
- // Every entity of those owners, in any organization: only to tell a credential
103
- // with no entity at all from one used elsewhere.
104
- Promise.all(owners.map((id) => this.moduleRepository.findEntitiesByUserId(id))),
49
+ async execute(user) {
50
+ const userId = user.getId();
51
+ const [credentials, entities] = await Promise.all([
52
+ this.credentialRepository.findCredentialsByUserId(userId),
53
+ this.moduleRepository.findEntitiesByUserId(userId),
105
54
  ]);
106
- const visible = visibleOf(found, ctx);
107
- const usedAnywhere = new Set(owned.flat().map(credentialIdOf).filter(Boolean));
108
- const listed = [];
109
- for (const credential of perOwner.flat().filter(Boolean)) {
110
- const id = String(credential.id);
111
- const referencing = visible.filter((entity) => credentialIdOf(entity) === id);
112
- if (referencing.length === 0 && usedAnywhere.has(id)) continue;
113
- const tenancy = credentialTenancy(credential, referencing, ctx);
114
- if (!tenancy || !matchesListFilters(tenancy, filters)) continue;
115
- listed.push({ ...withEntityUsage([credential], referencing)[0], tenancy });
116
- }
117
- return listed;
55
+ return withEntityUsage(credentials || [], entities);
118
56
  }
119
57
  }
120
58
 
121
- /** One credential the caller may see: see the header. 404 otherwise. */
59
+ /** One credential the caller owns, with its type and entity usage. */
122
60
  class GetCredentialForUserV2 {
123
- constructor({ credentialRepository, moduleRepository, scopePolicy }) {
61
+ constructor({ credentialRepository, moduleRepository }) {
124
62
  this.credentialRepository = credentialRepository;
125
63
  this.moduleRepository = moduleRepository;
126
- this.scopePolicy = scopePolicy || createScopePolicy();
127
64
  }
128
65
 
129
66
  async execute(credentialId, user) {
130
- const notFound = () =>
131
- Boom.notFound(`Credential ${credentialId} not found`, {
132
- code: 'CREDENTIAL_NOT_FOUND',
133
- });
134
67
  let credential = null;
135
68
  try {
136
69
  credential = await this.credentialRepository.findCredentialById(
@@ -141,32 +74,26 @@ class GetCredentialForUserV2 {
141
74
  throw error;
142
75
  }
143
76
  }
144
- if (!credential) throw notFound();
145
- const ctx = this.scopePolicy.accessContext(user);
146
-
147
- const referencing =
148
- (await this.moduleRepository.findEntities({ credentialId: credential.id })) || [];
149
- const visible = visibleOf(referencing, ctx);
150
- if (referencing.length > 0 && visible.length === 0) throw notFound();
151
- const tenancy = credentialTenancy(credential, visible, ctx);
152
- if (!tenancy) throw notFound();
153
- return { ...withEntityUsage([credential], visible)[0], tenancy };
77
+ if (!credential || !userOwns(user, credential.userId)) {
78
+ throw Boom.notFound(`Credential ${credentialId} not found`, {
79
+ code: 'CREDENTIAL_NOT_FOUND',
80
+ });
81
+ }
82
+ const entities = await this.moduleRepository.findEntitiesByUserId(
83
+ credential.userId
84
+ );
85
+ return withEntityUsage([credential], entities)[0];
154
86
  }
155
87
  }
156
88
 
157
89
  /**
158
- * Deletes a credential the caller may manage. The entities that used it stay,
159
- * with their credential unset, so they show as needing re-authorization rather
90
+ * Deletes a credential the caller owns. The entities that used it stay, with
91
+ * their credential unset, so they show as needing re-authorization rather
160
92
  * than disappearing from integrations.
161
- *
162
- * Refuses with 409 `CREDENTIAL_SHARED` while an entity the caller cannot see
163
- * still uses it: one credential row serves the same provider account in every
164
- * organization, and deleting it from one would break the other. The message
165
- * says nothing about the other connections (not even how many): remove the
166
- * entities that use it, and it goes with the last one.
167
93
  */
168
94
  class DeleteCredentialForUser {
169
- constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
95
+ constructor({ credentialRepository, moduleRepository, getCredentialForUser, scopePolicy }) {
96
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
170
97
  this.credentialRepository = credentialRepository;
171
98
  this.moduleRepository = moduleRepository;
172
99
  this.getCredentialForUser = getCredentialForUser;
@@ -177,15 +104,11 @@ class DeleteCredentialForUser {
177
104
  credentialId,
178
105
  user
179
106
  );
180
- assertCanManage(credential.tenancy.access, `credential ${credential.id}`);
181
- const usedBy =
182
- (await this.moduleRepository.findEntities({ credentialId: credential.id })) || [];
183
- if (usedBy.length > credential.entityIds.length) {
184
- throw Boom.conflict(
185
- `Credential ${credential.id} is still in use by other connections and cannot be deleted from here`,
186
- { code: 'CREDENTIAL_SHARED' }
187
- );
188
- }
107
+ // A credential an organization owns: the organization's admins only.
108
+ // (null: not the person's or the active organization's, which the
109
+ // owner check above already decided.)
110
+ const access = this.scopePolicy.accessToOwnedBy(user, credential.userId);
111
+ if (access !== null) assertCanManage(access, `credential ${credential.id}`);
189
112
  for (const entityId of credential.entityIds) {
190
113
  await this.moduleRepository.unsetCredential(entityId);
191
114
  }
@@ -1,4 +1,5 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
2
3
  const { assertCanManage } = require('../../modules/use-cases/manage-access');
3
4
 
4
5
  /**
@@ -18,9 +19,7 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
18
19
  return {
19
20
  entityType: credential.type,
20
21
  credentialId: String(credential.id),
21
- // A re-authorization keeps the scope the connection already has.
22
- fixedScope: credential.tenancy?.scope,
23
- access: credential.tenancy?.access,
22
+ ownerId: credential.userId,
24
23
  };
25
24
  }
26
25
 
@@ -32,12 +31,14 @@ class GetCredentialReauthorization {
32
31
  }
33
32
 
34
33
  async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
35
- const { entityType, credentialId: boundCredentialId } =
36
- await credentialEntityType(this.getCredentialForUser, credentialId, user);
34
+ const { ownerId, ...binding } = await credentialEntityType(
35
+ this.getCredentialForUser,
36
+ credentialId,
37
+ user
38
+ );
37
39
  return this.getAuthorizationStep.execute({
38
40
  user,
39
- entityType,
40
- credentialId: boundCredentialId,
41
+ ...binding,
41
42
  step,
42
43
  sessionId,
43
44
  state,
@@ -47,19 +48,21 @@ class GetCredentialReauthorization {
47
48
 
48
49
  /** POST /api/v2/credentials/:credentialId/reauthorize */
49
50
  class ReauthorizeCredentialForUser {
50
- constructor({ getCredentialForUser, submitAuthorizationStep }) {
51
+ constructor({ getCredentialForUser, submitAuthorizationStep, scopePolicy }) {
52
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
51
53
  this.getCredentialForUser = getCredentialForUser;
52
54
  this.submitAuthorizationStep = submitAuthorizationStep;
53
55
  }
54
56
 
55
57
  async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
56
- const { access, ...binding } = await credentialEntityType(
58
+ const { ownerId, ...binding } = await credentialEntityType(
57
59
  this.getCredentialForUser,
58
60
  credentialId,
59
61
  user
60
62
  );
61
63
  // A credential an organization owns: the organization's admins only.
62
- assertCanManage(access, `credential ${credentialId}`);
64
+ const access = this.scopePolicy.accessToOwnedBy(user, ownerId);
65
+ if (access !== null) assertCanManage(access, `credential ${credentialId}`);
63
66
  return this.submitAuthorizationStep.execute({
64
67
  user,
65
68
  ...binding,
@@ -12,9 +12,6 @@ const {
12
12
  getEntityTypeDefinitions,
13
13
  } = require('../modules/entity-type-registry');
14
14
  const { resolveOwnershipConfig } = require('../modules/entity-scope');
15
- const {
16
- resolveManagementApiConfig,
17
- } = require('../management-api/management-api-config');
18
15
 
19
16
  // Handlers without a database never load the encryption registry, so the
20
17
  // credential leaf keys are registered for redaction here too.
@@ -42,7 +39,6 @@ function registerCredentialLogKeys(appDefinition, moduleDefinitions) {
42
39
  * @throws {Error} Throws error if index.js file cannot be found in the backend directory.
43
40
  * @throws {Error} Throws error if user.authModes.adopterJwt is enabled without a usable user.jwtConfig.
44
41
  * @throws {OwnershipConfigError} Throws if an ownership or scope setting is invalid (ADR-056 §3.1).
45
- * @throws {ManagementApiConfigError} Throws if `managementApi.proxy.scopes` is invalid (ADR-056 §3.5).
46
42
  * @example
47
43
  * const { integrations, userConfig, telemetry } = loadAppDefinition();
48
44
  * console.log(`Found ${integrations.length} integrations`);
@@ -88,11 +84,6 @@ function loadAppDefinition() {
88
84
  // individual entity stops the app at load.
89
85
  const ownership = resolveOwnershipConfig(appDefinition, entityTypeRegistry);
90
86
 
91
- // And for the Management API: `managementApi.proxy.scopes` decides whose
92
- // credentials the entity proxy may act through (ADR-056 §3.5), so an
93
- // unknown scope stops the app here rather than being read as a default.
94
- resolveManagementApiConfig(managementApi);
95
-
96
87
  registerCredentialLogKeys(appDefinition, moduleDefinitions);
97
88
 
98
89
  // Degrade consistently: an invalid telemetry block must never take down a
package/index.js CHANGED
@@ -71,6 +71,7 @@ const {
71
71
  Options,
72
72
  createIntegrationRouter,
73
73
  checkRequiredParams,
74
+ getModulesDefinitionFromIntegrationClasses,
74
75
  LoadIntegrationContextUseCase,
75
76
  } = require('./integrations/index');
76
77
  const {
@@ -112,9 +113,6 @@ const {
112
113
  listBindingScopes,
113
114
  OwnershipConfigError,
114
115
  } = require('./modules/index');
115
- const {
116
- ManagementApiConfigError,
117
- } = require('./management-api/management-api-config');
118
116
  const application = require('./application');
119
117
  const utils = require('./utils');
120
118
 
@@ -169,6 +167,8 @@ module.exports = {
169
167
  Options,
170
168
  checkRequiredParams,
171
169
  createIntegrationRouter,
170
+ // Deprecated (ADR-059): use getEntityTypeRegistry.
171
+ getModulesDefinitionFromIntegrationClasses,
172
172
  LoadIntegrationContextUseCase,
173
173
  CreateProcess,
174
174
  UpdateProcessState,
@@ -229,7 +229,6 @@ module.exports = {
229
229
  resolveScope,
230
230
  listBindingScopes,
231
231
  OwnershipConfigError,
232
- ManagementApiConfigError,
233
232
  // queues
234
233
  QueuerUtil,
235
234
 
@@ -4,6 +4,9 @@ const {
4
4
  createIntegrationRouter,
5
5
  checkRequiredParams,
6
6
  } = require('./integration-router');
7
+ const {
8
+ getModulesDefinitionFromIntegrationClasses,
9
+ } = require('./utils/map-integration-dto');
7
10
  const {
8
11
  LoadIntegrationContextUseCase,
9
12
  } = require('./use-cases/load-integration-context');
@@ -18,6 +21,8 @@ module.exports = {
18
21
  Options,
19
22
  createIntegrationRouter,
20
23
  checkRequiredParams,
24
+ // Deprecated (ADR-059): a wrapper over getEntityTypeRegistry.
25
+ getModulesDefinitionFromIntegrationClasses,
21
26
  LoadIntegrationContextUseCase,
22
27
  validateExtensionBinding,
23
28
  getExtensionRoutes,
@@ -65,6 +65,9 @@ const { createScopePolicy } = require('../modules/scope-policy');
65
65
  const {
66
66
  AuthorizeEntityForUser,
67
67
  } = require('../modules/use-cases/authorize-entity-for-user');
68
+ const {
69
+ CreateEntityFromCredential,
70
+ } = require('../modules/use-cases/create-entity-from-credential');
68
71
  const {
69
72
  ResolveIntegrationOwnership,
70
73
  } = require('./use-cases/resolve-integration-ownership');
@@ -199,14 +202,22 @@ function createIntegrationRouter() {
199
202
  });
200
203
 
201
204
  // The owner of what an authorization creates follows its scope (ADR-056 §3.2).
205
+ const processAuthorizationCallback = new ProcessAuthorizationCallback({
206
+ moduleRepository,
207
+ credentialRepository,
208
+ integrationRepository,
209
+ moduleDefinitions,
210
+ });
202
211
  const authorizeEntityForUser = new AuthorizeEntityForUser({
203
212
  scopePolicy,
204
- processAuthorizationCallback: new ProcessAuthorizationCallback({
205
- moduleRepository,
206
- credentialRepository,
207
- integrationRepository,
208
- moduleDefinitions,
209
- }),
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,
210
221
  });
211
222
 
212
223
  const router = express();
@@ -231,6 +242,7 @@ function createIntegrationRouter() {
231
242
  getEntityOptionsById,
232
243
  refreshEntityOptions,
233
244
  authorizeEntityForUser,
245
+ createEntityFromCredential,
234
246
  });
235
247
  return router;
236
248
  }
@@ -529,6 +541,7 @@ function setEntityRoutes(router, authenticateUser, useCases) {
529
541
  getEntityOptionsById,
530
542
  refreshEntityOptions,
531
543
  authorizeEntityForUser,
544
+ createEntityFromCredential,
532
545
  } = useCases;
533
546
 
534
547
  router.route('/api/authorize').get(
@@ -603,35 +616,20 @@ function setEntityRoutes(router, authenticateUser, useCases) {
603
616
  router.route('/api/entity').post(
604
617
  catchAsyncError(async (req, res) => {
605
618
  const user = await authenticateUser.execute(req);
606
- const userId = user.getId();
607
619
  const params = checkRequiredParams(req.body, [
608
620
  'entityType',
609
621
  'data',
610
622
  ]);
611
623
  checkRequiredParams(req.body.data, ['credential_id']);
612
624
 
613
- // May want to pass along the user ID as well so credential ID's can't be fished???
614
- const credential = await getCredentialForUser.execute(
615
- params.data.credential_id,
616
- userId
617
- );
618
-
619
- if (!credential) {
620
- throw Boom.badRequest('Invalid credential ID');
621
- }
622
-
623
- const module = await getModuleInstanceFromType.execute(
624
- userId,
625
- params.entityType
626
- );
627
- const entityDetails = await module.getEntityDetails(
628
- module.api,
629
- null,
630
- null,
631
- 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
+ })
632
632
  );
633
-
634
- res.json(await module.findOrCreateEntity(entityDetails));
635
633
  })
636
634
  );
637
635
 
@@ -40,7 +40,8 @@ class IntegrationRepositoryDocumentDB extends IntegrationRepositoryInterface {
40
40
  */
41
41
  async findIntegrationsByOwnership(clauses) {
42
42
  if (!Array.isArray(clauses) || clauses.length === 0) return [];
43
- const records = await findMany(this.prisma, 'Integration', {
43
+ // Drained: a list can hold more than the first cursor batch (~101).
44
+ const records = await findManyDrained(this.prisma, 'Integration', {
44
45
  $or: clauses.map(({ userId, organizationId }) => ({
45
46
  userId: toObjectId(userId),
46
47
  organizationId:
@@ -19,5 +19,48 @@ function mapIntegrationClassToIntegrationDTO(integration) {
19
19
  };
20
20
  }
21
21
 
22
+ let deprecationLogged = false;
22
23
 
23
- module.exports = { mapIntegrationClassToIntegrationDTO };
24
+ /**
25
+ * @deprecated Since ADR-059. Read `entityTypeRegistry` / `moduleDefinitions`
26
+ * from `loadAppDefinition()`, or call `getEntityTypeRegistry(appDefinition)`
27
+ * and read each entry's `definition`.
28
+ *
29
+ * Kept as a wrapper over the entity type registry so callers that imported it
30
+ * keep working. It returns the registry's definitions, so it follows the
31
+ * registry's rules: one definition per entity type name (the first wins when
32
+ * two integrations bind different definitions under one name, as
33
+ * ModuleFactory already picked), and, when `entities` is passed, registry-only
34
+ * types are included and conflicts throw `EntityRegistryError`.
35
+ *
36
+ * @param {Array<Function>} integrationClasses - integration classes
37
+ * @param {Object} [entities] - `appDefinition.entities` (ADR-059)
38
+ * @returns {Array<Object>} API module definitions
39
+ */
40
+ function getModulesDefinitionFromIntegrationClasses(
41
+ integrationClasses,
42
+ entities
43
+ ) {
44
+ // Required here, not at the top: modules/ must not load at require time
45
+ // of every integration use case that only wants the DTO mapper.
46
+ const {
47
+ getEntityTypeRegistry,
48
+ getEntityTypeDefinitions,
49
+ } = require('../../modules/entity-type-registry');
50
+ if (!deprecationLogged) {
51
+ deprecationLogged = true;
52
+ const { getLogger } = require('../../logs');
53
+ getLogger('frigg.integrations').warn(
54
+ 'getModulesDefinitionFromIntegrationClasses is deprecated; use getEntityTypeRegistry (ADR-059)',
55
+ { eventName: 'frigg.integrations.deprecated_module_definitions' }
56
+ );
57
+ }
58
+ return getEntityTypeDefinitions(
59
+ getEntityTypeRegistry({ integrations: integrationClasses, entities })
60
+ );
61
+ }
62
+
63
+ module.exports = {
64
+ mapIntegrationClassToIntegrationDTO,
65
+ getModulesDefinitionFromIntegrationClasses,
66
+ };
@@ -9,7 +9,6 @@
9
9
  * timeoutMs: 25000, // whole upstream call, lower only
10
10
  * maxRequestBytes: 1048576,
11
11
  * maxResponseBytes: 5242880, // lower only
12
- * scopes: ['individual'], // entity scopes the proxy accepts (ADR-056 §3.5)
13
12
  * modules: { // per-module allow-list override
14
13
  * hubspot: { allow: 'module' },
15
14
  * erp: { allow: [{ method: 'GET', path: '/v1/customers/**' }] },
@@ -18,36 +17,10 @@
18
17
  * }
19
18
  * ```
20
19
  *
21
- * Limits that are out of range fall back to their defaults. `proxy.scopes` is a
22
- * security setting, so it does not fall back: a value that is not a list of
23
- * known tenant scopes throws ManagementApiConfigError, and loadAppDefinition
24
- * calls this so the app stops at load.
20
+ * TODO(F6): `proxy.scopes` (ADR-056 §3.5) is not read yet; see
21
+ * ExecuteEntityProxyRequest.
25
22
  */
26
23
 
27
- const { BaseError } = require('../errors/base-error');
28
- const { SCOPES } = require('../modules/scope-resolution');
29
-
30
- /** Raised for an invalid `appDefinition.managementApi` setting. */
31
- class ManagementApiConfigError extends BaseError {
32
- constructor(message, options) {
33
- super(message, options);
34
- this.code = 'MANAGEMENT_API_CONFIG_INVALID';
35
- }
36
- }
37
-
38
- /**
39
- * The scopes `managementApi.proxy.scopes` may list. `global` is not one of
40
- * them: a global entity is the app owner's and is never a tenant's to proxy.
41
- */
42
- const PROXY_SCOPE_VALUES = Object.freeze([SCOPES.INDIVIDUAL, SCOPES.ORGANIZATION]);
43
-
44
- /**
45
- * Narrowest by default (ADR-056 §3.5): proxying through a shared credential
46
- * lets any member act on the organization's account, so an app opts in to
47
- * `organization` explicitly.
48
- */
49
- const DEFAULT_PROXY_SCOPES = Object.freeze([SCOPES.INDIVIDUAL]);
50
-
51
24
  // ADR-052 §7. API Gateway HTTP APIs time out at 30s and Lambda responses cap
52
25
  // at 6MB, so the timeout and response defaults are also ceilings: an app may
53
26
  // lower them, not raise them.
@@ -74,41 +47,9 @@ function positiveOr(value, fallback, ceiling) {
74
47
  return isPositiveNumber(value) ? Math.min(value, ceiling) : fallback;
75
48
  }
76
49
 
77
- const proxyScopesError = (message) =>
78
- new ManagementApiConfigError(`managementApi.proxy.scopes: ${message}`);
79
-
80
- /**
81
- * `managementApi.proxy.scopes`: absent means the default; anything else must be
82
- * a non-empty list of PROXY_SCOPE_VALUES, each once.
83
- * @returns {ReadonlyArray<string>}
84
- * @throws {ManagementApiConfigError}
85
- */
86
- function resolveProxyScopes(scopes) {
87
- if (scopes === undefined) return DEFAULT_PROXY_SCOPES;
88
- const allowed = PROXY_SCOPE_VALUES.join(', ');
89
- if (!Array.isArray(scopes) || scopes.length === 0) {
90
- throw proxyScopesError(`must be a non-empty list drawn from ${allowed}`);
91
- }
92
- for (const scope of scopes) {
93
- if (scope === SCOPES.GLOBAL) {
94
- throw proxyScopesError(
95
- "'global' is not allowed: global entities belong to the app owner and are never proxied for a tenant"
96
- );
97
- }
98
- if (!PROXY_SCOPE_VALUES.includes(scope)) {
99
- throw proxyScopesError(`unknown scope ${JSON.stringify(scope)}; use ${allowed}`);
100
- }
101
- }
102
- if (new Set(scopes).size !== scopes.length) {
103
- throw proxyScopesError('lists a scope more than once');
104
- }
105
- return Object.freeze([...scopes]);
106
- }
107
-
108
50
  /**
109
51
  * @param {Object} [managementApi] - `appDefinition.managementApi`
110
- * @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, scopes: ReadonlyArray<string>, modules: Object } }}
111
- * @throws {ManagementApiConfigError} for an invalid `proxy.scopes`
52
+ * @returns {{ v1: boolean, proxy: { enable: boolean, timeoutMs: number, maxRequestBytes: number, maxResponseBytes: number, modules: Object } }}
112
53
  */
113
54
  function resolveManagementApiConfig(managementApi) {
114
55
  const config =
@@ -133,7 +74,6 @@ function resolveManagementApiConfig(managementApi) {
133
74
  proxy.maxResponseBytes,
134
75
  DEFAULT_PROXY_LIMITS.maxResponseBytes
135
76
  ),
136
- scopes: resolveProxyScopes(proxy.scopes),
137
77
  modules:
138
78
  proxy.modules && typeof proxy.modules === 'object'
139
79
  ? proxy.modules
@@ -142,10 +82,4 @@ function resolveManagementApiConfig(managementApi) {
142
82
  };
143
83
  }
144
84
 
145
- module.exports = {
146
- resolveManagementApiConfig,
147
- DEFAULT_PROXY_LIMITS,
148
- DEFAULT_PROXY_SCOPES,
149
- PROXY_SCOPE_VALUES,
150
- ManagementApiConfigError,
151
- };
85
+ module.exports = { resolveManagementApiConfig, DEFAULT_PROXY_LIMITS };
@@ -206,7 +206,6 @@ const SCHEMAS = {
206
206
  authIsValid: nullable({ type: 'boolean' }),
207
207
  entityIds: { type: 'array', items: { type: 'string' } },
208
208
  entityCount: { type: 'integer', minimum: 0 },
209
- ...tenancyProperties,
210
209
  createdAt: nullable({ type: 'string', format: 'date-time' }),
211
210
  updatedAt: nullable({ type: 'string', format: 'date-time' }),
212
211
  data: {