@friggframework/core 2.0.0--canary.704.6a606c3.0 → 2.0.0--canary.707.2702ff3.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 (213) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +28 -40
  3. package/README.md +105 -0
  4. package/application/commands/entity-commands.js +60 -18
  5. package/application/commands/integration-commands.js +20 -73
  6. package/application/commands/scheduler-commands.js +11 -7
  7. package/application/commands/user-commands.js +17 -5
  8. package/application/index.js +6 -2
  9. package/core/Worker.js +31 -7
  10. package/core/create-handler.js +9 -0
  11. package/credential/repositories/credential-repository-documentdb.js +34 -0
  12. package/credential/repositories/credential-repository-interface.js +15 -0
  13. package/credential/repositories/credential-repository-mongo.js +23 -0
  14. package/credential/repositories/credential-repository-postgres.js +28 -0
  15. package/credential/use-cases/credential-operations-for-user.js +202 -0
  16. package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
  17. package/database/config.js +48 -24
  18. package/database/documentdb-encryption-service.js +9 -22
  19. package/database/documentdb-utils.js +2 -0
  20. package/database/encryption/README.md +78 -14
  21. package/database/encryption/documentdb-encryption-service.md +78 -1
  22. package/database/encryption/encryption-config.js +219 -0
  23. package/database/encryption/encryption-schema-registry.js +121 -24
  24. package/database/encryption/field-encryption-service.js +69 -5
  25. package/database/encryption/integration-mapping-encryption.js +1 -1
  26. package/database/encryption/test-support/fake-ciphertext.js +15 -0
  27. package/database/organization-where.js +18 -0
  28. package/database/prisma.js +15 -38
  29. package/database/unique-violation.js +25 -0
  30. package/database/use-cases/check-encryption-health-use-case.js +43 -52
  31. package/errors/index.js +0 -4
  32. package/extensions/bootstrap-runner.js +43 -0
  33. package/extensions/extension-loader.js +230 -0
  34. package/extensions/index.js +44 -0
  35. package/extensions/initialize-app.js +123 -0
  36. package/extensions/prisma-composer.js +264 -0
  37. package/extensions/route-mounter.js +103 -0
  38. package/extensions/schema-composer.js +188 -0
  39. package/generated/prisma-mongodb/edge.js +43 -4
  40. package/generated/prisma-mongodb/index-browser.js +40 -1
  41. package/generated/prisma-mongodb/index.d.ts +4761 -133
  42. package/generated/prisma-mongodb/index.js +43 -4
  43. package/generated/prisma-mongodb/package.json +1 -1
  44. package/generated/prisma-mongodb/schema.prisma +92 -0
  45. package/generated/prisma-mongodb/wasm.js +43 -4
  46. package/generated/prisma-postgresql/edge.js +43 -4
  47. package/generated/prisma-postgresql/index-browser.js +40 -1
  48. package/generated/prisma-postgresql/index.d.ts +4961 -28
  49. package/generated/prisma-postgresql/index.js +43 -4
  50. package/generated/prisma-postgresql/package.json +1 -1
  51. package/generated/prisma-postgresql/schema.prisma +87 -0
  52. package/generated/prisma-postgresql/wasm.js +43 -4
  53. package/handlers/WEBHOOKS.md +0 -29
  54. package/handlers/app-definition-loader.js +128 -19
  55. package/handlers/app-handler-helpers.js +25 -3
  56. package/handlers/backend-utils.js +31 -25
  57. package/handlers/middleware/admin-auth.js +14 -1
  58. package/handlers/routers/auth.js +46 -10
  59. package/handlers/routers/health.js +13 -4
  60. package/handlers/routers/integration-defined-routers.js +144 -92
  61. package/handlers/routers/integration-webhook-routers.js +102 -64
  62. package/handlers/workers/integration-defined-workers.js +5 -2
  63. package/index.js +66 -3
  64. package/infrastructure/scheduler/eventbridge-scheduler-adapter.js +13 -67
  65. package/infrastructure/scheduler/index.js +19 -6
  66. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  67. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  68. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  69. package/integrations/index.js +1 -0
  70. package/integrations/integration-base.js +0 -29
  71. package/integrations/integration-router.js +131 -96
  72. package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
  73. package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
  74. package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
  75. package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
  76. package/integrations/repositories/integration-mapping-repository.js +0 -29
  77. package/integrations/repositories/integration-not-found.js +14 -0
  78. package/integrations/repositories/integration-repository-documentdb.js +35 -35
  79. package/integrations/repositories/integration-repository-interface.js +17 -31
  80. package/integrations/repositories/integration-repository-mongo.js +60 -129
  81. package/integrations/repositories/integration-repository-postgres.js +62 -134
  82. package/integrations/repositories/process-repository-mongo.js +1 -1
  83. package/integrations/schedule-config.js +80 -0
  84. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  85. package/integrations/use-cases/create-integration.js +72 -75
  86. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  87. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  88. package/integrations/use-cases/index.js +7 -1
  89. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  90. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  91. package/integrations/utils/map-integration-dto.js +43 -14
  92. package/management-api/create-management-api-router.js +49 -0
  93. package/management-api/error-response.js +116 -0
  94. package/management-api/management-api-config.js +151 -0
  95. package/management-api/meta-router.js +76 -0
  96. package/management-api/openapi/build-openapi.js +144 -0
  97. package/management-api/openapi/components.js +415 -0
  98. package/management-api/openapi/v1-operations.js +83 -0
  99. package/management-api/openapi/v2-operations.js +264 -0
  100. package/management-api/route-registry.js +411 -0
  101. package/management-api/test-support/http-client.js +39 -0
  102. package/management-api/use-cases/get-management-api-meta.js +57 -0
  103. package/management-api/v1-deprecation.js +120 -0
  104. package/management-api/v2/create-v2-router.js +85 -0
  105. package/management-api/v2/dependencies.js +281 -0
  106. package/management-api/v2/dto.js +143 -0
  107. package/management-api/v2/handlers/authorization.js +94 -0
  108. package/management-api/v2/handlers/credentials.js +33 -0
  109. package/management-api/v2/handlers/entities.js +77 -0
  110. package/management-api/v2/handlers/integrations.js +128 -0
  111. package/management-api/v2/handlers/proxy.js +34 -0
  112. package/management-api/v2/request.js +85 -0
  113. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  114. package/modules/domain/authorization-session.js +144 -0
  115. package/modules/entity-scope.js +15 -0
  116. package/modules/entity-type-registry.js +311 -0
  117. package/modules/index.js +14 -0
  118. package/modules/module-factory.js +12 -0
  119. package/modules/module.js +53 -2
  120. package/modules/proxy/proxy-bases.js +169 -0
  121. package/modules/proxy/proxy-errors.js +31 -0
  122. package/modules/proxy/proxy-path.js +183 -0
  123. package/modules/proxy/proxy-policy.js +277 -0
  124. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  125. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  126. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  127. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  128. package/modules/repositories/entity-not-found.js +18 -0
  129. package/modules/repositories/module-repository-documentdb.js +51 -6
  130. package/modules/repositories/module-repository-interface.js +19 -2
  131. package/modules/repositories/module-repository-mongo.js +67 -74
  132. package/modules/repositories/module-repository-postgres.js +66 -74
  133. package/modules/repositories/module-repository.js +2 -1
  134. package/modules/requester/proxy-request.js +245 -0
  135. package/modules/requester/requester.js +35 -5
  136. package/modules/scope-config.js +192 -0
  137. package/modules/scope-policy.js +112 -0
  138. package/modules/scope-resolution.js +288 -0
  139. package/modules/scope-visibility.js +247 -0
  140. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  141. package/modules/tests/doubles/ownership-matching.js +18 -0
  142. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  143. package/modules/use-cases/create-entity-from-credential.js +113 -0
  144. package/modules/use-cases/entity-operations-for-user.js +162 -0
  145. package/modules/use-cases/entity-types.js +139 -0
  146. package/modules/use-cases/execute-entity-proxy-request.js +331 -0
  147. package/modules/use-cases/get-authorization-requirements.js +100 -0
  148. package/modules/use-cases/get-entities-for-user.js +26 -13
  149. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  150. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  151. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  152. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  153. package/modules/use-cases/get-module.js +10 -4
  154. package/modules/use-cases/manage-access.js +21 -0
  155. package/modules/use-cases/multi-step-authorization.js +329 -0
  156. package/modules/use-cases/process-authorization-callback.js +53 -19
  157. package/modules/use-cases/refresh-entity-options.js +10 -4
  158. package/modules/use-cases/test-module-auth.js +10 -4
  159. package/modules/utils/map-module-dto.js +3 -5
  160. package/package.json +6 -6
  161. package/prisma-mongodb/schema.prisma +92 -0
  162. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  163. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  164. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  165. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  166. package/prisma-postgresql/schema.prisma +87 -0
  167. package/providers/index.js +23 -0
  168. package/providers/resolve-provider.js +93 -0
  169. package/providers/runtime-detection.js +124 -0
  170. package/queues/index.js +25 -0
  171. package/queues/providers/index.js +11 -0
  172. package/queues/providers/netlify-background-provider.js +150 -0
  173. package/queues/providers/qstash-queue-provider.js +219 -0
  174. package/queues/providers/sqs-queue-provider.js +70 -0
  175. package/queues/queue-client-interface.js +55 -0
  176. package/queues/queue-message-auth.js +193 -0
  177. package/queues/queue-provider-factory.js +101 -0
  178. package/queues/queue-provider.js +65 -0
  179. package/queues/sqs-queue-client.js +81 -0
  180. package/types/index.d.ts +2 -0
  181. package/types/modules/index.d.ts +214 -0
  182. package/types/user/index.d.ts +134 -0
  183. package/user/adopter-jwt-config.js +177 -0
  184. package/user/adopter-jwt-errors.js +59 -0
  185. package/user/adopter-token-verifier.js +122 -0
  186. package/user/app-user-helpers.js +97 -0
  187. package/user/create-user-authentication.js +64 -0
  188. package/user/membership.js +51 -0
  189. package/user/repositories/organization-membership-record.js +19 -0
  190. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  191. package/user/repositories/organization-membership-repository-factory.js +42 -0
  192. package/user/repositories/organization-membership-repository-interface.js +57 -0
  193. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  194. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  195. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  196. package/user/repositories/user-repository-documentdb.js +27 -2
  197. package/user/repositories/user-repository-interface.js +18 -0
  198. package/user/repositories/user-repository-mongo.js +24 -0
  199. package/user/repositories/user-repository-postgres.js +18 -0
  200. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  201. package/user/tests/doubles/test-user-repository.js +1 -1
  202. package/user/use-cases/authenticate-user.js +7 -1
  203. package/user/use-cases/ensure-organization-membership.js +92 -0
  204. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  205. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  206. package/user/use-cases/get-user-from-bearer-token.js +2 -7
  207. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  208. package/user/user.js +64 -1
  209. package/errors/mapping-already-exists-error.js +0 -14
  210. package/integrations/use-cases/add-entities-to-integration.js +0 -92
  211. package/integrations/use-cases/remove-entities-from-integration.js +0 -70
  212. package/integrations/utils/assert-entity-ids-input.js +0 -42
  213. package/modules/utils/to-credential-id.js +0 -14
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ # Unreleased
2
+
3
+ #### Behaviour change: module credential fields are now encrypted
4
+
5
+ - `credentialFields` declared in an API module's `encryption` config (for example `Definition.encryption.credentialFields`) were never registered for encryption: `loadModuleEncryptionSchemas` required a path that does not exist and the error was swallowed. They are now registered, for modules bound by integrations and for modules registered in `appDefinition.entities` (ADR-059).
6
+ - Existing rows are not rewritten. A credential saved before this release stays plaintext until it is next written, and is read as stored.
7
+ - Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value with that shape that fails to decrypt throws `FieldDecryptionError` in a deployed runtime (any stage, AWS Lambda or Netlify) and is returned as stored with a warning on a local run.
8
+ - An app's own `encryption.schema` is no longer replaced when module fields are added to it.
9
+
10
+ ---
11
+
1
12
  # v1.2.2 (Fri Aug 09 2024)
