@friggframework/core 2.0.0--canary.682.451c957.0 → 2.0.0--canary.666.89f6545.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 (173) hide show
  1. package/CLAUDE.md +41 -3
  2. package/README.md +0 -105
  3. package/application/commands/README.md +17 -11
  4. package/application/commands/entity-commands.js +18 -60
  5. package/application/commands/integration-commands.js +73 -20
  6. package/application/commands/user-commands.js +5 -17
  7. package/application/index.js +2 -6
  8. package/credential/repositories/credential-repository-documentdb.js +0 -34
  9. package/credential/repositories/credential-repository-interface.js +0 -15
  10. package/credential/repositories/credential-repository-mongo.js +0 -23
  11. package/credential/repositories/credential-repository-postgres.js +0 -28
  12. package/database/documentdb-encryption-service.js +2 -33
  13. package/database/documentdb-utils.js +0 -2
  14. package/database/encryption/README.md +3 -3
  15. package/database/encryption/ciphertext-format.js +24 -0
  16. package/database/encryption/encryption-schema-registry.js +109 -42
  17. package/database/encryption/field-encryption-service.js +13 -16
  18. package/database/prisma.js +10 -1
  19. package/errors/index.js +4 -0
  20. package/errors/mapping-already-exists-error.js +14 -0
  21. package/generated/prisma-mongodb/edge.js +4 -31
  22. package/generated/prisma-mongodb/index-browser.js +1 -28
  23. package/generated/prisma-mongodb/index.d.ts +74 -3463
  24. package/generated/prisma-mongodb/index.js +4 -31
  25. package/generated/prisma-mongodb/package.json +1 -1
  26. package/generated/prisma-mongodb/schema.prisma +0 -74
  27. package/generated/prisma-mongodb/wasm.js +4 -31
  28. package/generated/prisma-postgresql/edge.js +4 -31
  29. package/generated/prisma-postgresql/index-browser.js +1 -28
  30. package/generated/prisma-postgresql/index.d.ts +51 -3631
  31. package/generated/prisma-postgresql/index.js +4 -31
  32. package/generated/prisma-postgresql/package.json +1 -1
  33. package/generated/prisma-postgresql/schema.prisma +0 -70
  34. package/generated/prisma-postgresql/wasm.js +4 -31
  35. package/handlers/WEBHOOKS.md +29 -0
  36. package/handlers/app-definition-loader.js +16 -34
  37. package/handlers/app-handler-helpers.js +3 -25
  38. package/handlers/backend-utils.js +25 -31
  39. package/handlers/middleware/admin-auth.js +1 -14
  40. package/handlers/routers/auth.js +3 -11
  41. package/handlers/routers/health.js +4 -11
  42. package/handlers/workers/integration-defined-workers.js +2 -5
  43. package/index.js +2 -23
  44. package/infrastructure/scheduler/eventbridge-scheduler-adapter.js +67 -13
  45. package/integrations/index.js +0 -1
  46. package/integrations/integration-base.js +29 -0
  47. package/integrations/integration-router.js +87 -129
  48. package/integrations/repositories/integration-mapping-repository-documentdb.js +50 -0
  49. package/integrations/repositories/integration-mapping-repository-interface.js +15 -0
  50. package/integrations/repositories/integration-mapping-repository-mongo.js +29 -0
  51. package/integrations/repositories/integration-mapping-repository-postgres.js +30 -0
  52. package/integrations/repositories/integration-mapping-repository.js +29 -0
  53. package/integrations/repositories/integration-repository-documentdb.js +35 -35
  54. package/integrations/repositories/integration-repository-interface.js +31 -17
  55. package/integrations/repositories/integration-repository-mongo.js +129 -60
  56. package/integrations/repositories/integration-repository-postgres.js +134 -62
  57. package/integrations/repositories/process-repository-mongo.js +1 -1
  58. package/integrations/tests/doubles/test-integration-repository.js +1 -12
  59. package/integrations/use-cases/add-entities-to-integration.js +92 -0
  60. package/integrations/use-cases/create-integration.js +75 -72
  61. package/integrations/use-cases/get-integrations-for-user.js +40 -80
  62. package/integrations/use-cases/index.js +1 -5
  63. package/integrations/use-cases/remove-entities-from-integration.js +70 -0
  64. package/integrations/utils/assert-entity-ids-input.js +42 -0
  65. package/integrations/utils/map-integration-dto.js +15 -47
  66. package/jest-setup.js +13 -2
  67. package/jest-teardown.js +5 -2
  68. package/jest.config.js +4 -0
  69. package/jest.db.config.js +23 -0
  70. package/modules/index.js +0 -14
  71. package/modules/repositories/module-repository-documentdb.js +4 -48
  72. package/modules/repositories/module-repository-interface.js +2 -19
  73. package/modules/repositories/module-repository-mongo.js +73 -65
  74. package/modules/repositories/module-repository-postgres.js +73 -64
  75. package/modules/requester/requester.js +2 -27
  76. package/modules/use-cases/create-entity-from-credential.js +28 -36
  77. package/modules/use-cases/get-entities-for-user.js +5 -16
  78. package/modules/use-cases/get-entity-options-by-id.js +3 -9
  79. package/modules/use-cases/get-module.js +24 -19
  80. package/modules/use-cases/process-authorization-callback.js +18 -52
  81. package/modules/use-cases/refresh-entity-options.js +3 -9
  82. package/modules/use-cases/test-module-auth.js +3 -9
  83. package/modules/utils/map-module-dto.js +6 -10
  84. package/modules/utils/to-credential-id.js +14 -0
  85. package/package.json +8 -6
  86. package/prisma-mongodb/schema.prisma +0 -74
  87. package/prisma-postgresql/schema.prisma +0 -70
  88. package/queues/queuer-util.js +6 -5
  89. package/types/index.d.ts +0 -2
  90. package/user/repositories/user-repository-documentdb.js +2 -27
  91. package/user/repositories/user-repository-interface.js +0 -18
  92. package/user/repositories/user-repository-mongo.js +0 -24
  93. package/user/repositories/user-repository-postgres.js +0 -18
  94. package/user/tests/doubles/test-user-repository.js +1 -1
  95. package/user/use-cases/get-user-from-adopter-jwt.js +125 -86
  96. package/user/use-cases/get-user-from-bearer-token.js +7 -2
  97. package/user/use-cases/get-user-from-x-frigg-headers.js +80 -83
  98. package/user/user.js +0 -27
  99. package/credential/use-cases/credential-operations-for-user.js +0 -117
  100. package/credential/use-cases/reauthorize-credential-for-user.js +0 -66
  101. package/database/organization-where.js +0 -18
  102. package/database/unique-violation.js +0 -25
  103. package/integrations/repositories/integration-not-found.js +0 -14
  104. package/integrations/use-cases/integration-operations-for-user.js +0 -246
  105. package/integrations/use-cases/resolve-integration-ownership.js +0 -177
  106. package/management-api/create-management-api-router.js +0 -49
  107. package/management-api/error-response.js +0 -116
  108. package/management-api/management-api-config.js +0 -85
  109. package/management-api/meta-router.js +0 -57
  110. package/management-api/openapi/build-openapi.js +0 -144
  111. package/management-api/openapi/components.js +0 -407
  112. package/management-api/openapi/v1-operations.js +0 -83
  113. package/management-api/openapi/v2-operations.js +0 -261
  114. package/management-api/route-registry.js +0 -294
  115. package/management-api/test-support/http-client.js +0 -39
  116. package/management-api/use-cases/get-management-api-meta.js +0 -57
  117. package/management-api/v1-deprecation.js +0 -120
  118. package/management-api/v2/create-v2-router.js +0 -85
  119. package/management-api/v2/dependencies.js +0 -279
  120. package/management-api/v2/dto.js +0 -143
  121. package/management-api/v2/handlers/authorization.js +0 -94
  122. package/management-api/v2/handlers/credentials.js +0 -29
  123. package/management-api/v2/handlers/entities.js +0 -77
  124. package/management-api/v2/handlers/integrations.js +0 -128
  125. package/management-api/v2/handlers/proxy.js +0 -34
  126. package/management-api/v2/request.js +0 -85
  127. package/management-api/v2/test-support/fake-dependencies.js +0 -103
  128. package/modules/domain/authorization-session.js +0 -144
  129. package/modules/entity-scope.js +0 -15
  130. package/modules/entity-type-registry.js +0 -311
  131. package/modules/proxy/proxy-errors.js +0 -20
  132. package/modules/proxy/proxy-path.js +0 -179
  133. package/modules/proxy/proxy-policy.js +0 -248
  134. package/modules/repositories/authorization-session-repository-documentdb.js +0 -112
  135. package/modules/repositories/authorization-session-repository-factory.js +0 -31
  136. package/modules/repositories/authorization-session-repository-interface.js +0 -47
  137. package/modules/repositories/authorization-session-repository-prisma.js +0 -87
  138. package/modules/requester/proxy-request.js +0 -244
  139. package/modules/scope-config.js +0 -155
  140. package/modules/scope-policy.js +0 -80
  141. package/modules/scope-resolution.js +0 -277
  142. package/modules/scope-visibility.js +0 -165
  143. package/modules/tests/doubles/in-memory-authorization-session-repository.js +0 -45
  144. package/modules/tests/doubles/ownership-matching.js +0 -18
  145. package/modules/use-cases/authorize-entity-for-user.js +0 -59
  146. package/modules/use-cases/entity-operations-for-user.js +0 -157
  147. package/modules/use-cases/entity-types.js +0 -139
  148. package/modules/use-cases/execute-entity-proxy-request.js +0 -254
  149. package/modules/use-cases/get-authorization-requirements.js +0 -100
  150. package/modules/use-cases/get-entity-module-for-user.js +0 -106
  151. package/modules/use-cases/multi-step-authorization.js +0 -326
  152. package/modules/utils/public-entity.js +0 -59
  153. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +0 -28
  154. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +0 -26
  155. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +0 -25
  156. package/types/modules/index.d.ts +0 -155
  157. package/types/user/index.d.ts +0 -117
  158. package/user/adopter-jwt-config.js +0 -177
  159. package/user/adopter-jwt-errors.js +0 -59
  160. package/user/adopter-token-verifier.js +0 -122
  161. package/user/app-user-helpers.js +0 -97
  162. package/user/create-user-authentication.js +0 -64
  163. package/user/membership.js +0 -51
  164. package/user/repositories/organization-membership-record.js +0 -19
  165. package/user/repositories/organization-membership-repository-documentdb.js +0 -106
  166. package/user/repositories/organization-membership-repository-factory.js +0 -42
  167. package/user/repositories/organization-membership-repository-interface.js +0 -57
  168. package/user/repositories/organization-membership-repository-mongo.js +0 -13
  169. package/user/repositories/organization-membership-repository-postgres.js +0 -29
  170. package/user/repositories/organization-membership-repository-prisma.js +0 -81
  171. package/user/tests/doubles/adopter-jwt-fixtures.js +0 -220
  172. package/user/use-cases/ensure-organization-membership.js +0 -92
  173. package/user/use-cases/find-individual-users-by-organization.js +0 -54
