@friggframework/core 2.0.0--canary.681.2df9ef0.0 → 2.0.0--canary.683.f45da33.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 (83) hide show
  1. package/README.md +37 -1
  2. package/application/commands/integration-commands.js +20 -10
  3. package/application/index.js +6 -2
  4. package/credential/use-cases/credential-operations-for-user.js +9 -1
  5. package/credential/use-cases/reauthorize-credential-for-user.js +14 -4
  6. package/database/encryption/encryption-schema-registry.js +7 -0
  7. package/database/organization-where.js +18 -0
  8. package/generated/prisma-mongodb/edge.js +5 -3
  9. package/generated/prisma-mongodb/index-browser.js +2 -0
  10. package/generated/prisma-mongodb/index.d.ts +115 -2
  11. package/generated/prisma-mongodb/index.js +5 -3
  12. package/generated/prisma-mongodb/package.json +1 -1
  13. package/generated/prisma-mongodb/schema.prisma +15 -0
  14. package/generated/prisma-mongodb/wasm.js +5 -3
  15. package/generated/prisma-postgresql/edge.js +5 -3
  16. package/generated/prisma-postgresql/index-browser.js +2 -0
  17. package/generated/prisma-postgresql/index.d.ts +131 -2
  18. package/generated/prisma-postgresql/index.js +5 -3
  19. package/generated/prisma-postgresql/package.json +1 -1
  20. package/generated/prisma-postgresql/schema.prisma +15 -0
  21. package/generated/prisma-postgresql/wasm.js +5 -3
  22. package/handlers/app-definition-loader.js +25 -8
  23. package/handlers/backend-utils.js +31 -25
  24. package/handlers/routers/health.js +1 -4
  25. package/handlers/workers/integration-defined-workers.js +5 -2
  26. package/index.js +10 -2
  27. package/integrations/index.js +0 -4
  28. package/integrations/integration-router.js +101 -52
  29. package/integrations/repositories/integration-not-found.js +14 -0
  30. package/integrations/repositories/integration-repository-documentdb.js +34 -3
  31. package/integrations/repositories/integration-repository-interface.js +17 -1
  32. package/integrations/repositories/integration-repository-mongo.js +59 -76
  33. package/integrations/repositories/integration-repository-postgres.js +62 -76
  34. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  35. package/integrations/use-cases/create-integration.js +55 -20
  36. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  37. package/integrations/use-cases/index.js +5 -1
  38. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  39. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  40. package/integrations/utils/map-integration-dto.js +1 -15
  41. package/management-api/management-api-config.js +3 -0
  42. package/management-api/openapi/components.js +46 -1
  43. package/management-api/openapi/v2-operations.js +14 -1
  44. package/management-api/v2/dependencies.js +58 -14
  45. package/management-api/v2/dto.js +17 -0
  46. package/management-api/v2/handlers/authorization.js +1 -0
  47. package/management-api/v2/handlers/entities.js +10 -7
  48. package/management-api/v2/handlers/integrations.js +6 -3
  49. package/management-api/v2/request.js +32 -1
  50. package/modules/entity-scope.js +15 -0
  51. package/modules/entity-type-registry.js +311 -0
  52. package/modules/index.js +14 -0
  53. package/modules/repositories/module-repository-documentdb.js +41 -1
  54. package/modules/repositories/module-repository-interface.js +19 -2
  55. package/modules/repositories/module-repository-mongo.js +64 -73
  56. package/modules/repositories/module-repository-postgres.js +63 -73
  57. package/modules/scope-config.js +177 -0
  58. package/modules/scope-policy.js +96 -0
  59. package/modules/scope-resolution.js +288 -0
  60. package/modules/scope-visibility.js +212 -0
  61. package/modules/tests/doubles/ownership-matching.js +18 -0
  62. package/modules/use-cases/authorize-entity-for-user.js +59 -0
  63. package/modules/use-cases/entity-operations-for-user.js +50 -13
  64. package/modules/use-cases/entity-types.js +62 -14
  65. package/modules/use-cases/execute-entity-proxy-request.js +15 -0
  66. package/modules/use-cases/get-entities-for-user.js +16 -5
  67. package/modules/use-cases/get-entity-module-for-user.js +23 -3
  68. package/modules/use-cases/get-entity-options-by-id.js +5 -2
  69. package/modules/use-cases/get-module.js +5 -2
  70. package/modules/use-cases/manage-access.js +21 -0
  71. package/modules/use-cases/multi-step-authorization.js +36 -12
  72. package/modules/use-cases/process-authorization-callback.js +52 -18
  73. package/modules/use-cases/refresh-entity-options.js +5 -2
  74. package/modules/use-cases/test-module-auth.js +5 -2
  75. package/package.json +5 -5
  76. package/prisma-mongodb/schema.prisma +15 -0
  77. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  78. package/prisma-postgresql/schema.prisma +15 -0
  79. package/types/index.d.ts +1 -0
  80. package/types/modules/index.d.ts +145 -0
  81. package/types/user/index.d.ts +7 -0
  82. package/user/use-cases/authenticate-user.js +7 -1
  83. package/user/user.js +24 -1