2
13
 
3
14
  #### 🐛 Bug Fix
package/CLAUDE.md CHANGED
@@ -206,51 +206,11 @@ 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
210
209
 
211
210
  **Repositories**:
212
211
  - `integration-repository-factory.js` - Creates database-specific repositories
213
212
  - `integration-repository-mongo.js` - MongoDB implementation
214
213
  - `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.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.
254
214
  - `integration-mapping-repository-*.js` - Mapping data persistence.
255
215
  `queryMappings(integrationId, { where, orderBy, skip, take, omit })`
256
216
  returns `{ mappings, total }`: one filtered, ordered page of an
@@ -372,12 +332,14 @@ class MyIntegration extends IntegrationBase {
372
332
  - Password-based (bcrypt hashed)
373
333
  - Token-based (Bearer tokens)
374
334
  - App-based (external app user IDs)
335
+ - Adopter JWT (`authModes.adopterJwt`, `jwtConfig`; `jose`, JWKS or HMAC; see `README.md`)
375
336
 
376
337
  **Use Cases**:
377
338
  - `login-user.js` - User authentication
378
339
  - `create-individual-user.js` - Create personal account
379
340
  - `create-organization-user.js` - Create business account
380
341
  - `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
381
343
 
382
344
  **Repositories**:
383
345
  - `user-repository-factory.js` - Creates database-specific repositories