package/CLAUDE.md CHANGED
@@ -206,11 +206,51 @@ packages/core/
206
206
  - `delete-integration-for-user.js` - Remove integration
207
207
  - `get-integration-instance.js` - Load integration with modules
208
208
  - `load-integration-context.js` - Full integration context loading
209
+ - `add-entities-to-integration.js` / `remove-entities-from-integration.js` - Attach or detach entities on an existing integration
209
210
 
210
211
  **Repositories**:
211
212
  - `integration-repository-factory.js` - Creates database-specific repositories
212
213
  - `integration-repository-mongo.js` - MongoDB implementation
213
214
  - `integration-repository-postgres.js` - PostgreSQL implementation
215
+ - `addEntitiesToIntegration(integrationId, entityIds)` /
216
+ `removeEntitiesFromIntegration(integrationId, entityIds)` change the entity
217
+ set of an existing integration (all three adapters; `null` for an unknown
218
+ integration). Reach them through the `addEntitiesToIntegration` /
219
+ `removeEntitiesFromIntegration` commands. The add use case
220
+ (`add-entities-to-integration.js`) rejects unknown entities
221
+ (`ENTITIES_NOT_FOUND`, 404), entities of another user
222
+ (`ENTITY_USER_MISMATCH`, 403) and entities of a module the integration does
223
+ not already hold (`ENTITY_MODULE_MISMATCH`, 400). The remove use case
224
+ (`remove-entities-from-integration.js`) refuses to leave a module with no
225
+ entity (`LAST_ENTITY_OF_MODULE`, 409). Re-adding an attached entity or
226
+ removing a detached one changes nothing; a detached entity is not deleted.
227
+ `integration-repository-entities-parity.db.test.js` runs every adapter against
228
+ real databases when `INTEGRATION_ENTITIES_PARITY_MONGO_URL` /
229
+ `INTEGRATION_ENTITIES_PARITY_POSTGRES_URL` are set. The DocumentDB adapter
230
+ writes only `Integration.entityIds` (as its `createIntegration` does), not
231
+ `Entity.integrationIds`.
232
+ **One credential, many accounts**: when one credential covers several
233
+ provider accounts, give each account its own entity (externalId = account
234
+ id) and attach them all to one integration; webhooks then route with
235
+ `findIntegrationByEntityExternalId`. Traps:
236
+ (1) `IntegrationBase` keys modules by module name, so `this.<moduleKey>`
237
+ holds only one entity's module, and on the queue/webhook path
238
+ (`get-integration-instance.js`) `this.entities` holds entity **ids**, not
239
+ records. For per-account work (a webhook for one account, teardown of every
240
+ account in `onDelete`), load
241
+ `this.commands.loadIntegrationContextById(this.id)` and use
242
+ `context.modules`: one module per entity, each with its record on
243
+ `module.entity` (pick by `module.entity.externalId`).
244
+ (2) `findIntegrationByEntityExternalId` counts every entity with that
245
+ externalId, attached or not, across users: reuse an existing entity
246
+ (`findEntity`) when an account comes back, or delete it when it is revoked,
247
+ never create a second one.
248
+ (3) `createIntegration` reuses an existing integration of the same user
249
+ and type whose entities include all requested ones (an exact match wins),
250
+ so a reconnect that sends the original entities finds the integration that
251
+ now holds more.
252
+ (4) Every module's delegate is the integration: an auth failure on any
253
+ account marks the shared credential invalid and the integration ERROR.
214
254
  - `integration-mapping-repository-*.js` - Mapping data persistence.
