@oxyhq/core 1.11.10 → 1.11.12

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 (63) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +26 -18
  3. package/dist/cjs/OxyServices.base.js +21 -0
  4. package/dist/cjs/crypto/signatureService.js +11 -11
  5. package/dist/cjs/mixins/OxyServices.managedAccounts.js +117 -0
  6. package/dist/cjs/mixins/OxyServices.user.js +11 -0
  7. package/dist/cjs/mixins/OxyServices.utility.js +81 -2
  8. package/dist/cjs/mixins/index.js +2 -0
  9. package/dist/cjs/utils/asyncUtils.js +34 -5
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/HttpService.js +26 -18
  12. package/dist/esm/OxyServices.base.js +21 -0
  13. package/dist/esm/crypto/keyManager.js +3 -3
  14. package/dist/esm/crypto/polyfill.js +1 -1
  15. package/dist/esm/crypto/signatureService.js +12 -12
  16. package/dist/esm/mixins/OxyServices.language.js +1 -1
  17. package/dist/esm/mixins/OxyServices.managedAccounts.js +114 -0
  18. package/dist/esm/mixins/OxyServices.user.js +11 -0
  19. package/dist/esm/mixins/OxyServices.utility.js +81 -2
  20. package/dist/esm/mixins/index.js +2 -0
  21. package/dist/esm/utils/asyncUtils.js +34 -5
  22. package/dist/esm/utils/deviceManager.js +1 -1
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/HttpService.d.ts +3 -0
  25. package/dist/types/OxyServices.base.d.ts +17 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/types/mixins/OxyServices.analytics.d.ts +2 -0
  28. package/dist/types/mixins/OxyServices.assets.d.ts +2 -0
  29. package/dist/types/mixins/OxyServices.auth.d.ts +2 -0
  30. package/dist/types/mixins/OxyServices.developer.d.ts +2 -0
  31. package/dist/types/mixins/OxyServices.devices.d.ts +2 -0
  32. package/dist/types/mixins/OxyServices.features.d.ts +5 -1
  33. package/dist/types/mixins/OxyServices.fedcm.d.ts +2 -0
  34. package/dist/types/mixins/OxyServices.karma.d.ts +2 -0
  35. package/dist/types/mixins/OxyServices.language.d.ts +2 -0
  36. package/dist/types/mixins/OxyServices.location.d.ts +2 -0
  37. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +125 -0
  38. package/dist/types/mixins/OxyServices.payment.d.ts +2 -0
  39. package/dist/types/mixins/OxyServices.popup.d.ts +2 -0
  40. package/dist/types/mixins/OxyServices.privacy.d.ts +2 -0
  41. package/dist/types/mixins/OxyServices.redirect.d.ts +2 -0
  42. package/dist/types/mixins/OxyServices.security.d.ts +2 -0
  43. package/dist/types/mixins/OxyServices.topics.d.ts +2 -0
  44. package/dist/types/mixins/OxyServices.user.d.ts +14 -0
  45. package/dist/types/mixins/OxyServices.utility.d.ts +22 -0
  46. package/dist/types/models/interfaces.d.ts +2 -0
  47. package/dist/types/utils/asyncUtils.d.ts +6 -2
  48. package/package.json +1 -1
  49. package/src/HttpService.ts +30 -11
  50. package/src/OxyServices.base.ts +23 -0
  51. package/src/crypto/keyManager.ts +3 -3
  52. package/src/crypto/polyfill.ts +1 -1
  53. package/src/crypto/signatureService.ts +13 -12
  54. package/src/index.ts +1 -0
  55. package/src/mixins/OxyServices.language.ts +1 -1
  56. package/src/mixins/OxyServices.managedAccounts.ts +147 -0
  57. package/src/mixins/OxyServices.user.ts +18 -0
  58. package/src/mixins/OxyServices.utility.ts +103 -2
  59. package/src/mixins/index.ts +2 -0
  60. package/src/models/interfaces.ts +3 -0
  61. package/src/utils/__tests__/asyncUtils.test.ts +187 -0
  62. package/src/utils/asyncUtils.ts +39 -9
  63. package/src/utils/deviceManager.ts +1 -1
@@ -19,7 +19,6 @@ const cache_1 = require("./utils/cache");
19
19
  const requestUtils_1 = require("./utils/requestUtils");
20
20
  const asyncUtils_1 = require("./utils/asyncUtils");
