@friggframework/core 2.0.0--canary.679.a2bcc2f.0 → 2.0.0--canary.686.219e1e7.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 (143) hide show
  1. package/CLAUDE.md +28 -0
  2. package/README.md +105 -0
  3. package/application/commands/integration-commands.js +2 -2
  4. package/application/commands/scheduler-commands.js +11 -7
  5. package/application/commands/user-commands.js +17 -5
  6. package/core/Worker.js +31 -7
  7. package/core/create-handler.js +9 -0
  8. package/credential/use-cases/credential-operations-for-user.js +110 -25
  9. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  10. package/database/config.js +48 -24
  11. package/database/documentdb-utils.js +6 -1
  12. package/database/encryption/encryption-schema-registry.js +80 -12
  13. package/database/organization-where.js +18 -0
  14. package/database/prisma.js +4 -2
  15. package/database/unique-violation.js +25 -0
  16. package/extensions/bootstrap-runner.js +43 -0
  17. package/extensions/extension-loader.js +167 -0
  18. package/extensions/index.js +41 -0
  19. package/extensions/initialize-app.js +123 -0
  20. package/extensions/route-mounter.js +103 -0
  21. package/extensions/schema-composer.js +188 -0
  22. package/generated/prisma-mongodb/edge.js +27 -4
  23. package/generated/prisma-mongodb/index-browser.js +24 -1
  24. package/generated/prisma-mongodb/index.d.ts +3227 -47
  25. package/generated/prisma-mongodb/index.js +27 -4
  26. package/generated/prisma-mongodb/package.json +1 -1
  27. package/generated/prisma-mongodb/schema.prisma +65 -0
  28. package/generated/prisma-mongodb/wasm.js +27 -4
  29. package/generated/prisma-postgresql/edge.js +27 -4
  30. package/generated/prisma-postgresql/index-browser.js +24 -1
  31. package/generated/prisma-postgresql/index.d.ts +3519 -67
  32. package/generated/prisma-postgresql/index.js +27 -4
  33. package/generated/prisma-postgresql/package.json +1 -1
  34. package/generated/prisma-postgresql/schema.prisma +61 -0
  35. package/generated/prisma-postgresql/wasm.js +27 -4
  36. package/handlers/app-definition-loader.js +93 -12
  37. package/handlers/routers/auth.js +40 -12
  38. package/handlers/routers/integration-defined-routers.js +144 -92
  39. package/handlers/routers/integration-webhook-routers.js +102 -64
  40. package/index.js +58 -1
  41. package/infrastructure/scheduler/index.js +19 -6
  42. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  43. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  44. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  45. package/integrations/integration-router.js +115 -73
  46. package/integrations/repositories/integration-not-found.js +14 -0
  47. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  48. package/integrations/repositories/integration-repository-interface.js +17 -1
  49. package/integrations/repositories/integration-repository-mongo.js +59 -76
  50. package/integrations/repositories/integration-repository-postgres.js +62 -76
  51. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  52. package/integrations/use-cases/create-integration.js +55 -20
  53. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  54. package/integrations/use-cases/index.js +5 -1
  55. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  56. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  57. package/management-api/management-api-config.js +71 -2
  58. package/management-api/openapi/components.js +40 -0
  59. package/management-api/openapi/v2-operations.js +16 -4
  60. package/management-api/v2/dependencies.js +40 -15
  61. package/management-api/v2/dto.js +20 -0
  62. package/management-api/v2/handlers/authorization.js +1 -0
  63. package/management-api/v2/handlers/credentials.js +6 -2
  64. package/management-api/v2/handlers/entities.js +10 -7
  65. package/management-api/v2/handlers/integrations.js +6 -3
  66. package/management-api/v2/request.js +32 -1
  67. package/modules/entity-scope.js +15 -0
  68. package/modules/index.js +8 -0
  69. package/modules/module-factory.js +12 -0
  70. package/modules/module.js +44 -2
  71. package/modules/repositories/entity-not-found.js +18 -0
  72. package/modules/repositories/module-repository-documentdb.js +51 -6
  73. package/modules/repositories/module-repository-interface.js +19 -2
  74. package/modules/repositories/module-repository-mongo.js +67 -74
  75. package/modules/repositories/module-repository-postgres.js +66 -74
  76. package/modules/repositories/module-repository.js +2 -1
  77. package/modules/scope-config.js +192 -0
  78. package/modules/scope-policy.js +112 -0
  79. package/modules/scope-resolution.js +288 -0
  80. package/modules/scope-visibility.js +247 -0
  81. package/modules/tests/doubles/ownership-matching.js +18 -0
  82. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  83. package/modules/use-cases/create-entity-from-credential.js +113 -0
  84. package/modules/use-cases/entity-operations-for-user.js +50 -13
  85. package/modules/use-cases/entity-types.js +31 -7
  86. package/modules/use-cases/execute-entity-proxy-request.js +50 -3
  87. package/modules/use-cases/get-authorization-requirements.js +2 -2
  88. package/modules/use-cases/get-entities-for-user.js +26 -13
  89. package/modules/use-cases/get-entity-module-for-user.js +34 -7
  90. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  91. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  92. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  93. package/modules/use-cases/get-module.js +10 -4
  94. package/modules/use-cases/manage-access.js +21 -0
  95. package/modules/use-cases/multi-step-authorization.js +40 -13
  96. package/modules/use-cases/process-authorization-callback.js +53 -19
  97. package/modules/use-cases/refresh-entity-options.js +10 -4
  98. package/modules/use-cases/test-module-auth.js +10 -4
  99. package/package.json +6 -5
  100. package/prisma-mongodb/schema.prisma +65 -0
  101. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  102. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  103. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  104. package/prisma-postgresql/schema.prisma +61 -0
  105. package/providers/index.js +13 -0
  106. package/providers/resolve-provider.js +93 -0
  107. package/queues/index.js +25 -0
  108. package/queues/providers/index.js +11 -0
  109. package/queues/providers/netlify-background-provider.js +148 -0
  110. package/queues/providers/qstash-queue-provider.js +197 -0
  111. package/queues/providers/sqs-queue-provider.js +70 -0
  112. package/queues/queue-client-interface.js +55 -0
  113. package/queues/queue-message-auth.js +187 -0
  114. package/queues/queue-provider-factory.js +101 -0
  115. package/queues/queue-provider.js +62 -0
  116. package/queues/sqs-queue-client.js +81 -0
  117. package/types/index.d.ts +1 -0
  118. package/types/modules/index.d.ts +129 -0
  119. package/types/user/index.d.ts +134 -0
  120. package/user/adopter-jwt-config.js +177 -0
  121. package/user/adopter-jwt-errors.js +59 -0
  122. package/user/adopter-token-verifier.js +122 -0
  123. package/user/app-user-helpers.js +97 -0
  124. package/user/create-user-authentication.js +64 -0
  125. package/user/membership.js +51 -0
  126. package/user/repositories/organization-membership-record.js +19 -0
  127. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  128. package/user/repositories/organization-membership-repository-factory.js +42 -0
  129. package/user/repositories/organization-membership-repository-interface.js +57 -0
  130. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  131. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  132. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  133. package/user/repositories/user-repository-documentdb.js +27 -2
  134. package/user/repositories/user-repository-interface.js +18 -0
  135. package/user/repositories/user-repository-mongo.js +24 -0
  136. package/user/repositories/user-repository-postgres.js +18 -0
  137. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  138. package/user/use-cases/authenticate-user.js +7 -1
  139. package/user/use-cases/ensure-organization-membership.js +92 -0
  140. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  141. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  142. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  143. package/user/user.js +64 -1