215
255
  `queryMappings(integrationId, { where, orderBy, skip, take, omit })`
216
256
  returns `{ mappings, total }`: one filtered, ordered page of an
@@ -226,7 +266,7 @@ packages/core/
226
266
  mapping keys to leave out of the rows; never write such rows back. Path
227
267
  segments must match `^[A-Za-z_][A-Za-z0-9_]*$`. The PostgreSQL, MongoDB and
228
268
  DocumentDB adapters give the same pages and totals;
229
- `integration-mapping-repository-query-parity.test.js` checks that against
269
+ `integration-mapping-repository-query-parity.db.test.js` checks that against
230
270
  real databases when `QUERY_MAPPINGS_PARITY_MONGO_URL` /
231
271
  `QUERY_MAPPINGS_PARITY_POSTGRES_URL` are set. Two orderings still differ:
232
272
  strings compare by the database collation on PostgreSQL and by code point
@@ -332,14 +372,12 @@ class MyIntegration extends IntegrationBase {
332
372
  - Password-based (bcrypt hashed)
333
373
  - Token-based (Bearer tokens)
334
374
  - App-based (external app user IDs)
335
- - Adopter JWT (`authModes.adopterJwt`, `jwtConfig`; `jose`, JWKS or HMAC; see `README.md`)
336
375
 
337
376
  **Use Cases**:
338
377
  - `login-user.js` - User authentication
339
378
  - `create-individual-user.js` - Create personal account
340
379
  - `create-organization-user.js` - Create business account
341
380
  - `get-user-from-bearer-token.js` - Token authentication
342
- - `get-user-from-adopter-jwt.js` - Adopter JWT verification, find-or-create (ADR-056 §2); `adopter-jwt-config.js` validates `jwtConfig` at load
343
381
 
344
382
  **Repositories**:
345
383
  - `user-repository-factory.js` - Creates database-specific repositories
package/README.md CHANGED
@@ -305,7 +305,6 @@ Comprehensive user authentication and authorization system supporting both indiv
305
305
  - **Password-based**: Traditional username/password authentication
306
306
  - **Token-based**: Bearer token authentication with session management
307
307
  - **App-based**: External app user ID authentication (passwordless)
308
- - **Adopter token (JWT)**: A token issued by your own identity system, verified by Frigg and safe to send from a browser (see below)
309
308
 
310
309
  **Usage:**
311
310
  ```javascript
@@ -353,110 +352,6 @@ console.log('Individual user:', user.getIndividualUser());
353
352
  console.log('Organization user:', user.getOrganizationUser());
354
353
  ```
355
354
 
356
- #### Adopter token (JWT) authentication
357
-
358
- For a multi-tenant product (ADR-056 §2), let the browser send the token your identity system
359
- already issues. Frigg verifies the signature and claims, then finds or creates the individual
360
- user and the organization user the token names. Unlike `sharedSecret` (`x-frigg-*` headers), a
361
- token cannot name a user other than the one it was issued for, so it is safe in a browser.
362
-
363
- ```javascript
364
- // backend/index.js
365
- const appDefinition = {
366
- user: {
367
- primary: 'organization',
368
- individualUserRequired: true,
369
- organizationUserRequired: true,
370
- authModes: { adopterJwt: true, friggToken: false, sharedSecret: false },
371
- jwtConfig: {
372
- jwksUri: 'https://login.example.com/.well-known/jwks.json',
373
- issuer: 'https://login.example.com/',
374
- audience: 'my-frigg-api', // required with jwksUri
375
- },
376
- },
377
- };
378
- ```
379
-
380
- The client sends `Authorization: Bearer <token>`. `x-frigg-appuserid` / `x-frigg-apporgid`, if
381
- also sent, must match the token or the request is refused with 403. That match rule lives in
382
- `user/use-cases/authenticate-user.js` (`validateUserMatch`), not in the token verifier. A bearer
383
- value that is not a three-part JWT is not offered to the verifier at all; it falls through to
384
- `friggToken` (or is a 401 when that mode is off).
385
-
386
- | `jwtConfig` field | Default | Meaning |
387
- |---|---|---|
388
- | `jwksUri` | | Remote JWKS (rotating keys, one per verifier, cached by jose). Must be `https`; `http` only for `localhost`, `127.0.0.0/8` or `::1`. Set exactly one of this and `secret`. |
389
- | `secret` | | Shared HMAC secret. Read it from an environment variable. |
390
- | `issuer` | not checked | Required `iss`. With `jwksUri`, a startup warning is logged when it is unset. |
391
- | `audience` | not checked with `secret`; **required with `jwksUri`** | Accepted `aud` (a string or a list). |
392
- | `algorithms` | `['ES256','RS256']` with `jwksUri`, `['HS256']` with `secret` | Allow-list. HS* is only accepted with `secret`, never with `jwksUri`. The older single `algorithm` is a deprecated alias; setting both is an error. |
393
- | `clockToleranceSec` | `30` | Skew allowed on `exp` and `nbf`. |
394
- | `userIdClaim` | `sub` | App user id (required in the token). |
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). |
397
-
398
- Rules worth knowing:
399
-
400
- - Tokens must carry `exp`. `alg: none` and any algorithm outside the allow-list are refused.
401
- - Startup fails (`loadAppDefinition` throws) when `adopterJwt` is on and the config is unusable:
402
- neither or both of `jwksUri` and `secret`, a `jwksUri` that is not https (or loopback http),
403
- no `audience` with `jwksUri`, or an algorithm list that does not fit the key type.
404
- - Failures are `401` with a stable `data.reason`: `token_expired`, `token_not_yet_valid`,
405
- `token_invalid_signature`, `token_wrong_issuer`, `token_wrong_audience`,
406
- `token_missing_user_claim`, `token_malformed`. The message never contains the token or its claims.
407
- Any other verification failure, such as an unreachable or unresolvable JWKS, is `503`
408
- `jwks_unavailable`, not a 401, so clients do not drop good sessions.
409
- - **The token's organization is the request's active organization**, and a person can belong to
410
- several (ADR-056 §3.6). The first time a person presents a token for an organization they are
411
- not a member of, Frigg adds an `OrganizationMembership` and the request runs as that
412
- organization (`user.getActiveOrganizationId()`; `user.getId()` under `primary: 'organization'`).
413
- Nothing is moved: `IndividualUser.organizationId` is a legacy pointer, set only on a person's
414
- first link and read as an implicit membership. The same applies to `x-frigg-apporgid` with
415
- `sharedSecret`. With `user.strictMembership: true` those headers cannot add anyone to an organization
416
- that already existed (400 for a non-member, a brand-new `appUserId` included); only an organization
417
- the request itself creates is allowed. `strictUserValidation` is a deprecated alias with that same
418
- meaning. See
419
- [Organization memberships](../../docs/guides/organization-memberships.md). A token naming a second
420
- organization is no longer a `403` (`multi_org_unsupported` is gone).
421
- - Two first logins for the same new person or organization, even in different organizations, both
422
- succeed: a create that loses to a unique constraint finds the winner's row.
423
-
424
- #### Entity scopes and ownership (ADR-056 §3)
425
-
426
- Who owns an entity (a connected account) or an integration follows its **scope**, not `user.primary`:
427
-
428
- | Scope | Owned by | Created with |
429
- |---|---|---|
430
- | `individual` | the person (`individualUser`) | the active organization (`organizationId`), or none under `individualAcross: 'all'` |
431
- | `organization` | the active organization's user | that organization |
432
- | `global` | nobody (the app owner) | none; refused by the tenant API (admin routes only) |
433
-
434
- The scope of a new connection is the first of: the request (`scope` on `POST /api/v2/authorize`, only from
435
- the deciding level's `allow` list), the integration binding (`Definition.modules[key].scope` or
436
- `Definition.entities[key].scope`), `Definition.ownership.scope`, the entity type
437
- (`appDefinition.entities[name].scope`), `appDefinition.ownership.scope`, then `organization` for an
438
- organization-primary app and `individual` otherwise. A setting is `'organization'` or
439
- `{ default: 'individual', allow: ['individual', 'organization'] }`.
440
-
441
- ```javascript
442
- const appDefinition = {
443
- user: { primary: 'individual', authModes: { adopterJwt: true } },
444
- ownership: { scope: 'individual', individualAcross: 'organization' }, // the defaults
445
- entities: {
446
- google: { definition: google, scope: 'individual' },
447
- xero: { definition: xero, scope: { default: 'organization', allow: ['organization', 'individual'] } },
448
- },
449
- integrations: [Sync], // Sync.Definition.ownership = { owner: 'derive' }
450
- };
451
- ```
452
-
453
- `GET /api/v2/entities` and `/integrations` return, for the active organization, its own
454
- rows and the caller's, each with `scope`, `owner`, `organizationId` and `access`, and take `?scope=` and
455
- `?owner=me|org`. An individual entity is only visible and usable in the organization it was connected in
456
- (or in all of them, with `individualAcross: 'all'`). Rows written before this existed keep working: see
457
- [Entity scopes](../../docs/guides/entity-scopes.md), which also covers the schema (`organizationId` on
458
- `Entity` and `Integration`, no `scope` column) and what is deferred.
459
-
460
355
  ### 8. Lambda Utilities (`/lambda`)
