@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ # Unreleased
2
+
3
+ #### Behaviour change: module credential fields are now encrypted
4
+
5
+ - `credentialFields` declared in an API module's `encryption` config (for example `Definition.encryption.credentialFields`) were never registered for encryption: `loadModuleEncryptionSchemas` required a path that does not exist and the error was swallowed. They are now registered, for modules bound by integrations and for modules registered in `appDefinition.entities` (ADR-059).
6
+ - Existing rows are not rewritten. A credential saved before this release stays plaintext until it is next written, and is read as stored.
7
+ - Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value with that shape that fails to decrypt throws `FieldDecryptionError` in a deployed runtime (any stage, AWS Lambda or Netlify) and is returned as stored with a warning on a local run.
8
+ - An app's own `encryption.schema` is no longer replaced when module fields are added to it.
9
+
10
+ ---
11
+
1
12
  # v1.2.2 (Fri Aug 09 2024)
2
13
 
3
14
  #### 🐛 Bug Fix
package/CLAUDE.md CHANGED
@@ -206,51 +206,11 @@ packages/core/
206
206
  - `delete-integration-for-user.js` - Remove integration
207
207
  - `get-integration-instance.js` - Load integration with modules
208
208
  - `load-integration-context.js` - Full integration context loading
209
- - `add-entities-to-integration.js` / `remove-entities-from-integration.js` - Attach or detach entities on an existing integration
210
209
 
211
210
  **Repositories**:
212
211
  - `integration-repository-factory.js` - Creates database-specific repositories
213
212
  - `integration-repository-mongo.js` - MongoDB implementation
214
213
  - `integration-repository-postgres.js` - PostgreSQL implementation
215
- - `addEntitiesToIntegration(integrationId, entityIds)` /
216
- `removeEntitiesFromIntegration(integrationId, entityIds)` change the entity
217
- set of an existing integration (all three adapters; `null` for an unknown
218
- integration). Reach them through the `addEntitiesToIntegration` /
219
- `removeEntitiesFromIntegration` commands. The add use case
220
- (`add-entities-to-integration.js`) rejects unknown entities
221
- (`ENTITIES_NOT_FOUND`, 404), entities of another user
222
- (`ENTITY_USER_MISMATCH`, 403) and entities of a module the integration does
223
- not already hold (`ENTITY_MODULE_MISMATCH`, 400). The remove use case
224
- (`remove-entities-from-integration.js`) refuses to leave a module with no
225
- entity (`LAST_ENTITY_OF_MODULE`, 409). Re-adding an attached entity or
226
- removing a detached one changes nothing; a detached entity is not deleted.
227
- `integration-repository-entities-parity.test.js` runs every adapter against
228
- real databases when `INTEGRATION_ENTITIES_PARITY_MONGO_URL` /
229
- `INTEGRATION_ENTITIES_PARITY_POSTGRES_URL` are set. The DocumentDB adapter
230
- writes only `Integration.entityIds` (as its `createIntegration` does), not
231
- `Entity.integrationIds`.
232
- **One credential, many accounts**: when one credential covers several
233
- provider accounts, give each account its own entity (externalId = account
234
- id) and attach them all to one integration; webhooks then route with
235
- `findIntegrationByEntityExternalId`. Traps:
236
- (1) `IntegrationBase` keys modules by module name, so `this.<moduleKey>`
237
- holds only one entity's module, and on the queue/webhook path
238
- (`get-integration-instance.js`) `this.entities` holds entity **ids**, not
239
- records. For per-account work (a webhook for one account, teardown of every
240
- account in `onDelete`), load
241
- `this.commands.loadIntegrationContextById(this.id)` and use
242
- `context.modules`: one module per entity, each with its record on
243
- `module.entity` (pick by `module.entity.externalId`).
244
- (2) `findIntegrationByEntityExternalId` counts every entity with that
245
- externalId, attached or not, across users: reuse an existing entity
246
- (`findEntity`) when an account comes back, or delete it when it is revoked,
247
- never create a second one.
248
- (3) `createIntegration` reuses an existing integration of the same user
249
- and type whose entities include all requested ones (an exact match wins),
250
- so a reconnect that sends the original entities finds the integration that
251
- now holds more.
252
- (4) Every module's delegate is the integration: an auth failure on any
253
- account marks the shared credential invalid and the integration ERROR.
254
214
  - `integration-mapping-repository-*.js` - Mapping data persistence.