@@ -540,6 +502,29 @@ The single source of the max receive count is
540
502
  devtools integration builder uses it for the queue's `RedrivePolicy` and sets
541
503
  it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
542
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
+
543
528
  ### 8. Error Handling (`/errors`)
544
529
 
545
530
  **Purpose**: Standardized error types with proper HTTP semantics.
@@ -790,6 +775,9 @@ Use test doubles from `@friggframework/test` package for consistent mocking.
790
775
  - `SECRET_ARN` - AWS Secrets Manager ARN for auto-injection
791
776
  - `FRIGG_LOG_LEVEL` - Minimum log level (`TRACE` … `FATAL`, default `INFO`; `DEBUG` on local runs)
792
777
  - `FRIGG_QUEUE_MAX_RECEIVE_COUNT` - Set by devtools on integration queue workers whose queue the stack owns; feeds `delivery.maxReceiveCount`
778
+ - `FRIGG_QUEUE_SIGNING_SECRET` - Required for HTTP-delivered queues (Netlify Background Functions). `NetlifyBackgroundProvider` signs each message (HMAC-SHA256 over `timestamp.rawBody`, `x-frigg-queue-signature` / `x-frigg-queue-timestamp`) and `verifyEvent()` refuses anything unsigned, tampered, signed with another secret or older than 5 minutes. Unset → `send()` throws and every event is refused (`queues/queue-message-auth.js`)
779
+ - `FRIGG_QUEUE_ALLOW_UNSIGNED` - `true` accepts unsigned queue messages when no secret is set, outside production only (`STAGE` dev/test/local, `NODE_ENV` development/test, or `NETLIFY_DEV`); ignored in production and in any deployed function, whatever `STAGE` says (`providers/runtime-detection.js` `isDeployedRuntime`: AWS Lambda variables, or the active provider's `detect()`, e.g. provider-netlify via `FRIGG_PROVIDER=netlify`)
780
+ - `QSTASH_CURRENT_SIGNING_KEY` / `QSTASH_NEXT_SIGNING_KEY` - Required with the QStash queue: `QStashQueueProvider.verifyEvent()` checks the `Upstash-Signature` JWT with them
793
781
 
794
782
  ## Version Information
795
783
 
package/README.md CHANGED
@@ -305,6 +305,7 @@ 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)
308
309
 
