@friggframework/core 2.0.0--canary.704.79d9a63.0 → 2.0.0--canary.686.e94de4d.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 (207) hide show
  1. package/CLAUDE.md +28 -40
  2. package/README.md +105 -0
  3. package/application/commands/entity-commands.js +60 -18
  4. package/application/commands/integration-commands.js +20 -73
  5. package/application/commands/scheduler-commands.js +11 -7
  6. package/application/commands/user-commands.js +17 -5
  7. package/application/index.js +6 -2
  8. package/core/Worker.js +31 -7
  9. package/core/create-handler.js +9 -0
  10. package/credential/repositories/credential-repository-documentdb.js +34 -0
  11. package/credential/repositories/credential-repository-interface.js +15 -0
  12. package/credential/repositories/credential-repository-mongo.js +23 -0
  13. package/credential/repositories/credential-repository-postgres.js +28 -0
  14. package/credential/use-cases/credential-operations-for-user.js +202 -0
  15. package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
  16. package/database/config.js +48 -24
  17. package/database/documentdb-encryption-service.js +9 -22
  18. package/database/documentdb-utils.js +2 -0
  19. package/database/encryption/README.md +76 -14
  20. package/database/encryption/documentdb-encryption-service.md +78 -1
  21. package/database/encryption/encryption-config.js +219 -0
  22. package/database/encryption/encryption-schema-registry.js +93 -12
  23. package/database/encryption/integration-mapping-encryption.js +1 -1
  24. package/database/organization-where.js +18 -0
  25. package/database/prisma.js +15 -38
  26. package/database/unique-violation.js +25 -0
  27. package/database/use-cases/check-encryption-health-use-case.js +43 -52
  28. package/errors/index.js +0 -4
  29. package/extensions/bootstrap-runner.js +43 -0
  30. package/extensions/extension-loader.js +187 -0
  31. package/extensions/index.js +41 -0
  32. package/extensions/initialize-app.js +123 -0
  33. package/extensions/route-mounter.js +103 -0
  34. package/extensions/schema-composer.js +188 -0
  35. package/generated/prisma-mongodb/edge.js +43 -4
  36. package/generated/prisma-mongodb/index-browser.js +40 -1
  37. package/generated/prisma-mongodb/index.d.ts +4761 -133
  38. package/generated/prisma-mongodb/index.js +43 -4
  39. package/generated/prisma-mongodb/package.json +1 -1
  40. package/generated/prisma-mongodb/schema.prisma +92 -0
  41. package/generated/prisma-mongodb/wasm.js +43 -4
  42. package/generated/prisma-postgresql/edge.js +43 -4
  43. package/generated/prisma-postgresql/index-browser.js +40 -1
  44. package/generated/prisma-postgresql/index.d.ts +4961 -28
  45. package/generated/prisma-postgresql/index.js +43 -4
  46. package/generated/prisma-postgresql/package.json +1 -1
  47. package/generated/prisma-postgresql/schema.prisma +87 -0
  48. package/generated/prisma-postgresql/wasm.js +43 -4
  49. package/handlers/WEBHOOKS.md +0 -29
  50. package/handlers/app-definition-loader.js +121 -19
  51. package/handlers/app-handler-helpers.js +25 -3
  52. package/handlers/backend-utils.js +31 -25
  53. package/handlers/middleware/admin-auth.js +14 -1
  54. package/handlers/routers/auth.js +46 -10
  55. package/handlers/routers/health.js +13 -4
  56. package/handlers/routers/integration-defined-routers.js +144 -92
  57. package/handlers/routers/integration-webhook-routers.js +102 -64
  58. package/handlers/workers/integration-defined-workers.js +5 -2
  59. package/index.js +65 -3
  60. package/infrastructure/scheduler/index.js +19 -6
  61. package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
  62. package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
  63. package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
  64. package/integrations/index.js +1 -0
  65. package/integrations/integration-base.js +0 -29
  66. package/integrations/integration-router.js +131 -96
  67. package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
  68. package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
  69. package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
  70. package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
  71. package/integrations/repositories/integration-mapping-repository.js +0 -29
  72. package/integrations/repositories/integration-not-found.js +14 -0
  73. package/integrations/repositories/integration-repository-documentdb.js +35 -35
  74. package/integrations/repositories/integration-repository-interface.js +17 -31
  75. package/integrations/repositories/integration-repository-mongo.js +60 -129
  76. package/integrations/repositories/integration-repository-postgres.js +62 -134
  77. package/integrations/repositories/process-repository-mongo.js +1 -1
  78. package/integrations/schedule-config.js +80 -0
  79. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  80. package/integrations/use-cases/create-integration.js +72 -75
  81. package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
  82. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  83. package/integrations/use-cases/index.js +7 -1
  84. package/integrations/use-cases/integration-operations-for-user.js +256 -0
  85. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  86. package/integrations/utils/map-integration-dto.js +43 -14
  87. package/management-api/create-management-api-router.js +49 -0
  88. package/management-api/error-response.js +116 -0
  89. package/management-api/management-api-config.js +151 -0
  90. package/management-api/meta-router.js +76 -0
  91. package/management-api/openapi/build-openapi.js +144 -0
  92. package/management-api/openapi/components.js +408 -0
  93. package/management-api/openapi/v1-operations.js +83 -0
  94. package/management-api/openapi/v2-operations.js +264 -0
  95. package/management-api/route-registry.js +411 -0
  96. package/management-api/test-support/http-client.js +39 -0
  97. package/management-api/use-cases/get-management-api-meta.js +57 -0
  98. package/management-api/v1-deprecation.js +120 -0
  99. package/management-api/v2/create-v2-router.js +85 -0
  100. package/management-api/v2/dependencies.js +281 -0
  101. package/management-api/v2/dto.js +143 -0
  102. package/management-api/v2/handlers/authorization.js +94 -0
  103. package/management-api/v2/handlers/credentials.js +33 -0
  104. package/management-api/v2/handlers/entities.js +77 -0
  105. package/management-api/v2/handlers/integrations.js +128 -0
  106. package/management-api/v2/handlers/proxy.js +34 -0
  107. package/management-api/v2/request.js +85 -0
  108. package/management-api/v2/test-support/fake-dependencies.js +103 -0
  109. package/modules/domain/authorization-session.js +144 -0
  110. package/modules/entity-scope.js +15 -0
  111. package/modules/entity-type-registry.js +311 -0
  112. package/modules/index.js +14 -0
  113. package/modules/module-factory.js +12 -0
  114. package/modules/module.js +53 -2
  115. package/modules/proxy/proxy-errors.js +20 -0
  116. package/modules/proxy/proxy-path.js +179 -0
  117. package/modules/proxy/proxy-policy.js +248 -0
  118. package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
  119. package/modules/repositories/authorization-session-repository-factory.js +31 -0
  120. package/modules/repositories/authorization-session-repository-interface.js +47 -0
  121. package/modules/repositories/authorization-session-repository-prisma.js +87 -0
  122. package/modules/repositories/entity-not-found.js +18 -0
  123. package/modules/repositories/module-repository-documentdb.js +51 -6
  124. package/modules/repositories/module-repository-interface.js +19 -2
  125. package/modules/repositories/module-repository-mongo.js +67 -74
  126. package/modules/repositories/module-repository-postgres.js +66 -74
  127. package/modules/repositories/module-repository.js +2 -1
  128. package/modules/requester/proxy-request.js +244 -0
  129. package/modules/requester/requester.js +27 -2
  130. package/modules/scope-config.js +192 -0
  131. package/modules/scope-policy.js +112 -0
  132. package/modules/scope-resolution.js +288 -0
  133. package/modules/scope-visibility.js +247 -0
  134. package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
  135. package/modules/tests/doubles/ownership-matching.js +18 -0
  136. package/modules/use-cases/authorize-entity-for-user.js +61 -0
  137. package/modules/use-cases/create-entity-from-credential.js +113 -0
  138. package/modules/use-cases/entity-operations-for-user.js +162 -0
  139. package/modules/use-cases/entity-types.js +139 -0
  140. package/modules/use-cases/execute-entity-proxy-request.js +286 -0
  141. package/modules/use-cases/get-authorization-requirements.js +100 -0
  142. package/modules/use-cases/get-entities-for-user.js +26 -13
  143. package/modules/use-cases/get-entity-module-for-user.js +113 -0
  144. package/modules/use-cases/get-entity-options-by-id.js +10 -4
  145. package/modules/use-cases/get-entity-options-by-type.js +1 -1
  146. package/modules/use-cases/get-module-instance-from-type.js +1 -1
  147. package/modules/use-cases/get-module.js +10 -4
  148. package/modules/use-cases/manage-access.js +21 -0
  149. package/modules/use-cases/multi-step-authorization.js +329 -0
  150. package/modules/use-cases/process-authorization-callback.js +53 -19
  151. package/modules/use-cases/refresh-entity-options.js +10 -4
  152. package/modules/use-cases/test-module-auth.js +10 -4
  153. package/modules/utils/map-module-dto.js +3 -5
  154. package/package.json +6 -5
  155. package/prisma-mongodb/schema.prisma +92 -0
  156. package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
  157. package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
  158. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  159. package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
  160. package/prisma-postgresql/schema.prisma +87 -0
  161. package/providers/index.js +23 -0
  162. package/providers/resolve-provider.js +93 -0
  163. package/providers/runtime-detection.js +124 -0
  164. package/queues/index.js +25 -0
  165. package/queues/providers/index.js +11 -0
  166. package/queues/providers/netlify-background-provider.js +150 -0
  167. package/queues/providers/qstash-queue-provider.js +219 -0
  168. package/queues/providers/sqs-queue-provider.js +70 -0
  169. package/queues/queue-client-interface.js +55 -0
  170. package/queues/queue-message-auth.js +193 -0
  171. package/queues/queue-provider-factory.js +101 -0
  172. package/queues/queue-provider.js +65 -0
  173. package/queues/sqs-queue-client.js +81 -0
  174. package/types/index.d.ts +2 -0
  175. package/types/modules/index.d.ts +197 -0
  176. package/types/user/index.d.ts +134 -0
  177. package/user/adopter-jwt-config.js +177 -0
  178. package/user/adopter-jwt-errors.js +59 -0
  179. package/user/adopter-token-verifier.js +122 -0
  180. package/user/app-user-helpers.js +97 -0
  181. package/user/create-user-authentication.js +64 -0
  182. package/user/membership.js +51 -0
  183. package/user/repositories/organization-membership-record.js +19 -0
  184. package/user/repositories/organization-membership-repository-documentdb.js +106 -0
  185. package/user/repositories/organization-membership-repository-factory.js +42 -0
  186. package/user/repositories/organization-membership-repository-interface.js +57 -0
  187. package/user/repositories/organization-membership-repository-mongo.js +13 -0
  188. package/user/repositories/organization-membership-repository-postgres.js +29 -0
  189. package/user/repositories/organization-membership-repository-prisma.js +81 -0
  190. package/user/repositories/user-repository-documentdb.js +27 -2
  191. package/user/repositories/user-repository-interface.js +18 -0
  192. package/user/repositories/user-repository-mongo.js +24 -0
  193. package/user/repositories/user-repository-postgres.js +18 -0
  194. package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
  195. package/user/tests/doubles/test-user-repository.js +1 -1
  196. package/user/use-cases/authenticate-user.js +7 -1
  197. package/user/use-cases/ensure-organization-membership.js +92 -0
  198. package/user/use-cases/find-individual-users-by-organization.js +54 -0
  199. package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
  200. package/user/use-cases/get-user-from-bearer-token.js +2 -7
  201. package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
  202. package/user/user.js +64 -1
  203. package/errors/mapping-already-exists-error.js +0 -14
  204. package/integrations/use-cases/add-entities-to-integration.js +0 -92
  205. package/integrations/use-cases/remove-entities-from-integration.js +0 -70
  206. package/integrations/utils/assert-entity-ids-input.js +0 -42
  207. package/modules/utils/to-credential-id.js +0 -14