461
356
 
462
357
  AWS Lambda-specific utilities and helpers.
@@ -67,9 +67,10 @@ class MyIntegration extends IntegrationBase {
67
67
  async hydrateFromExternalUser(externalUserId) {
68
68
  // Find integration context by external entity ID
69
69
  const result =
70
- await this.commands.findIntegrationContextByExternalEntityId(
71
- externalUserId
72
- );
70
+ await this.commands.findIntegrationContextByExternalEntityId({
71
+ externalId: externalUserId,
72
+ type: 'my-integration', // integration config.type to match
73
+ });
73
74
 
74
75
  if (result.error) {
75
76
  return { error: result.error };
@@ -231,9 +232,10 @@ Manage integration records and load full integration contexts.
231
232
  ```javascript
232
233
  // Find integration context by external entity ID
233
234
  // Returns { context, error } where context includes record + hydrated modules
234
- const result = await commands.findIntegrationContextByExternalEntityId(
235
- 'external-user-or-workspace-id'
236
- );
235
+ const result = await commands.findIntegrationContextByExternalEntityId({
236
+ externalId: 'external-user-or-workspace-id',
237
+ type: 'my-integration', // integration config.type to match
238
+ });
237
239
 
238
240
  if (!result.error) {
239
241
  integration.setIntegrationRecord(result.context);
@@ -299,7 +301,10 @@ try {
299
301
  For integration context operations, errors are returned in the result:
300
302
 
301
303
  ```javascript
302
- const result = await commands.findIntegrationContextByExternalEntityId(userId);
304
+ const result = await commands.findIntegrationContextByExternalEntityId({
305
+ externalId: userId,
306
+ type: 'my-integration',
307
+ });
303
308
 
304
309
  if (result.error) {
305
310
  return { error: result.error };
@@ -349,9 +354,10 @@ const result =
349
354
  const { createFriggCommands } = require('@friggframework/core');
350
355
 
351
356
  this.commands = createFriggCommands({ integrationClass: MyIntegration });
352
- const result = await this.commands.findIntegrationContextByExternalEntityId(
353
- userId
354
- );
357
+ const result = await this.commands.findIntegrationContextByExternalEntityId({
358
+ externalId: userId,
359
+ type: 'my-integration',
360
+ });
355
361
  ```
356
362
 
357
363
  ## Best Practices
@@ -433,7 +439,7 @@ if (!user) {
433
439
  | | `findEntitiesByIds(ids)` | Find multiple by IDs |
434
440
  | | `updateEntity(id, updates)` | Update entity properties |
435
441
  | | `deleteEntity(id)` | Delete entity |
436
- | **Integration** | `findIntegrationContextByExternalEntityId(externalId)` | Load integration + modules by external ID |
442
+ | **Integration** | `findIntegrationContextByExternalEntityId({ externalId, type })` | Load integration + modules by external ID |
437
443
  | | `loadIntegrationContextById(integrationId)` | Load integration + modules by ID |
438
444
 
439
445
  ## Support
@@ -1,12 +1,24 @@
1
1
  const {
2
2
  createModuleRepository,
3
3
  } = require('../../modules/repositories/module-repository-factory');
4
+ const { toCredentialId } = require('../../modules/utils/to-credential-id');
4
5
 
5
6
  const ERROR_CODE_MAP = {
6
7
  ENTITY_NOT_FOUND: 404,
7
8
  INVALID_ENTITY_DATA: 400,
8
9
  };
9
10
 
11
+ function toEntityDto(entity) {
12
+ return {
13
+ id: entity.id,
14
+ userId: entity.userId,
15
+ externalId: entity.externalId,
16
+ name: entity.name,
17
+ moduleName: entity.moduleName,
18
+ credentialId: toCredentialId(entity.credential),
19
+ };
20
+ }
21
+
10
22
  function mapErrorToResponse(error) {
11
23
  const status = ERROR_CODE_MAP[error?.code] || 500;
12
24
  return {
@@ -66,16 +78,7 @@ function createEntityCommands() {
66
78
 
67
79
  const entity = await moduleRepo.createEntity(entityData);
68
80
 
69
- return {
70
- id: entity.id,
71
- userId: entity.userId,
72
- externalId: entity.externalId,
73
- name: entity.name,
74
- moduleName: entity.moduleName,
75
- credentialId: entity.credential?._id
76
- ? entity.credential._id.toString()
77
- : entity.credential,
78
- };
81
+ return toEntityDto(entity);
79
82
  } catch (error) {
80
83
  return mapErrorToResponse(error);
81
84
  }
@@ -109,16 +112,7 @@ function createEntityCommands() {
109
112
  return null;
110
113
  }
111
114
 
112
- return {
113
- id: entity.id,
114
- userId: entity.userId,
115
- externalId: entity.externalId,
116
- name: entity.name,
117
- moduleName: entity.moduleName,
118
- credentialId: entity.credential?._id
119
- ? entity.credential._id.toString()
120
- : entity.credential,
121
- };
115
+ return toEntityDto(entity);
122
116
  } catch (error) {
123
117
  return mapErrorToResponse(error);
124
118
  }
@@ -139,16 +133,7 @@ function createEntityCommands() {
139
133
 
140
134
  const entities = await moduleRepo.findEntitiesByUserId(userId);
141
135
 
142
- return entities.map((entity) => ({
143
- id: entity.id,
144
- userId: entity.userId,
145
- externalId: entity.externalId,
146
- name: entity.name,
147
- moduleName: entity.moduleName,
148
- credentialId: entity.credential?._id
149
- ? entity.credential._id.toString()
150
- : entity.credential,
151
- }));
136
+ return entities.map((entity) => toEntityDto(entity));
152
137
  } catch (error) {
153
138
  if (error.code) {
154
139
  return mapErrorToResponse(error);
@@ -180,16 +165,7 @@ function createEntityCommands() {
180
165
  moduleName
181
166
  );
182
167
 
183
- return entities.map((entity) => ({
184
- id: entity.id,
185
- userId: entity.userId,
186
- externalId: entity.externalId,
187
- name: entity.name,
188
- moduleName: entity.moduleName,
189
- credentialId: entity.credential?._id
190
- ? entity.credential._id.toString()
191
- : entity.credential,
192
- }));
168
+ return entities.map((entity) => toEntityDto(entity));
193
169
  } catch (error) {
194
170
  if (error.code) {
195
171
  return mapErrorToResponse(error);
@@ -213,16 +189,7 @@ function createEntityCommands() {
213
189
 
214
190
  const entity = await moduleRepo.findEntityById(entityId);
215
191
 
216
- return {
217
- id: entity.id,
218
- userId: entity.userId,
219
- externalId: entity.externalId,
220
- name: entity.name,
221
- moduleName: entity.moduleName,
222
- credentialId: entity.credential?._id
223
- ? entity.credential._id.toString()
224
- : entity.credential,
225
- };
192
+ return toEntityDto(entity);
226
193
  } catch (error) {
227
194
  return mapErrorToResponse(error);
228
195
  }
@@ -250,16 +217,7 @@ function createEntityCommands() {
250
217
  throw error;
251
218
  }
252
219
 
253
- return {
254
- id: entity.id,
255
- userId: entity.userId,
256
- externalId: entity.externalId,
257
- name: entity.name,
258
- moduleName: entity.moduleName,
259
- credentialId: entity.credential?._id
260
- ? entity.credential._id.toString()
261
- : entity.credential,
262
- };
220
+ return toEntityDto(entity);
263
221
  } catch (error) {
264
222
  return mapErrorToResponse(error);
265
223
  }
@@ -18,7 +18,7 @@ const {
18
18
  ListIntegrationsByEntityExternalIdUseCase,
19
19
  } = require('../../integrations/use-cases/list-integrations-by-entity-external-id');
20
20
  const {
21
- GetIntegrationsByUserId,
21
+ GetIntegrationsForUser,
22
22
  } = require('../../integrations/use-cases/get-integrations-for-user');
23
23
  const {
24
24
  CreateIntegration,
@@ -30,9 +30,14 @@ const {
30
30
  PatchIntegrationConfig,
31
31
  } = require('../../integrations/use-cases/patch-integration-config');
32
32
  const {
33
- getEntityTypeRegistry,
34
- getEntityTypeDefinitions,
35
- } = require('../../modules/entity-type-registry');
33
+ AddEntitiesToIntegration,
34
+ } = require('../../integrations/use-cases/add-entities-to-integration');
35
+ const {
36
+ RemoveEntitiesFromIntegration,
37
+ } = require('../../integrations/use-cases/remove-entities-from-integration');
38
+ const {
39
+ getModulesDefinitionFromIntegrationClasses,
40
+ } = require('../../integrations/utils/map-integration-dto');
36
41
 
37
42
  const ERROR_CODE_MAP = {
38
43
  ENTITY_NOT_FOUND: 401,
@@ -41,6 +46,11 @@ const ERROR_CODE_MAP = {
41
46
  EXTERNAL_ID_REQUIRED: 400,
42
47
  TYPE_REQUIRED: 400,
43
48
  INTEGRATION_RECORD_NOT_FOUND: 404,
49
+ INVALID_INTEGRATION_DATA: 400,
50
+ ENTITIES_NOT_FOUND: 404,
51
+ ENTITY_USER_MISMATCH: 403,
52
+ ENTITY_MODULE_MISMATCH: 400,
53
+ LAST_ENTITY_OF_MODULE: 409,
44
54
  };
45
55
 
46
56
  function mapErrorToResponse(error) {
@@ -52,16 +62,7 @@ function mapErrorToResponse(error) {
52
62
  };
53
63
  }
54
64
 
55
- /**
56
- * @param {Object} [params]
57
- * @param {Function} [params.integrationClass]
58
- * @param {Array<object>} [params.moduleDefinitions] - the app's offered module
59
- * definitions (`loadAppDefinition().moduleDefinitions`). Integration code
60
- * calls this from a constructor with no app definition in hand, so when it
61
- * is omitted the commands hydrate with this integration's own modules only
62
- * (a registry built from `{ integrations: [integrationClass] }`).
63
- */
64
- function createIntegrationCommands({ integrationClass, moduleDefinitions } = {}) {
65
+ function createIntegrationCommands({ integrationClass } = {}) {
65
66
  // Always use Frigg's default repositories and use cases
66
67
  const integrationRepository = createIntegrationRepository();
67
68
 
@@ -118,13 +119,13 @@ function createIntegrationCommands({ integrationClass, moduleDefinitions } = {})
118
119
 
119
120
  const moduleRepository = createModuleRepository();
120
121
 
122
+ const moduleDefinitions = getModulesDefinitionFromIntegrationClasses([
123
+ integrationClass,
124
+ ]);
125
+
121
126
  const moduleFactory = new ModuleFactory({
122
127
  moduleRepository,
123
- moduleDefinitions:
124
- moduleDefinitions ??
125
- getEntityTypeDefinitions(
126
- getEntityTypeRegistry({ integrations: [integrationClass] })
127
- ),
128
+ moduleDefinitions,
128
129
  });
129
130
 
130
131
  const loadIntegrationContextUseCase = new LoadIntegrationContextUseCase({
@@ -152,7 +153,7 @@ function createIntegrationCommands({ integrationClass, moduleDefinitions } = {})
152
153
  moduleRepository,
153
154
  });
154
155
 
155
- const getIntegrationsForUserUseCase = new GetIntegrationsByUserId({
156
+ const getIntegrationsForUserUseCase = new GetIntegrationsForUser({
156
157
  integrationRepository,
157
158
  integrationClasses: [integrationClass],
158
159
  moduleFactory,
@@ -173,6 +174,17 @@ function createIntegrationCommands({ integrationClass, moduleDefinitions } = {})
173
174
  integrationRepository,
174
175
  });
175
176
 
177
+ const addEntitiesToIntegrationUseCase = new AddEntitiesToIntegration({
178
+ integrationRepository,
179
+ moduleRepository,
180
+ });
181
+
182
+ const removeEntitiesFromIntegrationUseCase =
183
+ new RemoveEntitiesFromIntegration({
184
+ integrationRepository,
185
+ moduleRepository,
186
+ });
187
+
176
188
  return {
177
189
  findIntegrationById,
178
190
  listIntegrations,
@@ -317,6 +329,47 @@ function createIntegrationCommands({ integrationClass, moduleDefinitions } = {})
317
329
  }
318
330
  },
319
331
 
332
+ /**
333
+ * Attach entities to an existing integration. Every entity must
334
+ * exist, belong to the integration's user, and be of a module the
335
+ * integration already holds; entities that are already attached are
336
+ * left as they are.
337
+ * @param {Object} params
338
+ * @param {string} params.integrationId - Integration ID
339
+ * @param {Array<string>} params.entityIds - Entity IDs to attach
340
+ * @returns {Promise<Object>} Updated integration
341
+ */
342
+ async addEntitiesToIntegration({ integrationId, entityIds }) {
343
+ try {
344
+ return await addEntitiesToIntegrationUseCase.execute(
345
+ integrationId,
346
+ entityIds
347
+ );
348
+ } catch (error) {
349
+ return mapErrorToResponse(error);
350
+ }
351
+ },
352
+
353
+ /**
354
+ * Detach entities from an integration. Entities that are not
355
+ * attached are ignored; detached entities are not deleted. Each
356
+ * module must keep at least one entity.
357
+ * @param {Object} params
358
+ * @param {string} params.integrationId - Integration ID
359
+ * @param {Array<string>} params.entityIds - Entity IDs to detach
360
+ * @returns {Promise<Object>} Updated integration
361
+ */
362
+ async removeEntitiesFromIntegration({ integrationId, entityIds }) {
363
+ try {
364
+ return await removeEntitiesFromIntegrationUseCase.execute(
365
+ integrationId,
366
+ entityIds
367
+ );
368
+ } catch (error) {
369
+ return mapErrorToResponse(error);
370
+ }
371
+ },
372
+
320
373
  /**
321
374
  * Delete an integration by ID
322
375
  * @param {string} integrationId - Integration ID to delete
@@ -1,12 +1,6 @@
1
1
  const {
2
2
  createUserRepository,
3
3
  } = require('../../user/repositories/user-repository-factory');
4
- const {
5
- createOrganizationMembershipRepository,
6
- } = require('../../user/repositories/organization-membership-repository-factory');
7
- const {
8
- FindIndividualUsersByOrganization,
9
- } = require('../../user/use-cases/find-individual-users-by-organization');
10
4
 
11
5
  const ERROR_CODE_MAP = {
12
6
  USER_NOT_FOUND: 404,
@@ -32,12 +26,6 @@ function mapErrorToResponse(error) {
32
26
  */
33
27
  function createUserCommands() {
34
28
  const userRepository = createUserRepository();
35
- const findIndividualUsersByOrganization =
36
- new FindIndividualUsersByOrganization({
37
- userRepository,
38
- organizationMembershipRepository:
39
- createOrganizationMembershipRepository(),
40
- });
41
29
 
42
30
  return {
43
31
  /**
@@ -182,8 +170,7 @@ function createUserCommands() {
182
170
  },
183
171
 
184
172
  /**
185
- * Find all individual users who belong to an organization user: those with
186
- * a membership and those the legacy organizationId pointer places there.
173
+ * Find all individual users linked to an organization user
187
174
  * @param {string} organizationUserId - Organization user ID to search for
188
175
  * @returns {Promise<Object[]>} Array of individual user objects (empty if none)
189
176
  */
@@ -195,9 +182,10 @@ function createUserCommands() {
195
182
  throw error;
196
183
  }
197
184
 
198
- const users = await findIndividualUsersByOrganization.execute(
199
- organizationUserId
200
- );
185
+ const users =
186
+ await userRepository.findIndividualUsersByOrganizationId(
187
+ organizationUserId
188
+ );
201
189
 
202
190
  return (users || []).map((user) => ({
203
191
  id: user._id?.toString() || user.id,
@@ -17,7 +17,6 @@ const { createUsageCommands } = require('./commands/usage-commands');
17
17
  *
18
18
  * @param {Object} params
19
19
  * @param {Object} params.integrationClass - Integration class (required)
20
- * @param {Array<object>} [params.moduleDefinitions] - the app's offered module definitions; defaults to the integration's own modules
21
20
  * @returns {Object} Unified commands object with all CRUD operations
22
21
  *
23
22
  * @example
@@ -25,12 +24,9 @@ const { createUsageCommands } = require('./commands/usage-commands');
25
24
  * const user = await commands.createUser({ username: 'user@example.com' });
26
25
  * const credential = await commands.createCredential({ userId: user.id, ... });
27
26
  */
28
- function createFriggCommands({ integrationClass, moduleDefinitions }) {
27
+ function createFriggCommands({ integrationClass }) {
29
28
  // All commands use Frigg's default repositories and use cases
30
- const integrationCommands = createIntegrationCommands({
31
- integrationClass,
32
- moduleDefinitions,
33
- });
29
+ const integrationCommands = createIntegrationCommands({ integrationClass });
34
30
 
35
31
  const userCommands = createUserCommands();
36
32
 
@@ -186,40 +186,6 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
186
186
  return this._mapCredential(decryptedCredential);
187
187
  }
188
188
 
189
- /**
190
- * All credentials owned by a user, newest first.
191
- *
192
- * @param {string} userId - User ID
193
- * @returns {Promise<Array<Object>>} Credentials with string IDs
194
- */
195
- async findCredentialsByUserId(userId) {
196
- const userObjectId = toObjectId(userId);
197
- if (!userObjectId) return [];
198
- const docs = await findManyDrained(
199
- this.prisma,
200
- 'Credential',
201
- { userId: userObjectId },
202
- { sort: { createdAt: -1 } }
203
- );
204
- const credentials = [];
205
- for (const doc of docs) {
206
- const decrypted = await this.encryptionService.decryptFields(
207
- 'Credential',
208
- doc
209
- );
210
- credentials.push({
211
- ...this._mapCredential(decrypted),
212
- id: fromObjectId(doc._id),
213
- userId: fromObjectId(doc.userId) ?? doc.userId,
214
- externalId: doc.externalId ?? null,
215
- authIsValid: doc.authIsValid ?? null,
216
- createdAt: doc.createdAt ?? null,
217
- updatedAt: doc.updatedAt ?? null,
218
- });
219
- }
220
- return credentials;
221
- }
222
-
223
189
  async updateCredential(credentialId, updates) {
224
190
  const objectId = toObjectId(credentialId);
225
191
  if (!objectId) return null;
@@ -80,21 +80,6 @@ class CredentialRepositoryInterface {
80
80
  );
81
81
  }
82
82
 
83
- /**
84
- * All credentials owned by a user, newest first.
85
- *
86
- * @param {string|number} userId - User ID (string from application layer)
87
- * @returns {Promise<Array<Object>>} Credentials with string IDs, `createdAt`
88
- * and `updatedAt`, and the (decrypted) data fields spread in, as
89
- * findCredentialById returns them. Empty when the user has none.
90
- * @abstract
91
- */
92
- async findCredentialsByUserId(userId) {
93
- throw new Error(
94
- 'Method findCredentialsByUserId must be implemented by subclass'
95
- );
96
- }
97
-
98
83
  /**
99
84
  * Update a credential by ID
100
85
  *