309
310
  **Usage:**
310
311
  ```javascript
@@ -352,6 +353,110 @@ console.log('Individual user:', user.getIndividualUser());
352
353
  console.log('Organization user:', user.getOrganizationUser());
353
354
  ```
354
355
 
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). 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
+
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
+
355
460
  ### 8. Lambda Utilities (`/lambda`)
356
461
 
357
462
  AWS Lambda-specific utilities and helpers.
@@ -1,24 +1,12 @@
1
1
  const {
2
2
  createModuleRepository,
3
3
  } = require('../../modules/repositories/module-repository-factory');
4
- const { toCredentialId } = require('../../modules/utils/to-credential-id');
5
4
 
6
5
  const ERROR_CODE_MAP = {
7
6
  ENTITY_NOT_FOUND: 404,
8
7
  INVALID_ENTITY_DATA: 400,
9
8
  };
10
9
 
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
-
22
10
  function mapErrorToResponse(error) {
23
11
  const status = ERROR_CODE_MAP[error?.code] || 500;
24
12
  return {
@@ -78,7 +66,16 @@ function createEntityCommands() {
78
66
 
79
67
  const entity = await moduleRepo.createEntity(entityData);
80
68
 
81
- return toEntityDto(entity);
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
+ };
82
79
  } catch (error) {
83
80
  return mapErrorToResponse(error);
84
81
  }
@@ -112,7 +109,16 @@ function createEntityCommands() {
112
109
  return null;
113
110
  }
114
111
 
115
- return toEntityDto(entity);
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
+ };
116
122
  } catch (error) {
117
123
  return mapErrorToResponse(error);
118
124
  }