@@ -1,5 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
3
+ const { entityNotFoundError } = require('./entity-not-found');
3
4
 
4
5
  /**
5
6
  * PostgreSQL Module Repository Adapter
@@ -108,6 +109,26 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
108
109
  return credentialMap;
109
110
  }
110
111
 
112
+ /**
113
+ * The one place a stored entity row becomes the entity the application sees.
114
+ * The provider's `data` is spread FIRST and every column is pinned after it:
115
+ * a stored `id`, `userId` or `organizationId` in `data` must not decide who
116
+ * owns the entity or who sees it.
117
+ * @private
118
+ */
119
+ _mapEntity(row, credential) {
120
+ return {
121
+ ...(row.data || {}),
122
+ id: row.id.toString(),
123
+ credential,
124
+ userId: row.userId?.toString(),
125
+ name: row.name,
126
+ externalId: row.externalId,
127
+ moduleName: row.moduleName,
128
+ organizationId: row.organizationId?.toString() ?? null,
129
+ };
130
+ }
131
+
111
132
  /**
112
133
  * Find entity by ID with credential
113
134
  * Replaces: Entity.findById(entityId).populate('credential')
@@ -124,20 +145,12 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
124
145
  });
125
146
 
126
147
  if (!entity) {
127
- throw new Error(`Entity ${entityId} not found`);
148
+ throw entityNotFoundError(entityId);
128
149
  }
129
150
 
130
151
  const credential = await this._fetchCredential(entity.credentialId);
131
152
 
132
- return {
133
- id: entity.id.toString(),
134
- credential,
135
- userId: entity.userId?.toString(),
136
- name: entity.name,
137
- externalId: entity.externalId,
138
- moduleName: entity.moduleName,
139
- ...(entity.data || {}),
140
- };
153
+ return this._mapEntity(entity, credential);
141
154
  }
142
155
 
143
156
  /**
@@ -157,15 +170,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
157
170
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
158
171
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
159
172
 
160
- return entities.map((e) => ({
161
- id: e.id.toString(),
162
- credential: credentialMap.get(e.credentialId) || null,
163
- userId: e.userId?.toString(),
164
- name: e.name,
165
- externalId: e.externalId,
166
- moduleName: e.moduleName,
167
- ...(e.data || {}),
168
- }));
173
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
169
174
  }
170
175
 
171
176
  /**
@@ -185,15 +190,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
185
190
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
186
191
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
187
192
 
188
- return entities.map((e) => ({
189
- id: e.id.toString(),
190
- credential: credentialMap.get(e.credentialId) || null,
191
- userId: e.userId?.toString(),
192
- name: e.name,
193
- externalId: e.externalId,
194
- moduleName: e.moduleName,
195
- ...(e.data || {}),
196
- }));
193
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
197
194
  }
198
195
 
199
196
  /**
@@ -217,15 +214,32 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
217
214
  const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
218
215
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
219
216
 
220
- return entities.map((e) => ({
221
- id: e.id.toString(),
222
- credential: credentialMap.get(e.credentialId) || null,
223
- userId: e.userId?.toString(),
224
- name: e.name,
225
- externalId: e.externalId,
226
- moduleName: e.moduleName,
227
- ...(e.data || {}),
228
- }));
217
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
218
+ }
219
+
220
+ /**
221
+ * Find entities matching any of the owner/organization pairs, in one query.
222
+ * `organizationId: null` matches rows with no organization. The caller
223
+ * (a use case) decides which pairs; this only runs them.
224
+ *
225
+ * @param {Array<{userId: string, organizationId: (string|null)}>} clauses
226
+ * @returns {Promise<Array>} Array of entity objects with string IDs
227
+ */
228
+ async findEntitiesByOwnership(clauses) {
229
+ if (!Array.isArray(clauses) || clauses.length === 0) return [];
230
+ const entities = await this.prisma.entity.findMany({
231
+ where: {
232
+ OR: clauses.map(({ userId, organizationId }) => ({
233
+ userId: this._convertId(userId),
234
+ organizationId: this._convertId(organizationId),
235
+ })),
236
+ },
237
+ });
238
+
239
+ const credentialIds = entities.map(e => e.credentialId).filter(Boolean);
240
+ const credentialMap = await this._fetchCredentialsBulk(credentialIds);
241
+
242
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
229
243
  }
