@friggframework/core 2.0.0--canary.679.a2bcc2f.0 → 2.0.0--canary.686.87e9875.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 (136) hide show
  1. package/CLAUDE.md +25 -0
  2. package/README.md +105 -0
  3. package/application/commands/integration-commands.js +2 -2
  4. package/application/commands/user-commands.js +17 -5
  5. package/core/Worker.js +31 -7
  6. package/credential/use-cases/credential-operations-for-user.js +110 -25
  7. package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
  8. package/database/config.js +48 -24
  9. package/database/documentdb-utils.js +6 -1
  10. package/database/encryption/encryption-schema-registry.js +80 -12
  11. package/database/organization-where.js +18 -0
  12. package/database/prisma.js +4 -2
  13. package/database/unique-violation.js +25 -0
  14. package/extensions/bootstrap-runner.js +43 -0
  15. package/extensions/extension-loader.js +167 -0
  16. package/extensions/index.js +41 -0
  17. package/extensions/initialize-app.js +123 -0
  18. package/extensions/route-mounter.js +103 -0
  19. package/extensions/schema-composer.js +188 -0
  20. package/generated/prisma-mongodb/edge.js +27 -4
  21. package/generated/prisma-mongodb/index-browser.js +24 -1
  22. package/generated/prisma-mongodb/index.d.ts +3227 -47
  23. package/generated/prisma-mongodb/index.js +27 -4
  24. package/generated/prisma-mongodb/package.json +1 -1
  25. package/generated/prisma-mongodb/schema.prisma +65 -0
  26. package/generated/prisma-mongodb/wasm.js +27 -4
  27. package/generated/prisma-postgresql/edge.js +27 -4
  28. package/generated/prisma-postgresql/index-browser.js +24 -1
  29. package/generated/prisma-postgresql/index.d.ts +3519 -67
  30. package/generated/prisma-postgresql/index.js +27 -4
  31. package/generated/prisma-postgresql/package.json +1 -1
  32. package/generated/prisma-postgresql/schema.prisma +61 -0
  33. package/generated/prisma-postgresql/wasm.js +27 -4
  34. package/handlers/app-definition-loader.js +93 -12
  35. package/handlers/routers/auth.js +40 -12
  36. package/handlers/routers/integration-defined-routers.js +144 -92
  37. package/handlers/routers/integration-webhook-routers.js +102 -64
  38. package/index.js +58 -1
  39. package/infrastructure/scheduler/index.js +19 -6
  40. package/infrastructure/scheduler/netlify-scheduler-adapter.js +185 -0
  41. package/infrastructure/scheduler/scheduler-service-factory.js +36 -7
  42. package/integrations/integration-router.js +115 -73
  43. package/integrations/repositories/integration-not-found.js +14 -0
  44. package/integrations/repositories/integration-repository-documentdb.js +35 -3
  45. package/integrations/repositories/integration-repository-interface.js +17 -1
  46. package/integrations/repositories/integration-repository-mongo.js +59 -76
  47. package/integrations/repositories/integration-repository-postgres.js +62 -76
  48. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  49. package/integrations/use-cases/create-integration.js +55 -20
  50. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  51. package/integrations/use-cases/index.js +5 -1
  52. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  53. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  54. package/management-api/management-api-config.js +71 -2
  55. package/management-api/openapi/components.js +40 -0
  56. package/management-api/openapi/v2-operations.js +16 -4
  57. package/management-api/v2/dependencies.js +40 -15
  58. package/management-api/v2/dto.js +20 -0
  59. package/management-api/v2/handlers/authorization.js +1 -0
  60. package/management-api/v2/handlers/credentials.js +6 -2
  61. package/management-api/v2/handlers/entities.js +10 -7
  62. package/management-api/v2/handlers/integrations.js +6 -3
  63. package/management-api/v2/request.js +32 -1
  64. package/modules/entity-scope.js +15 -0
  65. package/modules/index.js +8 -0
  66. package/modules/module-factory.js +12 -0
  67. package/modules/module.js +35 -2
  68. package/modules/repositories/entity-not-found.js +18 -0
  69. package/modules/repositories/module-repository-documentdb.js +51 -6
  70. package/modules/repositories/module-repository-interface.js +19 -2
  71. package/modules/repositories/module-repository-mongo.js +67 -74
  72. package/modules/repositories/module-repository-postgres.js +66 -74
  73. package/modules/repositories/module-repository.js +2 -1
  74. package/modules/scope-config.js +192 -0
  75. package/modules/scope-policy.js +112 -0
  76. package/modules/scope-resolution.js +288 -0
  77. package/modules/scope-visibility.js +247 -0
  78. package/modules/tests/doubles/ownership-matching.js +18 -0
  79. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  80. package/modules/use-cases/create-entity-from-credential.js +113 -0
  81. package/modules/use-cases/entity-operations-for-user.js +50 -13
  82. package/modules/use-cases/entity-types.js +31 -7
  83. package/modules/use-cases/execute-entity-proxy-request.js +50 -3
  84. package/modules/use-cases/get-entities-for-user.js +16 -5
  85. package/modules/use-cases/get-entity-module-for-user.js +32 -5
  86. package/modules/use-cases/get-entity-options-by-id.js +9 -3
  87. package/modules/use-cases/get-module.js +9 -3
  88. package/modules/use-cases/manage-access.js +21 -0
  89. package/modules/use-cases/multi-step-authorization.js +39 -12
  90. package/modules/use-cases/process-authorization-callback.js +52 -18
  91. package/modules/use-cases/refresh-entity-options.js +9 -3
  92. package/modules/use-cases/test-module-auth.js +9 -3
  93. package/package.json +6 -5
  94. package/prisma-mongodb/schema.prisma +65 -0
  95. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  96. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  97. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  98. package/prisma-postgresql/schema.prisma +61 -0
  99. package/providers/index.js +13 -0
  100. package/providers/resolve-provider.js +93 -0
  101. package/queues/index.js +25 -0
  102. package/queues/providers/index.js +11 -0
  103. package/queues/providers/netlify-background-provider.js +76 -0
  104. package/queues/providers/qstash-queue-provider.js +122 -0
  105. package/queues/providers/sqs-queue-provider.js +61 -0
  106. package/queues/queue-client-interface.js +55 -0
  107. package/queues/queue-provider-factory.js +101 -0
  108. package/queues/queue-provider.js +46 -0
  109. package/queues/sqs-queue-client.js +81 -0
  110. package/types/index.d.ts +1 -0
  111. package/types/modules/index.d.ts +129 -0
  112. package/types/user/index.d.ts +134 -0
  113. package/user/adopter-jwt-config.js +177 -0
  114. package/user/adopter-jwt-errors.js +59 -0
  115. package/user/adopter-token-verifier.js +122 -0
  116. package/user/app-user-helpers.js +97 -0
  117. package/user/create-user-authentication.js +64 -0
  118. package/user/membership.js +51 -0
  119. package/user/repositories/organization-membership-record.js +19 -0
  120. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  121. package/user/repositories/organization-membership-repository-factory.js +42 -0
  122. package/user/repositories/organization-membership-repository-interface.js +57 -0
  123. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  124. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  125. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  126. package/user/repositories/user-repository-documentdb.js +27 -2
  127. package/user/repositories/user-repository-interface.js +18 -0
  128. package/user/repositories/user-repository-mongo.js +24 -0
  129. package/user/repositories/user-repository-postgres.js +18 -0
  130. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  131. package/user/use-cases/authenticate-user.js +7 -1
  132. package/user/use-cases/ensure-organization-membership.js +92 -0
  133. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  134. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  135. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  136. package/user/user.js +64 -1