@@ -133,7 +139,16 @@ function createEntityCommands() {
133
139
 
134
140
  const entities = await moduleRepo.findEntitiesByUserId(userId);
135
141
 
136
- return entities.map((entity) => toEntityDto(entity));
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
+ }));
137
152
  } catch (error) {
138
153
  if (error.code) {
139
154
  return mapErrorToResponse(error);
@@ -165,7 +180,16 @@ function createEntityCommands() {
165
180
  moduleName
166
181
  );
167
182
 
168
- return entities.map((entity) => toEntityDto(entity));
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
+ }));
169
193
  } catch (error) {
170
194
  if (error.code) {
171
195
  return mapErrorToResponse(error);
@@ -189,7 +213,16 @@ function createEntityCommands() {
189
213
 
190
214
  const entity = await moduleRepo.findEntityById(entityId);
191
215
 
192
- return toEntityDto(entity);
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
+ };
193
226
  } catch (error) {
194
227
  return mapErrorToResponse(error);
195
228
  }
@@ -217,7 +250,16 @@ function createEntityCommands() {
217
250
  throw error;
218
251
  }
219
252
 
220
- return toEntityDto(entity);
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
+ };
221
263
  } catch (error) {
222
264
  return mapErrorToResponse(error);
223
265
  }
@@ -18,7 +18,7 @@ const {
18
18
  ListIntegrationsByEntityExternalIdUseCase,
19
19
  } = require('../../integrations/use-cases/list-integrations-by-entity-external-id');
20
20
  const {
21
- GetIntegrationsForUser,
21
+ GetIntegrationsByUserId,
22
22
  } = require('../../integrations/use-cases/get-integrations-for-user');