230
244
 
231
245
  /**
@@ -264,15 +278,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
264
278
 
265
279
  const credential = await this._fetchCredential(entity.credentialId);
266
280
 
267
- return {
268
- id: entity.id.toString(),
269
- credential,
270
- userId: entity.userId?.toString(),
271
- name: entity.name,
272
- externalId: entity.externalId,
273
- moduleName: entity.moduleName,
274
- ...(entity.data || {}),
275
- };
281
+ return this._mapEntity(entity, credential);
276
282
  }
277
283
 
278
284
  /**
@@ -292,15 +298,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
292
298
  .filter(Boolean);
293
299
  const credentialMap = await this._fetchCredentialsBulk(credentialIds);
294
300
 
295
- return entities.map((e) => ({
296
- id: e.id.toString(),
297
- credential: credentialMap.get(e.credentialId) || null,
298
- userId: e.userId?.toString(),
299
- name: e.name,
300
- externalId: e.externalId,
301
- moduleName: e.moduleName,
302
- ...(e.data || {}),
303
- }));
301
+ return entities.map((e) => (this._mapEntity(e, credentialMap.get(e.credentialId) || null)));
304
302
  }
305
303
 
306
304
  /**
@@ -308,9 +306,13 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
308
306
  * Replaces: Entity.create(entityData)
309
307
  *
310
308
  * @param {Object} entityData - Entity data (with string IDs from application layer)
309
+ * @param {{organizationId?: (string|null)}} [tenancy] - the organization the entity
310
+ * belongs to (ADR-056 §3.6). A separate argument, not a field of `entityData`,
311
+ * because `entityData` carries provider fields and one named `organizationId`
312
+ * must not be taken for ours.
311
313
  * @returns {Promise<Object>} Created entity object with string IDs
312
314
  */
