@friggframework/core 2.0.0--canary.704.79d9a63.0 → 2.0.0--canary.680.daae24e.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 (117) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/CLAUDE.md +0 -40
  3. package/application/commands/entity-commands.js +60 -18
  4. package/application/commands/integration-commands.js +18 -71
  5. package/application/index.js +6 -2
  6. package/credential/repositories/credential-repository-documentdb.js +34 -0
  7. package/credential/repositories/credential-repository-interface.js +15 -0
  8. package/credential/repositories/credential-repository-mongo.js +23 -0
  9. package/credential/repositories/credential-repository-postgres.js +28 -0
  10. package/credential/use-cases/credential-operations-for-user.js +117 -0
  11. package/credential/use-cases/reauthorize-credential-for-user.js +66 -0
  12. package/database/documentdb-encryption-service.js +9 -22
  13. package/database/documentdb-utils.js +1 -4
  14. package/database/encryption/README.md +78 -14
  15. package/database/encryption/documentdb-encryption-service.md +78 -1
  16. package/database/encryption/encryption-config.js +240 -0
  17. package/database/encryption/encryption-schema-registry.js +41 -12
  18. package/database/encryption/field-encryption-service.js +69 -5
  19. package/database/encryption/integration-mapping-encryption.js +1 -1
  20. package/database/encryption/test-support/fake-ciphertext.js +15 -0
  21. package/database/prisma.js +11 -36
  22. package/database/use-cases/check-encryption-health-use-case.js +43 -52
  23. package/errors/index.js +0 -4
  24. package/generated/prisma-mongodb/edge.js +20 -4
  25. package/generated/prisma-mongodb/index-browser.js +17 -1
  26. package/generated/prisma-mongodb/index.d.ts +1543 -95
  27. package/generated/prisma-mongodb/index.js +20 -4
  28. package/generated/prisma-mongodb/package.json +1 -1
  29. package/generated/prisma-mongodb/schema.prisma +27 -0
  30. package/generated/prisma-mongodb/wasm.js +20 -4
  31. package/generated/prisma-postgresql/edge.js +20 -4
  32. package/generated/prisma-postgresql/index-browser.js +17 -1
  33. package/generated/prisma-postgresql/index.d.ts +1570 -89
  34. package/generated/prisma-postgresql/index.js +20 -4
  35. package/generated/prisma-postgresql/package.json +1 -1
  36. package/generated/prisma-postgresql/schema.prisma +26 -0
  37. package/generated/prisma-postgresql/wasm.js +20 -4
  38. package/handlers/WEBHOOKS.md +0 -29
  39. package/handlers/app-definition-loader.js +19 -8
  40. package/handlers/app-handler-helpers.js +25 -3
  41. package/handlers/backend-utils.js +31 -25
  42. package/handlers/middleware/admin-auth.js +14 -1
  43. package/handlers/routers/auth.js +11 -3
  44. package/handlers/routers/health.js +11 -4
  45. package/handlers/workers/integration-defined-workers.js +5 -2
  46. package/index.js +4 -4
  47. package/integrations/index.js +0 -4
  48. package/integrations/integration-base.js +0 -29
  49. package/integrations/integration-router.js +16 -23
  50. package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
  51. package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
  52. package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
  53. package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
  54. package/integrations/repositories/integration-mapping-repository.js +0 -29
  55. package/integrations/repositories/integration-repository-documentdb.js +0 -32
  56. package/integrations/repositories/integration-repository-interface.js +0 -30
  57. package/integrations/repositories/integration-repository-mongo.js +1 -53
  58. package/integrations/repositories/integration-repository-postgres.js +0 -58
  59. package/integrations/repositories/process-repository-mongo.js +1 -1
  60. package/integrations/use-cases/create-integration.js +19 -57
  61. package/integrations/use-cases/integration-operations-for-user.js +225 -0
  62. package/integrations/utils/map-integration-dto.js +1 -15
  63. package/management-api/create-management-api-router.js +49 -0
  64. package/management-api/error-response.js +116 -0
  65. package/management-api/management-api-config.js +82 -0
  66. package/management-api/meta-router.js +57 -0
  67. package/management-api/openapi/build-openapi.js +144 -0
  68. package/management-api/openapi/components.js +368 -0
  69. package/management-api/openapi/v1-operations.js +83 -0
  70. package/management-api/openapi/v2-operations.js +252 -0
  71. package/management-api/route-registry.js +294 -0
  72. package/management-api/test-support/http-client.js +39 -0
  73. package/management-api/use-cases/get-management-api-meta.js +57 -0
  74. package/management-api/v1-deprecation.js +120 -0
  75. package/management-api/v2/create-v2-router.js +85 -0
  76. package/management-api/v2/dependencies.js +256 -0
  77. package/management-api/v2/dto.js +123 -0
  78. package/management-api/v2/handlers/authorization.js +93 -0
  79. package/management-api/v2/handlers/credentials.js +29 -0
  80. package/management-api/v2/handlers/entities.js +74 -0
  81. package/management-api/v2/handlers/integrations.js +125 -0
  82. package/management-api/v2/handlers/proxy.js +34 -0
  83. package/management-api/v2/request.js +54 -0
  84. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  85. package/modules/domain/authorization-session.js +144 -0
  86. package/modules/entity-type-registry.js +311 -0
  87. package/modules/index.js +6 -0
  88. package/modules/proxy/proxy-errors.js +20 -0
  89. package/modules/proxy/proxy-path.js +179 -0
  90. package/modules/proxy/proxy-policy.js +248 -0
  91. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  92. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  93. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  94. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  95. package/modules/requester/proxy-request.js +244 -0
  96. package/modules/requester/requester.js +27 -2
  97. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  98. package/modules/use-cases/entity-operations-for-user.js +125 -0
  99. package/modules/use-cases/entity-types.js +115 -0
  100. package/modules/use-cases/execute-entity-proxy-request.js +239 -0
  101. package/modules/use-cases/get-authorization-requirements.js +100 -0
  102. package/modules/use-cases/get-entity-module-for-user.js +86 -0
  103. package/modules/use-cases/multi-step-authorization.js +302 -0
  104. package/modules/utils/map-module-dto.js +3 -5
  105. package/package.json +5 -5
  106. package/prisma-mongodb/schema.prisma +27 -0
  107. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  108. package/prisma-postgresql/schema.prisma +26 -0
  109. package/types/index.d.ts +1 -0
  110. package/types/modules/index.d.ts +58 -0
  111. package/user/tests/doubles/test-user-repository.js +1 -1
  112. package/user/use-cases/get-user-from-bearer-token.js +2 -7
  113. package/errors/mapping-already-exists-error.js +0 -14
  114. package/integrations/use-cases/add-entities-to-integration.js +0 -92
  115. package/integrations/use-cases/remove-entities-from-integration.js +0 -70
  116. package/integrations/utils/assert-entity-ids-input.js +0 -42
  117. package/modules/utils/to-credential-id.js +0 -14
