@friggframework/core 2.0.0--canary.663.8de669c.0 → 2.0.0--canary.686.821f8a3.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.
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +20 -10
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +202 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +2 -0
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +121 -24
- package/database/encryption/field-encryption-service.js +69 -5
- package/database/encryption/test-support/fake-ciphertext.js +15 -0
- package/database/organization-where.js +18 -0
- package/database/prisma.js +4 -2
- package/database/unique-violation.js +25 -0
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +187 -0
- package/extensions/index.js +41 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +43 -4
- package/generated/prisma-mongodb/index-browser.js +40 -1
- package/generated/prisma-mongodb/index.d.ts +4761 -133
- package/generated/prisma-mongodb/index.js +43 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +92 -0
- package/generated/prisma-mongodb/wasm.js +43 -4
- package/generated/prisma-postgresql/edge.js +43 -4
- package/generated/prisma-postgresql/index-browser.js +40 -1
- package/generated/prisma-postgresql/index.d.ts +4961 -28
- package/generated/prisma-postgresql/index.js +43 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +87 -0
- package/generated/prisma-postgresql/wasm.js +43 -4
- package/handlers/app-definition-loader.js +121 -19
- package/handlers/app-handler-helpers.js +25 -3
- package/handlers/backend-utils.js +31 -25
- package/handlers/middleware/admin-auth.js +14 -1
- package/handlers/routers/auth.js +46 -10
- package/handlers/routers/health.js +13 -4
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +65 -1
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +1 -0
- package/integrations/integration-router.js +131 -96
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -3
- package/integrations/repositories/integration-repository-interface.js +17 -1
- package/integrations/repositories/integration-repository-mongo.js +59 -76
- package/integrations/repositories/integration-repository-postgres.js +62 -76
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +256 -0
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +43 -14
- package/management-api/create-management-api-router.js +49 -0
- package/management-api/error-response.js +116 -0
- package/management-api/management-api-config.js +151 -0
- package/management-api/meta-router.js +76 -0
- package/management-api/openapi/build-openapi.js +144 -0
- package/management-api/openapi/components.js +408 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +264 -0
- package/management-api/route-registry.js +411 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +281 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +33 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/proxy/proxy-errors.js +20 -0
- package/modules/proxy/proxy-path.js +179 -0
- package/modules/proxy/proxy-policy.js +248 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/requester/proxy-request.js +244 -0
- package/modules/requester/requester.js +27 -2
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +162 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +286 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +113 -0
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +329 -0
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +92 -0
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +87 -0
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +2 -0
- package/types/modules/index.d.ts +197 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
|
@@ -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
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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',
|