@friggframework/core 2.0.0--canary.680.daae24e.0 → 2.0.0--canary.686.588657b.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +0 -11
  2. package/CLAUDE.md +28 -0
  3. package/README.md +105 -0
  4. package/application/commands/integration-commands.js +2 -2
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/core/Worker.js +31 -7
  8. package/core/create-handler.js +9 -0
  9. package/credential/use-cases/credential-operations-for-user.js +110 -25
  10. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  11. package/database/config.js +48 -24
  12. package/database/documentdb-utils.js +6 -1
  13. package/database/encryption/README.md +0 -2
  14. package/database/encryption/encryption-config.js +10 -31
  15. package/database/encryption/encryption-schema-registry.js +99 -47
  16. package/database/encryption/field-encryption-service.js +5 -69
  17. package/database/organization-where.js +18 -0
  18. package/database/prisma.js +4 -2
  19. package/database/unique-violation.js +25 -0
  20. package/extensions/bootstrap-runner.js +43 -0
  21. package/extensions/extension-loader.js +187 -0
  22. package/extensions/index.js +41 -0
  23. package/extensions/initialize-app.js +123 -0
  24. package/extensions/route-mounter.js +103 -0
  25. package/extensions/schema-composer.js +188 -0
  26. package/generated/prisma-mongodb/edge.js +27 -4
  27. package/generated/prisma-mongodb/index-browser.js +24 -1
  28. package/generated/prisma-mongodb/index.d.ts +3227 -47
  29. package/generated/prisma-mongodb/index.js +27 -4
  30. package/generated/prisma-mongodb/package.json +1 -1
  31. package/generated/prisma-mongodb/schema.prisma +65 -0
  32. package/generated/prisma-mongodb/wasm.js +27 -4
  33. package/generated/prisma-postgresql/edge.js +27 -4
  34. package/generated/prisma-postgresql/index-browser.js +24 -1
  35. package/generated/prisma-postgresql/index.d.ts +3519 -67
  36. package/generated/prisma-postgresql/index.js +27 -4
  37. package/generated/prisma-postgresql/package.json +1 -1
  38. package/generated/prisma-postgresql/schema.prisma +61 -0
  39. package/generated/prisma-postgresql/wasm.js +27 -4
  40. package/handlers/app-definition-loader.js +103 -12
  41. package/handlers/routers/auth.js +40 -12
  42. package/handlers/routers/health.js +4 -2
  43. package/handlers/routers/integration-defined-routers.js +144 -92
  44. package/handlers/routers/integration-webhook-routers.js +102 -64
  45. package/index.js +63 -1
  46. package/infrastructure/scheduler/index.js +19 -6
  47. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  48. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  49. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  50. package/integrations/index.js +5 -0
  51. package/integrations/integration-router.js +115 -73
  52. package/integrations/repositories/integration-not-found.js +14 -0
  53. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  54. package/integrations/repositories/integration-repository-interface.js +17 -1
  55. package/integrations/repositories/integration-repository-mongo.js +59 -76
  56. package/integrations/repositories/integration-repository-postgres.js +62 -76
  57. package/integrations/schedule-config.js +80 -0
  58. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  59. package/integrations/use-cases/create-integration.js +55 -20
  60. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  61. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  62. package/integrations/use-cases/index.js +7 -1
  63. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  64. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  65. package/integrations/utils/map-integration-dto.js +44 -1
  66. package/management-api/management-api-config.js +71 -2
  67. package/management-api/meta-router.js +21 -2
  68. package/management-api/openapi/components.js +40 -0
  69. package/management-api/openapi/v2-operations.js +16 -4
  70. package/management-api/route-registry.js +123 -6
  71. package/management-api/v2/dependencies.js +40 -15
  72. package/management-api/v2/dto.js +20 -0
  73. package/management-api/v2/handlers/authorization.js +1 -0
  74. package/management-api/v2/handlers/credentials.js +6 -2
  75. package/management-api/v2/handlers/entities.js +10 -7
  76. package/management-api/v2/handlers/integrations.js +6 -3
  77. package/management-api/v2/request.js +32 -1
  78. package/modules/entity-scope.js +15 -0
  79. package/modules/index.js +8 -0
  80. package/modules/module-factory.js +12 -0
  81. package/modules/module.js +53 -2
  82. package/modules/repositories/entity-not-found.js +18 -0
  83. package/modules/repositories/module-repository-documentdb.js +51 -6
  84. package/modules/repositories/module-repository-interface.js +19 -2
  85. package/modules/repositories/module-repository-mongo.js +67 -74
  86. package/modules/repositories/module-repository-postgres.js +66 -74
  87. package/modules/repositories/module-repository.js +2 -1
  88. package/modules/scope-config.js +192 -0
  89. package/modules/scope-policy.js +112 -0
  90. package/modules/scope-resolution.js +288 -0
  91. package/modules/scope-visibility.js +247 -0
  92. package/modules/tests/doubles/ownership-matching.js +18 -0
  93. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  94. package/modules/use-cases/create-entity-from-credential.js +113 -0
  95. package/modules/use-cases/entity-operations-for-user.js +50 -13
  96. package/modules/use-cases/entity-types.js +31 -7
  97. package/modules/use-cases/execute-entity-proxy-request.js +50 -3
  98. package/modules/use-cases/get-authorization-requirements.js +2 -2
  99. package/modules/use-cases/get-entities-for-user.js +26 -13
  100. package/modules/use-cases/get-entity-module-for-user.js +34 -7
  101. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  102. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  103. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  104. package/modules/use-cases/get-module.js +10 -4
  105. package/modules/use-cases/manage-access.js +21 -0
  106. package/modules/use-cases/multi-step-authorization.js +40 -13
  107. package/modules/use-cases/process-authorization-callback.js +53 -19
  108. package/modules/use-cases/refresh-entity-options.js +10 -4
  109. package/modules/use-cases/test-module-auth.js +10 -4
  110. package/package.json +6 -5
  111. package/prisma-mongodb/schema.prisma +65 -0
  112. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  113. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  114. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  115. package/prisma-postgresql/schema.prisma +61 -0
  116. package/providers/index.js +23 -0
  117. package/providers/resolve-provider.js +93 -0
  118. package/providers/runtime-detection.js +124 -0
  119. package/queues/index.js +25 -0
  120. package/queues/providers/index.js +11 -0
  121. package/queues/providers/netlify-background-provider.js +150 -0
  122. package/queues/providers/qstash-queue-provider.js +219 -0
  123. package/queues/providers/sqs-queue-provider.js +70 -0
  124. package/queues/queue-client-interface.js +55 -0
  125. package/queues/queue-message-auth.js +193 -0
  126. package/queues/queue-provider-factory.js +101 -0
  127. package/queues/queue-provider.js +65 -0
  128. package/queues/sqs-queue-client.js +81 -0
  129. package/types/index.d.ts +1 -0
  130. package/types/modules/index.d.ts +139 -0
  131. package/types/user/index.d.ts +134 -0
  132. package/user/adopter-jwt-config.js +177 -0
  133. package/user/adopter-jwt-errors.js +59 -0
  134. package/user/adopter-token-verifier.js +122 -0
  135. package/user/app-user-helpers.js +97 -0
  136. package/user/create-user-authentication.js +64 -0
  137. package/user/membership.js +51 -0
  138. package/user/repositories/organization-membership-record.js +19 -0
  139. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  140. package/user/repositories/organization-membership-repository-factory.js +42 -0
  141. package/user/repositories/organization-membership-repository-interface.js +57 -0
  142. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  143. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  144. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  145. package/user/repositories/user-repository-documentdb.js +27 -2
  146. package/user/repositories/user-repository-interface.js +18 -0
  147. package/user/repositories/user-repository-mongo.js +24 -0
  148. package/user/repositories/user-repository-postgres.js +18 -0
  149. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  150. package/user/use-cases/authenticate-user.js +7 -1
  151. package/user/use-cases/ensure-organization-membership.js +92 -0
  152. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  153. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  154. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  155. package/user/user.js +64 -1
  156. package/database/encryption/test-support/fake-ciphertext.js +0 -15