255
215
  `queryMappings(integrationId, { where, orderBy, skip, take, omit })`
256
216
  returns `{ mappings, total }`: one filtered, ordered page of an
@@ -1,24 +1,12 @@
1
1
  const {
2
2
  createModuleRepository,
3
3
  } = require('../../modules/repositories/module-repository-factory');
4
- const { toCredentialId } = require('../../modules/utils/to-credential-id');
5
4
 
6
5
  const ERROR_CODE_MAP = {
7
6
  ENTITY_NOT_FOUND: 404,
8
7
  INVALID_ENTITY_DATA: 400,
9
8
  };
10
9
 
11
- function toEntityDto(entity) {
12
- return {
13
- id: entity.id,
14
- userId: entity.userId,
15
- externalId: entity.externalId,
16
- name: entity.name,
17
- moduleName: entity.moduleName,
18
- credentialId: toCredentialId(entity.credential),
19
- };
20
- }
21
-
22
10
  function mapErrorToResponse(error) {
23
11
  const status = ERROR_CODE_MAP[error?.code] || 500;
24
12
  return {
@@ -78,7 +66,16 @@ function createEntityCommands() {
78
66
 
79
67
  const entity = await moduleRepo.createEntity(entityData);
80
68
 
81
- return toEntityDto(entity);
69
+ return {
70
+ id: entity.id,
71
+ userId: entity.userId,
72
+ externalId: entity.externalId,
73
+ name: entity.name,
74
+ moduleName: entity.moduleName,
75
+ credentialId: entity.credential?._id
76
+ ? entity.credential._id.toString()
77
+ : entity.credential,
78
+ };
82
79
  } catch (error) {
83
80
  return mapErrorToResponse(error);
84
81
  }
@@ -112,7 +109,16 @@ function createEntityCommands() {
112
109
  return null;
113
110
  }
114
111
 
115
- return toEntityDto(entity);
112
+ return {
113
+ id: entity.id,
114
+ userId: entity.userId,
115
+ externalId: entity.externalId,
116
+ name: entity.name,
117
+ moduleName: entity.moduleName,
118
+ credentialId: entity.credential?._id
119
+ ? entity.credential._id.toString()
120
+ : entity.credential,
121
+ };
116
122
  } catch (error) {
117
123
  return mapErrorToResponse(error);
118
124
  }
@@ -133,7 +139,16 @@ function createEntityCommands() {
133
139
 
134
140
  const entities = await moduleRepo.findEntitiesByUserId(userId);
135
141
 
136
- return entities.map((entity) => toEntityDto(entity));
142
+ return entities.map((entity) => ({
143
+ id: entity.id,
144
+ userId: entity.userId,
145
+ externalId: entity.externalId,
146
+ name: entity.name,
147
+ moduleName: entity.moduleName,
148
+ credentialId: entity.credential?._id
149
+ ? entity.credential._id.toString()
150
+ : entity.credential,
151
+ }));
137
152
  } catch (error) {
138
153
  if (error.code) {
139
154
  return mapErrorToResponse(error);
@@ -165,7 +180,16 @@ function createEntityCommands() {
165
180
  moduleName
166
181
  );
167
182
 
168
- return entities.map((entity) => toEntityDto(entity));
183
+ return entities.map((entity) => ({
184
+ id: entity.id,
185
+ userId: entity.userId,
186
+ externalId: entity.externalId,
187
+ name: entity.name,
188
+ moduleName: entity.moduleName,
189
+ credentialId: entity.credential?._id
190
+ ? entity.credential._id.toString()
191
+ : entity.credential,
192
+ }));
169
193
  } catch (error) {
170
194
  if (error.code) {
171
195
  return mapErrorToResponse(error);
@@ -189,7 +213,16 @@ function createEntityCommands() {
189
213
 
190
214
  const entity = await moduleRepo.findEntityById(entityId);
191
215
 
192
- return toEntityDto(entity);
216
+ return {
217
+ id: entity.id,
218
+ userId: entity.userId,
219
+ externalId: entity.externalId,
220
+ name: entity.name,
221
+ moduleName: entity.moduleName,
222
+ credentialId: entity.credential?._id
223
+ ? entity.credential._id.toString()
224
+ : entity.credential,
225
+ };
193
226
  } catch (error) {
194
227
  return mapErrorToResponse(error);
195
228
  }
@@ -217,7 +250,16 @@ function createEntityCommands() {
217
250
  throw error;
218
251
  }
219
252
 
220
- return toEntityDto(entity);
253
+ return {
254
+ id: entity.id,
255
+ userId: entity.userId,
256
+ externalId: entity.externalId,
257
+ name: entity.name,
258
+ moduleName: entity.moduleName,
259
+ credentialId: entity.credential?._id
260
+ ? entity.credential._id.toString()
261
+ : entity.credential,
262
+ };
221
263
  } catch (error) {
222
264
  return mapErrorToResponse(error);
223
265
  }
@@ -30,14 +30,9 @@ const {
30
30
  PatchIntegrationConfig,
31
31
  } = require('../../integrations/use-cases/patch-integration-config');
32
32
  const {
33
- AddEntitiesToIntegration,
34
- } = require('../../integrations/use-cases/add-entities-to-integration');
35
- const {
36
- RemoveEntitiesFromIntegration,
37
- } = require('../../integrations/use-cases/remove-entities-from-integration');
38
- const {
39
- getModulesDefinitionFromIntegrationClasses,
40
- } = require('../../integrations/utils/map-integration-dto');
33
+ getEntityTypeRegistry,
34
+ getEntityTypeDefinitions,
35
+ } = require('../../modules/entity-type-registry');
41
36
 
42
37
  const ERROR_CODE_MAP = {
43
38
  ENTITY_NOT_FOUND: 401,
@@ -46,11 +41,6 @@ const ERROR_CODE_MAP = {
46
41
  EXTERNAL_ID_REQUIRED: 400,
47
42
  TYPE_REQUIRED: 400,
48
43
  INTEGRATION_RECORD_NOT_FOUND: 404,
49
- INVALID_INTEGRATION_DATA: 400,
50
- ENTITIES_NOT_FOUND: 404,
51
- ENTITY_USER_MISMATCH: 403,
52
- ENTITY_MODULE_MISMATCH: 400,
53
- LAST_ENTITY_OF_MODULE: 409,
54
44
  };
55
45
 
56
46
  function mapErrorToResponse(error) {
@@ -62,7 +52,16 @@ function mapErrorToResponse(error) {
62
52
  };
63
53
  }
64
54
 
65
- function createIntegrationCommands({ integrationClass } = {}) {
55
+ /**
56
+ * @param {Object} [params]
57
+ * @param {Function} [params.integrationClass]
58
+ * @param {Array<object>} [params.moduleDefinitions] - the app's offered module
59
+ * definitions (`loadAppDefinition().moduleDefinitions`). Integration code
60
+ * calls this from a constructor with no app definition in hand, so when it
61
+ * is omitted the commands hydrate with this integration's own modules only
62
+ * (a registry built from `{ integrations: [integrationClass] }`).
63
+ */
64
+ function createIntegrationCommands({ integrationClass, moduleDefinitions } = {}) {
66
65
  // Always use Frigg's default repositories and use cases
67
66
  const integrationRepository = createIntegrationRepository();
68
67
 
@@ -119,13 +118,13 @@ function createIntegrationCommands({ integrationClass } = {}) {
119
118
 
120
119
  const moduleRepository = createModuleRepository();
121
120
 
122
- const moduleDefinitions = getModulesDefinitionFromIntegrationClasses([
123
- integrationClass,
124
- ]);
125
-
126
121
  const moduleFactory = new ModuleFactory({
127
122
  moduleRepository,
128
- moduleDefinitions,
123
+ moduleDefinitions:
124
+ moduleDefinitions ??
125
+ getEntityTypeDefinitions(
126
+ getEntityTypeRegistry({ integrations: [integrationClass] })
127
+ ),
129
128
  });
130
129
 
131
130
  const loadIntegrationContextUseCase = new LoadIntegrationContextUseCase({
@@ -174,17 +173,6 @@ function createIntegrationCommands({ integrationClass } = {}) {
174
173
  integrationRepository,
175
174
  });
176
175
 
177
- const addEntitiesToIntegrationUseCase = new AddEntitiesToIntegration({
178
- integrationRepository,
179
- moduleRepository,
180
- });
181
-
182
- const removeEntitiesFromIntegrationUseCase =
183
- new RemoveEntitiesFromIntegration({
184
- integrationRepository,
185
- moduleRepository,
186
- });
187
-
188
176
  return {
189
177
  findIntegrationById,
190
178
  listIntegrations,
@@ -329,47 +317,6 @@ function createIntegrationCommands({ integrationClass } = {}) {
329
317
  }
330
318
  },
331
319
 
332
- /**
333
- * Attach entities to an existing integration. Every entity must
334
- * exist, belong to the integration's user, and be of a module the
335
- * integration already holds; entities that are already attached are
336
- * left as they are.
337
- * @param {Object} params
338
- * @param {string} params.integrationId - Integration ID
339
- * @param {Array<string>} params.entityIds - Entity IDs to attach
340
- * @returns {Promise<Object>} Updated integration
341
- */
342
- async addEntitiesToIntegration({ integrationId, entityIds }) {
343
- try {
344
- return await addEntitiesToIntegrationUseCase.execute(
345
- integrationId,
346
- entityIds
347
- );
348
- } catch (error) {
349
- return mapErrorToResponse(error);
350
- }
351
- },
352
-
353
- /**
354
- * Detach entities from an integration. Entities that are not
355
- * attached are ignored; detached entities are not deleted. Each
356
- * module must keep at least one entity.
357
- * @param {Object} params
358
- * @param {string} params.integrationId - Integration ID
359
- * @param {Array<string>} params.entityIds - Entity IDs to detach
360
- * @returns {Promise<Object>} Updated integration
361
- */
362
- async removeEntitiesFromIntegration({ integrationId, entityIds }) {
363
- try {
364
- return await removeEntitiesFromIntegrationUseCase.execute(
365
- integrationId,
366
- entityIds
367
- );
368
- } catch (error) {
369
- return mapErrorToResponse(error);
370
- }
371
- },
372
-
373
320
  /**
374
321
  * Delete an integration by ID
375
322
  * @param {string} integrationId - Integration ID to delete
@@ -17,6 +17,7 @@ const { createUsageCommands } = require('./commands/usage-commands');
17
17
  *
18
18
  * @param {Object} params
19
19
  * @param {Object} params.integrationClass - Integration class (required)
20
+ * @param {Array<object>} [params.moduleDefinitions] - the app's offered module definitions; defaults to the integration's own modules
20
21
  * @returns {Object} Unified commands object with all CRUD operations
21
22
  *
22
23
  * @example
@@ -24,9 +25,12 @@ const { createUsageCommands } = require('./commands/usage-commands');
24
25
  * const user = await commands.createUser({ username: 'user@example.com' });
25
26
  * const credential = await commands.createCredential({ userId: user.id, ... });
26
27
  */
27
- function createFriggCommands({ integrationClass }) {
28
+ function createFriggCommands({ integrationClass, moduleDefinitions }) {
28
29
  // All commands use Frigg's default repositories and use cases
29
- const integrationCommands = createIntegrationCommands({ integrationClass });
30
+ const integrationCommands = createIntegrationCommands({
31
+ integrationClass,
32
+ moduleDefinitions,
33
+ });
30
34
 
31
35
  const userCommands = createUserCommands();
32
36
 
@@ -186,6 +186,40 @@ class CredentialRepositoryDocumentDB extends CredentialRepositoryInterface {
186
186
  return this._mapCredential(decryptedCredential);
187
187
  }
188
188
 
189
+ /**
190
+ * All credentials owned by a user, newest first.
191
+ *
192
+ * @param {string} userId - User ID
193
+ * @returns {Promise<Array<Object>>} Credentials with string IDs
194
+ */
195
+ async findCredentialsByUserId(userId) {
196
+ const userObjectId = toObjectId(userId);
197
+ if (!userObjectId) return [];
198
+ const docs = await findManyDrained(
199
+ this.prisma,
200
+ 'Credential',
201
+ { userId: userObjectId },
202
+ { sort: { createdAt: -1 } }
203
+ );
204
+ const credentials = [];
205
+ for (const doc of docs) {
206
+ const decrypted = await this.encryptionService.decryptFields(
207
+ 'Credential',
208
+ doc
209
+ );
210
+ credentials.push({
211
+ ...this._mapCredential(decrypted),
212
+ id: fromObjectId(doc._id),
213
+ userId: fromObjectId(doc.userId) ?? doc.userId,
214
+ externalId: doc.externalId ?? null,
215
+ authIsValid: doc.authIsValid ?? null,
216
+ createdAt: doc.createdAt ?? null,
217
+ updatedAt: doc.updatedAt ?? null,
218
+ });
219
+ }
220
+ return credentials;
221
+ }
222
+
189
223
  async updateCredential(credentialId, updates) {
190
224
  const objectId = toObjectId(credentialId);
191
225
  if (!objectId) return null;
@@ -80,6 +80,21 @@ class CredentialRepositoryInterface {
80
80
  );
81
81
  }
82
82
 
83
+ /**
84
+ * All credentials owned by a user, newest first.
85
+ *
86
+ * @param {string|number} userId - User ID (string from application layer)
87
+ * @returns {Promise<Array<Object>>} Credentials with string IDs, `createdAt`
88
+ * and `updatedAt`, and the (decrypted) data fields spread in, as
89
+ * findCredentialById returns them. Empty when the user has none.
90
+ * @abstract
91
+ */
92
+ async findCredentialsByUserId(userId) {
93
+ throw new Error(
94
+ 'Method findCredentialsByUserId must be implemented by subclass'
95
+ );
96
+ }
97
+
83
98
  /**
84
99
  * Update a credential by ID
85
100
  *
@@ -188,6 +188,29 @@ class CredentialRepositoryMongo extends CredentialRepositoryInterface {
188
188
  };
189
189
  }
190
190
 
191
+ /**
192
+ * All credentials owned by a user, newest first.
193
+ *
194
+ * @param {string} userId - User ID
195
+ * @returns {Promise<Array<Object>>} Credentials with string IDs
196
+ */
197
+ async findCredentialsByUserId(userId) {
198
+ if (!userId) return [];
199
+ const credentials = await this.prisma.credential.findMany({
200
+ where: { userId: String(userId) },
201
+ orderBy: { createdAt: 'desc' },
202
+ });
203
+ return credentials.map((credential) => ({
204
+ ...(credential.data || {}),
205
+ id: credential.id,
206
+ userId: credential.userId,
207
+ externalId: credential.externalId,
208
+ authIsValid: credential.authIsValid,
209
+ createdAt: credential.createdAt,
210
+ updatedAt: credential.updatedAt,
211
+ }));
212
+ }
213
+
191
214
  /**
192
215
  * Update a credential by ID
193
216
  * Replaces: Credential.findByIdAndUpdate(credentialId, { $set: updates })
@@ -205,6 +205,34 @@ class CredentialRepositoryPostgres extends CredentialRepositoryInterface {
205
205
  };
206
206
  }
207
207
 
208
+ /**
209
+ * All credentials owned by a user, newest first.
210
+ *
211
+ * @param {string} userId - User ID (string from application layer)
212
+ * @returns {Promise<Array<Object>>} Credentials with string IDs
213
+ */
214
+ async findCredentialsByUserId(userId) {
215
+ let intUserId;
216
+ try {
217
+ intUserId = this._convertId(userId);
218
+ } catch {
219
+ return [];
220
+ }
221
+ const credentials = await this.prisma.credential.findMany({
222
+ where: { userId: intUserId },
223
+ orderBy: { createdAt: 'desc' },
224
+ });
225
+ return credentials.map((credential) => ({
226
+ ...(credential.data || {}),
227
+ id: credential.id.toString(),
228
+ userId: credential.userId?.toString(),
229
+ externalId: credential.externalId,
230
+ authIsValid: credential.authIsValid,
231
+ createdAt: credential.createdAt,
232
+ updatedAt: credential.updatedAt,
233
+ }));
234
+ }
235
+
208
236
  /**
209
237
  * Update a credential by ID
210
238
  *
@@ -0,0 +1,117 @@
1
+ const Boom = require('@hapi/boom');
2
+ const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
3
+
4
+ /**
5
+ * Management API v2 credential operations. A credential has no module name
6
+ * of its own; its `type` and the entities that use it come from the
7
+ * caller's entities. A missing or foreign credential is a 404.
8
+ */
9
+
10
+ const credentialIdOf = (entity) => {
11
+ const ref = entity.credential ?? entity.credentialId;
12
+ if (ref === undefined || ref === null) return null;
13
+ if (typeof ref === 'object') {
14
+ const id = ref.id ?? ref._id;
15
+ return id === undefined || id === null ? null : String(id);
16
+ }
17
+ return String(ref);
18
+ };
19
+
20
+ /** Adds `type` and `entityIds` from the user's entities to each credential. */
21
+ function withEntityUsage(credentials, entities) {
22
+ const usage = new Map();
23
+ for (const entity of entities || []) {
24
+ const credentialId = credentialIdOf(entity);
25
+ if (!credentialId) continue;
26
+ const entry = usage.get(credentialId) || { type: null, entityIds: [] };
27
+ entry.type = entry.type || entity.moduleName || null;
28
+ entry.entityIds.push(String(entity.id));
29
+ usage.set(credentialId, entry);
30
+ }
31
+ return credentials.map((credential) => {
32
+ const entry = usage.get(String(credential.id)) || {
33
+ type: null,
34
+ entityIds: [],
35
+ };
36
+ return { ...credential, type: entry.type, entityIds: entry.entityIds };
37
+ });
38
+ }
39
+
40
+ /** The caller's credentials with their type and entity usage. */
41
+ class ListCredentialsForUser {
42
+ constructor({ credentialRepository, moduleRepository }) {
43
+ this.credentialRepository = credentialRepository;
44
+ this.moduleRepository = moduleRepository;
45
+ }
46
+
47
+ async execute(user) {
48
+ const userId = user.getId();
49
+ const [credentials, entities] = await Promise.all([
50
+ this.credentialRepository.findCredentialsByUserId(userId),
51
+ this.moduleRepository.findEntitiesByUserId(userId),
52
+ ]);
53
+ return withEntityUsage(credentials || [], entities);
54
+ }
55
+ }
56
+
57
+ /** One credential the caller owns, with its type and entity usage. */
58
+ class GetCredentialForUserV2 {
59
+ constructor({ credentialRepository, moduleRepository }) {
60
+ this.credentialRepository = credentialRepository;
61
+ this.moduleRepository = moduleRepository;
62
+ }
63
+
64
+ async execute(credentialId, user) {
65
+ let credential = null;
66
+ try {
67
+ credential = await this.credentialRepository.findCredentialById(
68
+ credentialId
69
+ );
70
+ } catch (error) {
71
+ if (!/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
72
+ throw error;
73
+ }
74
+ }
75
+ if (!credential || !userOwns(user, credential.userId)) {
76
+ throw Boom.notFound(`Credential ${credentialId} not found`, {
77
+ code: 'CREDENTIAL_NOT_FOUND',
78
+ });
79
+ }
80
+ const entities = await this.moduleRepository.findEntitiesByUserId(
81
+ credential.userId
82
+ );
83
+ return withEntityUsage([credential], entities)[0];
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Deletes a credential the caller owns. The entities that used it stay, with
89
+ * their credential unset, so they show as needing re-authorization rather
90
+ * than disappearing from integrations.
91
+ */
92
+ class DeleteCredentialForUser {
93
+ constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
94
+ this.credentialRepository = credentialRepository;
95
+ this.moduleRepository = moduleRepository;
96
+ this.getCredentialForUser = getCredentialForUser;
97
+ }
98
+
99
+ async execute(credentialId, user) {
100
+ const credential = await this.getCredentialForUser.execute(
101
+ credentialId,
102
+ user
103
+ );
104
+ for (const entityId of credential.entityIds) {
105
+ await this.moduleRepository.unsetCredential(entityId);
106
+ }
107
+ await this.credentialRepository.deleteCredentialById(credential.id);
108
+ return { id: String(credential.id), entityIds: credential.entityIds };
109
+ }
110
+ }
111
+
112
+ module.exports = {
113
+ ListCredentialsForUser,
114
+ GetCredentialForUserV2,
115
+ DeleteCredentialForUser,
116
+ withEntityUsage,
117
+ };
@@ -0,0 +1,66 @@
1
+ const Boom = require('@hapi/boom');
2
+
3
+ /**
4
+ * Re-authorization of a credential the caller owns (Management API v2). It
5
+ * runs the same flow as /api/v2/authorize for the credential's entity type,
6
+ * with the session bound to the credential so it cannot be reused for
7
+ * anything else.
8
+ */
9
+ async function credentialEntityType(getCredentialForUser, credentialId, user) {
10
+ const credential = await getCredentialForUser.execute(credentialId, user);
11
+ if (!credential.type) {
12
+ throw Boom.conflict(
13
+ `Credential ${credentialId} is not used by any entity, so its type is unknown; authorize a new connection instead`,
14
+ { code: 'CREDENTIAL_TYPE_UNKNOWN' }
15
+ );
16
+ }
17
+ return { entityType: credential.type, credentialId: String(credential.id) };
18
+ }
19
+
20
+ /** GET /api/v2/credentials/:credentialId/reauthorize */
21
+ class GetCredentialReauthorization {
22
+ constructor({ getCredentialForUser, getAuthorizationStep }) {
23
+ this.getCredentialForUser = getCredentialForUser;
24
+ this.getAuthorizationStep = getAuthorizationStep;
25
+ }
26
+
27
+ async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
28
+ const binding = await credentialEntityType(
29
+ this.getCredentialForUser,
30
+ credentialId,
31
+ user
32
+ );
33
+ return this.getAuthorizationStep.execute({
34
+ user,
35
+ ...binding,
36
+ step,
37
+ sessionId,
38
+ state,
39
+ });
40
+ }
41
+ }
42
+
43
+ /** POST /api/v2/credentials/:credentialId/reauthorize */
44
+ class ReauthorizeCredentialForUser {
45
+ constructor({ getCredentialForUser, submitAuthorizationStep }) {
46
+ this.getCredentialForUser = getCredentialForUser;
47
+ this.submitAuthorizationStep = submitAuthorizationStep;
48
+ }
49
+
50
+ async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
51
+ const binding = await credentialEntityType(
52
+ this.getCredentialForUser,
53
+ credentialId,
54
+ user
55
+ );
56
+ return this.submitAuthorizationStep.execute({
57
+ user,
58
+ ...binding,
59
+ data,
60
+ step,
61
+ sessionId,
62
+ });
63
+ }
64
+ }
65
+
66
+ module.exports = { GetCredentialReauthorization, ReauthorizeCredentialForUser };