@@ -3,6 +3,8 @@ const { FetchError } = require('../../errors/fetch-error');
3
3
  const { getLogger } = require('../../logs');
4
4
  const { AuthorizationSession } = require('../domain/authorization-session');
5
5
  const { findDefinition, getAuthStepCount } = require('./entity-types');
6
+ const { describeRecord } = require('../scope-visibility');
7
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
6
8
 
7
9
  const log = getLogger('frigg.api.authorize');
8
10
 
@@ -144,21 +146,30 @@ class SubmitAuthorizationStep {
144
146
  moduleDefinitions,
145
147
  getAuthorizationRequirements,
146
148
  authorizationSessionRepository,
147
- processAuthorizationCallback,
149
+ authorizeEntityForUser,
148
150
  moduleRepository,
149
151
  credentialRepository,
150
152
  createModule,
153
+ scopePolicy,
151
154
  }) {
155
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
152
156
  this.moduleDefinitions = moduleDefinitions || [];
153
157
  this.getAuthorizationRequirements = getAuthorizationRequirements;
154
158
  this.sessions = authorizationSessionRepository;
155
- this.processAuthorizationCallback = processAuthorizationCallback;
159
+ this.authorizeEntityForUser = authorizeEntityForUser;
156
160
  this.moduleRepository = moduleRepository;
157
161
  this.credentialRepository = credentialRepository;
158
162
  this.createModule = createModule || defaultCreateModule;
159
163
  }
160
164
 