@@ -0,0 +1,302 @@
1
+ const Boom = require('@hapi/boom');
2
+ const { FetchError } = require('../../errors/fetch-error');
3
+ const { getLogger } = require('../../logs');
4
+ const { AuthorizationSession } = require('../domain/authorization-session');
5
+ const { findDefinition, getAuthStepCount } = require('./entity-types');
6
+
7
+ const log = getLogger('frigg.api.authorize');
8
+
9
+ const defaultCreateModule = (params) => {
10
+ const { Module } = require('../module');
11
+ return new Module(params);
12
+ };
13
+
14
+ const isMultiStep = (definition) =>
15
+ getAuthStepCount(definition) > 1 ||
16
+ typeof definition.processAuthorizationStep === 'function';
17
+
18
+ const sessionNotFound = () =>
19
+ Boom.notFound('Authorization session not found or expired', {
20
+ code: 'AUTHORIZATION_SESSION_NOT_FOUND',
21
+ });
22
+
23
+ /**
24
+ * Maps a failure from the module or the provider to a client-facing error.
25
+ * Boom errors pass through. A provider HTTP failure is a 502; a failed auth
26
+ * test or a ClientSafeError is a 400. Anything else stays a 500.
27
+ */
28
+ function toAuthorizationError(error, { passMessage = false } = {}) {
29
+ if (error?.isBoom) return error;
30
+ if (error instanceof FetchError) {
31
+ return Boom.badGateway('The provider rejected the authorization request', {
32
+ code: 'UPSTREAM_ERROR',
33
+ expose: true,
34
+ details: { upstreamStatus: error.statusCode ?? null },
35
+ });
36
+ }
37
+ if (error?.isClientSafe || passMessage) {
38
+ return Boom.badRequest(error?.message || 'Authorization failed', {
39
+ code: 'AUTHORIZATION_FAILED',
40
+ });
41
+ }
42
+ if (error?.message === 'Authorization failed') {
43
+ return Boom.badRequest(
44
+ 'Authorization failed: the credentials did not pass the module auth test',
45
+ { code: 'AUTHORIZATION_FAILED' }
46
+ );
47
+ }
48
+ return error;
49
+ }
50
+
51
+ /**
52
+ * Loads the session a caller continues, checking it belongs to them, to the
53
+ * entity type and (for re-authorization) to the credential.
54
+ */
55
+ async function loadSession(repository, sessionId, binding) {
56
+ if (typeof sessionId !== 'string' || !sessionId) throw sessionNotFound();
57
+ const session = await repository.findBySessionId(sessionId);
58
+ if (!session || session.isExpired() || !session.belongsTo(binding)) {
59
+ throw sessionNotFound();
60
+ }
61
+ if (session.completed) {
62
+ throw Boom.conflict('Authorization session is already complete', {
63
+ code: 'AUTHORIZATION_SESSION_COMPLETED',
64
+ });
65
+ }
66
+ return session;
67
+ }
68
+
69
+ async function startSession(repository, binding, maxSteps) {
70
+ // Expired sessions are removed opportunistically; PostgreSQL has no TTL.
71
+ Promise.resolve()
72
+ .then(() => repository.deleteExpired())
73
+ .catch((error) =>
74
+ log.warn('Expired authorization sessions were not removed', {
75
+ eventName: `${log.name}.session_cleanup_failed`,
76
+ error,
77
+ })
78
+ );
79
+ return repository.create(AuthorizationSession.start({ ...binding, maxSteps }));
80
+ }
81
+
82
+ /**
83
+ * GET /api/v2/authorize (and GET /api/v2/credentials/:id/reauthorize): what
84
+ * the caller must submit next. A multi-step flow gets a session on step 1;
85
+ * later steps need that sessionId and must be the session's current step.
86
+ */
87
+ class GetAuthorizationStep {
88
+ /**
89
+ * @param {Object} params
90
+ * @param {Array<Object>} params.moduleDefinitions
91
+ * @param {import('./get-authorization-requirements').GetAuthorizationRequirements} params.getAuthorizationRequirements
92
+ * @param {import('../repositories/authorization-session-repository-interface').AuthorizationSessionRepositoryInterface} params.authorizationSessionRepository
93
+ */
94
+ constructor({ moduleDefinitions, getAuthorizationRequirements, authorizationSessionRepository }) {
95
+ this.moduleDefinitions = moduleDefinitions || [];
96
+ this.getAuthorizationRequirements = getAuthorizationRequirements;
97
+ this.sessions = authorizationSessionRepository;
98
+ }
99
+
100
+ async execute({ user, entityType, step = 1, sessionId, credentialId = null, state }) {
101
+ const definition = findDefinition(this.moduleDefinitions, entityType);
102
+ const userId = user.getId();
103
+ const requirementsFor = (n) =>
104
+ this.getAuthorizationRequirements.execute({ entityType, step: n, userId, state });
105
+
106
+ if (!isMultiStep(definition)) return requirementsFor(step);
107
+
108
+ const binding = { userId, entityType, credentialId };
109
+ let session;
110
+ if (sessionId) {
111
+ session = await loadSession(this.sessions, sessionId, binding);
112
+ if (step !== session.currentStep) {
113
+ throw Boom.conflict(
114
+ `This session expects step ${session.currentStep}`,
115
+ { code: 'STEP_OUT_OF_ORDER', details: { expectedStep: session.currentStep } }
116
+ );
117
+ }
118
+ } else {
119
+ if (step !== 1) {
120
+ throw Boom.badRequest('sessionId is required after step 1', {
121
+ code: 'SESSION_REQUIRED',
122
+ });
123
+ }
124
+ session = await startSession(this.sessions, binding, getAuthStepCount(definition));
125
+ }
126
+ return { ...(await requirementsFor(step)), sessionId: session.sessionId };
127
+ }
128
+ }
129
+
130
+ /**
131
+ * POST /api/v2/authorize (and POST /api/v2/credentials/:id/reauthorize).
132
+ *
133
+ * Single-step modules complete at once through ProcessAuthorizationCallback
134
+ * (the v1 authorize logic). Multi-step modules run
135
+ * definition.processAuthorizationStep(api, step, data, stepData) per step;
136
+ * when it reports `completed`, its `authData` goes through the same
137
+ * callback. Returns either
138
+ * `{ status: 'complete', entity, credential }` (domain records; the handler
139
+ * masks them) or
140
+ * `{ status: 'pending', step, totalSteps, sessionId, requirements, message? }`.
141
+ */
142
+ class SubmitAuthorizationStep {
143
+ constructor({
144
+ moduleDefinitions,
145
+ getAuthorizationRequirements,
146
+ authorizationSessionRepository,
147
+ processAuthorizationCallback,
148
+ moduleRepository,
149
+ credentialRepository,
150
+ createModule,
151
+ }) {
152
+ this.moduleDefinitions = moduleDefinitions || [];
153
+ this.getAuthorizationRequirements = getAuthorizationRequirements;
154
+ this.sessions = authorizationSessionRepository;
155
+ this.processAuthorizationCallback = processAuthorizationCallback;
156
+ this.moduleRepository = moduleRepository;
157
+ this.credentialRepository = credentialRepository;
158
+ this.createModule = createModule || defaultCreateModule;
159
+ }
160
+
161
+ async execute({ user, entityType, data, step = 1, sessionId, credentialId = null }) {
162
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
163
+ throw Boom.badRequest('data must be an object', {
164
+ code: 'VALIDATION_ERROR',
165
+ details: { field: 'data' },
166
+ });
167
+ }
168
+ const definition = findDefinition(this.moduleDefinitions, entityType);
169
+ const userId = user.getId();
170
+
171
+ if (!isMultiStep(definition)) {
172
+ if (step !== 1) {
173
+ throw Boom.badRequest(`${entityType} has a single authorization step`, {
174
+ code: 'INVALID_STEP',
175
+ });
176
+ }
177
+ return this.complete({ userId, entityType, params: data, credentialId });
178
+ }
179
+
180
+ const totalSteps = getAuthStepCount(definition);
181
+ const binding = { userId, entityType, credentialId };
182
+ let session;
183
+ if (sessionId) {
184
+ session = await loadSession(this.sessions, sessionId, binding);
185
+ if (step === 1 && session.currentStep !== 1) {
186
+ session.restart();
187
+ } else if (step !== session.currentStep) {
188
+ throw Boom.conflict(`This session expects step ${session.currentStep}`, {
189
+ code: 'STEP_OUT_OF_ORDER',
190
+ details: { expectedStep: session.currentStep },
191
+ });
192
+ }
193
+ } else {
194
+ if (step !== 1) {
195
+ throw Boom.badRequest('sessionId is required after step 1', {
196
+ code: 'SESSION_REQUIRED',
197
+ });
198
+ }
199
+ session = await startSession(this.sessions, binding, totalSteps);
200
+ }
201
+
202
+ if (typeof definition.processAuthorizationStep !== 'function') {
203
+ throw Boom.badImplementation(
204
+ `Module ${entityType} declares ${totalSteps} steps but no processAuthorizationStep`
205
+ );
206
+ }
207
+
208
+ const module = this.createModule({ userId, definition });
209
+ let result;
210
+ try {
211
+ result = await definition.processAuthorizationStep(
212
+ module.api,
213
+ step,
214
+ data,
215
+ session.stepData
216
+ );
217
+ } catch (error) {
218
+ // processAuthorizationStep errors are written for the end user
219
+ // (docs/MULTI_STEP_AUTH_MIGRATION_GUIDE.md), e.g. "Invalid code".
220
+ throw toAuthorizationError(error, { passMessage: true });
221
+ }
222
+
223
+ if (result?.completed) {
224
+ session.markComplete();
225
+ await this.sessions.update(session);
226
+ const outcome = await this.complete({
227
+ userId,
228
+ entityType,
229
+ params: result.authData || {},
230
+ credentialId,
231
+ });
232
+ // The collected data is no longer needed once the credential exists.
233
+ await Promise.resolve()
234
+ .then(() => this.sessions.deleteBySessionId(session.sessionId))
235
+ .catch(() => {});
236
+ return outcome;
237
+ }
238
+
239
+ const nextStep = result?.nextStep ?? step + 1;
240
+ try {
241
+ session.advanceTo(nextStep, result?.stepData);
242
+ } catch (error) {
243
+ throw Boom.badImplementation(
244
+ `Module ${entityType} returned an invalid next step: ${error.message}`
245
+ );
246
+ }
247
+ await this.sessions.update(session);
248
+
249
+ const requirements = await this.getAuthorizationRequirements.execute({
250
+ entityType,
251
+ step: nextStep,
252
+ userId,
253
+ });
254
+ return {
255
+ status: 'pending',
256
+ step: nextStep,
257
+ totalSteps,
258
+ sessionId: session.sessionId,
259
+ requirements,
260
+ ...(result?.message && { message: String(result.message) }),
261
+ };
262
+ }
263
+
264
+ async complete({ userId, entityType, params, credentialId }) {
265
+ let callback;
266
+ try {
267
+ callback = await this.processAuthorizationCallback.execute(
268
+ userId,
269
+ entityType,
270
+ params
271
+ );
272
+ } catch (error) {
273
+ throw toAuthorizationError(error);
274
+ }
275
+ const [entity, credential] = await Promise.all([
276
+ this.moduleRepository.findEntityById(callback.entity_id),
277
+ this.credentialRepository.findCredentialById(callback.credential_id),
278
+ ]);
279
+ const outcome = {
280
+ status: 'complete',
281
+ entity,
282
+ credential: credential && {
283
+ ...credential,
284
+ type: entityType,
285
+ entityIds: entity ? [String(entity.id)] : [],
286
+ },
287
+ };
288
+ if (credentialId && String(callback.credential_id) !== String(credentialId)) {
289
+ // The provider account differs from the one being re-authorized,
290
+ // so a separate credential was stored.
291
+ outcome.previousCredentialId = String(credentialId);
292
+ }
293
+ return outcome;
294
+ }
295
+ }
296
+
297
+ module.exports = {
298
+ GetAuthorizationStep,
299
+ SubmitAuthorizationStep,
300
+ toAuthorizationError,
301
+ isMultiStep,
302
+ };
@@ -1,5 +1,3 @@
1
- const { toCredentialId } = require('./to-credential-id');
2
-
3
1
  /**
4
2
  * @param {import('../module').Module} moduleInstance
5
3
  * Convert a Module domain instance to a plain DTO suitable for JSON responses.
@@ -12,9 +10,9 @@ function mapModuleClassToModuleDTO(moduleInstance) {
12
10
  name: moduleInstance.name,
13
11
  userId: moduleInstance.userId,
14
12
  entity: moduleInstance.entity,
15
- credentialId: toCredentialId(moduleInstance.credential),
16
- type: moduleInstance.getName(),
13
+ credentialId: moduleInstance.credential?._id?.toString(),
14
+ type: moduleInstance.getName()
17
15
  };
18
16
  }
19
17
 
20
- module.exports = { mapModuleClassToModuleDTO };
18
+ module.exports = { mapModuleClassToModuleDTO };
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.704.79d9a63.0",
4
+ "version": "2.0.0--canary.680.daae24e.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -47,9 +47,9 @@
47
47
  }
48
48
  },
49
49
  "devDependencies": {
50
- "@friggframework/eslint-config": "2.0.0--canary.704.79d9a63.0",
51
- "@friggframework/prettier-config": "2.0.0--canary.704.79d9a63.0",
52
- "@friggframework/test": "2.0.0--canary.704.79d9a63.0",
50
+ "@friggframework/eslint-config": "2.0.0--canary.680.daae24e.0",
51
+ "@friggframework/prettier-config": "2.0.0--canary.680.daae24e.0",
52
+ "@friggframework/test": "2.0.0--canary.680.daae24e.0",
53
53
  "@prisma/client": "^6.19.3",
54
54
  "@types/lodash": "4.17.15",
55
55
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -89,5 +89,5 @@
89
89
  "publishConfig": {
90
90
  "access": "public"
91
91
  },
92
- "gitHead": "79d9a635432643710e9fb815cf16453d01f7e12c"
92
+ "gitHead": "daae24ec9d6b03c59759b6b42f6f99369b683981"
93
93
  }
@@ -453,3 +453,30 @@ model UsageCounter {
453
453
  @@index([metric, integrationType, window])
454
454
  @@map("UsageCounter")
455
455
  }
456
+
457
+ // ============================================================================
458
+ // MANAGEMENT API MODELS
459
+ // ============================================================================
460
+
461
+ /// Multi-step authorization state for Management API v2 (/api/v2/authorize,
462
+ /// /api/v2/credentials/:id/reauthorize). Short-lived: expired documents are
463
+ /// removed opportunistically. stepData is encrypted at rest.
464
+ model AuthorizationSession {
465
+ id String @id @default(auto()) @map("_id") @db.ObjectId
466
+ sessionId String @unique
467
+ userId String
468
+ entityType String
469
+ credentialId String?
470
+ currentStep Int @default(1)
471
+ maxSteps Int
472
+ stepData Json @default("{}")
473
+ expiresAt DateTime
474
+ completed Boolean @default(false)
475
+
476
+ createdAt DateTime @default(now())
477
+ updatedAt DateTime @updatedAt
478
+
479
+ @@index([userId, entityType])
480
+ @@index([expiresAt])
481
+ @@map("AuthorizationSession")
482
+ }
@@ -0,0 +1,28 @@
1
+ -- CreateTable
2
+ -- Multi-step authorization state for Management API v2 (/api/v2/authorize).
3
+ -- Short-lived rows; "stepData" is encrypted at rest by the application.
4
+ CREATE TABLE "AuthorizationSession" (
5
+ "id" SERIAL NOT NULL,
6
+ "sessionId" TEXT NOT NULL,
7
+ "userId" TEXT NOT NULL,
8
+ "entityType" TEXT NOT NULL,
9
+ "credentialId" TEXT,
10
+ "currentStep" INTEGER NOT NULL DEFAULT 1,
11
+ "maxSteps" INTEGER NOT NULL,
12
+ "stepData" JSONB NOT NULL DEFAULT '{}',
13
+ "expiresAt" TIMESTAMP(3) NOT NULL,
14
+ "completed" BOOLEAN NOT NULL DEFAULT false,
15
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16
+ "updatedAt" TIMESTAMP(3) NOT NULL,
17
+
18
+ CONSTRAINT "AuthorizationSession_pkey" PRIMARY KEY ("id")
19
+ );
20
+
21
+ -- CreateIndex
22
+ CREATE UNIQUE INDEX "AuthorizationSession_sessionId_key" ON "AuthorizationSession"("sessionId");
23
+
24
+ -- CreateIndex
25
+ CREATE INDEX "AuthorizationSession_userId_entityType_idx" ON "AuthorizationSession"("userId", "entityType");
26
+
27
+ -- CreateIndex
28
+ CREATE INDEX "AuthorizationSession_expiresAt_idx" ON "AuthorizationSession"("expiresAt");
@@ -434,3 +434,29 @@ model UsageCounter {
434
434
  // Covers series() (metric + integrationType + window range).
435
435
  @@index([metric, integrationType, window])
436
436
  }
437
+
438
+ // ============================================================================
439
+ // MANAGEMENT API MODELS
440
+ // ============================================================================
441
+
442
+ /// Multi-step authorization state for Management API v2 (/api/v2/authorize,
443
+ /// /api/v2/credentials/:id/reauthorize). Short-lived: expired rows are removed
444
+ /// opportunistically. stepData is encrypted at rest.
445
+ model AuthorizationSession {
446
+ id Int @id @default(autoincrement())
447
+ sessionId String @unique
448
+ userId String
449
+ entityType String
450
+ credentialId String?
451
+ currentStep Int @default(1)
452
+ maxSteps Int
453
+ stepData Json @default("{}")
454
+ expiresAt DateTime
455
+ completed Boolean @default(false)
456
+
457
+ createdAt DateTime @default(now())
458
+ updatedAt DateTime @updatedAt
459
+
460
+ @@index([userId, entityType])
461
+ @@index([expiresAt])
462
+ }
package/types/index.d.ts CHANGED
@@ -12,3 +12,4 @@
12
12
  /// <reference path="./eslint-config/index.d.ts" />
13
13
  /// <reference path="./associations/index.d.ts" />
14
14
  /// <reference path="./syncs/index.d.ts" />
15
+ /// <reference path="./modules/index.d.ts" />
@@ -0,0 +1,58 @@
1
+ declare module "@friggframework/core" {
2
+ /** Where an entity type comes from: the app's `entities` map or an integration binding it. */
3
+ export type EntityTypeSource =
4
+ | { kind: "app" }
5
+ | { kind: "integration"; name: string };
6
+
7
+ export type EntityScope = "individual" | "organization" | "global";
8
+
9
+ /** App-level settings carried from the `entities` entry; empty for an inferred-only type. */
10
+ export interface EntityTypeSettings {
11
+ scope?: EntityScope | { default?: EntityScope; allow?: EntityScope[] };
12
+ proxy?: Record<string, unknown>;
13
+ display?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface EntityTypeEntry {
17
+ /** The API module definition. */
18
+ definition: any;
19
+ /** Frozen. `{ kind: 'app' }` first when registered, then each binding integration. */
20
+ sources: readonly EntityTypeSource[];
21
+ /** Frozen. */
22
+ settings: Readonly<EntityTypeSettings>;
23
+ }
24
+
25
+ /** Entity type name (the definition's moduleName) to its entry. */
26
+ export type EntityTypeRegistry = Map<string, EntityTypeEntry>;
27
+
28
+ /** One `appDefinition.entities` entry. The key must equal the definition's moduleName (and getName() when defined). */
29
+ export interface EntityRegistration extends EntityTypeSettings {
30
+ definition: any;
31
+ }
32
+
33
+ export interface AppDefinitionEntities {
34
+ /** false turns inference off: every module an integration binds must be registered. Default true. */
35
+ $infer?: boolean;
36
+ [entityType: string]: EntityRegistration | boolean | undefined;
37
+ }
38
+
39
+ export interface AppDefinition {
40
+ integrations: any[];
41
+ /** App-level entity type registry (ADR-059). */
42
+ entities?: AppDefinitionEntities;
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ /**
47
+ * Builds the registry from `entities` plus the modules integrations bind.
48
+ * Throws EntityRegistryError on an invalid registry.
49
+ */
50
+ export function getEntityTypeRegistry(
51
+ appDefinition?: Pick<AppDefinition, "entities" | "integrations">
52
+ ): EntityTypeRegistry;
53
+
54
+ /** Invalid `entities` registry. `code` is always `ENTITY_REGISTRY_INVALID`. */
55
+ export class EntityRegistryError extends Error {
56
+ code: "ENTITY_REGISTRY_INVALID";
57
+ }
58
+ }
@@ -23,7 +23,7 @@ class TestUserRepository {
23
23
 
24
24
  async createToken(userId, rawToken, minutes = 120) {
25
25
  const token = `token-for-${userId}-for-${minutes}-mins`;
26
- this.tokens.set(token, { id: token, userId, rawToken });
26
+ this.tokens.set(token, { user: userId, rawToken });
27
27
  return token;
28
28
  }
29
29
 
@@ -40,13 +40,8 @@ class GetUserFromBearerToken {
40
40
  throw Boom.unauthorized('Session Token Not Found');
41
41
  }
42
42
 
43
- const tokenUserId = sessionToken.userId;
44
- if (!tokenUserId) {
45
- throw Boom.unauthorized('Session Token Has No User');
46
- }
47
-
48
43
  if (this.userConfig.primary === 'organization') {
49
- const organizationUserData = await this.userRepository.findOrganizationUserById(tokenUserId);
44
+ const organizationUserData = await this.userRepository.findOrganizationUserById(sessionToken.user);
50
45
 
51
46
  if (!organizationUserData) {
52
47
  throw Boom.unauthorized('Organization User Not Found');
@@ -62,7 +57,7 @@ class GetUserFromBearerToken {
62
57
  );
63
58
  }
64
59
 
65
- const individualUserData = await this.userRepository.findIndividualUserById(tokenUserId);
60
+ const individualUserData = await this.userRepository.findIndividualUserById(sessionToken.user);
66
61
 
67
62
  if (!individualUserData) {
68
63
  throw Boom.unauthorized('Individual User Not Found');
@@ -1,14 +0,0 @@
1
- const { BaseError } = require('./base-error');
2
-
3
- class MappingAlreadyExistsError extends BaseError {
4
- constructor(integrationId, sourceId, ...errorOptions) {
5
- super(
6
- `Mapping already exists for integration ${integrationId} and sourceId ${sourceId}`,
7
- ...errorOptions
8
- );
9
- this.integrationId = integrationId;
10
- this.sourceId = sourceId;
11
- }
12
- }
13
-
14
- module.exports = { MappingAlreadyExistsError };
@@ -1,92 +0,0 @@
1
- const {
2
- assertEntityIdsInput,
3
- integrationNotFound,
4
- loadIntegration,
5
- } = require('../utils/assert-entity-ids-input');
6
-
7
- /**
8
- * Attaches entities to an existing integration, e.g. when a user grants
9
- * access to more accounts after the integration was created. Every entity
10
- * must exist, belong to the integration's user, and be of a module the
11
- * integration already holds (so hydration can load it). Entities that are
12
- * already attached are left as they are.
13
- * @class AddEntitiesToIntegration
14
- */
15
- class AddEntitiesToIntegration {
16
- /**
17
- * @param {Object} params
18
- * @param {import('../repositories/integration-repository-interface').IntegrationRepositoryInterface} params.integrationRepository
19
- * @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
20
- */
21
- constructor({ integrationRepository, moduleRepository }) {
22
- this.integrationRepository = integrationRepository;
23
- this.moduleRepository = moduleRepository;
24
- }
25
-
26
- /**
27
- * @param {string} integrationId
28
- * @param {string[]} entityIds
29
- * @returns {Promise<Object>} The updated integration record.
30
- */
31
- async execute(integrationId, entityIds) {
32
- assertEntityIdsInput(integrationId, entityIds);
33
-
34
- const integration = await loadIntegration(
35
- this.integrationRepository,
36
- integrationId
37
- );
38
-
39
- const entities = await this.moduleRepository.findEntitiesByIds(
40
- entityIds
41
- );
42
- const foundIds = new Set(entities.map((e) => String(e.id)));
43
- const missing = entityIds.filter((id) => !foundIds.has(String(id)));
44
- if (missing.length > 0) {
45
- const error = new Error(
46
- `Entities not found: ${missing.join(', ')}`
47
- );
48
- error.code = 'ENTITIES_NOT_FOUND';
49
- throw error;
50
- }
51
-
52
- const foreign = entities.filter(
53
- (e) => String(e.userId) !== String(integration.userId)
54
- );
55
- if (foreign.length > 0) {
56
- const error = new Error(
57
- `Entities do not belong to the integration's user: ${foreign
58
- .map((e) => e.id)
59
- .join(', ')}`
60
- );
61
- error.code = 'ENTITY_USER_MISMATCH';
62
- throw error;
63
- }
64
-
65
- const attached = await this.moduleRepository.findEntitiesByIds(
66
- integration.entitiesIds ?? []
67
- );
68
- const heldModules = new Set(attached.map((e) => e.moduleName));
69
- const otherModules = entities.filter(
70
- (e) => !heldModules.has(e.moduleName)
71
- );
72
- if (otherModules.length > 0) {
73
- const error = new Error(
74
- `Entities belong to a module the integration does not hold: ${otherModules
75
- .map((e) => `${e.id} (${e.moduleName})`)
76
- .join(', ')}`
77
- );
78
- error.code = 'ENTITY_MODULE_MISMATCH';
79
- throw error;
80
- }
81
-
82
- const updated =
83
- await this.integrationRepository.addEntitiesToIntegration(
84
- integrationId,
85
- entityIds
86
- );
87
- if (!updated) throw integrationNotFound(integrationId);
88
- return updated;
89
- }
90
- }
91
-
92
- module.exports = { AddEntitiesToIntegration };