package/CHANGELOG.md CHANGED
@@ -1,14 +1,3 @@
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
-
12
1
  # v1.2.2 (Fri Aug 09 2024)
13
2
 
14
3
  #### 🐛 Bug Fix
package/CLAUDE.md CHANGED
@@ -332,12 +332,14 @@ class MyIntegration extends IntegrationBase {
332
332
  - Password-based (bcrypt hashed)
333
333
  - Token-based (Bearer tokens)
334
334
  - App-based (external app user IDs)
335
+ - Adopter JWT (`authModes.adopterJwt`, `jwtConfig`; `jose`, JWKS or HMAC; see `README.md`)
335
336
 
336
337
  **Use Cases**:
337
338
  - `login-user.js` - User authentication
338
339
  - `create-individual-user.js` - Create personal account
339
340
  - `create-organization-user.js` - Create business account
340
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
341
343
 
342
344
  **Repositories**:
343
345
  - `user-repository-factory.js` - Creates database-specific repositories
@@ -500,6 +502,29 @@ The single source of the max receive count is
500
502
  devtools integration builder uses it for the queue's `RedrivePolicy` and sets
501
503
  it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
502
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
+
503
528
  ### 8. Error Handling (`/errors`)
504
529
 
505
530
  **Purpose**: Standardized error types with proper HTTP semantics.
@@ -750,6 +775,9 @@ Use test doubles from `@friggframework/test` package for consistent mocking.
750
775
  - `SECRET_ARN` - AWS Secrets Manager ARN for auto-injection
751
776
  - `FRIGG_LOG_LEVEL` - Minimum log level (`TRACE` … `FATAL`, default `INFO`; `DEBUG` on local runs)
752
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
753
781
 
754
782
  ## Version Information
755
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.
@@ -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,
@@ -152,7 +152,7 @@ function createIntegrationCommands({ integrationClass, moduleDefinitions } = {})
152
152
  moduleRepository,
153
153
  });