package/README.md CHANGED
@@ -393,7 +393,7 @@ value that is not a three-part JWT is not offered to the verifier at all; it fal
393
393
  | `clockToleranceSec` | `30` | Skew allowed on `exp` and `nbf`. |
394
394
  | `userIdClaim` | `sub` | App user id (required in the token). |
395
395
  | `orgIdClaim` | `org_id` | App organization id (optional). |
396
- | `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). |
396
+ | `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). A token with no role is a member for permissions too: it cannot create or change organization-level entities or integrations; `user.orgAdminRoles` (default `['ADMIN']`) names the roles that can. |
397
397
 
398
398
  Rules worth knowing:
399
399
 
@@ -421,6 +421,42 @@ Rules worth knowing:
421
421
  - Two first logins for the same new person or organization, even in different organizations, both
422
422
  succeed: a create that loses to a unique constraint finds the winner's row.
423
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
+
424
460
  ### 8. Lambda Utilities (`/lambda`)
425
461
 
426
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,
@@ -30,8 +30,9 @@ const {
30
30
  PatchIntegrationConfig,
31
31
  } = require('../../integrations/use-cases/patch-integration-config');
32
32
  const {
33
- getModulesDefinitionFromIntegrationClasses,
34
- } = require('../../integrations/utils/map-integration-dto');
33
+ getEntityTypeRegistry,
34
+ getEntityTypeDefinitions,
35
+ } = require('../../modules/entity-type-registry');
35
36
 
36
37
  const ERROR_CODE_MAP = {
37
38
  ENTITY_NOT_FOUND: 401,
@@ -51,7 +52,16 @@ function mapErrorToResponse(error) {
51
52
  };
52
53
  }
53
54
 