161
- async execute({ user, entityType, data, step = 1, sessionId, credentialId = null }) {
165
+ /**
166
+ * @param {Object} params
167
+ * @param {string} [params.scope] - the scope the caller asks for, from the
168
+ * deciding level's allow list (ADR-056 §3.1). Never `global`.
169
+ * @param {string} [params.fixedScope] - re-authorization keeps the scope the
170
+ * credential already has, whatever the config says now.
171
+ */
172
+ async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope, fixedScope }) {
162
173
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
163
174
  throw Boom.badRequest('data must be an object', {
164
175
  code: 'VALIDATION_ERROR',
@@ -166,7 +177,16 @@ class SubmitAuthorizationStep {
166
177
  });
167
178
  }
168
179
  const definition = findDefinition(this.moduleDefinitions, entityType);
180
+ // `userId` binds a session to the caller and is what GET /authorize
181
+ // computes too. The owner of the entity this creates is decided by
182
+ // scope (§3.2), not by user.primary, and is `ownership.ownerId`.
169
183
  const userId = user.getId();
184
+ const ownership = this.authorizeEntityForUser.resolveOwnership({
185
+ user,
186
+ entityType,
187
+ scope,
188
+ fixedScope,
189
+ });
170
190
 
171
191
  if (!isMultiStep(definition)) {
172
192
  if (step !== 1) {
@@ -174,7 +194,7 @@ class SubmitAuthorizationStep {
174
194
  code: 'INVALID_STEP',
175
195
  });
176
196
  }
177
- return this.complete({ userId, entityType, params: data, credentialId });
197
+ return this.complete({ ownership, user, entityType, params: data, credentialId });
178
198
  }
179
199
 
180
200
  const totalSteps = getAuthStepCount(definition);
@@ -205,7 +225,7 @@ class SubmitAuthorizationStep {
205
225
  );
206
226
  }
207
227
 
208
- const module = this.createModule({ userId, definition });
228
+ const module = this.createModule({ userId: ownership.ownerId, definition });
209
229
  let result;