@@ -8,6 +8,27 @@ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
8
8
 
9
9
  const nullable = (schema) => ({ ...schema, nullable: true });
10
10
 
11
+ // Whose a record is and what the caller may do with it (ADR-056 §3.4). Added to
12
+ // entities, credentials and integrations by the list and get use cases.
13
+ const tenancyProperties = {
14
+ scope: { type: 'string', enum: ['individual', 'organization'], description: 'Whose it is. `global` records are not shown to tenants.' },
15
+ owner: {
16
+ type: 'object',
17
+ properties: { type: { type: 'string', enum: ['individual', 'organization'] }, id: { type: 'string' } },
18
+ description: "The user that owns it: the person, or the organization's user",
19
+ },
20
+ organizationId: nullable({
21
+ type: 'string',
22
+ description:
23
+ 'The organization it belongs to. Null for an individual entity connected outside an organization, or under ownership.individualAcross "all".',
24
+ }),
25
+ access: {
26
+ type: 'string',
27
+ enum: ['manage', 'use', 'view'],
28
+ description: 'manage (create, reauthorize, delete), use (bind it, proxy through it) or view (metadata only)',
29
+ },
30
+ };
31
+
11
32
  const errorBody = {
12
33
  type: 'object',
13
34
  required: ['error'],
@@ -69,6 +90,7 @@ const SCHEMAS = {
69
90
  config: { type: 'object', additionalProperties: true },
70
91
  version: nullable({ type: 'string' }),
71
92
  messages: { type: 'object', additionalProperties: true },
93
+ ...tenancyProperties,
72
94
  },
73
95
  },