313
- async createEntity(entityData) {
315
+ async createEntity(entityData, tenancy = {}) {
314
316
  const {
315
317
  user,
316
318
  userId,
@@ -328,6 +330,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
328
330
  name,
329
331
  moduleName,
330
332
  externalId,
333
+ organizationId: this._convertId(tenancy.organizationId ?? null),
331
334
  data: dynamicData,
332
335
  };
333
336
 
@@ -337,15 +340,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
337
340
 
338
341
  const credentialObj = await this._fetchCredential(entity.credentialId);
339
342
 
340
- return {
341
- id: entity.id.toString(),
342
- credential: credentialObj,
343
- userId: entity.userId?.toString(),
344
- name: entity.name,
345
- externalId: entity.externalId,
346
- moduleName: entity.moduleName,
347
- ...(entity.data || {}),
348
- };
343
+ return this._mapEntity(entity, credentialObj);
349
344
  }
350
345
 
351
346
  /**
@@ -401,15 +396,7 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
401
396
 
402
397
  const credentialObj = await this._fetchCredential(entity.credentialId);
403
398
 
404
- return {
405
- id: entity.id.toString(),
406
- credential: credentialObj,
407
- userId: entity.userId?.toString(),
408
- name: entity.name,
409
- externalId: entity.externalId,
410
- moduleName: entity.moduleName,
411
- ...(entity.data || {}),
412
- };
399
+ return this._mapEntity(entity, credentialObj);
413
400
  } catch (error) {
414
401
  if (error.code === 'P2025') {
415
402
  return null;
@@ -474,6 +461,11 @@ class ModuleRepositoryPostgres extends ModuleRepositoryInterface {
474
461
  if (filter.moduleName) where.moduleName = filter.moduleName;
475
462
  if (filter.externalId) where.externalId = this._toString(filter.externalId);
476
463
 
464
+ // `null` means "no organization"; absent means "any".
465
+ if (filter.organizationId !== undefined) {
466
+ where.organizationId = this._convertId(filter.organizationId);
467
+ }
468
+
477
469
  return where;
478
470
  }
479
471
  }
@@ -1,5 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ModuleRepositoryInterface } = require('./module-repository-interface');
3
+ const { entityNotFoundError } = require('./entity-not-found');
3
4
 
4
5
  /**
5
6
  * Prisma-based Module Repository
@@ -37,7 +38,7 @@ class ModuleRepository extends ModuleRepositoryInterface {
37
38
  });
38
39
 
39
40
  if (!entity) {
40
- throw new Error(`Entity ${entityId} not found`);
41
+ throw entityNotFoundError(entityId);
41
42
  }
42
43
 
43
44
  return {
@@ -0,0 +1,244 @@
1
+ const { FetchError } = require('../../errors/fetch-error');
2
+ const { ProxyRequestError } = require('../proxy/proxy-errors');
3
+ const {
4
+ normalizeProxyPath,
5
+ joinProxyUrl,
6
+ } = require('../proxy/proxy-path');
7
+
8
+ const MAX_REDIRECTS = 3;
9
+
10
+ /**
11
+ * The generic, path-checked request behind the Management API entity proxy
12
+ * (ADR-052 §8). It runs through the requester's own `_request`, so auth
13
+ * headers, token refresh (single-flight), the per-attempt timeout, telemetry
14
+ * and log redaction all apply. What it adds:
15
+ *
16
+ * - the URL is always `module base URL + normalised path`; the caller cannot
17
+ * name a host, scheme or port;
18
+ * - no backoff retries (the caller decides; a 429 comes back with its
19
+ * Retry-After), and upstream errors are returned, not thrown;
20
+ * - redirects are handled manually: same origin and under the base path only,
21
+ * at most three hops, each re-checked with `isAllowedRedirect`;
22
+ * - one deadline for the whole call and a cap on the response body.
23
+ *
24
+ * @param {import('./requester').Requester} requester
25
+ * @param {Object} params
26
+ * @param {string} params.method - upper case
27
+ * @param {string} params.path - caller path, validated here
28
+ * @param {Object} [params.query]
29
+ * @param {Object} [params.headers] - already filtered by the caller's policy
30
+ * @param {string|Buffer} [params.body] - already serialised
31
+ * @param {string} [params.baseUrl] - module-declared proxy base URL; falls
32
+ * back to the requester's `proxyBaseUrl`, `baseUrl` or `baseURL`
33
+ * @param {number} params.timeoutMs - budget for the whole call
34
+ * @param {number} params.maxResponseBytes
35
+ * @param {(path: string, method: string) => boolean} [params.isAllowedRedirect]
36
+ * @returns {Promise<{ status: number, headers: Object, body: Buffer, contentType: string, redirectBlocked: boolean }>}
37
+ * @throws {ProxyRequestError} INVALID_PROXY_REQUEST, PROXY_NOT_CONFIGURED,
38
+ * INVALID_CREDENTIALS, TIMEOUT, NETWORK_ERROR, RESPONSE_TOO_LARGE
39
+ */
40
+ async function proxyRequest(requester, params) {
41
+ const {
42
+ method,
43
+ path,
44
+ query,
45
+ headers = {},
46
+ body,
47
+ baseUrl,
48
+ timeoutMs,
49
+ maxResponseBytes,
50
+ isAllowedRedirect,
51
+ } = params;
52
+
53
+ const normalizedPath = normalizeProxyPath(path);
54
+ const base =
55
+ baseUrl ??
56
+ requester.proxyBaseUrl ??
57
+ requester.baseUrl ??
58
+ requester.baseURL;
59
+ if (!base) {
60
+ throw new ProxyRequestError(
61
+ 'PROXY_NOT_CONFIGURED',
62
+ 'The module declares no base URL to proxy to'
63
+ );
64
+ }
65
+ const target = joinProxyUrl(base, normalizedPath);
66
+ const deadline = Date.now() + timeoutMs;
67
+
68
+ const run = async () => {
69
+ let url = target.url;
70
+ let currentMethod = method;
71
+ let currentBody = body;
72
+ let currentQuery = query;
73
+ for (let hop = 0; ; hop++) {
74
+ const remaining = deadline - Date.now();
75
+ if (remaining <= 0) throw timeoutError();
76
+
77
+ const response = await requester._request(url, {
78
+ method: currentMethod,
79
+ headers: { ...headers },
80
+ query: currentQuery,
81
+ body: currentBody,
82
+ returnFullRes: true,
83
+ returnErrorResponses: true,
84
+ noRetry: true,
85
+ redirect: 'manual',
86
+ size: maxResponseBytes,
87
+ timeoutMs: remaining,
88
+ });
89
+
90
+ const location = response.headers.get('location');
91
+ if (response.status >= 300 && response.status < 400 && location) {
92
+ const next = sameApiRedirect(location, url, target);
93
+ const nextMethod = redirectMethod(response.status, currentMethod);
94
+ if (
95
+ next &&
96
+ hop < MAX_REDIRECTS &&
97
+ (!isAllowedRedirect || isAllowedRedirect(next.path, nextMethod))
98
+ ) {
99
+ if (nextMethod !== currentMethod) currentBody = undefined;
100
+ currentMethod = nextMethod;
101
+ url = next.url;
102
+ currentQuery = undefined;
103
+ continue;
104
+ }
105
+ return readResponse(response, maxResponseBytes, {
106
+ redirectBlocked: !next,
107
+ });
108
+ }
109
+ return readResponse(response, maxResponseBytes, {
110
+ redirectBlocked: false,
111
+ });
112
+ }
113
+ };
114
+
115
+ let timer;
116
+ const timeout = new Promise((_, reject) => {
117
+ timer = setTimeout(() => reject(timeoutError()), timeoutMs);
118
+ });
119
+ const work = run();
120
+ // If the deadline wins, the request still settles later; nothing awaits it.
121
+ work.catch(() => {});
122
+ try {
123
+ return await Promise.race([work, timeout]);
124
+ } catch (error) {
125
+ throw toProxyError(error);
126
+ } finally {
127
+ clearTimeout(timer);
128
+ }
129
+ }
130
+
131
+ function timeoutError() {
132
+ return new ProxyRequestError('TIMEOUT', 'The upstream API did not answer in time');
133
+ }
134
+
135
+ /**
136
+ * A redirect target the proxy may follow: same origin as the module base URL
137
+ * and under its base path. Returns null for anything else.
138
+ */
139
+ function sameApiRedirect(location, currentUrl, target) {
140
+ let next;
141
+ try {
142
+ next = new URL(location, currentUrl);
143
+ } catch {
144
+ return null;
145
+ }
146
+ if (next.origin !== target.origin || next.protocol !== 'https:') return null;
147
+ let decodedPath;
148
+ try {
149
+ decodedPath = decodeURIComponent(next.pathname);
150
+ } catch {
151
+ return null;
152
+ }
153
+ if (
154
+ !(decodedPath === target.basePath || decodedPath.startsWith(`${target.basePath}/`))
155
+ ) {
156
+ return null;
157
+ }
158
+ const relativePath = decodedPath.slice(target.basePath.length) || '/';
159
+ let checkedPath;
160
+ try {
161
+ checkedPath = normalizeProxyPath(relativePath);
162
+ } catch {
163
+ return null;
164
+ }
165
+ // _rawRequest percent-encodes the URL once, so hand it the decoded form.
166
+ return {
167
+ url: `${target.origin}${target.basePath}${checkedPath}${next.search ? decodeURI(next.search) : ''}`,
168
+ path: checkedPath,
169
+ };
170
+ }
171
+
172
+ /** Method for the next hop, as fetch does it. */
173
+ function redirectMethod(status, method) {
174
+ if (status === 303 && method !== 'HEAD') return 'GET';
175
+ if ((status === 301 || status === 302) && method === 'POST') return 'GET';
176
+ return method;
177
+ }
178
+
179
+ async function readResponse(response, maxResponseBytes, { redirectBlocked }) {
180
+ const declaredLength = Number(response.headers.get('content-length'));
181
+ if (Number.isFinite(declaredLength) && declaredLength > maxResponseBytes) {
182
+ destroyBody(response);
183
+ throw tooLarge();
184
+ }
185
+ const buffer = Buffer.from(await response.arrayBuffer());
186
+ if (buffer.length > maxResponseBytes) throw tooLarge();
187
+ const headers = {};
188
+ response.headers.forEach((value, name) => {
189
+ headers[name.toLowerCase()] = value;
190
+ });
191
+ return {
192
+ status: response.status,
193
+ headers,
194
+ body: buffer,
195
+ contentType: headers['content-type'] || '',
196
+ redirectBlocked,
197
+ };
198
+ }
199
+
200
+ function destroyBody(response) {
201
+ try {
202
+ response.body?.destroy?.();
203
+ } catch {
204
+ // nothing to clean up
205
+ }
206
+ }
207
+
208
+ function tooLarge() {
209
+ return new ProxyRequestError(
210
+ 'RESPONSE_TOO_LARGE',
211
+ 'The upstream response is larger than the proxy allows'
212
+ );
213
+ }
214
+
215
+ /** Maps requester and fetch failures to the proxy's error codes. */
216
+ function toProxyError(error) {
217
+ if (error instanceof ProxyRequestError) return error;
218
+ const causeType = error?.cause?.type ?? error?.type;
219
+ if (causeType === 'max-size') return tooLarge();
220
+ if (error?.isTimeout) return timeoutError();
221
+ if (error instanceof FetchError || error?.name === 'FetchError') {
222
+ if (error.statusCode === 401) {
223
+ return new ProxyRequestError(
224
+ 'INVALID_CREDENTIALS',
225
+ 'The upstream API rejected the stored credentials; re-authorize the entity',
226
+ { upstreamStatus: 401 }
227
+ );
228
+ }
229
+ if (!error.statusCode) {
230
+ return new ProxyRequestError(
231
+ 'NETWORK_ERROR',
232
+ 'Could not reach the upstream API'
233
+ );
234
+ }
235
+ return new ProxyRequestError(
236
+ 'UPSTREAM_ERROR',
237
+ 'The upstream API returned an error',
238
+ { upstreamStatus: error.statusCode }
239
+ );
240
+ }
241
+ return error;
242
+ }
243
+
244
+ module.exports = { proxyRequest, MAX_REDIRECTS };
@@ -8,6 +8,7 @@ const { getLogger } = require('../../logs');
8
8
  const { redactUrl } = require('../../logs/redact');
9
9
  const { toSanitizedSurrogate } = require('../../logs/serialize');
10
10
  const { getLoggerScope } = require('../../logs/context');
11
+ const { proxyRequest } = require('./proxy-request');
11
12
 
12
13
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
13
14
 
@@ -237,7 +238,9 @@ class Requester extends Delegate {
237
238
  // recursion (with its own backoff sleeps) always gets a clean
238
239
  // signal. Timer is cleared in the finally block regardless of
239
240
  // outcome.
240
- const timeoutMs = this.requestTimeoutMs;
241
+ // options.timeoutMs lets one call use a tighter budget (the
242
+ // Management API proxy passes what is left of its deadline).
243
+ const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
241
244
  const controller = timeoutMs > 0 ? new AbortController() : null;
242
245
  const timeoutHandle = controller
243
246
  ? setTimeout(() => controller.abort(), timeoutMs)
@@ -275,7 +278,11 @@ class Requester extends Delegate {
275
278
  // stall at batch scale.
276
279
  const isTimeout =
277
280
  e?.name === 'AbortError' || e?.type === 'aborted';
278
- if (e?.code === 'ECONNRESET' && attempt < this.backOff.length) {
281
+ if (
282
+ e?.code === 'ECONNRESET' &&
283
+ !options.noRetry &&
284
+ attempt < this.backOff.length
285
+ ) {
279
286
  clearRequestTimer();
280
287
  const delay = this.backOff[attempt] * 1000;
281
288
  await new Promise((resolve) => setTimeout(resolve, delay));
@@ -301,6 +308,7 @@ class Requester extends Delegate {
301
308
  // If the status is retriable and there are back off requests left, retry the request
302
309
  if (
303
310
  (status === 429 || status >= 500) &&
311
+ !options.noRetry &&
304
312
  attempt < this.backOff.length
305
313
  ) {
306
314
  clearRequestTimer();
@@ -386,6 +394,12 @@ class Requester extends Delegate {
386
394
  // If the error wasn't retried, throw. FetchError.create reads
387
395
  // the response body (response.text()) — timer must still be
388
396
  // alive to catch a stalled body stream.
397
+ // The proxy relays upstream errors to its caller instead of
398
+ // throwing; 401 keeps the refresh / invalidate handling above.
399
+ if (status >= 400 && options.returnErrorResponses) {
400
+ return response;
401
+ }
402
+
389
403
  if (status >= 400) {
390
404
  this._logRequestFailed(encodedUrl, options, status);
391
405
  const fetchError = await FetchError.create({
@@ -528,6 +542,17 @@ class Requester extends Delegate {
528
542
  return res;
529
543
  }
530
544
 
545
+ /**
546
+ * Generic, path-checked request for the Management API entity proxy
547
+ * (ADR-052 §8). The URL is always the module base URL plus a validated
548
+ * path; see ./proxy-request.js. Module methods are not involved.
549
+ *
550
+ * @param {Object} params - see proxyRequest()
551
+ */
552
+ async _proxyRequest(params) {
553
+ return proxyRequest(this, params);
554
+ }
555
+
531
556
  async _delete(options) {
532
557
  const fetchOptions = {
533
558
  method: 'DELETE',