54
- 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 } = {}) {
55
65
  // Always use Frigg's default repositories and use cases
56
66
  const integrationRepository = createIntegrationRepository();
57
67
 
@@ -108,13 +118,13 @@ function createIntegrationCommands({ integrationClass } = {}) {
108
118
 
109
119
  const moduleRepository = createModuleRepository();
110
120
 
111
- const moduleDefinitions = getModulesDefinitionFromIntegrationClasses([
112
- integrationClass,
113
- ]);
114
-
115
121
  const moduleFactory = new ModuleFactory({
116
122
  moduleRepository,
117
- moduleDefinitions,
123
+ moduleDefinitions:
124
+ moduleDefinitions ??
125
+ getEntityTypeDefinitions(
126
+ getEntityTypeRegistry({ integrations: [integrationClass] })
127
+ ),
118
128
  });
119
129
 
120
130
  const loadIntegrationContextUseCase = new LoadIntegrationContextUseCase({
@@ -142,7 +152,7 @@ function createIntegrationCommands({ integrationClass } = {}) {
142
152
  moduleRepository,
143
153
  });
144
154
 
145
- const getIntegrationsForUserUseCase = new GetIntegrationsForUser({
155
+ const getIntegrationsForUserUseCase = new GetIntegrationsByUserId({
146
156
  integrationRepository,
147
157
  integrationClasses: [integrationClass],
148
158
  moduleFactory,
@@ -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
 
@@ -1,5 +1,7 @@
1
1
  const Boom = require('@hapi/boom');
2
2
  const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
3
+ const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
4
+ const { assertCanManage } = require('../../modules/use-cases/manage-access');
3
5
 
4
6
  /**
5
7
  * Management API v2 credential operations. A credential has no module name
@@ -90,7 +92,8 @@ class GetCredentialForUserV2 {
90
92
  * than disappearing from integrations.
91
93
  */
92
94
  class DeleteCredentialForUser {
93
- constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
95
+ constructor({ credentialRepository, moduleRepository, getCredentialForUser, scopePolicy }) {
96
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
94
97
  this.credentialRepository = credentialRepository;
95
98
  this.moduleRepository = moduleRepository;
96
99
  this.getCredentialForUser = getCredentialForUser;
@@ -101,6 +104,11 @@ class DeleteCredentialForUser {
101
104
  credentialId,
102
105
  user
103
106
  );
107
+ // A credential an organization owns: the organization's admins only.
108
+ // (null: not the person's or the active organization's, which the
109
+ // owner check above already decided.)
110
+ const access = this.scopePolicy.accessToOwnedBy(user, credential.userId);
111
+ if (access !== null) assertCanManage(access, `credential ${credential.id}`);
104
112
  for (const entityId of credential.entityIds) {
105
113
  await this.moduleRepository.unsetCredential(entityId);
106
114
  }
@@ -1,4 +1,6 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
3
+ const { assertCanManage } = require('../../modules/use-cases/manage-access');
2
4
 
3
5
  /**
4
6
  * Re-authorization of a credential the caller owns (Management API v2). It
@@ -14,7 +16,11 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
14
16
  { code: 'CREDENTIAL_TYPE_UNKNOWN' }
15
17
  );
16
18
  }
17
- return { entityType: credential.type, credentialId: String(credential.id) };
19
+ return {
20
+ entityType: credential.type,
21
+ credentialId: String(credential.id),
22
+ ownerId: credential.userId,
23
+ };
18
24
  }
19
25
 
20
26
  /** GET /api/v2/credentials/:credentialId/reauthorize */
@@ -25,7 +31,7 @@ class GetCredentialReauthorization {
25
31
  }
26
32
 
27
33
  async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
28
- const binding = await credentialEntityType(
34
+ const { ownerId, ...binding } = await credentialEntityType(
29
35
  this.getCredentialForUser,
30
36
  credentialId,
31
37
  user
@@ -42,17 +48,21 @@ class GetCredentialReauthorization {
42
48
 
43
49
  /** POST /api/v2/credentials/:credentialId/reauthorize */
44
50
  class ReauthorizeCredentialForUser {
45
- constructor({ getCredentialForUser, submitAuthorizationStep }) {
51
+ constructor({ getCredentialForUser, submitAuthorizationStep, scopePolicy }) {
52
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
46
53
  this.getCredentialForUser = getCredentialForUser;
47
54
  this.submitAuthorizationStep = submitAuthorizationStep;
48
55
  }
49
56
 
50
57
  async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
51
- const binding = await credentialEntityType(
58
+ const { ownerId, ...binding } = await credentialEntityType(
52
59
  this.getCredentialForUser,
53
60
  credentialId,
54
61
  user
55
62
  );
63
+ // A credential an organization owns: the organization's admins only.
64
+ const access = this.scopePolicy.accessToOwnedBy(user, ownerId);
65
+ if (access !== null) assertCanManage(access, `credential ${credentialId}`);
56
66
  return this.submitAuthorizationStep.execute({
57
67
  user,
58
68
  ...binding,
@@ -180,6 +180,13 @@ function loadModuleEncryptionSchemas(integrations) {
180
180
  return;
181
181
  }
182
182
 
183
+ // NOTE: this path does not exist ('../integrations' resolves to
184
+ // database/integrations), so today this function throws MODULE_NOT_FOUND,
185
+ // loadCustomEncryptionSchema swallows it, and module credentialFields are
186
+ // effectively NOT registered. Fixing it changes which fields are encrypted
187
+ // and needs a decrypt that tolerates plaintext, so it ships separately:
188
+ // branch claude/internal-mcp-03b-module-credential-encryption. Left as is
189
+ // here on purpose (ADR-059 must not change what gets encrypted).
183
190
  const { getModulesDefinitionFromIntegrationClasses } = require('../integrations/utils/map-integration-dto');
184
191
 
185
192
  const moduleDefinitions = getModulesDefinitionFromIntegrationClasses(integrations);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The Prisma `where` fragment for an `organizationId` on MongoDB (ADR-056
3
+ * §3.6), shared by the entity and integration adapters.
4
+ *
5
+ * Prisma on MongoDB: `null` matches only an explicit null and `isSet: false`
6
+ * only a missing field. A row written before the column existed is the common
7
+ * "no organization" case, so `null` here means both.
8
+ */
9
+ function organizationWhere(organizationId) {
10
+ if (organizationId === null) {
11
+ return {
12
+ OR: [{ organizationId: null }, { organizationId: { isSet: false } }],
13
+ };
14
+ }
15
+ return { organizationId };
16
+ }
17
+
18
+ module.exports = { organizationWhere };