21
21
  const errorUtils_1 = require("./utils/errorUtils");
22
- const debugUtils_1 = require("./shared/utils/debugUtils");
23
22
  const jwt_decode_1 = require("jwt-decode");
24
23
  const platform_1 = require("./utils/platform");
25
24
  /**
@@ -84,6 +83,8 @@ class HttpService {
84
83
  this.tokenRefreshPromise = null;
85
84
  this.tokenRefreshCooldownUntil = 0;
86
85
  this._onTokenRefreshed = null;
86
+ // Acting-as identity for managed accounts
87
+ this._actingAsUserId = null;
87
88
  // Performance monitoring
88
89
  this.requestMetrics = {
89
90
  totalRequests: 0,
@@ -188,9 +189,12 @@ class HttpService {
188
189
  if (isNativeApp && isStateChangingMethod) {
189
190
  headers['X-Native-App'] = 'true';
190
191
  }
191
- // Debug logging for CSRF issues
192
- if (isStateChangingMethod && (0, debugUtils_1.isDev)()) {
193
- console.log('[HttpService] CSRF Debug:', {
192
+ // Debug logging for CSRF issues — routed through the SimpleLogger so
193
+ // it only fires when consumers opt in via `enableLogging`. Previously
194
+ // this was a bare console.log that leaked noise into every host app's
195
+ // stdout in development.
196
+ if (isStateChangingMethod) {
197
+ this.logger.debug('CSRF Debug:', {
194
198
  url,
195
199
  method,
196
200
  isNativeApp,
@@ -200,6 +204,10 @@ class HttpService {
200
204
  hasNativeAppHeader: headers['X-Native-App'] === 'true',
201
205
  });
202
206
  }
207
+ // Add X-Acting-As header for managed account identity delegation
208
+ if (this._actingAsUserId) {
209
+ headers['X-Acting-As'] = this._actingAsUserId;
210
+ }
203
211
  // Merge custom headers if provided
204
212
  if (config.headers) {
205
213
  Object.entries(config.headers).forEach(([key, value]) => {
@@ -406,23 +414,20 @@ class HttpService {
406
414
  // Return cached token if available
407
415
  const cachedToken = this.tokenStore.getCsrfToken();
408
416
  if (cachedToken) {
409
- if ((0, debugUtils_1.isDev)())
410
- console.log('[HttpService] Using cached CSRF token');
417
+ this.logger.debug('Using cached CSRF token');
411
418
  return cachedToken;
412
419
  }
413
420
  // Deduplicate concurrent CSRF token fetches
414
421
  const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
415
422
  if (existingPromise) {
416
- if ((0, debugUtils_1.isDev)())
417
- console.log('[HttpService] Waiting for existing CSRF fetch');
423
+ this.logger.debug('Waiting for existing CSRF fetch');
418
424
  return existingPromise;
419
425
  }
420
426
  const fetchPromise = (async () => {
421
427
  const maxAttempts = 2;
422
428
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
423
429
  try {
424
- if ((0, debugUtils_1.isDev)())
425
- console.log('[HttpService] Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
430
+ this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
426
431
  // Use AbortController for timeout (more compatible than AbortSignal.timeout)
427
432
  const controller = new AbortController();
428
433
  const timeoutId = setTimeout(() => controller.abort(), 5000);
@@ -433,12 +438,10 @@ class HttpService {
433
438
  signal: controller.signal,
434
439
  });
435
440
  clearTimeout(timeoutId);
436
- if ((0, debugUtils_1.isDev)())
437
- console.log('[HttpService] CSRF fetch response:', response.status, response.ok);
441
+ this.logger.debug('CSRF fetch response:', response.status, response.ok);
438
442
  if (response.ok) {
439
443
  const data = await response.json();
440
- if ((0, debugUtils_1.isDev)())
441
- console.log('[HttpService] CSRF response data:', data);
444
+ this.logger.debug('CSRF response data:', data);
442
445
  const token = data.csrfToken || null;
443
446
  this.tokenStore.setCsrfToken(token);
444
447
  this.logger.debug('CSRF token fetched');
@@ -451,13 +454,11 @@ class HttpService {
451
454
  this.logger.debug('CSRF token from header');
452
455
  return headerToken;
453
456
  }
454
- if ((0, debugUtils_1.isDev)())
455
- console.log('[HttpService] CSRF fetch failed with status:', response.status);
457
+ this.logger.debug('CSRF fetch failed with status:', response.status);
456
458
  this.logger.warn('Failed to fetch CSRF token:', response.status);
457
459
  }
458
460
  catch (error) {
459
- if ((0, debugUtils_1.isDev)())
460
- console.log('[HttpService] CSRF fetch error:', error);
461
+ this.logger.debug('CSRF fetch error:', error);
461
462
  this.logger.warn('CSRF token fetch error:', error);
462
463
  }
463
464
  // Wait before retry (500ms)
@@ -582,6 +583,13 @@ class HttpService {
582
583
  async delete(url, config) {
583
584
  return this.request({ method: 'DELETE', url, ...config });
584
585
  }
586
+ // Acting-as identity management (managed accounts)
587
+ setActingAs(userId) {
588
+ this._actingAsUserId = userId;
589
+ }
590
+ getActingAs() {
591
+ return this._actingAsUserId;
592
+ }
585
593
  // Token management
586
594
  setTokens(accessToken, refreshToken = '') {
587
595
  this.tokenStore.setTokens(accessToken, refreshToken);
@@ -141,6 +141,27 @@ class OxyServicesBase {
141
141
  getAccessToken() {
142
142
  return this.httpService.getAccessToken();
143
143
  }
144
+ /**
145
+ * Set the acting-as identity for managed accounts.
146
+ *
147
+ * When set, all subsequent API requests will include the `X-Acting-As` header,
148
+ * causing the server to attribute actions to the managed account. The
149
+ * authenticated user must be an authorized manager of the target account.
150
+ *
151
+ * Pass `null` to clear and revert to the authenticated user's own identity.
152
+ *
153
+ * @param userId - The managed account user ID, or null to clear
154
+ */
155
+ setActingAs(userId) {
156
+ this.httpService.setActingAs(userId);
157
+ }
158
+ /**
159
+ * Get the current acting-as identity (managed account user ID), or null
160
+ * if operating as the authenticated user's own identity.
161
+ */
162
+ getActingAs() {
163
+ return this.httpService.getActingAs();
164
+ }
144
165
  /**
145
166
  * Wait for authentication to be ready
146
167
  *
@@ -43,20 +43,24 @@ exports.SignatureService = void 0;
43
43
  const elliptic_1 = require("elliptic");
44
44
  const keyManager_1 = require("./keyManager");
45
45
  const platform_1 = require("../utils/platform");
46
- // Lazy import for expo-crypto
46
+ // Lazy imports for platform-specific crypto
47
47
  let ExpoCrypto = null;
48
+ let NodeCrypto = null;
48
49
  const ec = new elliptic_1.ec('secp256k1');
49
- /**
50
- * Initialize expo-crypto module
51
- */
52
50
  async function initExpoCrypto() {
53
51
  if (!ExpoCrypto) {
54
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
55
52
  const moduleName = 'expo-crypto';
56
53
  ExpoCrypto = await Promise.resolve(`${moduleName}`).then(s => __importStar(require(s)));
57
54
  }
58
55
  return ExpoCrypto;
59
56
  }