74
96
  ListIntegrationsResponse: {
@@ -129,6 +151,7 @@ const SCHEMAS = {
129
151
  credentialId: nullable({ type: 'string' }),
130
152
  userId: nullable({ type: 'string' }),
131
153
  authIsValid: nullable({ type: 'boolean' }),
154
+ ...tenancyProperties,
132
155
  },
133
156
  },
134
157
  ListEntitiesResponse: {
@@ -153,6 +176,16 @@ const SCHEMAS = {
153
176
  isMultiStep: { type: 'boolean' },
154
177
  stepCount: { type: 'integer', minimum: 1 },
155
178
  capabilities: {},
179
+ scope: {
180
+ type: 'object',
181
+ description:
182
+ 'The scope a new connection of this type gets and the scopes a request may choose (ADR-056 §3.1). `global` is never offered.',
183
+ properties: {
184
+ default: { type: 'string', enum: ['individual', 'organization', 'global'] },
185
+ allow: { type: 'array', items: { type: 'string', enum: ['individual', 'organization'] } },
186
+ source: { type: 'string', enum: ['binding', 'integration', 'entityType', 'app', 'default'] },
187
+ },
188
+ },
156
189
  },
157
190
  },
158
191
  ListEntityTypesResponse: {
@@ -173,6 +206,7 @@ const SCHEMAS = {
173
206
  authIsValid: nullable({ type: 'boolean' }),
174
207
  entityIds: { type: 'array', items: { type: 'string' } },
175
208
  entityCount: { type: 'integer', minimum: 0 },
209
+ ...tenancyProperties,
176
210
  createdAt: nullable({ type: 'string', format: 'date-time' }),
177
211
  updatedAt: nullable({ type: 'string', format: 'date-time' }),
178
212
  data: {
@@ -197,6 +231,12 @@ const SCHEMAS = {
197
231
  data: { type: 'object', additionalProperties: true, description: 'OAuth callback params or step form data' },
198
232
  step: { type: 'integer', minimum: 1, default: 1 },
199
233
  sessionId: { type: 'string', description: 'Required for step 2 and later' },
234
+ scope: {
235
+ type: 'string',
236
+ enum: ['individual', 'organization'],
237
+ description:
238
+ "The scope to connect at, from the entity type's allow list (ADR-056 §3.1). Omit to take the type's default. `global` is refused (403).",
239
+ },
200
240
  },
201
241
  },
202
242
  ReauthorizeRequest: {
@@ -34,6 +34,11 @@ const stepQuery = [
34
34
  query('step', 'Step number, 1-based (default 1)', { type: 'integer', minimum: 1 }),
35
35
  query('sessionId', 'Session from step 1; required for later steps'),
36
36
  ];
37
+ // ADR-056 §3.4: the same two filters on every list that mixes ownership.
38
+ const listFilters = [
39
+ query('scope', 'Only records of this scope', { type: 'string', enum: ['individual', 'organization'] }),
40
+ query('owner', 'Only the caller\'s own (`me`) or the active organization\'s (`org`)', { type: 'string', enum: ['me', 'org'] }),
41
+ ];
37
42
  const anyJson = { description: 'Defined by the integration or module' };
38
43
 
39
44
  const V2_OPERATIONS = {
@@ -41,7 +46,8 @@ const V2_OPERATIONS = {
41
46
  listIntegrations: {
42
47
  summary: "List the caller's integrations",
43
48
  description:
44
- 'Returns only `{ integrations }`. Integration types are at /api/v2/integrations/options and connected accounts at /api/v2/entities.',
49
+ 'Returns only `{ integrations }`: the caller\'s and the active organization\'s, created in that organization (ADR-056 §3.6). Each carries `scope`, `owner`, `organizationId` and `access`. Integration types are at /api/v2/integrations/options and connected accounts at /api/v2/entities.',
50
+ parameters: listFilters,
45
51
  responses: { 200: ok('Integrations', ref('ListIntegrationsResponse')), ...errors('Unauthorized') },
46
52
  },
47
53
  listIntegrationOptions: {
@@ -108,6 +114,9 @@ const V2_OPERATIONS = {
108
114
  // Entities
109
115
  listEntities: {
110
116
  summary: "List the caller's connected entities",
117
+ description:
118
+ "The active organization's entities and the caller's individual ones (ADR-056 §3.6): an individual entity belongs to the organization it was connected in, unless `ownership.individualAcross` is `all`. Each carries `scope`, `owner`, `organizationId` and `access`.",
119
+ parameters: listFilters,
111
120
  responses: { 200: ok('Entities', ref('ListEntitiesResponse')), ...errors('Unauthorized') },
112
121
  },
113
122
  listEntityTypes: {
@@ -156,14 +165,14 @@ const V2_OPERATIONS = {
156
165
  proxyEntityRequest: {
157
166
  summary: "Call the entity's API through Frigg (beta)",
158
167
  description:
159
- "ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list. The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
168
+ "ADR-052. Off unless the app sets managementApi.proxy.enable. Path-only input relative to the module's base URL; deny by default, only operations in the module's (or the app's) allow-list. ADR-056 §3.5: the caller needs use access to the entity in the active organization (an entity they cannot see there is 404), and the entity's scope must be in managementApi.proxy.scopes (default individual only; 403 PROXY_NOT_ALLOWED with details.reason 'scope', or 'access' for view-only access). The HTTP status follows the upstream status; upstream 5xx is 502 UPSTREAM_ERROR, 429 keeps Retry-After.",
160
169
  requestBody: body(ref('ProxyRequest')),
161
170
  responses: {
162
171
  200: ok('Upstream answer (the status mirrors the upstream status)', ref('ProxyResponse')),
163
172
  400: ok('INVALID_PROXY_REQUEST', ref('ProxyError')),
164
173
  401: ok('INVALID_CREDENTIALS or unauthenticated', ref('ProxyError')),
165
- 403: ok('PROXY_NOT_ALLOWED', ref('ProxyError')),
166
- 404: ok('Entity not found', ref('Error')),
174
+ 403: ok("PROXY_NOT_ALLOWED: not in the allow-list, or details.reason 'scope' / 'access'", ref('ProxyError')),
175
+ 404: ok('Entity not found, or not visible in the active organization', ref('Error')),
167
176
  413: ok('PAYLOAD_TOO_LARGE', ref('ProxyError')),
168
177
  415: ok('UNSUPPORTED_MEDIA_TYPE', ref('ProxyError')),
169
178
  429: ok('RATE_LIMITED (Retry-After passed through)', ref('ProxyError')),
@@ -175,6 +184,9 @@ const V2_OPERATIONS = {
175
184
  // Credentials
176
185
  listCredentials: {
177
186
  summary: "List the caller's credentials (masked)",
187
+ description:
188
+ 'A credential is listed through the entities that use it that the caller may see, so each organization lists only its own connections; one no entity uses is listed to its owner. Each carries `scope`, `owner`, `organizationId` and `access`, taken from who owns the credential.',
189
+ parameters: listFilters,
178
190
  responses: { 200: ok('Credentials', ref('ListCredentialsResponse')), ...errors('Unauthorized') },
179
191
  },
180
192
  getCredential: {
@@ -29,13 +29,18 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
29
29
  getEntityTypeRegistry,
30
30
  getEntityTypeDefinitions,
31
31
  } = require('../../modules/entity-type-registry');
32
- const { GetUserFromBearerToken } = require('../../user/use-cases/get-user-from-bearer-token');
33
- const { GetUserFromXFriggHeaders } = require('../../user/use-cases/get-user-from-x-frigg-headers');
34
- const { GetUserFromAdopterJwt } = require('../../user/use-cases/get-user-from-adopter-jwt');
32
+ const { createScopePolicy } = require('../../modules/scope-policy');
33
+ const {
34
+ AuthorizeEntityForUser,
35
+ } = require('../../modules/use-cases/authorize-entity-for-user');
36
+ const {
37
+ ResolveIntegrationOwnership,
38
+ } = require('../../integrations/use-cases/resolve-integration-ownership');
35
39
  const {
36
40
  AuthenticateWithSharedSecret,
37
41
  } = require('../../user/use-cases/authenticate-with-shared-secret');
38
42
  const { AuthenticateUser } = require('../../user/use-cases/authenticate-user');
43
+ const { buildUserAuthentication } = require('../../user/create-user-authentication');
39
44
  const { CreateIntegration } = require('../../integrations/use-cases/create-integration');
40
45
  const { UpdateIntegration } = require('../../integrations/use-cases/update-integration');
41
46
  const {
@@ -87,6 +92,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
87
92
  appDefinition.entityTypeRegistry ??
88
93
  getEntityTypeRegistry(appDefinition);
89
94
  const moduleDefinitions = getEntityTypeDefinitions(registry);
95
+ // Ownership by scope (ADR-056 §3): one object every use case asks.
96
+ const scopePolicy = createScopePolicy({
97
+ ownership: appDefinition.ownership,
98
+ entityTypeRegistry: registry,
99
+ });
90
100
 
91
101
  const moduleRepository = createModuleRepository();
92
102
  const integrationRepository = createIntegrationRepository();
@@ -95,9 +105,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
95
105
  const authorizationSessionRepository = createAuthorizationSessionRepository();
96
106
 
97
107
  const authenticateUser = new AuthenticateUser({
98
- getUserFromBearerToken: new GetUserFromBearerToken({ userRepository, userConfig }),
99
- getUserFromXFriggHeaders: new GetUserFromXFriggHeaders({ userRepository, userConfig }),
100
- getUserFromAdopterJwt: new GetUserFromAdopterJwt({ userRepository, userConfig }),
108
+ ...buildUserAuthentication({ userConfig, userRepository }),
101
109
  authenticateWithSharedSecret: new AuthenticateWithSharedSecret(),
102
110
  userConfig,
103
111
  });
@@ -111,10 +119,12 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
111
119
  const getEntityModuleForUser = new GetEntityModuleForUser({
112
120
  moduleRepository,
113
121
  moduleDefinitions,
122
+ scopePolicy,
114
123
  });
115
124
  const getCredentialForUser = new credentialOps.GetCredentialForUserV2({
116
125
  credentialRepository,
117
126
  moduleRepository,
127
+ scopePolicy,
118
128
  });
119
129
  const getAuthorizationRequirements = new GetAuthorizationRequirements({
120
130
  moduleDefinitions,
@@ -128,17 +138,22 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
128
138
  moduleDefinitions,
129
139
  getAuthorizationRequirements,
130
140
  authorizationSessionRepository,
131
- processAuthorizationCallback: new ProcessAuthorizationCallback({
132
- moduleRepository,
133
- credentialRepository,
134
- integrationRepository,
135
- moduleDefinitions,
141
+ authorizeEntityForUser: new AuthorizeEntityForUser({
142
+ scopePolicy,
143
+ processAuthorizationCallback: new ProcessAuthorizationCallback({
144
+ moduleRepository,
145
+ credentialRepository,
146
+ integrationRepository,
147
+ moduleDefinitions,
148
+ }),
136
149
  }),
137
150
  moduleRepository,
138
151
  credentialRepository,
152
+ scopePolicy,
139
153
  });
140
154
  const getOwnedIntegrationRecord = new integrationOps.GetOwnedIntegrationRecord({
141
155
  integrationRepository,
156
+ scopePolicy,
142
157
  });
143
158
  const getIntegrationInstanceForUser = new integrationOps.GetIntegrationInstanceForUser({
144
159
  getOwnedIntegrationRecord,
@@ -148,6 +163,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
148
163
  return {
149
164
  config,
150
165
  moduleDefinitions,
166
+ scopePolicy,
151
167
  repositories: {
152
168
  moduleRepository,
153
169
  integrationRepository,
@@ -163,6 +179,7 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
163
179
  integrationClasses,
164
180
  moduleFactory,
165
181
  moduleRepository,
182
+ scopePolicy,
166
183
  }),
167
184
  getPossibleIntegrations: new GetPossibleIntegrations({ integrationClasses }),
168
185
  createIntegrationForUser: new integrationOps.CreateIntegrationForUser({
@@ -171,7 +188,11 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
171
188
  integrationClasses,
172
189
  moduleFactory,
173
190
  }),
174
- getEntityModuleForUser,
191
+ resolveIntegrationOwnership: new ResolveIntegrationOwnership({
192
+ moduleRepository,
193
+ integrationClasses,
194
+ scopePolicy,
195
+ }),
175
196
  integrationClasses,
176
197
  }),
177
198
  getIntegrationInstanceForUser,
@@ -203,7 +224,10 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
203
224
 
204
225
  // Entities
205
226
  getEntityModuleForUser,
206
- listEntitiesForUser: new entityOps.ListEntitiesForUser({ moduleRepository }),
227
+ listEntitiesForUser: new entityOps.ListEntitiesForUser({
228
+ moduleRepository,
229
+ scopePolicy,
230
+ }),
207
231
  getEntityForUser: new entityOps.GetEntityForUser({ getEntityModuleForUser }),
208
232
  deleteEntityForUser: new entityOps.DeleteEntityForUser({
209
233
  getEntityModuleForUser,
@@ -215,14 +239,15 @@ function buildV2Dependencies({ appDefinition, config, entityTypeRegistry }) {
215
239
  refreshEntityOptionsForUser: new entityOps.RefreshEntityOptionsForUser({
216
240
  getEntityModuleForUser,
217
241
  }),
218
- listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry }),
219
- getEntityType: new GetEntityType({ entityTypeRegistry: registry }),
242
+ listEntityTypes: new ListEntityTypes({ entityTypeRegistry: registry, scopePolicy }),
243
+ getEntityType: new GetEntityType({ entityTypeRegistry: registry, scopePolicy }),
220
244
  getAuthorizationRequirements,
221
245
 
222
246
  // Credentials
223
247
  listCredentialsForUser: new credentialOps.ListCredentialsForUser({
224
248
  credentialRepository,
225
249
  moduleRepository,
250
+ scopePolicy,
226
251
  }),
227
252
  getCredentialForUser,
228
253
  deleteCredentialForUser: new credentialOps.DeleteCredentialForUser({
@@ -13,6 +13,20 @@ const idOf = (value) => {
13
13
  return String(value);
14
14
  };
15
15
 
16
+ /**
17
+ * `{ scope, owner, organizationId, access }` (ADR-056 §3.4): whose a record is
18
+ * and what the caller may do with it. Empty when the use case did not say.
19
+ */
20
+ function tenancyFields(tenancy) {
21
+ if (!tenancy) return {};
22
+ return {
23
+ scope: tenancy.scope,
24
+ owner: tenancy.owner,
25
+ organizationId: tenancy.organizationId ?? null,
26
+ access: tenancy.access,
27
+ };
28
+ }
29
+
16
30
  /** An entity (connected account). */
17
31
  function toEntityDto(entity) {
18
32
  const credential =
@@ -30,6 +44,7 @@ function toEntityDto(entity) {
30
44
  typeof credential?.authIsValid === 'boolean'
31
45
  ? credential.authIsValid
32
46
  : null,
47
+ ...tenancyFields(entity.tenancy),
33
48
  };
34
49
  }
35
50
 
@@ -42,6 +57,7 @@ function toIntegrationDto(integration) {
42
57
  config: integration.config ?? {},
43
58
  version: integration.version ?? null,
44
59
  messages: integration.messages ?? {},
60
+ ...tenancyFields(integration.tenancy),
45
61
  };
46
62
  }
47
63
 
@@ -59,6 +75,8 @@ const CREDENTIAL_METADATA = new Set([
59
75
  'type',
60
76
  'entityIds',
61
77
  'status',
78
+ // Added by the credential use cases (ADR-056 §3.4); never provider data.
79
+ 'tenancy',
62
80
  ]);
63
81
 
64
82
  /**
@@ -110,11 +128,13 @@ function maskCredential(credential) {
110
128
  entityCount: entityIds.length,
111
129
  createdAt: toIso(credential.createdAt),
112
130
  updatedAt: toIso(credential.updatedAt),
131
+ ...tenancyFields(credential.tenancy),
113
132
  data,
114
133
  };
115
134
  }
116
135
 
117
136
  module.exports = {
137
+ tenancyFields,
118
138
  toEntityDto,
119
139
  toIntegrationDto,
120
140
  maskCredential,
@@ -63,6 +63,7 @@ function createAuthorizationHandlers(useCases) {
63
63
  data: body.data,
64
64
  step,
65
65
  sessionId,
66
+ scope: optionalString(body.scope, 'scope'),
66
67
  });
67
68
  res.json(toAuthorizationResponse(result));
68
69
  },
@@ -1,4 +1,5 @@
1
1
  const { maskCredential } = require('../dto');
2
+ const { parseListFilters } = require('../request');
2
3
 
3
4
  /** Management API v2 credential handlers, keyed by operationId. */
4
5
  function createCredentialHandlers(useCases) {
@@ -6,8 +7,11 @@ function createCredentialHandlers(useCases) {
6
7
  useCases;
7
8
 
8
9
  return {
9
- async listCredentials(_req, res, user) {
10
- const credentials = await listCredentialsForUser.execute(user);
10
+ async listCredentials(req, res, user) {
11
+ const credentials = await listCredentialsForUser.execute(
12
+ user,
13
+ parseListFilters(req.query)
14
+ );
11
15
  res.json({ credentials: credentials.map(maskCredential) });
12
16
  },
13
17
 
@@ -1,5 +1,5 @@
1
1
  const { toEntityDto } = require('../dto');
2
- const { objectBody, parseStep } = require('../request');
2
+ const { objectBody, parseStep, parseListFilters } = require('../request');
3
3
 
4
4
  /** Management API v2 entity and entity-type handlers, keyed by operationId. */
5
5
  function createEntityHandlers(useCases) {
@@ -16,17 +16,20 @@ function createEntityHandlers(useCases) {
16
16
  } = useCases;
17
17
 
18
18
  return {
19
- async listEntities(_req, res, user) {
20
- const entities = await listEntitiesForUser.execute(user);
19
+ async listEntities(req, res, user) {
20
+ const entities = await listEntitiesForUser.execute(
21
+ user,
22
+ parseListFilters(req.query)
23
+ );
21
24
  res.json({ entities: entities.map(toEntityDto) });
22
25
  },
23
26
 
24
- async listEntityTypes(_req, res) {
25
- res.json({ types: await listEntityTypes.execute() });
27
+ async listEntityTypes(_req, res, user) {
28
+ res.json({ types: await listEntityTypes.execute({ user }) });
26
29
  },
27
30
 
28
- async getEntityType(req, res) {
29
- res.json(await getEntityType.execute(req.params.entityType));
31
+ async getEntityType(req, res, user) {
32
+ res.json(await getEntityType.execute(req.params.entityType, { user }));
30
33
  },
31
34
 
32
35
  // Describes a step's requirements without starting an authorization
@@ -1,5 +1,5 @@
1
1
  const { toIntegrationDto } = require('../dto');
2
- const { objectBody, optionalString } = require('../request');
2
+ const { objectBody, optionalString, parseListFilters } = require('../request');
3
3
 
4
4
  /**
5
5
  * Management API v2 integration handlers, keyed by registry operationId.
@@ -27,8 +27,11 @@ function createIntegrationHandlers(useCases) {
27
27
  );
28
28
 
29
29
  return {
30
- async listIntegrations(_req, res, user) {
31
- const integrations = await getIntegrationsForUser.execute(user.getId());
30
+ async listIntegrations(req, res, user) {
31
+ const integrations = await getIntegrationsForUser.execute(
32
+ user,
33
+ parseListFilters(req.query)
34
+ );
32
35
  res.json({ integrations: integrations.map(toIntegrationDto) });
33
36
  },
34
37
 
@@ -1,4 +1,5 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { LIST_OWNERS } = require('../../modules/scope-visibility');
2
3
 
3
4
  /** The JSON body as an object, or a 400 VALIDATION_ERROR. */
4
5
  function objectBody(req) {
@@ -51,4 +52,34 @@ function optionalString(value, name) {
51
52
  return value;
52
53
  }
53
54
 
54
- module.exports = { objectBody, requireFields, parseStep, optionalString };
55
+ const LIST_SCOPES = ['individual', 'organization'];
56
+
57
+ /**
58
+ * `?scope=individual|organization` and `?owner=me|org` on a list (ADR-056
59
+ * §3.4). Anything else is a 400.
60
+ */
61
+ function parseListFilters(query = {}) {
62
+ const scope = optionalString(query.scope, 'scope');
63
+ const owner = optionalString(query.owner, 'owner');
64
+ if (scope !== undefined && !LIST_SCOPES.includes(scope)) {
65
+ throw Boom.badRequest(`scope must be one of ${LIST_SCOPES.join(', ')}`, {
66
+ code: 'VALIDATION_ERROR',
67
+ details: { field: 'scope' },
68
+ });
69
+ }
70
+ if (owner !== undefined && !Object.hasOwn(LIST_OWNERS, owner)) {
71
+ throw Boom.badRequest('owner must be me or org', {
72
+ code: 'VALIDATION_ERROR',
73
+ details: { field: 'owner' },
74
+ });
75
+ }
76
+ return { scope, owner };
77
+ }
78
+
79
+ module.exports = {
80
+ objectBody,
81
+ requireFields,
82
+ parseStep,
83
+ optionalString,
84
+ parseListFilters,
85
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Entity scopes and ownership (ADR-056 §3), re-exported from the four files it
3
+ * is split into:
4
+ *
5
+ * scope-resolution constants, scope resolution, ownership on create
6
+ * scope-config reading and validating app-definition settings
7
+ * scope-visibility who may see which stored row
8
+ * scope-policy the object a use case asks
9
+ */
10
+ module.exports = {
11
+ ...require('./scope-resolution'),
12
+ ...require('./scope-config'),
13
+ ...require('./scope-visibility'),
14
+ ...require('./scope-policy'),
15
+ };
package/modules/index.js CHANGED
@@ -8,6 +8,11 @@ const {
8
8
  getEntityTypeRegistry,
9
9
  EntityRegistryError,
10
10
  } = require('./entity-type-registry');
11
+ const {
12
+ resolveScope,
13
+ listBindingScopes,
14
+ OwnershipConfigError,
15
+ } = require('./entity-scope');
11
16
 
12
17
  module.exports = {
13
18
  ApiKeyRequester,
@@ -18,4 +23,7 @@ module.exports = {
18
23
  ModuleFactory,
19
24
  getEntityTypeRegistry,
20
25
  EntityRegistryError,
26
+ resolveScope,
27
+ listBindingScopes,
28
+ OwnershipConfigError,
21
29
  };
@@ -45,6 +45,18 @@ class ModuleFactory {
45
45
  );
46
46
  }
47
47
 
48
+ // definition.env may be an async function (e.g. the db-credentials
49
+ // extension's bootstrap loads OAuth app credentials from the
50
+ // database); resolve it first. A static env keeps the plain
51
+ // constructor, exactly as before.
52
+ if (typeof moduleDefinition.env === 'function') {
53
+ return await Module.create({
54
+ userId,
55
+ entity,
56
+ definition: moduleDefinition,
57
+ });
58
+ }
59
+
48
60
  return new Module({
49
61
  userId,
50
62
  entity,
package/modules/module.js CHANGED
@@ -21,8 +21,9 @@ class Module extends Delegate {
21
21
  * @param {string} params.userId The user id
22
22
  * @param {Object} params.entity The entity record from the database
23
23
  * @param {string} [params.state] Optional OAuth state value forwarded to the API client (round-trips through the OAuth provider).
24
+ * @param {Object} [params.resolvedEnv] definition.env already resolved by Module.create(); defaults to the static definition.env.
24
25
  */
25
- constructor({ definition, userId = null, entity: entityObj = null, state = null }) {
26
+ constructor({ definition, userId = null, entity: entityObj = null, state = null, resolvedEnv = null }) {
26
27
  super({ definition, userId, entity: entityObj });
27
28
 
28
29
  this.validateDefinition(definition);
@@ -54,8 +55,22 @@ class Module extends Delegate {
54
55
 
55
56
  Object.assign(this, this.definition.requiredAuthMethods);
56
57
 
58
+ // A pre-resolved env (Module.create()) supports definition.env as an
59
+ // async function, e.g. OAuth app credentials loaded from the database
60
+ // by the db-credentials extension; otherwise the static object.
61
+ // No resolved env (the null default; an explicit undefined takes it
62
+ // too) means the static definition.env applies.
63
+ const envValues = resolvedEnv ?? this.definition.env;
64
+ if (typeof envValues === 'function') {
65
+ // Spreading a function yields no keys, so the API client would
66
+ // silently get no client_id / client_secret.
67
+ throw new TypeError(
68
+ `Module ${this.name}: definition.env is a function; build the module with await Module.create(...)`
69
+ );
70
+ }
71
+
57
72
  const apiParams = {
58
- ...this.definition.env,
73
+ ...envValues,
59
74
  delegate: this,
60
75
  logger: this.logger,
61
76
  ...(state ? { state } : {}),
@@ -67,6 +82,33 @@ class Module extends Delegate {
67
82
  this.api = new this.apiClass(apiParams);
68
83
  }
69
84
 
85
+ /**
86
+ * Resolves definition.env, supporting both a static object and a
87
+ * (possibly async) function.
88
+ *
89
+ * @param {Object} definition - Module definition
90
+ * @returns {Promise<Object|undefined>} Resolved env values
91
+ */
92
+ static async resolveEnv(definition) {
93
+ if (typeof definition.env === 'function') {
94
+ return await definition.env();
95
+ }
96
+ return definition.env;
97
+ }
98
+
99
+ /**
100
+ * Async factory that resolves definition.env first, so a module whose
101
+ * env is a function gets its values. Prefer it over `new Module(...)`
102
+ * wherever the call site can await.
103
+ *
104
+ * @param {Object} params - same as the constructor, minus resolvedEnv
105
+ * @returns {Promise<Module>}
106
+ */
107
+ static async create({ definition, userId = null, entity = null, state = null }) {
108
+ const resolvedEnv = await Module.resolveEnv(definition);
109
+ return new Module({ definition, userId, entity, state, resolvedEnv });
110
+ }
111
+
70
112
  getName() {
71
113
  return this.name;
72
114
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The error `findEntityById` throws for an id with no row, and the one
3
+ * predicate that recognises it (as integration-not-found.js does for
4
+ * integrations). The adapters build the error here and use cases match it
5
+ * here, so a missing entity can be told from any other failure without
6
+ * widening a regex.
7
+ *
8
+ * Why it matters: a use case that answers 404 for another tenant's entity must
9
+ * answer the same 404 for an id that does not exist. Otherwise the status code
10
+ * says which ids exist (ADR-052 §3, ADR-056 §3.6).
11
+ */
12
+ const MESSAGE = /^Entity .+ not found$/;
13
+
14
+ const entityNotFoundError = (id) => new Error(`Entity ${id} not found`);
15
+
16
+ const isEntityNotFound = (error) => MESSAGE.test(error?.message || '');
17
+
18
+ module.exports = { entityNotFoundError, isEntityNotFound };