210
230
  try {
211
231
  result = await definition.processAuthorizationStep(
@@ -224,7 +244,8 @@ class SubmitAuthorizationStep {
224
244
  session.markComplete();
225
245
  await this.sessions.update(session);
226
246
  const outcome = await this.complete({
227
- userId,
247
+ ownership,
248
+ user,
228
249
  entityType,
229
250
  params: result.authData || {},
230
251
  credentialId,
@@ -261,14 +282,15 @@ class SubmitAuthorizationStep {
261
282
  };
262
283
  }
263
284
 
264
- async complete({ userId, entityType, params, credentialId }) {
285
+ async complete({ ownership, user, entityType, params, credentialId }) {
265
286
  let callback;
266
287
  try {
267
- callback = await this.processAuthorizationCallback.execute(
268
- userId,
288
+ ({ result: callback } = await this.authorizeEntityForUser.execute({
289
+ user,
269
290
  entityType,
270
- params
271
- );
291
+ data: params,
292
+ ownership,
293
+ }));
272
294
  } catch (error) {
273
295
  throw toAuthorizationError(error);
274
296
  }
@@ -276,13 +298,18 @@ class SubmitAuthorizationStep {
276
298
  this.moduleRepository.findEntityById(callback.entity_id),
277
299
  this.credentialRepository.findCredentialById(callback.credential_id),
278
300
  ]);
301
+ // What the new entity is to this caller (§3.4), so a client need not list again.
302
+ const tenancy = entity
303
+ ? describeRecord(entity, this.scopePolicy.accessContext(user), 'entity')
304
+ : null;
279
305
  const outcome = {
280
306
  status: 'complete',
281
- entity,
307
+ entity: entity && tenancy ? { ...entity, tenancy } : entity,
282
308
  credential: credential && {
283
309
  ...credential,
284
310
  type: entityType,
285
311
  entityIds: entity ? [String(entity.id)] : [],
312
+ ...(tenancy && { tenancy }),
286
313
  },
287
314
  };
288
315
  if (credentialId && String(callback.credential_id) !== String(credentialId)) {
@@ -1,5 +1,16 @@
1
1
  const { Module } = require('../module');
2
2
  const { ModuleConstants } = require('../ModuleConstants');
3
+ const { pickExistingEntity } = require('../scope-resolution');
4
+
5
+ /**
6
+ * What an entity belongs to when the caller says nothing: the person's own,
7
+ * outside any organization.
8
+ */
9
+ const DEFAULT_TENANCY = Object.freeze({
10
+ scope: 'individual',
11
+ organizationId: null,
12
+ legacyOrgId: null,
13
+ });
3
14
  const { getLogger } = require('../../logs');
4
15
 
5
16
  const log = getLogger('frigg.modules.authorization_callback');
@@ -30,7 +41,17 @@ class ProcessAuthorizationCallback {
30
41
  this.integrationRepository = integrationRepository;
31
42
  }
32
43
 
33
- async execute(userId, entityType, params) {
44
+ /**
45
+ * @param {string} userId - the OWNER of what this creates (ADR-056 §3.2): the
46
+ * person for `individual`, the organization's user for `organization`.
47
+ * Not necessarily the caller; callers resolve it from the scope.
48
+ * @param {string} entityType
49
+ * @param {Object} params - what the module's authorization needs
50
+ * @param {{scope: string, organizationId: (string|null), legacyOrgId: (string|null)}} [tenancy]
51
+ * the entity's scope and organization (§3.6); default: the person's own,
52
+ * with no organization
53
+ */
54
+ async execute(userId, entityType, params, tenancy = DEFAULT_TENANCY) {
34
55
  log.debug('Authorization callback started', {
35
56
  eventName: `${log.name}.started`,
36
57
  userId,
@@ -57,10 +78,7 @@ class ProcessAuthorizationCallback {
57
78
  userId,
58
79
  entityType
59
80
  );
60
- const existingEntity =
61
- existingEntities && existingEntities.length > 0
62
- ? existingEntities[0]
63
- : null;
81
+ const existingEntity = pickExistingEntity(existingEntities, tenancy);
64
82
 
65
83
  if (existingEntity) {
66
84
  log.debug('Existing entity found', {
@@ -156,7 +174,8 @@ class ProcessAuthorizationCallback {
156
174
  const persistedEntity = await this.findOrCreateEntity(
157
175
  entityDetails,
158
176
  entityType,
159
- module.credential.id
177
+ module.credential.id,
178
+ tenancy
160
179
  );
161
180
  log.debug('Authorization callback completed', {
162
181
  eventName: `${log.name}.completed`,
@@ -233,7 +252,12 @@ class ProcessAuthorizationCallback {
233
252
  module.credential = persisted;
234
253
  }
235
254
 
236
- async findOrCreateEntity(entityDetails, moduleName, credentialId) {
255
+ async findOrCreateEntity(
256
+ entityDetails,
257
+ moduleName,
258
+ credentialId,
259
+ tenancy = DEFAULT_TENANCY
260
+ ) {
237
261
  const { identifiers, details } = entityDetails;
238
262
 
239
263
  // Support both 'user' and 'userId' field names from module definitions
@@ -247,11 +271,16 @@ class ProcessAuthorizationCallback {
247
271
  );
248
272
  }
249
273
 
250
- const existingEntity = await this.moduleRepository.findEntity({
251
- externalId: identifiers.externalId,
252
- user: userId,
253
- moduleName: moduleName,
254
- });
274
+ // The same provider account in two organizations is two entities
275
+ // (ADR-056 §3.6), so the match is per organization.
276
+ const existingEntity = pickExistingEntity(
277
+ await this.moduleRepository.findEntities({
278
+ externalId: identifiers.externalId,
279
+ user: userId,
280
+ moduleName: moduleName,
281
+ }),
282
+ tenancy
283
+ );
255
284
 
256
285
  if (existingEntity) {
257
286
  // Repoint the entity's credentialId when re-auth produced a
@@ -282,12 +311,17 @@ class ProcessAuthorizationCallback {
282
311
  return existingEntity;
283
312
  }
284
313
 
285
- return await this.moduleRepository.createEntity({
286
- ...identifiers,
287
- ...details,
288
- moduleName: moduleName,
289
- credential: credentialId,
290
- });
314
+ // `organizationId` is not part of the entity data: that carries provider
315
+ // fields, and one that happens to be called organizationId is theirs.
316
+ return await this.moduleRepository.createEntity(
317
+ {
318
+ ...identifiers,
319
+ ...details,
320
+ moduleName: moduleName,
321
+ credential: credentialId,
322
+ },
323
+ { organizationId: tenancy.organizationId ?? null }
324
+ );
291
325
  }
292
326
  }
293
327
 
@@ -1,4 +1,5 @@
1
1
  const { Module } = require('../module');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
2
3
 
3
4
  class RefreshEntityOptions {
4
5
  /**
@@ -6,9 +7,11 @@ class RefreshEntityOptions {
6
7
  * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
7
8
  * @param {} params.moduleDefinitions
8
9
  */
9
- constructor({ moduleRepository, moduleDefinitions }) {
10
+ constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
10
11
  this.moduleRepository = moduleRepository;
11
12
  this.moduleDefinitions = moduleDefinitions;
13
+ // Who may load an entity follows its scope (ADR-056 §3.6).
14
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
12
15
  }
13
16
 
14
17
  /**
@@ -36,7 +39,7 @@ class RefreshEntityOptions {
36
39
 
37
40
  // Validate entity ownership
38
41
  const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
39
- ? userIdOrUser.ownsUserId(entity.userId)
42
+ ? this.scopePolicy.canUseEntity(userIdOrUser, entity)
40
43
  : entity.userId?.toString() === userId?.toString();
41
44
 
42
45
  if (!isOwned) {
@@ -58,8 +61,11 @@ class RefreshEntityOptions {
58
61
  );
59
62
  }
60
63
 
64
+ // Built for the entity's OWNER, not the caller: a caller may use an
65
+ // organization's entity, and a token refresh during this call must
66
+ // update the owner's credential, not create one under the caller.
61
67
  const module = new Module({
62
- userId,
68
+ userId: entity.userId ?? userId,
63
69
  entity,
64
70
  definition: moduleDefinition,
65
71
  });
@@ -1,4 +1,5 @@
1
1
  const { Module } = require('../module');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
2
3
 
3
4
  class TestModuleAuth {
4
5
  /**
@@ -6,9 +7,11 @@ class TestModuleAuth {
6
7
  * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository - Repository for module data operations.
7
8
  * @param {Array<Object>} params.moduleDefinitions - Array of module definitions.
8
9
  */
9
- constructor({ moduleRepository, moduleDefinitions }) {
10
+ constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
10
11
  this.moduleRepository = moduleRepository;
11
12
  this.moduleDefinitions = moduleDefinitions;
13
+ // Who may load an entity follows its scope (ADR-056 §3.6).
14
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
12
15
  }
13
16
 
14
17
  /**
@@ -35,7 +38,7 @@ class TestModuleAuth {
35
38
 
36
39
  // Validate entity ownership
37
40
  const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
38
- ? userIdOrUser.ownsUserId(entity.userId)
41
+ ? this.scopePolicy.canUseEntity(userIdOrUser, entity)
39
42
  : entity.userId?.toString() === userId?.toString();
40
43
 
41
44
  if (!isOwned) {
@@ -57,8 +60,11 @@ class TestModuleAuth {
57
60
  );
58
61
  }
59
62
 
63
+ // Built for the entity's OWNER, not the caller: a caller may use an
64
+ // organization's entity, and a token refresh during this call must
65
+ // update the owner's credential, not create one under the caller.
60
66
  const module = new Module({
61
- userId,
67
+ userId: entity.userId ?? userId,
62
68
  entity,
63
69
  definition: moduleDefinition,
64
70
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.679.a2bcc2f.0",
4
+ "version": "2.0.0--canary.686.87e9875.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -28,6 +28,7 @@
28
28
  "express-async-handler": "^1.2.0",
29
29
  "form-data": "^4.0.6",
30
30
  "fs-extra": "^11.2.0",
31
+ "jose": "^5.10.0",
31
32
  "lodash": "4.18.1",
32
33
  "lodash.get": "^4.4.2",
33
34
  "node-fetch": "^2.6.7",
@@ -47,9 +48,9 @@
47
48
  }
48
49
  },
49
50
  "devDependencies": {
50
- "@friggframework/eslint-config": "2.0.0--canary.679.a2bcc2f.0",
51
- "@friggframework/prettier-config": "2.0.0--canary.679.a2bcc2f.0",
52
- "@friggframework/test": "2.0.0--canary.679.a2bcc2f.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.686.87e9875.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.686.87e9875.0",
53
+ "@friggframework/test": "2.0.0--canary.686.87e9875.0",
53
54
  "@prisma/client": "^6.19.3",
54
55
  "@types/lodash": "4.17.15",
55
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -89,5 +90,5 @@
89
90
  "publishConfig": {
90
91
  "access": "public"
91
92
  },
92
- "gitHead": "a2bcc2f124b0574c0d868beff77b4da848fd7849"
93
+ "gitHead": "87e9875d4dba36bf3bb90400e862d5fb9db17ae1"
93
94
  }
@@ -56,6 +56,14 @@ model User {
56
56
  integrations Integration[]
57
57
  processes Process[]
58
58
 
59
+ // Organization memberships (ADR-056 §3.6). `organizationId` above is the legacy
60
+ // single-org pointer: set on a person's first link, never moved afterwards, and
61
+ // read as an implicit membership when no row exists for it.
62
+ // As an individual: the organizations this person belongs to.
63
+ memberships OrganizationMembership[] @relation("IndividualMemberships")
64
+ // As an organization: the people who belong to it.
65
+ orgMembers OrganizationMembership[] @relation("OrganizationMemberships")
66
+
59
67
  @@unique([username, appUserId])
60
68
  @@index([type])
61
69
  @@index([appUserId])
@@ -126,6 +134,14 @@ model Entity {
126
134
  moduleName String?
127
135
  externalId String?
128
136
 
137
+ // Organization this entity was connected in (ADR-056 §3.6): the active
138
+ // organization for `individual`, the owner for `organization`, null for
139
+ // `global`. Null on an `individual` entity is also what every row written
140
+ // before this column existed has, and what `individualAcross: 'all'` writes.
141
+ // Deliberately not a relation: a deleted organization must leave its rows
142
+ // invisible, not turn them into "unscoped" ones (SetNull would do that).
143
+ organizationId String? @db.ObjectId
144
+
129
145
  data Json @default("{}")
130
146
 
131
147
  createdAt DateTime @default(now())
@@ -142,6 +158,7 @@ model Entity {
142
158
  associationObjects AssociationObject[]
143
159
 
144
160
  @@index([userId])
161
+ @@index([userId, organizationId])
145
162
  @@index([externalId])
146
163
  @@index([moduleName])
147
164
  @@index([credentialId])
@@ -159,6 +176,11 @@ model Integration {
159
176
  user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
160
177
  status IntegrationStatus @default(IN_CREATION)
161
178
 
179
+ // Organization this integration was created in (ADR-056 §3.6): the active
180
+ // organization at create time. Null for rows that predate the column. Not a
181
+ // relation, for the same reason as Entity.organizationId.
182
+ organizationId String? @db.ObjectId
183
+
162
184
  // Configuration and version
163
185
  config Json? // Integration configuration object
164
186
  version String?
@@ -183,6 +205,7 @@ model Integration {
183
205
  processes Process[]
184
206
 
185
207
  @@index([userId])
208
+ @@index([userId, organizationId])
186
209
  @@index([status])
187
210
  @@map("Integration")
188
211
  }
@@ -480,3 +503,45 @@ model AuthorizationSession {
480
503
  @@index([expiresAt])
481
504
  @@map("AuthorizationSession")
482
505
  }
506
+
507
+ // ============================================================================
508
+ // ORGANIZATION MEMBERSHIP (ADR-056 §3.6)
509
+ // ============================================================================
510
+
511
+ /// One person's membership of one organization. Unique on the pair, so adding a
512
+ /// membership is an idempotent upsert. `orgRole` is the organization role the
513
+ /// adopter token carried when the membership was created (MEMBER when it named none). NoAction on delete
514
+ /// because MongoDB gives no referential integrity: delete a user's memberships
515
+ /// before the user.
516
+ model OrganizationMembership {
517
+ id String @id @default(auto()) @map("_id") @db.ObjectId
518
+ individualUserId String @db.ObjectId
519
+ organizationUserId String @db.ObjectId
520
+ orgRole String @default("MEMBER")
521
+ createdAt DateTime @default(now())
522
+
523
+ individualUser User @relation("IndividualMemberships", fields: [individualUserId], references: [id], onDelete: NoAction, onUpdate: NoAction)
524
+ organizationUser User @relation("OrganizationMemberships", fields: [organizationUserId], references: [id], onDelete: NoAction, onUpdate: NoAction)
525
+
526
+ @@unique([individualUserId, organizationUserId])
527
+ @@index([organizationUserId])
528
+ @@map("OrganizationMembership")
529
+ }
530
+
531
+ /// One-time scheduled jobs for poll-and-dispatch scheduling pattern.
532
+ /// Used by providers without native one-time scheduling APIs (e.g., Netlify).
533
+ /// A cron function queries for due jobs and dispatches them to a queue.
534
+ model ScheduledJob {
535
+ id String @id @default(auto()) @map("_id") @db.ObjectId
536
+ scheduleName String @unique
537
+ scheduledAt DateTime
538
+ queueResourceId String
539
+ payload Json?
540
+ state String @default("PENDING")
541
+
542
+ createdAt DateTime @default(now())
543
+ updatedAt DateTime @updatedAt
544
+
545
+ @@index([state, scheduledAt])
546
+ @@map("ScheduledJob")
547
+ }
@@ -0,0 +1,26 @@
1
+ -- CreateTable
2
+ -- Organization memberships (ADR-056 §3.6). One row per person per organization.
3
+ -- "User"."organizationId" stays as the legacy single-org pointer. It is NOT
4
+ -- backfilled into this table: the domain (user/membership.js) reads it as an
5
+ -- implicit membership when no row exists (see docs/guides/organization-memberships.md).
6
+ CREATE TABLE "OrganizationMembership" (
7
+ "id" SERIAL NOT NULL,
8
+ "individualUserId" INTEGER NOT NULL,
9
+ "organizationUserId" INTEGER NOT NULL,
10
+ "orgRole" TEXT NOT NULL DEFAULT 'MEMBER',
11
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+
13
+ CONSTRAINT "OrganizationMembership_pkey" PRIMARY KEY ("id")
14
+ );
15
+
16
+ -- CreateIndex
17
+ CREATE INDEX "OrganizationMembership_organizationUserId_idx" ON "OrganizationMembership"("organizationUserId");
18
+
19
+ -- CreateIndex
20
+ CREATE UNIQUE INDEX "OrganizationMembership_individualUserId_organizationUserId_key" ON "OrganizationMembership"("individualUserId", "organizationUserId");
21
+
22
+ -- AddForeignKey
23
+ ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_individualUserId_fkey" FOREIGN KEY ("individualUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
24
+
25
+ -- AddForeignKey
26
+ ALTER TABLE "OrganizationMembership" ADD CONSTRAINT "OrganizationMembership_organizationUserId_fkey" FOREIGN KEY ("organizationUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -0,0 +1,25 @@
1
+ -- Entity and Integration carry the organization they were created in (ADR-056 §3.6).
2
+ -- Nullable, no default and no backfill: a row written before this migration keeps
3
+ -- NULL, and the domain (modules/entity-scope.js) reads NULL as "predates scoping"
4
+ -- (docs/guides/entity-scopes.md). No scope column: the owner's user type says it.
5
+ -- Deliberately no foreign key: a deleted organization must leave its rows
6
+ -- invisible, and ON DELETE SET NULL would turn them into unscoped ones.
7
+ --
8
+ -- Release note: the two CREATE INDEX statements below are plain (not
9
+ -- CONCURRENTLY, which Prisma migrations cannot run inside a transaction). Each
10
+ -- takes a SHARE lock on its table while it builds, so writes to "Entity" and
11
+ -- "Integration" wait for it. Both tables are small in a typical Frigg
12
+ -- deployment; on a very large one, create the indexes by hand first with
13
+ -- CREATE INDEX CONCURRENTLY IF NOT EXISTS under the same names, then deploy.
14
+
15
+ -- AlterTable
16
+ ALTER TABLE "Entity" ADD COLUMN "organizationId" INTEGER;
17
+
18
+ -- AlterTable
19
+ ALTER TABLE "Integration" ADD COLUMN "organizationId" INTEGER;
20
+
21
+ -- CreateIndex
22
+ CREATE INDEX "Entity_userId_organizationId_idx" ON "Entity"("userId", "organizationId");
23
+
24
+ -- CreateIndex
25
+ CREATE INDEX "Integration_userId_organizationId_idx" ON "Integration"("userId", "organizationId");
@@ -0,0 +1,28 @@
1
+ -- One-time scheduled jobs for the poll-and-dispatch scheduler
2
+ -- (NetlifySchedulerAdapter, friggframework/frigg#545): a cron function reads
3
+ -- PENDING rows that are due and dispatches them to a queue.
4
+ --
5
+ -- IF NOT EXISTS on purpose: #545 shipped this model in the Prisma schema with
6
+ -- no migration, so an app that ran its canary may already have the table from
7
+ -- `prisma db push`. On such a database this migration is a no-op; on a fresh
8
+ -- one it is the SQL `prisma migrate diff` generates.
9
+
10
+ -- CreateTable
11
+ CREATE TABLE IF NOT EXISTS "ScheduledJob" (
12
+ "id" SERIAL NOT NULL,
13
+ "scheduleName" TEXT NOT NULL,
14
+ "scheduledAt" TIMESTAMP(3) NOT NULL,
15
+ "queueResourceId" TEXT NOT NULL,
16
+ "payload" JSONB,
17
+ "state" TEXT NOT NULL DEFAULT 'PENDING',
18
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
19
+ "updatedAt" TIMESTAMP(3) NOT NULL,
20
+
21
+ CONSTRAINT "ScheduledJob_pkey" PRIMARY KEY ("id")
22
+ );
23
+
24
+ -- CreateIndex
25
+ CREATE UNIQUE INDEX IF NOT EXISTS "ScheduledJob_scheduleName_key" ON "ScheduledJob"("scheduleName");
26
+
27
+ -- CreateIndex
28
+ CREATE INDEX IF NOT EXISTS "ScheduledJob_state_scheduledAt_idx" ON "ScheduledJob"("state", "scheduledAt");
@@ -56,6 +56,14 @@ model User {
56
56
  integrations Integration[]
57
57
  processes Process[]
58
58
 
59
+ // Organization memberships (ADR-056 §3.6). `organizationId` above is the legacy
60
+ // single-org pointer: set on a person's first link, never moved afterwards, and
61
+ // read as an implicit membership when no row exists for it.
62
+ // As an individual: the organizations this person belongs to.
63
+ memberships OrganizationMembership[] @relation("IndividualMemberships")
64
+ // As an organization: the people who belong to it.
65
+ orgMembers OrganizationMembership[] @relation("OrganizationMemberships")
66
+
59
67
  @@unique([username, appUserId])
60
68
  @@index([type])
61
69
  @@index([appUserId])
@@ -123,6 +131,14 @@ model Entity {
123
131
  moduleName String?
124
132
  externalId String?
125
133
 
134
+ // Organization this entity was connected in (ADR-056 §3.6): the active
135
+ // organization for `individual`, the owner for `organization`, null for
136
+ // `global`. Null on an `individual` entity is also what every row written
137
+ // before this column existed has, and what `individualAcross: 'all'` writes.
138
+ // Deliberately not a relation: a deleted organization must leave its rows
139
+ // invisible, not turn them into "unscoped" ones (SetNull would do that).
140
+ organizationId Int?
141
+
126
142
  data Json @default("{}")
127
143
 
128
144
  createdAt DateTime @default(now())
@@ -136,6 +152,7 @@ model Entity {
136
152
  associationObjects AssociationObject[]
137
153
 
138
154
  @@index([userId])
155
+ @@index([userId, organizationId])
139
156
  @@index([externalId])
140
157
  @@index([moduleName])
141
158
  @@index([credentialId])
@@ -152,6 +169,11 @@ model Integration {
152
169
  user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
153
170
  status IntegrationStatus @default(IN_CREATION)
154
171
 
172
+ // Organization this integration was created in (ADR-056 §3.6): the active
173
+ // organization at create time. Null for rows that predate the column. Not a
174
+ // relation, for the same reason as Entity.organizationId.
175
+ organizationId Int?
176
+
155
177
  // Configuration and version
156
178
  config Json? // Integration configuration object
157
179
  version String?
@@ -175,6 +197,7 @@ model Integration {
175
197
  processes Process[]
176
198
 
177
199
  @@index([userId])
200
+ @@index([userId, organizationId])
178
201
  @@index([status])
179
202
  }
180
203
 
@@ -460,3 +483,41 @@ model AuthorizationSession {
460
483
  @@index([userId, entityType])
461
484
  @@index([expiresAt])
462
485
  }
486
+
487
+ // ============================================================================
488
+ // ORGANIZATION MEMBERSHIP (ADR-056 §3.6)
489
+ // ============================================================================
490
+
491
+ /// One person's membership of one organization. Unique on the pair, so adding a
492
+ /// membership is an idempotent upsert. `orgRole` is the organization role the
493
+ /// adopter token carried when the membership was created (MEMBER when it named none).
494
+ model OrganizationMembership {
495
+ id Int @id @default(autoincrement())
496
+ individualUserId Int
497
+ organizationUserId Int
498
+ orgRole String @default("MEMBER")
499
+ createdAt DateTime @default(now())
500
+
501
+ individualUser User @relation("IndividualMemberships", fields: [individualUserId], references: [id], onDelete: Cascade, onUpdate: NoAction)
502
+ organizationUser User @relation("OrganizationMemberships", fields: [organizationUserId], references: [id], onDelete: Cascade, onUpdate: NoAction)
503
+
504
+ @@unique([individualUserId, organizationUserId])
505
+ @@index([organizationUserId])
506
+ }
507
+
508
+ /// One-time scheduled jobs for poll-and-dispatch scheduling pattern.
509
+ /// Used by providers without native one-time scheduling APIs (e.g., Netlify).
510
+ /// A cron function queries for due jobs and dispatches them to a queue.
511
+ model ScheduledJob {
512
+ id Int @id @default(autoincrement())
513
+ scheduleName String @unique
514
+ scheduledAt DateTime
515
+ queueResourceId String
516
+ payload Json?
517
+ state String @default("PENDING")
518
+
519
+ createdAt DateTime @default(now())
520
+ updatedAt DateTime @updatedAt
521
+
522
+ @@index([state, scheduledAt])
523
+ }
@@ -0,0 +1,13 @@
1
+ const {
2
+ resolveProvider,
3
+ determineProviderName,
4
+ providerPackageName,
5
+ KNOWN_PROVIDERS,
6
+ } = require('./resolve-provider');
7
+
8
+ module.exports = {
9
+ resolveProvider,
10
+ determineProviderName,
11
+ providerPackageName,
12
+ KNOWN_PROVIDERS,
13
+ };