57
+ async function initNodeCrypto() {
58
+ if (!NodeCrypto) {
59
+ const moduleName = 'crypto';
60
+ NodeCrypto = await Promise.resolve(`${moduleName}`).then(s => __importStar(require(s)));
61
+ }
62
+ return NodeCrypto;
63
+ }
60
64
  /**
61
65
  * Compute SHA-256 hash of a string
62
66
  */
@@ -66,10 +70,9 @@ async function sha256(message) {
66
70
  const Crypto = await initExpoCrypto();
67
71
  return Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, message);
68
72
  }
69
- // In Node.js, use Node's crypto module
70
73
  if ((0, platform_1.isNodeJS)()) {
71
74
  try {
72
- const nodeCrypto = await Promise.resolve().then(() => __importStar(require('crypto')));
75
+ const nodeCrypto = await initNodeCrypto();
73
76
  return nodeCrypto.createHash('sha256').update(message).digest('hex');
74
77
  }
75
78
  catch {
@@ -97,12 +100,9 @@ class SignatureService {
97
100
  .map((b) => b.toString(16).padStart(2, '0'))
98
101
  .join('');
99
102
  }
100
- // In Node.js, use Node's crypto module
101
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
102
103
  if ((0, platform_1.isNodeJS)()) {
103
104
  try {
104
- const cryptoModuleName = 'crypto';
105
- const nodeCrypto = await Promise.resolve(`${cryptoModuleName}`).then(s => __importStar(require(s)));
105
+ const nodeCrypto = await initNodeCrypto();
106
106
  return nodeCrypto.randomBytes(32).toString('hex');
107
107
  }
108
108
  catch {
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OxyServicesManagedAccountsMixin = OxyServicesManagedAccountsMixin;
4
+ function OxyServicesManagedAccountsMixin(Base) {
5
+ return class extends Base {
6
+ constructor(...args) {
7
+ super(...args);
8
+ }
9
+ /**
10
+ * Create a new managed account (sub-account).
11
+ *
12
+ * The server creates a User document with `isManagedAccount: true` and links
13
+ * it to the authenticated user as owner.
14
+ */
15
+ async createManagedAccount(data) {
16
+ try {
17
+ return await this.makeRequest('POST', '/managed-accounts', data, {
18
+ cache: false,
19
+ });
20
+ }
21
+ catch (error) {
22
+ throw this.handleError(error);
23
+ }
24
+ }
25
+ /**
26
+ * List all accounts the authenticated user manages.
27
+ */
28
+ async getManagedAccounts() {
29
+ try {
30
+ return await this.makeRequest('GET', '/managed-accounts', undefined, {
31
+ cache: true,
32
+ cacheTTL: 2 * 60 * 1000, // 2 minutes cache
33
+ });
34
+ }
35
+ catch (error) {
36
+ throw this.handleError(error);
37
+ }
38
+ }
39
+ /**
40
+ * Get details for a specific managed account.
41
+ */
42
+ async getManagedAccountDetails(accountId) {
43
+ try {
44
+ return await this.makeRequest('GET', `/managed-accounts/${accountId}`, undefined, {
45
+ cache: true,
46
+ cacheTTL: 2 * 60 * 1000,
47
+ });
48
+ }
49
+ catch (error) {
50
+ throw this.handleError(error);
51
+ }
52
+ }
53
+ /**
54
+ * Update a managed account's profile data.
55
+ * Requires owner or admin role.
56
+ */
57
+ async updateManagedAccount(accountId, data) {
58
+ try {
59
+ return await this.makeRequest('PUT', `/managed-accounts/${accountId}`, data, {
60
+ cache: false,
61
+ });
62
+ }
63
+ catch (error) {
64
+ throw this.handleError(error);
65
+ }
66
+ }
67
+ /**
68
+ * Delete a managed account permanently.
69
+ * Requires owner role.
70
+ */
71
+ async deleteManagedAccount(accountId) {
72
+ try {
73
+ await this.makeRequest('DELETE', `/managed-accounts/${accountId}`, undefined, {
74
+ cache: false,
75
+ });
76
+ }
77
+ catch (error) {
78
+ throw this.handleError(error);
79
+ }
80
+ }
81
+ /**
82
+ * Add a manager to a managed account.
83
+ * Requires owner or admin role on the account.
84
+ *
85
+ * @param accountId - The managed account to add the manager to
86
+ * @param userId - The user to grant management access
87
+ * @param role - The role to assign: 'admin' or 'editor'
88
+ */
89
+ async addManager(accountId, userId, role) {
90
+ try {
91
+ await this.makeRequest('POST', `/managed-accounts/${accountId}/managers`, { userId, role }, {
92
+ cache: false,
93
+ });
94
+ }
95
+ catch (error) {
96
+ throw this.handleError(error);
97
+ }
98
+ }
99
+ /**
100
+ * Remove a manager from a managed account.
101
+ * Requires owner role.
102
+ *
103
+ * @param accountId - The managed account
104
+ * @param userId - The manager to remove
105
+ */
106
+ async removeManager(accountId, userId) {
107
+ try {
108
+ await this.makeRequest('DELETE', `/managed-accounts/${accountId}/managers/${userId}`, undefined, {
109
+ cache: false,
110
+ });
111
+ }
112
+ catch (error) {
113
+ throw this.handleError(error);
114
+ }
115
+ }
116
+ };
117
+ }
@@ -21,6 +21,17 @@ function OxyServicesUserMixin(Base) {
21
21
  throw this.handleError(error);
22
22
  }
23
23
  }
24
+ /**
25
+ * Lightweight username lookup for login flows.
26
+ * Returns minimal public info: exists, color, avatar, displayName.
27
+ * Faster than getProfileByUsername — no stats, no formatting.
28
+ */
29
+ async lookupUsername(username) {
30
+ return await this.makeRequest('GET', `/auth/lookup/${encodeURIComponent(username)}`, undefined, {
31
+ cache: true,
32
+ cacheTTL: 60 * 1000, // 1 minute cache
33
+ });
34
+ }
24
35
  /**
25
36
  * Search user profiles
26
37
  */
@@ -46,6 +46,41 @@ function OxyServicesUtilityMixin(Base) {
46
46
  return class extends Base {
47
47
  constructor(...args) {
48
48
  super(...args);
49
+ /** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
50
+ this._actingAsCache = new Map();
51
+ }
52
+ /**
53
+ * Verify that a user is authorized to act as a managed account.
54
+ * Results are cached in-memory for 5 minutes to avoid repeated API calls.
55
+ *
56
+ * @internal Used by the auth() middleware — not part of the public API
57
+ */
58
+ async verifyActingAs(userId, accountId) {
59
+ const cacheKey = `${userId}:${accountId}`;
60
+ const now = Date.now();
61
+ // Check cache
62
+ const cached = this._actingAsCache.get(cacheKey);
63
+ if (cached && cached.expiresAt > now) {
64
+ return cached.result;
65
+ }
66
+ // Query the API
67
+ try {
68
+ const result = await this.makeRequest('GET', '/managed-accounts/verify', { accountId, userId }, { cache: false, retry: false, timeout: 5000 });
69
+ // Cache successful result for 5 minutes
70
+ this._actingAsCache.set(cacheKey, {
71
+ result: result && result.authorized ? result : null,
72
+ expiresAt: now + 5 * 60 * 1000,
73
+ });
74
+ return result && result.authorized ? result : null;
75
+ }
76
+ catch {
77
+ // Cache negative result for 1 minute to avoid hammering on transient errors
78
+ this._actingAsCache.set(cacheKey, {
79
+ result: null,
80
+ expiresAt: now + 1 * 60 * 1000,
81
+ });
82
+ return null;
83
+ }
49
84
  }
50
85
  /**
51
86
  * Fetch link metadata
@@ -108,6 +143,45 @@ function OxyServicesUtilityMixin(Base) {
108
143
  const oxyInstance = this;
109
144
  // Return an async middleware function
110
145
  return async (req, res, next) => {
146
+ // Process X-Acting-As header for managed account identity delegation.
147
+ // Called after successful authentication, before next(). If the header
148
+ // is present, verifies authorization and swaps the request identity to
149
+ // the managed account, preserving the original user for audit trails.
150
+ const processActingAs = async () => {
151
+ const actingAsUserId = req.headers['x-acting-as'];
152
+ if (!actingAsUserId)
153
+ return true; // No header, proceed normally
154
+ const verification = await oxyInstance.verifyActingAs(req.userId, actingAsUserId);
155
+ if (!verification) {
156
+ const error = {
157
+ error: 'ACTING_AS_UNAUTHORIZED',
158
+ message: 'Not authorized to act as this account',
159
+ code: 'ACTING_AS_UNAUTHORIZED',
160
+ status: 403,
161
+ };
162
+ if (onError) {
163
+ onError(error);
164
+ }
165
+ else {
166
+ res.status(403).json(error);
167
+ }
168
+ return false;
169
+ }
170
+ // Preserve original user for audit trails
171
+ req.originalUser = { id: req.userId, ...req.user };
172
+ req.actingAs = { userId: actingAsUserId, role: verification.role };
173
+ // Swap user identity to the managed account
174
+ req.userId = actingAsUserId;
175
+ req.user = { id: actingAsUserId };
176
+ // Also set _id for routes that use Pattern B (req.user._id)
177
+ if (req.user) {
178
+ req.user._id = actingAsUserId;
179
+ }
180
+ if (debug) {
181
+ console.log(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${req.originalUser.id}`);
182
+ }
183
+ return true;
184
+ };
111
185
  try {
112
186
  // Extract token from Authorization header or query params
113
187
  const authHeader = req.headers['authorization'];
@@ -330,7 +404,10 @@ function OxyServicesUtilityMixin(Base) {
330
404
  if (debug) {
331
405
  console.log(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`);
332
406
  }
333
- return next();
407
+ // Process X-Acting-As header before proceeding
408
+ if (await processActingAs())
409
+ return next();
410
+ return;
334
411
  }
335
412
  catch (validationError) {
336
413
  if (debug) {
@@ -381,7 +458,9 @@ function OxyServicesUtilityMixin(Base) {
381
458
  if (debug) {
382
459
  console.log(`[oxy.auth] OK user=${userId} (no session)`);
383
460
  }
384
- next();
461
+ // Process X-Acting-As header before proceeding
462
+ if (await processActingAs())
463
+ next();
385
464
  }
386
465
  catch (error) {
387
466
  const apiError = oxyInstance.handleError(error);
@@ -27,6 +27,7 @@ const OxyServices_security_1 = require("./OxyServices.security");
27
27
  const OxyServices_utility_1 = require("./OxyServices.utility");
28
28
  const OxyServices_features_1 = require("./OxyServices.features");
29
29
  const OxyServices_topics_1 = require("./OxyServices.topics");
30
+ const OxyServices_managedAccounts_1 = require("./OxyServices.managedAccounts");
30
31
  /**
31
32
  * Mixin pipeline - applied in order from first to last.
32
33
  *
@@ -64,6 +65,7 @@ const MIXIN_PIPELINE = [
64
65
  OxyServices_security_1.OxyServicesSecurityMixin,
65
66
  OxyServices_features_1.OxyServicesFeaturesMixin,
66
67
  OxyServices_topics_1.OxyServicesTopicsMixin,
68
+ OxyServices_managedAccounts_1.OxyServicesManagedAccountsMixin,
67
69
  // Utility (last, can use all above)
68
70
  OxyServices_utility_1.OxyServicesUtilityMixin,
69
71
  ];
@@ -44,18 +44,47 @@ async function parallelWithErrorHandling(operations, errorHandler) {
44
44
  const results = await Promise.allSettled(operations.map((op, index) => withErrorHandling(op, error => errorHandler?.(error, index))));
45
45
  return results.map(result => result.status === 'fulfilled' ? result.value : null);
46
46
  }
47
+ /**
48
+ * Extract an HTTP status code from an error value, tolerating both the
49
+ * axios-style nested shape (`error.response.status`) and the flat shape
50
+ * produced by {@link handleHttpError} / fetch-based clients (`error.status`).
51
+ *
52
+ * Centralising this lookup prevents retry predicates from silently falling
53
+ * through when one of the two shapes is missing, which previously caused
54
+ * @oxyhq/core to retry 4xx responses and turn sub-10ms failures into
55
+ * multi-second stalls for every missing-resource lookup.
56
+ */
57
+ function extractHttpStatus(error) {
58
+ if (!error || typeof error !== 'object')
59
+ return undefined;
60
+ const candidate = error;
61
+ const flat = candidate.status;
62
+ if (typeof flat === 'number' && Number.isFinite(flat))
63
+ return flat;
64
+ const nested = candidate.response?.status;
65
+ if (typeof nested === 'number' && Number.isFinite(nested))
66
+ return nested;
67
+ return undefined;
68
+ }
47
69
  /**
48
70
  * Retry an async operation with exponential backoff
49
71
  *
50
- * By default, does not retry on 4xx errors (client errors).
51
- * Use shouldRetry callback to customize retry behavior.
72
+ * By default, does not retry on 4xx errors (client errors). The default
73
+ * predicate accepts both the axios-style `error.response.status` and the
74
+ * flat `error.status` shape produced by {@link handleHttpError}, so callers
75
+ * never accidentally retry a deterministic client failure.
76
+ *
77
+ * Use the `shouldRetry` callback to customize retry behavior.
52
78
  */
53
79
  async function retryAsync(operation, maxRetries = 3, baseDelay = 1000, shouldRetry) {
54
80
  let lastError;
55
- // Default shouldRetry: don't retry on 4xx errors
81
+ // Default shouldRetry: don't retry on 4xx errors (client errors).
82
+ // Checks BOTH `error.status` (flat shape from handleHttpError / fetch
83
+ // clients) AND `error.response.status` (axios-style shape) so neither
84
+ // representation can leak a client error into the retry loop.
56
85
  const defaultShouldRetry = (error) => {
57
- // Don't retry on 4xx errors (client errors)
58
- if (error?.response?.status >= 400 && error?.response?.status < 500) {
86
+ const status = extractHttpStatus(error);
87
+ if (status !== undefined && status >= 400 && status < 500) {
59
88
  return false;
60
89
  }
61
90
  return true;