154
154
 
155
- const getIntegrationsForUserUseCase = new GetIntegrationsForUser({
155
+ const getIntegrationsForUserUseCase = new GetIntegrationsByUserId({
156
156
  integrationRepository,
157
157
  integrationClasses: [integrationClass],
158
158
  moduleFactory,
@@ -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,
package/core/Worker.js CHANGED
@@ -1,4 +1,3 @@
1
- const { SQSClient, GetQueueUrlCommand, SendMessageCommand } = require('@aws-sdk/client-sqs');
2
1
  const _ = require('lodash');
3
2
  const { RequiredPropertyError } = require('../errors');
4
3
  const { get } = require('../assertions');
@@ -6,18 +5,44 @@ const { readQueueDelivery } = require('../queues/queue-delivery');
6
5
  const { runMessageScope } = require('./invocation-scope');
7
6
  const { getLogger } = require('../logs');
8
7
 
9
- const sqs = new SQSClient({ region: process.env.AWS_REGION });
10
8
  const log = getLogger('frigg.worker');
11
9
 
10
+ /**
11
+ * Worker - queue message producer/consumer base class.
12
+ *
13
+ * Subclass and override _run(). The queue transport is behind
14
+ * QueueClientInterface and can be injected (friggframework/frigg#545):
15
+ * `new MyWorker({ queueClient })`. Unlike #545 it is not required: without
16
+ * one the worker uses an SqsQueueClient configured exactly as before
17
+ * (AWS_REGION), created on first use rather than at module load, so
18
+ * requiring core constructs no AWS client and a Netlify/QStash worker that
19
+ * only calls _run() never touches SQS.
20
+ */
12
21
  class Worker {
22
+ /**
23
+ * @param {Object} [options]
24
+ * @param {import('../queues/queue-client-interface').QueueClientInterface} [options.queueClient]
25
+ */
26
+ constructor(options = {}) {
27
+ this._queueClient = options.queueClient || null;
28
+ }
29
+
30
+ _getQueueClient() {
31
+ if (!this._queueClient) {
32
+ const { SqsQueueClient } = require('../queues/sqs-queue-client');
33
+ this._queueClient = new SqsQueueClient({
34
+ clientConfig: { region: process.env.AWS_REGION },
35
+ });
36
+ }
37
+ return this._queueClient;
38
+ }
39
+
13
40
  async getQueueURL(params) {
14
41
  // Passing params in because there will be multiple QueueNames
15
42
  // let params = {
16
43
  // QueueName: process.env.QueueName
17
44
  // };
18
- const command = new GetQueueUrlCommand(params);
19
- const data = await sqs.send(command);
20
- return data.QueueUrl;
45
+ return this._getQueueClient().getQueueUrl(params);
21
46
  }
22
47
 
23
48
  async run(params, context = {}) {
@@ -91,8 +116,7 @@ class Worker {
91
116
  }
92
117
 
93
118
  async sendAsyncSQSMessage(params) {
94
- const command = new SendMessageCommand(params);
95
- const data = await sqs.send(command);
119
+ const data = await this._getQueueClient().sendMessage(params);
96
120
  return data.MessageId;
97
121
  }
98
122
 
@@ -81,6 +81,15 @@ const createHandler = (optionByName = {}) => {
81
81
  if (shouldUseDatabase) {
82
82
  const { connectPrisma } = require('../database/prisma');
83
83
  await connectPrisma();
84
+
85
+ // Extension bootstraps (e.g. db-credentials swapping a
86
+ // module's env for its DB loader) run once per
87
+ // process, after the connect and before any request
88
+ // builds a module. AWS and Netlify both reach here.
89
+ const {
90
+ initializeApp,
91
+ } = require('../extensions/initialize-app');
92
+ await initializeApp();
84
93
  }
85
94
 
86
95
  // Helps reuse the database connection. Lowers response times.
@@ -1,10 +1,28 @@
1
1
  const Boom = require('@hapi/boom');
2
- const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
2
+ const {
3
+ createScopePolicy,
4
+ describeRecord,
5
+ visibilityClauses,
6
+ matchesListFilters,
7
+ SCOPES,
8
+ } = require('../../modules/entity-scope');
9
+ const { assertCanManage } = require('../../modules/use-cases/manage-access');
3
10
 
4
11
  /**
5
- * Management API v2 credential operations. A credential has no module name
6
- * of its own; its `type` and the entities that use it come from the
7
- * caller's entities. A missing or foreign credential is a 404.
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.
8
26
  */
9
27
 
10
28
  const credentialIdOf = (entity) => {
@@ -37,31 +55,82 @@ function withEntityUsage(credentials, entities) {
37
55
  });
38
56
  }
39
57
 
40
- /** The caller's credentials with their type and entity usage. */
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
+ */
41
88
  class ListCredentialsForUser {
42
- constructor({ credentialRepository, moduleRepository }) {
89
+ constructor({ credentialRepository, moduleRepository, scopePolicy }) {
43
90
  this.credentialRepository = credentialRepository;
44
91
  this.moduleRepository = moduleRepository;
92
+ this.scopePolicy = scopePolicy || createScopePolicy();
45
93
  }
46
94
 
47
- async execute(user) {
48
- const userId = user.getId();
49
- const [credentials, entities] = await Promise.all([
50
- this.credentialRepository.findCredentialsByUserId(userId),
51
- this.moduleRepository.findEntitiesByUserId(userId),
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))),
52
105
  ]);
53
- return withEntityUsage(credentials || [], entities);
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;
54
118
  }
55
119
  }
56
120
 
57
- /** One credential the caller owns, with its type and entity usage. */
121
+ /** One credential the caller may see: see the header. 404 otherwise. */
58
122
  class GetCredentialForUserV2 {
59
- constructor({ credentialRepository, moduleRepository }) {
123
+ constructor({ credentialRepository, moduleRepository, scopePolicy }) {
60
124
  this.credentialRepository = credentialRepository;
61
125
  this.moduleRepository = moduleRepository;
126
+ this.scopePolicy = scopePolicy || createScopePolicy();
62
127
  }
63
128
 
64
129
  async execute(credentialId, user) {
130
+ const notFound = () =>
131
+ Boom.notFound(`Credential ${credentialId} not found`, {
132
+ code: 'CREDENTIAL_NOT_FOUND',
133
+ });
65
134
  let credential = null;
66
135
  try {
67
136
  credential = await this.credentialRepository.findCredentialById(
@@ -72,22 +141,29 @@ class GetCredentialForUserV2 {
72
141
  throw error;
73
142
  }
74
143
  }
75
- if (!credential || !userOwns(user, credential.userId)) {
76
- throw Boom.notFound(`Credential ${credentialId} not found`, {
77
- code: 'CREDENTIAL_NOT_FOUND',
78
- });
79
- }
80
- const entities = await this.moduleRepository.findEntitiesByUserId(
81
- credential.userId
82
- );
83
- return withEntityUsage([credential], entities)[0];
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 };
84
154
  }
85
155
  }
86
156
 
87
157
  /**
88
- * Deletes a credential the caller owns. The entities that used it stay, with
89
- * their credential unset, so they show as needing re-authorization rather
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
160
  * 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.
91
167
  */
92
168
  class DeleteCredentialForUser {
93
169
  constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
@@ -101,6 +177,15 @@ class DeleteCredentialForUser {
101
177
  credentialId,
102
178
  user
103
179
  );
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
+ }
104
189
  for (const entityId of credential.entityIds) {
105
190
  await this.moduleRepository.unsetCredential(entityId);
106
191
  }
@@ -1,4 +1,5 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { assertCanManage } = require('../../modules/use-cases/manage-access');
2
3
 
3
4
  /**
4
5
  * Re-authorization of a credential the caller owns (Management API v2). It
@@ -14,7 +15,13 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
14
15
  { code: 'CREDENTIAL_TYPE_UNKNOWN' }
15
16
  );
16
17
  }
17
- return { entityType: credential.type, credentialId: String(credential.id) };
18
+ return {
19
+ entityType: credential.type,
20
+ 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,
24
+ };
18
25
  }
19
26
 
20
27
  /** GET /api/v2/credentials/:credentialId/reauthorize */
@@ -25,14 +32,12 @@ class GetCredentialReauthorization {
25
32
  }
26
33
 
27
34
  async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
28
- const binding = await credentialEntityType(
29
- this.getCredentialForUser,
30
- credentialId,
31
- user
32
- );
35
+ const { entityType, credentialId: boundCredentialId } =
36
+ await credentialEntityType(this.getCredentialForUser, credentialId, user);
33
37
  return this.getAuthorizationStep.execute({
34
38
  user,
35
- ...binding,
39
+ entityType,
40
+ credentialId: boundCredentialId,
36
41
  step,
37
42
  sessionId,
38
43
  state,
@@ -48,11 +53,13 @@ class ReauthorizeCredentialForUser {
48
53
  }
49
54
 
50
55
  async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
51
- const binding = await credentialEntityType(
56
+ const { access, ...binding } = await credentialEntityType(
52
57
  this.getCredentialForUser,
53
58
  credentialId,
54
59
  user
55
60
  );
61
+ // A credential an organization owns: the organization's admins only.
62
+ assertCanManage(access, `credential ${credentialId}`);
56
63
  return this.submitAuthorizationStep.execute({
57
64
  user,
58
65
  ...binding,