23
23
  const {
24
24
  CreateIntegration,
@@ -30,14 +30,9 @@ const {
30
30
  PatchIntegrationConfig,
31
31
  } = require('../../integrations/use-cases/patch-integration-config');
32
32
  const {
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');
33
+ getEntityTypeRegistry,
34
+ getEntityTypeDefinitions,
35
+ } = require('../../modules/entity-type-registry');
41
36
 
42
37
  const ERROR_CODE_MAP = {
43
38
  ENTITY_NOT_FOUND: 401,
@@ -46,11 +41,6 @@ const ERROR_CODE_MAP = {
46
41
  EXTERNAL_ID_REQUIRED: 400,
47
42
  TYPE_REQUIRED: 400,
48
43
  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,
54
44
  };
55
45
 
56
46
  function mapErrorToResponse(error) {
@@ -62,7 +52,16 @@ function mapErrorToResponse(error) {
62
52
  };
63
53
  }
64
54
 
65
- function createIntegrationCommands({ integrationClass } = {}) {
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 } = {}) {
66
65
  // Always use Frigg's default repositories and use cases
67
66
  const integrationRepository = createIntegrationRepository();
68
67
 
@@ -119,13 +118,13 @@ function createIntegrationCommands({ integrationClass } = {}) {
119
118
 
120
119
  const moduleRepository = createModuleRepository();
121
120
 
122
- const moduleDefinitions = getModulesDefinitionFromIntegrationClasses([
123
- integrationClass,
124
- ]);
125
-
126
121
  const moduleFactory = new ModuleFactory({
127
122
  moduleRepository,
128
- moduleDefinitions,
123
+ moduleDefinitions:
124
+ moduleDefinitions ??
125
+ getEntityTypeDefinitions(
126
+ getEntityTypeRegistry({ integrations: [integrationClass] })
127
+ ),
129
128
  });
130
129
 
131
130
  const loadIntegrationContextUseCase = new LoadIntegrationContextUseCase({
@@ -153,7 +152,7 @@ function createIntegrationCommands({ integrationClass } = {}) {
153
152
  moduleRepository,
154
153
  });
155
154
 
156
- const getIntegrationsForUserUseCase = new GetIntegrationsForUser({
155
+ const getIntegrationsForUserUseCase = new GetIntegrationsByUserId({
157
156
  integrationRepository,
158
157
  integrationClasses: [integrationClass],
159
158
  moduleFactory,
@@ -174,17 +173,6 @@ function createIntegrationCommands({ integrationClass } = {}) {
174
173
  integrationRepository,
175
174
  });
176
175
 
177
- const addEntitiesToIntegrationUseCase = new AddEntitiesToIntegration({
178
- integrationRepository,
179
- moduleRepository,
180
- });
181
-
182
- const removeEntitiesFromIntegrationUseCase =
183
- new RemoveEntitiesFromIntegration({
184
- integrationRepository,
185
- moduleRepository,
186
- });
187
-
188
176
  return {
189
177
  findIntegrationById,
190
178
  listIntegrations,
@@ -329,47 +317,6 @@ function createIntegrationCommands({ integrationClass } = {}) {
329
317
  }
330
318
  },
331
319
 
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
-
373
320
  /**
374
321
  * Delete an integration by ID
375
322
  * @param {string} integrationId - Integration ID to delete
@@ -121,17 +121,19 @@ function createSchedulerCommands({ integrationName, schedulerService }) {
121
121
  throw error;
122
122
  }
123
123
 
124
- if (!queueUrl) {
124
+ // An adapter with its own notion of a queue target (Netlify:
125
+ // the worker function) resolves it; otherwise the target is
126
+ // an SQS queue and its URL is required.
127
+ const service = getSchedulerService();
128
+ const resolvesOwnTarget =
129
+ typeof service?.toQueueResourceId === 'function';
130
+
131
+ if (!queueUrl && !resolvesOwnTarget) {
125
132
  const error = new Error('queueUrl is required');
126
133
  error.code = 'INVALID_JOB_DATA';
127
134
  throw error;
128
135
  }
129
136
 
130
- // Derive ARN from URL (business logic - transformation)
131
- const queueArn = deriveArnFromQueueUrl(queueUrl);
132
-
133
- // Get scheduler service (via DI or factory)
134
- const service = getSchedulerService();
135
137
  if (!service) {
136
138
  console.warn(
137
139
  `[${integrationName}] Scheduler not configured, skipping job schedule`
@@ -157,7 +159,9 @@ function createSchedulerCommands({ integrationName, schedulerService }) {
157
159
  const result = await service.scheduleOneTime({
158
160
  scheduleName: jobId,
159
161
  scheduleAt: scheduledAt,
160
- queueResourceId: queueArn,
162
+ queueResourceId: resolvesOwnTarget
163
+ ? service.toQueueResourceId(queueUrl)
164
+ : deriveArnFromQueueUrl(queueUrl),
161
165
  payload: sqsPayload,
162
166
  });
163
167
 
@@ -1,6 +1,12 @@
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');
4
10
 
5
11
  const ERROR_CODE_MAP = {
6
12
  USER_NOT_FOUND: 404,
@@ -26,6 +32,12 @@ function mapErrorToResponse(error) {
26
32
  */
27
33
  function createUserCommands() {
28
34
  const userRepository = createUserRepository();
35
+ const findIndividualUsersByOrganization =
36
+ new FindIndividualUsersByOrganization({
37
+ userRepository,
38
+ organizationMembershipRepository:
39
+ createOrganizationMembershipRepository(),
40
+ });
29
41
 
30
42
  return {
31
43
  /**
@@ -170,7 +182,8 @@ function createUserCommands() {
170
182
  },
171
183
 
172
184
  /**
173
- * Find all individual users linked to an organization user
185
+ * Find all individual users who belong to an organization user: those with
186
+ * a membership and those the legacy organizationId pointer places there.
174
187
  * @param {string} organizationUserId - Organization user ID to search for
175
188
  * @returns {Promise<Object[]>} Array of individual user objects (empty if none)
176
189
  */
@@ -182,10 +195,9 @@ function createUserCommands() {
182
195
  throw error;
183
196
  }
184
197
 
185
- const users =
186
- await userRepository.findIndividualUsersByOrganizationId(
187
- organizationUserId
188
- );
198
+ const users = await findIndividualUsersByOrganization.execute(
199
+ organizationUserId
200
+ );
189
201
 
190
202
  return (users || []).map((user) => ({
191
203
  id: user._id?.toString() || user.id,
@@ -17,6 +17,7 @@ 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
20
21
  * @returns {Object} Unified commands object with all CRUD operations
21
22
  *
22
23
  * @example
@@ -24,9 +25,12 @@ const { createUsageCommands } = require('./commands/usage-commands');
24
25
  * const user = await commands.createUser({ username: 'user@example.com' });
25
26
  * const credential = await commands.createCredential({ userId: user.id, ... });
26
27
  */
27
- function createFriggCommands({ integrationClass }) {
28
+ function createFriggCommands({ integrationClass, moduleDefinitions }) {
28
29
  // All commands use Frigg's default repositories and use cases
29
- const integrationCommands = createIntegrationCommands({ integrationClass });
30
+ const integrationCommands = createIntegrationCommands({
31
+ integrationClass,
32
+ moduleDefinitions,
33
+ });
30
34
 
31
35
  const userCommands = createUserCommands();
32
36