@oxy.so/core 1.4.0 → 1.5.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.utility.js +173 -6
- package/dist/cjs/server/auth.js +29 -0
- package/dist/cjs/server/index.js +3 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +173 -6
- package/dist/esm/server/auth.js +28 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +49 -0
- package/dist/types/server/auth.d.ts +19 -1
- package/dist/types/server/index.d.ts +2 -2
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/mixins/OxyServices.utility.ts +226 -5
- package/src/mixins/__tests__/authRefusalObservability.test.ts +396 -0
- package/src/server/auth.ts +37 -1
- package/src/server/index.ts +2 -0
|
@@ -209,10 +209,51 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
209
209
|
* @returns Express middleware function
|
|
210
210
|
*/
|
|
211
211
|
auth(options = {}) {
|
|
212
|
-
const { debug = false, onError, loadUser = false, optional = false, jwtSecret, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', this.getBaseURL()).toString(), expectedIssuer = OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
|
|
212
|
+
const { debug = false, onError, onRefusal, loadUser = false, optional = false, jwtSecret, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', this.getBaseURL()).toString(), expectedIssuer = OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
|
|
213
213
|
// Cross-mixin method access: typed as a structural subset of the
|
|
214
214
|
// composed OxyServices we know we have at runtime.
|
|
215
215
|
const oxyInstance = this;
|
|
216
|
+
/**
|
|
217
|
+
* Make a refusal observable to the HOST, without telling the client
|
|
218
|
+
* anything it is not already told.
|
|
219
|
+
*
|
|
220
|
+
* Called at the top of every branch that rejects a PRESENTED credential,
|
|
221
|
+
* including the ones `optional` then swallows — those are the expensive
|
|
222
|
+
* ones, because they leave no trace anywhere else. The response bodies
|
|
223
|
+
* below are untouched by design: what a client sees does not change.
|
|
224
|
+
*
|
|
225
|
+
* `warn`, not `debug`: a debug-gated reason is only there for whoever
|
|
226
|
+
* already suspects this middleware, and the failure mode this exists for
|
|
227
|
+
* is the one where nobody does. The volume is bounded by refusals, and a
|
|
228
|
+
* host drowning in them has a fault worth the lines.
|
|
229
|
+
*/
|
|
230
|
+
const recordRefusal = (req, refusal) => {
|
|
231
|
+
const recorded = { ...refusal, optional };
|
|
232
|
+
req.oxyAuthRefusal = recorded;
|
|
233
|
+
logger.warn(`[oxy.auth] refused ${recorded.code}: ${recorded.reason}`, {
|
|
234
|
+
component: 'auth',
|
|
235
|
+
method: 'auth',
|
|
236
|
+
code: recorded.code,
|
|
237
|
+
stage: recorded.stage,
|
|
238
|
+
reason: recorded.reason,
|
|
239
|
+
status: recorded.status,
|
|
240
|
+
// Says whether the client will see this status or the host's own
|
|
241
|
+
// generic answer — the difference that made the JWKS outage invisible.
|
|
242
|
+
optional: recorded.optional,
|
|
243
|
+
path: req.path,
|
|
244
|
+
});
|
|
245
|
+
if (onRefusal) {
|
|
246
|
+
try {
|
|
247
|
+
onRefusal(recorded);
|
|
248
|
+
}
|
|
249
|
+
catch (observerError) {
|
|
250
|
+
logger.warn('[oxy.auth] onRefusal observer threw', {
|
|
251
|
+
component: 'auth',
|
|
252
|
+
method: 'auth',
|
|
253
|
+
}, observerError);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
216
257
|
// Return an async middleware function
|
|
217
258
|
return async (req, res, next) => {
|
|
218
259
|
try {
|
|
@@ -250,6 +291,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
250
291
|
decoded = jwtDecode(token);
|
|
251
292
|
}
|
|
252
293
|
catch (decodeError) {
|
|
294
|
+
recordRefusal(req, {
|
|
295
|
+
code: 'INVALID_TOKEN_FORMAT',
|
|
296
|
+
stage: 'token',
|
|
297
|
+
reason: 'Bearer credential is not a decodable JWT',
|
|
298
|
+
status: 401,
|
|
299
|
+
});
|
|
253
300
|
if (debug) {
|
|
254
301
|
logger.debug('[oxy.auth] Token decode failed', {
|
|
255
302
|
component: 'auth',
|
|
@@ -296,6 +343,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
296
343
|
// else (e.g. Node crypto failing to load on a misconfigured host)
|
|
297
344
|
// genuinely IS a 500.
|
|
298
345
|
if (verifyError instanceof ServiceTokenConfigurationError) {
|
|
346
|
+
recordRefusal(req, {
|
|
347
|
+
code: 'SERVICE_TOKEN_NOT_CONFIGURED',
|
|
348
|
+
stage: 'service-token',
|
|
349
|
+
reason: verifyError.message,
|
|
350
|
+
status: 403,
|
|
351
|
+
});
|
|
299
352
|
if (optional) {
|
|
300
353
|
req.userId = null;
|
|
301
354
|
req.user = null;
|
|
@@ -314,6 +367,20 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
314
367
|
if (verifyError instanceof ServiceTokenStructureError ||
|
|
315
368
|
verifyError instanceof ServiceTokenSignatureError ||
|
|
316
369
|
verifyError instanceof ServiceTokenClaimError) {
|
|
370
|
+
const code = verifyError instanceof ServiceTokenClaimError
|
|
371
|
+
? 'INVALID_SERVICE_TOKEN_CLAIMS'
|
|
372
|
+
: 'INVALID_SERVICE_TOKEN';
|
|
373
|
+
// The SDK's own message is the whole diagnostic value here —
|
|
374
|
+
// "signing key is unknown" (rotated/unpublished kid), "key set
|
|
375
|
+
// is unavailable" (JWKS empty, unreachable or malformed) and
|
|
376
|
+
// "audience mismatch" are three different outages that answer
|
|
377
|
+
// the client identically.
|
|
378
|
+
recordRefusal(req, {
|
|
379
|
+
code,
|
|
380
|
+
stage: 'service-token',
|
|
381
|
+
reason: `${verifyError.name}: ${verifyError.message}`,
|
|
382
|
+
status: 401,
|
|
383
|
+
});
|
|
317
384
|
if (debug) {
|
|
318
385
|
logger.debug('[oxy.auth] Service token rejected', {
|
|
319
386
|
component: 'auth',
|
|
@@ -327,11 +394,6 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
327
394
|
req.user = null;
|
|
328
395
|
return next();
|
|
329
396
|
}
|
|
330
|
-
const code = verifyError instanceof ServiceTokenSignatureError
|
|
331
|
-
? 'INVALID_SERVICE_TOKEN'
|
|
332
|
-
: verifyError instanceof ServiceTokenStructureError
|
|
333
|
-
? 'INVALID_SERVICE_TOKEN'
|
|
334
|
-
: 'INVALID_SERVICE_TOKEN_CLAIMS';
|
|
335
397
|
const error = {
|
|
336
398
|
error: code,
|
|
337
399
|
message: verifyError.message,
|
|
@@ -359,6 +421,14 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
359
421
|
// Check expiration — reject tokens at exact expiry second (use <=)
|
|
360
422
|
const now = Math.floor(Date.now() / 1000);
|
|
361
423
|
if (!Number.isInteger(decoded.exp) || decoded.exp <= now) {
|
|
424
|
+
recordRefusal(req, {
|
|
425
|
+
code: 'TOKEN_EXPIRED',
|
|
426
|
+
stage: 'service-token',
|
|
427
|
+
reason: Number.isInteger(decoded.exp)
|
|
428
|
+
? 'Service token expired'
|
|
429
|
+
: 'Service token has no integer exp claim',
|
|
430
|
+
status: 401,
|
|
431
|
+
});
|
|
362
432
|
if (optional) {
|
|
363
433
|
req.userId = null;
|
|
364
434
|
req.user = null;
|
|
@@ -370,6 +440,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
370
440
|
return res.status(401).json(error);
|
|
371
441
|
}
|
|
372
442
|
if (decoded.nbf !== undefined && (!Number.isInteger(decoded.nbf) || decoded.nbf > now)) {
|
|
443
|
+
recordRefusal(req, {
|
|
444
|
+
code: 'INVALID_SERVICE_TOKEN_CLAIMS',
|
|
445
|
+
stage: 'service-token',
|
|
446
|
+
reason: 'Service token is not yet valid (nbf)',
|
|
447
|
+
status: 401,
|
|
448
|
+
});
|
|
373
449
|
const error = { error: 'INVALID_SERVICE_TOKEN_CLAIMS', message: 'Service token is not yet valid', code: 'INVALID_SERVICE_TOKEN_CLAIMS', status: 401 };
|
|
374
450
|
if (onError)
|
|
375
451
|
return onError(error);
|
|
@@ -391,6 +467,22 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
391
467
|
|| !Array.isArray(decoded.scopes)
|
|
392
468
|
|| !decoded.scopes.every((scope) => typeof scope === 'string' && scope.length > 0 && scope === scope.trim())
|
|
393
469
|
|| new Set(decoded.scopes).size !== decoded.scopes.length) {
|
|
470
|
+
recordRefusal(req, {
|
|
471
|
+
code: 'INVALID_SERVICE_TOKEN',
|
|
472
|
+
stage: 'service-token',
|
|
473
|
+
// Names the field rather than its value: an exact-match claim
|
|
474
|
+
// failing on invisible whitespace reads as "present" in a log
|
|
475
|
+
// that only says which claims were missing.
|
|
476
|
+
reason: `Service token claims unusable (${[
|
|
477
|
+
!isExactNonEmptyServiceClaim(appId) ? 'appId' : null,
|
|
478
|
+
!isExactNonEmptyServiceClaim(decoded.appName) ? 'appName' : null,
|
|
479
|
+
!isExactNonEmptyServiceClaim(credentialId) ? 'credentialId' : null,
|
|
480
|
+
!isExactNonEmptyServiceClaim(ownerAccountId) ? 'ownerAccountId' : null,
|
|
481
|
+
!isOxyServiceEnvironment(environment) ? 'environment' : null,
|
|
482
|
+
!Array.isArray(decoded.scopes) ? 'scopes' : null,
|
|
483
|
+
].filter((field) => field !== null).join(', ') || 'scopes'})`,
|
|
484
|
+
status: 401,
|
|
485
|
+
});
|
|
394
486
|
if (optional) {
|
|
395
487
|
req.userId = null;
|
|
396
488
|
req.user = null;
|
|
@@ -405,6 +497,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
405
497
|
const oxyUserIdRaw = req.headers['x-oxy-user-id'];
|
|
406
498
|
const oxyUserId = isExactNonEmptyServiceClaim(oxyUserIdRaw) ? oxyUserIdRaw : null;
|
|
407
499
|
if (oxyUserIdRaw !== undefined && oxyUserId === null) {
|
|
500
|
+
recordRefusal(req, {
|
|
501
|
+
code: 'INVALID_SERVICE_TOKEN_CLAIMS',
|
|
502
|
+
stage: 'service-token',
|
|
503
|
+
reason: 'X-Oxy-User-Id is not an exact non-empty id',
|
|
504
|
+
status: 401,
|
|
505
|
+
});
|
|
408
506
|
const error = {
|
|
409
507
|
error: 'INVALID_SERVICE_TOKEN_CLAIMS',
|
|
410
508
|
message: 'Delegated user id must be an exact non-empty id',
|
|
@@ -429,6 +527,16 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
429
527
|
appId,
|
|
430
528
|
attemptedUserId: oxyUserId,
|
|
431
529
|
});
|
|
530
|
+
// A verifier with no service credentials of its own cannot
|
|
531
|
+
// reach the verify endpoint at all, so `null` here can mean
|
|
532
|
+
// "no grant" OR "this host cannot ask". Both refuse, and both
|
|
533
|
+
// are worth naming on the request.
|
|
534
|
+
recordRefusal(req, {
|
|
535
|
+
code: 'SERVICE_ACTING_AS_UNAUTHORIZED',
|
|
536
|
+
stage: 'service-token',
|
|
537
|
+
reason: `No delegation grant for app ${appId} acting as ${oxyUserId} (or this verifier could not reach the grant check)`,
|
|
538
|
+
status: 403,
|
|
539
|
+
});
|
|
432
540
|
const error = {
|
|
433
541
|
error: 'SERVICE_ACTING_AS_UNAUTHORIZED',
|
|
434
542
|
message: 'Service not authorized to act as this user',
|
|
@@ -474,6 +582,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
474
582
|
// compared against the id the validated session resolves to.
|
|
475
583
|
const claimedUserId = readStringClaim(decoded.userId) ?? readStringClaim(decoded.id);
|
|
476
584
|
if (!claimedUserId) {
|
|
585
|
+
recordRefusal(req, {
|
|
586
|
+
code: 'INVALID_TOKEN_PAYLOAD',
|
|
587
|
+
stage: 'token',
|
|
588
|
+
reason: 'Token carries no usable user id claim',
|
|
589
|
+
status: 401,
|
|
590
|
+
});
|
|
477
591
|
if (optional) {
|
|
478
592
|
req.userId = null;
|
|
479
593
|
req.user = null;
|
|
@@ -492,6 +606,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
492
606
|
// Check token expiration locally first (fast path)
|
|
493
607
|
// Reject tokens at exact expiry second (use <=)
|
|
494
608
|
if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
|
|
609
|
+
recordRefusal(req, {
|
|
610
|
+
code: 'TOKEN_EXPIRED',
|
|
611
|
+
stage: 'token',
|
|
612
|
+
reason: 'User access token expired',
|
|
613
|
+
status: 401,
|
|
614
|
+
});
|
|
495
615
|
if (optional) {
|
|
496
616
|
req.userId = null;
|
|
497
617
|
req.user = null;
|
|
@@ -514,6 +634,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
514
634
|
// required this.
|
|
515
635
|
const sessionId = readStringClaim(decoded.sessionId);
|
|
516
636
|
if (!sessionId) {
|
|
637
|
+
recordRefusal(req, {
|
|
638
|
+
code: 'SESSION_REQUIRED',
|
|
639
|
+
stage: 'token',
|
|
640
|
+
reason: 'User access token is not bound to a session',
|
|
641
|
+
status: 401,
|
|
642
|
+
});
|
|
517
643
|
if (optional) {
|
|
518
644
|
req.userId = null;
|
|
519
645
|
req.user = null;
|
|
@@ -536,6 +662,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
536
662
|
useHeaderValidation: true,
|
|
537
663
|
});
|
|
538
664
|
if (!validationResult || !validationResult.valid || !validationResult.user) {
|
|
665
|
+
recordRefusal(req, {
|
|
666
|
+
code: 'INVALID_SESSION',
|
|
667
|
+
stage: 'session',
|
|
668
|
+
reason: 'Session is invalid, revoked or expired',
|
|
669
|
+
status: 401,
|
|
670
|
+
});
|
|
539
671
|
if (optional) {
|
|
540
672
|
req.userId = null;
|
|
541
673
|
req.user = null;
|
|
@@ -554,6 +686,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
554
686
|
// The session — not the token — is the source of truth for identity.
|
|
555
687
|
const validatedUserId = getUserIdentityId(validationResult.user);
|
|
556
688
|
if (!validatedUserId) {
|
|
689
|
+
recordRefusal(req, {
|
|
690
|
+
code: 'INVALID_SESSION',
|
|
691
|
+
stage: 'session',
|
|
692
|
+
reason: 'Session did not resolve to a usable identity',
|
|
693
|
+
status: 401,
|
|
694
|
+
});
|
|
557
695
|
if (optional) {
|
|
558
696
|
req.userId = null;
|
|
559
697
|
req.user = null;
|
|
@@ -579,6 +717,12 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
579
717
|
claimedUserId,
|
|
580
718
|
validatedUserId,
|
|
581
719
|
});
|
|
720
|
+
recordRefusal(req, {
|
|
721
|
+
code: 'SESSION_USER_MISMATCH',
|
|
722
|
+
stage: 'session',
|
|
723
|
+
reason: 'Token user claim does not own the presented session',
|
|
724
|
+
status: 401,
|
|
725
|
+
});
|
|
582
726
|
if (optional) {
|
|
583
727
|
req.userId = null;
|
|
584
728
|
req.user = null;
|
|
@@ -609,6 +753,15 @@ export function OxyServicesUtilityMixin(Base) {
|
|
|
609
753
|
return next();
|
|
610
754
|
}
|
|
611
755
|
catch (validationError) {
|
|
756
|
+
recordRefusal(req, {
|
|
757
|
+
code: 'SESSION_VALIDATION_ERROR',
|
|
758
|
+
stage: 'session',
|
|
759
|
+
// Name and transport code only. The message of a failed
|
|
760
|
+
// `validateSession` can carry the request URL, and that URL
|
|
761
|
+
// contains the session id — which is a credential.
|
|
762
|
+
reason: `Session validation call failed (${describeErrorSafely(validationError)})`,
|
|
763
|
+
status: 401,
|
|
764
|
+
});
|
|
612
765
|
if (debug) {
|
|
613
766
|
logger.debug('[oxy.auth] Session validation failed', {
|
|
614
767
|
component: 'auth',
|
|
@@ -1034,6 +1187,20 @@ async function verifyServiceTokenSignature(token, options) {
|
|
|
1034
1187
|
* those values out of URL construction and identity comparison, so an
|
|
1035
1188
|
* unexpected shape becomes a 401 rather than a stringified surprise.
|
|
1036
1189
|
*/
|
|
1190
|
+
/**
|
|
1191
|
+
* A short, log-safe description of a thrown value: its class name and, when the
|
|
1192
|
+
* transport attached one, its `code` (`ECONNREFUSED`, `ETIMEDOUT`, `ENOTFOUND`
|
|
1193
|
+
* …). Never the message — a failed request's message can quote the URL it was
|
|
1194
|
+
* made against, and a session-validation URL contains the session id.
|
|
1195
|
+
*/
|
|
1196
|
+
function describeErrorSafely(error) {
|
|
1197
|
+
if (!(error instanceof Error))
|
|
1198
|
+
return typeof error;
|
|
1199
|
+
const code = error.code;
|
|
1200
|
+
return typeof code === 'string' && /^[A-Za-z0-9_]{1,40}$/.test(code)
|
|
1201
|
+
? `${error.name}: ${code}`
|
|
1202
|
+
: error.name;
|
|
1203
|
+
}
|
|
1037
1204
|
function readStringClaim(value) {
|
|
1038
1205
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
1039
1206
|
}
|
package/dist/esm/server/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { logger } from '../logger/index.js';
|
|
1
2
|
import { OXY_SERVICE_ENVIRONMENTS } from '../utils/oxyServiceEnvironment.js';
|
|
2
3
|
export { OXY_SERVICE_ENVIRONMENTS };
|
|
3
4
|
function normalizeId(value) {
|
|
@@ -106,9 +107,36 @@ export function getRequiredOxyUserId(req) {
|
|
|
106
107
|
}
|
|
107
108
|
return userId;
|
|
108
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Why the credential on this request was refused, or `null` when none was
|
|
112
|
+
* presented (or it was accepted).
|
|
113
|
+
*
|
|
114
|
+
* Set by `oxy.auth()` — including the `optional: true` mount the composed
|
|
115
|
+
* helpers use, where a refusal otherwise leaves no trace: the request simply
|
|
116
|
+
* arrives unauthenticated and the host answers its own generic 401. Hosts log
|
|
117
|
+
* this beside their own 401; it is diagnostic and **must not be put into a
|
|
118
|
+
* response body**, which is exactly what it is kept out of.
|
|
119
|
+
*/
|
|
120
|
+
export function getOxyAuthRefusal(req) {
|
|
121
|
+
return req.oxyAuthRefusal ?? null;
|
|
122
|
+
}
|
|
109
123
|
export function requireOxyAuth(req, res, next) {
|
|
110
124
|
const userId = getOxyUserId(req);
|
|
111
125
|
if (!userId) {
|
|
126
|
+
// The body stays exactly as generic as it was. The LOG does not: this 401
|
|
127
|
+
// is the one a caller sees when the optional resolver already refused a
|
|
128
|
+
// real credential, and printing that code here is what turns "Homiio got
|
|
129
|
+
// 401, Alia logged nothing" into one greppable line.
|
|
130
|
+
const refusal = getOxyAuthRefusal(req);
|
|
131
|
+
if (refusal) {
|
|
132
|
+
logger.warn(`[oxy.auth] 401 after refusal ${refusal.code}: ${refusal.reason}`, {
|
|
133
|
+
component: 'auth',
|
|
134
|
+
method: 'requireOxyAuth',
|
|
135
|
+
code: refusal.code,
|
|
136
|
+
stage: refusal.stage,
|
|
137
|
+
reason: refusal.reason,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
112
140
|
res.status(401).json({
|
|
113
141
|
error: 'Unauthorized',
|
|
114
142
|
message: 'Authentication required',
|
package/dist/esm/server/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* app.use(createOxyRateLimit(oxy, { store: redisStore }));
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
|
|
17
|
+
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
|
|
18
18
|
export { createOxyRateLimit } from './rateLimit.js';
|
|
19
19
|
// SSRF-safe upstream fetch + URL validation (Node-only).
|
|
20
20
|
export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch.js';
|