@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
@@ -9,23 +9,28 @@ import { ec as EC } from 'elliptic';
9
9
  import { KeyManager } from './keyManager';
10
10
  import { isReactNative, isNodeJS } from '../utils/platform';
11
11
 
12
- // Lazy import for expo-crypto
12
+ // Lazy imports for platform-specific crypto
13
13
  let ExpoCrypto: typeof import('expo-crypto') | null = null;
14
+ let NodeCrypto: typeof import('crypto') | null = null;
14
15
 
15
16
  const ec = new EC('secp256k1');
16
17
 
17
- /**
18
- * Initialize expo-crypto module
19
- */
20
18
  async function initExpoCrypto(): Promise<typeof import('expo-crypto')> {
21
19
  if (!ExpoCrypto) {
22
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
23
20
  const moduleName = 'expo-crypto';
24
- ExpoCrypto = await import(moduleName);
21
+ ExpoCrypto = await import(/* @vite-ignore */ moduleName);
25
22
  }
26
23
  return ExpoCrypto!;
27
24
  }
28
25
 
26
+ async function initNodeCrypto(): Promise<typeof import('crypto')> {
27
+ if (!NodeCrypto) {
28
+ const moduleName = 'crypto';
29
+ NodeCrypto = await import(/* @vite-ignore */ moduleName);
30
+ }
31
+ return NodeCrypto!;
32
+ }
33
+
29
34
  /**
30
35
  * Compute SHA-256 hash of a string
31
36
  */
@@ -39,10 +44,9 @@ async function sha256(message: string): Promise<string> {
39
44
  );
40
45
  }
41
46
 
42
- // In Node.js, use Node's crypto module
43
47
  if (isNodeJS()) {
44
48
  try {
45
- const nodeCrypto = await import('crypto');
49
+ const nodeCrypto = await initNodeCrypto();
46
50
  return nodeCrypto.createHash('sha256').update(message).digest('hex');
47
51
  } catch {
48
52
  // Fall through to Web Crypto API
@@ -85,12 +89,9 @@ export class SignatureService {
85
89
  .join('');
86
90
  }
87
91
 
88
- // In Node.js, use Node's crypto module
89
- // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
90
92
  if (isNodeJS()) {
91
93
  try {
92
- const cryptoModuleName = 'crypto';
93
- const nodeCrypto = await import(cryptoModuleName);
94
+ const nodeCrypto = await initNodeCrypto();
94
95
  return nodeCrypto.randomBytes(32).toString('hex');
95
96
  } catch {
96
97
  // Fall through to Web Crypto API
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export type { PopupAuthOptions } from './mixins/OxyServices.popup';
32
32
  export type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
33
33
  export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
34
34
  export type { ServiceApp } from './mixins/OxyServices.utility';
35
+ export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount } from './mixins/OxyServices.managedAccounts';
35
36
 
36
37
  // --- Crypto / Identity ---
37
38
  export { KeyManager, SignatureService, RecoveryPhraseService } from './crypto';
@@ -25,7 +25,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
25
25
  try {
26
26
  // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
27
27
  const moduleName = '@react-native-async-storage/async-storage';
28
- const asyncStorageModule = await import(moduleName);
28
+ const asyncStorageModule = await import(/* @vite-ignore */ moduleName);
29
29
  const storage = asyncStorageModule.default as unknown as { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; removeItem: (key: string) => Promise<void> };
30
30
  return {
31
31
  getItem: storage.getItem.bind(storage),
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Managed Accounts Methods Mixin
3
+ *
4
+ * Provides SDK methods for creating and managing sub-accounts (managed identities).
5
+ * Managed accounts are full User documents without passwords, accessible only
6
+ * by their owners/managers via the X-Acting-As header mechanism.
7
+ */
8
+ import type { User } from '../models/interfaces';
9
+ import type { OxyServicesBase } from '../OxyServices.base';
10
+
11
+ export interface CreateManagedAccountInput {
12
+ username: string;
13
+ name?: { first?: string; last?: string };
14
+ bio?: string;
15
+ avatar?: string;
16
+ }
17
+
18
+ export interface ManagedAccountManager {
19
+ userId: string;
20
+ role: 'owner' | 'admin' | 'editor';
21
+ addedAt: string;
22
+ addedBy?: string;
23
+ }
24
+
25
+ export interface ManagedAccount {
26
+ accountId: string;
27
+ ownerId: string;
28
+ managers: ManagedAccountManager[];
29
+ account?: User;
30
+ createdAt?: string;
31
+ updatedAt?: string;
32
+ }
33
+
34
+ export function OxyServicesManagedAccountsMixin<T extends typeof OxyServicesBase>(Base: T) {
35
+ return class extends Base {
36
+ constructor(...args: any[]) {
37
+ super(...(args as [any]));
38
+ }
39
+
40
+ /**
41
+ * Create a new managed account (sub-account).
42
+ *
43
+ * The server creates a User document with `isManagedAccount: true` and links
44
+ * it to the authenticated user as owner.
45
+ */
46
+ async createManagedAccount(data: CreateManagedAccountInput): Promise<ManagedAccount> {
47
+ try {
48
+ return await this.makeRequest<ManagedAccount>('POST', '/managed-accounts', data, {
49
+ cache: false,
50
+ });
51
+ } catch (error) {
52
+ throw this.handleError(error);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * List all accounts the authenticated user manages.
58
+ */
59
+ async getManagedAccounts(): Promise<ManagedAccount[]> {
60
+ try {
61
+ return await this.makeRequest<ManagedAccount[]>('GET', '/managed-accounts', undefined, {
62
+ cache: true,
63
+ cacheTTL: 2 * 60 * 1000, // 2 minutes cache
64
+ });
65
+ } catch (error) {
66
+ throw this.handleError(error);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Get details for a specific managed account.
72
+ */
73
+ async getManagedAccountDetails(accountId: string): Promise<ManagedAccount> {
74
+ try {
75
+ return await this.makeRequest<ManagedAccount>('GET', `/managed-accounts/${accountId}`, undefined, {
76
+ cache: true,
77
+ cacheTTL: 2 * 60 * 1000,
78
+ });
79
+ } catch (error) {
80
+ throw this.handleError(error);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Update a managed account's profile data.
86
+ * Requires owner or admin role.
87
+ */
88
+ async updateManagedAccount(accountId: string, data: Partial<CreateManagedAccountInput>): Promise<ManagedAccount> {
89
+ try {
90
+ return await this.makeRequest<ManagedAccount>('PUT', `/managed-accounts/${accountId}`, data, {
91
+ cache: false,
92
+ });
93
+ } catch (error) {
94
+ throw this.handleError(error);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Delete a managed account permanently.
100
+ * Requires owner role.
101
+ */
102
+ async deleteManagedAccount(accountId: string): Promise<void> {
103
+ try {
104
+ await this.makeRequest<void>('DELETE', `/managed-accounts/${accountId}`, undefined, {
105
+ cache: false,
106
+ });
107
+ } catch (error) {
108
+ throw this.handleError(error);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Add a manager to a managed account.
114
+ * Requires owner or admin role on the account.
115
+ *
116
+ * @param accountId - The managed account to add the manager to
117
+ * @param userId - The user to grant management access
118
+ * @param role - The role to assign: 'admin' or 'editor'
119
+ */
120
+ async addManager(accountId: string, userId: string, role: 'admin' | 'editor'): Promise<void> {
121
+ try {
122
+ await this.makeRequest<void>('POST', `/managed-accounts/${accountId}/managers`, { userId, role }, {
123
+ cache: false,
124
+ });
125
+ } catch (error) {
126
+ throw this.handleError(error);
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Remove a manager from a managed account.
132
+ * Requires owner role.
133
+ *
134
+ * @param accountId - The managed account
135
+ * @param userId - The manager to remove
136
+ */
137
+ async removeManager(accountId: string, userId: string): Promise<void> {
138
+ try {
139
+ await this.makeRequest<void>('DELETE', `/managed-accounts/${accountId}/managers/${userId}`, undefined, {
140
+ cache: false,
141
+ });
142
+ } catch (error) {
143
+ throw this.handleError(error);
144
+ }
145
+ }
146
+ };
147
+ }
@@ -24,6 +24,24 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
24
24
  }
25
25
  }
26
26
 
27
+ /**
28
+ * Lightweight username lookup for login flows.
29
+ * Returns minimal public info: exists, color, avatar, displayName.
30
+ * Faster than getProfileByUsername — no stats, no formatting.
31
+ */
32
+ async lookupUsername(username: string): Promise<{
33
+ exists: boolean;
34
+ username: string;
35
+ color: string | null;
36
+ avatar: string | null;
37
+ displayName: string;
38
+ }> {
39
+ return await this.makeRequest('GET', `/auth/lookup/${encodeURIComponent(username)}`, undefined, {
40
+ cache: true,
41
+ cacheTTL: 60 * 1000, // 1 minute cache
42
+ });
43
+ }
44
+
27
45
  /**
28
46
  * Search user profiles
29
47
  */
@@ -20,6 +20,15 @@ interface JwtPayload {
20
20
  [key: string]: any;
21
21
  }
22
22
 
23
+ /**
24
+ * Result from the managed-accounts verification endpoint.
25
+ * Indicates whether a user is authorized to act as a given managed account.
26
+ */
27
+ interface ActingAsVerification {
28
+ authorized: boolean;
29
+ role: 'owner' | 'admin' | 'editor';
30
+ }
31
+
23
32
  /**
24
33
  * Service app metadata attached to requests authenticated with service tokens
25
34
  */
@@ -50,9 +59,55 @@ interface AuthMiddlewareOptions {
50
59
 
51
60
  export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base: T) {
52
61
  return class extends Base {
62
+ /** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
63
+ _actingAsCache = new Map<string, { result: ActingAsVerification | null; expiresAt: number }>();
64
+
53
65
  constructor(...args: any[]) {
54
66
  super(...(args as [any]));
55
67
  }
68
+
69
+ /**
70
+ * Verify that a user is authorized to act as a managed account.
71
+ * Results are cached in-memory for 5 minutes to avoid repeated API calls.
72
+ *
73
+ * @internal Used by the auth() middleware — not part of the public API
74
+ */
75
+ async verifyActingAs(userId: string, accountId: string): Promise<ActingAsVerification | null> {
76
+ const cacheKey = `${userId}:${accountId}`;
77
+ const now = Date.now();
78
+
79
+ // Check cache
80
+ const cached = this._actingAsCache.get(cacheKey);
81
+ if (cached && cached.expiresAt > now) {
82
+ return cached.result;
83
+ }
84
+
85
+ // Query the API
86
+ try {
87
+ const result = await this.makeRequest<ActingAsVerification>(
88
+ 'GET',
89
+ '/managed-accounts/verify',
90
+ { accountId, userId },
91
+ { cache: false, retry: false, timeout: 5000 }
92
+ );
93
+
94
+ // Cache successful result for 5 minutes
95
+ this._actingAsCache.set(cacheKey, {
96
+ result: result && result.authorized ? result : null,
97
+ expiresAt: now + 5 * 60 * 1000,
98
+ });
99
+
100
+ return result && result.authorized ? result : null;
101
+ } catch {
102
+ // Cache negative result for 1 minute to avoid hammering on transient errors
103
+ this._actingAsCache.set(cacheKey, {
104
+ result: null,
105
+ expiresAt: now + 1 * 60 * 1000,
106
+ });
107
+ return null;
108
+ }
109
+ }
110
+
56
111
  /**
57
112
  * Fetch link metadata
58
113
  */
@@ -125,6 +180,49 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
125
180
 
126
181
  // Return an async middleware function
127
182
  return async (req: any, res: any, next: any) => {
183
+ // Process X-Acting-As header for managed account identity delegation.
184
+ // Called after successful authentication, before next(). If the header
185
+ // is present, verifies authorization and swaps the request identity to
186
+ // the managed account, preserving the original user for audit trails.
187
+ const processActingAs = async (): Promise<boolean> => {
188
+ const actingAsUserId = req.headers['x-acting-as'] as string | undefined;
189
+ if (!actingAsUserId) return true; // No header, proceed normally
190
+
191
+ const verification = await oxyInstance.verifyActingAs(req.userId, actingAsUserId);
192
+ if (!verification) {
193
+ const error = {
194
+ error: 'ACTING_AS_UNAUTHORIZED',
195
+ message: 'Not authorized to act as this account',
196
+ code: 'ACTING_AS_UNAUTHORIZED',
197
+ status: 403,
198
+ };
199
+ if (onError) {
200
+ onError(error);
201
+ } else {
202
+ res.status(403).json(error);
203
+ }
204
+ return false;
205
+ }
206
+
207
+ // Preserve original user for audit trails
208
+ req.originalUser = { id: req.userId, ...req.user };
209
+ req.actingAs = { userId: actingAsUserId, role: verification.role };
210
+
211
+ // Swap user identity to the managed account
212
+ req.userId = actingAsUserId;
213
+ req.user = { id: actingAsUserId } as any;
214
+ // Also set _id for routes that use Pattern B (req.user._id)
215
+ if (req.user) {
216
+ (req.user as any)._id = actingAsUserId;
217
+ }
218
+
219
+ if (debug) {
220
+ console.log(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${req.originalUser.id}`);
221
+ }
222
+
223
+ return true;
224
+ };
225
+
128
226
  try {
129
227
  // Extract token from Authorization header or query params
130
228
  const authHeader = req.headers['authorization'];
@@ -360,7 +458,9 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
360
458
  console.log(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`);
361
459
  }
362
460
 
363
- return next();
461
+ // Process X-Acting-As header before proceeding
462
+ if (await processActingAs()) return next();
463
+ return;
364
464
  } catch (validationError) {
365
465
  if (debug) {
366
466
  console.log(`[oxy.auth] Session validation failed:`, validationError);
@@ -414,7 +514,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
414
514
  console.log(`[oxy.auth] OK user=${userId} (no session)`);
415
515
  }
416
516
 
417
- next();
517
+ // Process X-Acting-As header before proceeding
518
+ if (await processActingAs()) next();
418
519
  } catch (error) {
419
520
  const apiError = oxyInstance.handleError(error) as any;
420
521
 
@@ -24,6 +24,7 @@ import { OxyServicesSecurityMixin } from './OxyServices.security';
24
24
  import { OxyServicesUtilityMixin } from './OxyServices.utility';
25
25
  import { OxyServicesFeaturesMixin } from './OxyServices.features';
26
26
  import { OxyServicesTopicsMixin } from './OxyServices.topics';
27
+ import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
27
28
 
28
29
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
30
  type MixinFunction = (Base: any) => any;
@@ -68,6 +69,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
68
69
  OxyServicesSecurityMixin,
69
70
  OxyServicesFeaturesMixin,
70
71
  OxyServicesTopicsMixin,
72
+ OxyServicesManagedAccountsMixin,
71
73
 
72
74
  // Utility (last, can use all above)
73
75
  OxyServicesUtilityMixin,
@@ -74,6 +74,9 @@ export interface User {
74
74
  automation?: {
75
75
  ownerId?: string;
76
76
  };
77
+ // Managed account fields
78
+ isManagedAccount?: boolean;
79
+ managedBy?: string;
77
80
  [key: string]: unknown;
78
81
  }
79
82
 
@@ -0,0 +1,187 @@
1
+ import { retryAsync } from '../asyncUtils';
2
+ import { handleHttpError } from '../errorUtils';
3
+
4
+ /**
5
+ * Regression coverage for the 1.11.11 retry storm:
6
+ *
7
+ * HttpService wraps fetch errors through handleHttpError before rethrowing.
8
+ * handleHttpError returns a flat ApiError ({ message, code, status }) without
9
+ * a nested `.response` field. Prior to the fix, retryAsync's default
10
+ * shouldRetry predicate only inspected `error.response.status`, so every 4xx
11
+ * response was treated as retryable. That turned ~5ms 404 lookups into 8-10s
12
+ * stalls because every Mention endpoint hitting Oxy for a missing
13
+ * user/topic hit the full retry+backoff schedule.
14
+ *
15
+ * These tests lock the fix in place: both the nested and flat shapes MUST
16
+ * short-circuit retries for 4xx, and 5xx/network errors MUST still retry.
17
+ */
18
+ describe('retryAsync default shouldRetry predicate', () => {
19
+ it('does not retry on a flat ApiError-shaped 404 (handleHttpError output)', async () => {
20
+ let attempts = 0;
21
+ const started = Date.now();
22
+ const apiError = { message: 'Not found', code: 'NOT_FOUND', status: 404 };
23
+
24
+ await expect(
25
+ retryAsync(async () => {
26
+ attempts++;
27
+ throw apiError;
28
+ }, 3, 50)
29
+ ).rejects.toBe(apiError);
30
+
31
+ expect(attempts).toBe(1);
32
+ // Sanity: we should NOT have slept through any backoff windows.
33
+ expect(Date.now() - started).toBeLessThan(100);
34
+ });
35
+
36
+ it('does not retry on an axios-style nested 404 (response.status)', async () => {
37
+ let attempts = 0;
38
+ const axiosError = {
39
+ message: 'Not found',
40
+ response: { status: 404, statusText: 'Not Found' },
41
+ };
42
+
43
+ await expect(
44
+ retryAsync(async () => {
45
+ attempts++;
46
+ throw axiosError;
47
+ }, 3, 50)
48
+ ).rejects.toBe(axiosError);
49
+
50
+ expect(attempts).toBe(1);
51
+ });
52
+
53
+ it('does not retry on any 4xx flat-shape (400/401/403/422)', async () => {
54
+ for (const status of [400, 401, 403, 422]) {
55
+ let attempts = 0;
56
+ await expect(
57
+ retryAsync(async () => {
58
+ attempts++;
59
+ throw { message: 'client', code: 'X', status };
60
+ }, 2, 10)
61
+ ).rejects.toBeDefined();
62
+ expect(attempts).toBe(1);
63
+ }
64
+ });
65
+
66
+ it('retries on flat-shape 500 errors until maxRetries', async () => {
67
+ let attempts = 0;
68
+ await expect(
69
+ retryAsync(async () => {
70
+ attempts++;
71
+ throw { message: 'boom', code: 'INTERNAL_ERROR', status: 500 };
72
+ }, 2, 1)
73
+ ).rejects.toBeDefined();
74
+ expect(attempts).toBe(3); // initial + 2 retries
75
+ });
76
+
77
+ it('retries on nested-shape 503 errors until maxRetries', async () => {
78
+ let attempts = 0;
79
+ await expect(
80
+ retryAsync(async () => {
81
+ attempts++;
82
+ throw { message: 'unavailable', response: { status: 503 } };
83
+ }, 2, 1)
84
+ ).rejects.toBeDefined();
85
+ expect(attempts).toBe(3);
86
+ });
87
+
88
+ it('retries on network-style errors without any status (TypeError)', async () => {
89
+ let attempts = 0;
90
+ await expect(
91
+ retryAsync(async () => {
92
+ attempts++;
93
+ throw new TypeError('Failed to fetch');
94
+ }, 2, 1)
95
+ ).rejects.toBeDefined();
96
+ expect(attempts).toBe(3);
97
+ });
98
+
99
+ it('returns the successful result without extra attempts', async () => {
100
+ let attempts = 0;
101
+ const result = await retryAsync(async () => {
102
+ attempts++;
103
+ return 'ok' as const;
104
+ }, 3, 1);
105
+ expect(result).toBe('ok');
106
+ expect(attempts).toBe(1);
107
+ });
108
+
109
+ it('recovers after a transient 5xx followed by success', async () => {
110
+ let attempts = 0;
111
+ const result = await retryAsync(async () => {
112
+ attempts++;
113
+ if (attempts < 2) {
114
+ throw { message: 'transient', status: 502 };
115
+ }
116
+ return 'recovered' as const;
117
+ }, 3, 1);
118
+ expect(result).toBe('recovered');
119
+ expect(attempts).toBe(2);
120
+ });
121
+
122
+ it('honours a custom shouldRetry predicate even when default would retry', async () => {
123
+ let attempts = 0;
124
+ await expect(
125
+ retryAsync(
126
+ async () => {
127
+ attempts++;
128
+ throw { message: 'nope', status: 500 };
129
+ },
130
+ 5,
131
+ 1,
132
+ () => false
133
+ )
134
+ ).rejects.toBeDefined();
135
+ expect(attempts).toBe(1);
136
+ });
137
+
138
+ it('ignores non-numeric status fields instead of treating them as 4xx', async () => {
139
+ let attempts = 0;
140
+ await expect(
141
+ retryAsync(async () => {
142
+ attempts++;
143
+ throw { message: 'weird', status: 'oops' as unknown as number };
144
+ }, 2, 1)
145
+ ).rejects.toBeDefined();
146
+ // Non-numeric status must NOT be interpreted as 4xx — should retry normally.
147
+ expect(attempts).toBe(3);
148
+ });
149
+ });
150
+
151
+ /**
152
+ * handleHttpError is the wire between fetch-thrown errors and retryAsync.
153
+ * Lock in that it exposes the HTTP status at the top level so the retry
154
+ * predicate above can see it.
155
+ */
156
+ describe('handleHttpError preserves HTTP status for retry predicates', () => {
157
+ it('flattens a fetch-style error with .response.status into ApiError.status', () => {
158
+ const fetchError = Object.assign(new Error('Not found'), {
159
+ status: 404,
160
+ response: { status: 404, statusText: 'Not Found' },
161
+ });
162
+ const result = handleHttpError(fetchError);
163
+ expect(result.status).toBe(404);
164
+ expect(result.code).toBe('NOT_FOUND');
165
+ expect(result.message).toBe('Not found');
166
+ });
167
+
168
+ it('preserves 401 status from fetch errors', () => {
169
+ const fetchError = Object.assign(new Error('Unauthorized'), {
170
+ status: 401,
171
+ response: { status: 401, statusText: 'Unauthorized' },
172
+ });
173
+ const result = handleHttpError(fetchError);
174
+ expect(result.status).toBe(401);
175
+ expect(result.code).toBe('UNAUTHORIZED');
176
+ });
177
+
178
+ it('maps 500 to INTERNAL_ERROR with status preserved', () => {
179
+ const fetchError = Object.assign(new Error('boom'), {
180
+ status: 500,
181
+ response: { status: 500, statusText: 'Internal Server Error' },
182
+ });
183
+ const result = handleHttpError(fetchError);
184
+ expect(result.status).toBe(500);
185
+ expect(result.code).toBe('INTERNAL_ERROR');
186
+ });
187
+ });
@@ -47,11 +47,38 @@ export async function parallelWithErrorHandling<T>(
47
47
  );
48
48
  }
49
49
 
50
+ /**
51
+ * Extract an HTTP status code from an error value, tolerating both the
52
+ * axios-style nested shape (`error.response.status`) and the flat shape
53
+ * produced by {@link handleHttpError} / fetch-based clients (`error.status`).
54
+ *
55
+ * Centralising this lookup prevents retry predicates from silently falling
56
+ * through when one of the two shapes is missing, which previously caused
57
+ * @oxyhq/core to retry 4xx responses and turn sub-10ms failures into
58
+ * multi-second stalls for every missing-resource lookup.
59
+ */
60
+ function extractHttpStatus(error: unknown): number | undefined {
61
+ if (!error || typeof error !== 'object') return undefined;
62
+ const candidate = error as {
63
+ status?: unknown;
64
+ response?: { status?: unknown } | null;
65
+ };
66
+ const flat = candidate.status;
67
+ if (typeof flat === 'number' && Number.isFinite(flat)) return flat;
68
+ const nested = candidate.response?.status;
69
+ if (typeof nested === 'number' && Number.isFinite(nested)) return nested;
70
+ return undefined;
71
+ }
72
+
50
73
  /**
51
74
  * Retry an async operation with exponential backoff
52
- *
53
- * By default, does not retry on 4xx errors (client errors).
54
- * Use shouldRetry callback to customize retry behavior.
75
+ *
76
+ * By default, does not retry on 4xx errors (client errors). The default
77
+ * predicate accepts both the axios-style `error.response.status` and the
78
+ * flat `error.status` shape produced by {@link handleHttpError}, so callers
79
+ * never accidentally retry a deterministic client failure.
80
+ *
81
+ * Use the `shouldRetry` callback to customize retry behavior.
55
82
  */
56
83
  export async function retryAsync<T>(
57
84
  operation: () => Promise<T>,
@@ -60,16 +87,19 @@ export async function retryAsync<T>(
60
87
  shouldRetry?: (error: any) => boolean
61
88
  ): Promise<T> {
62
89
  let lastError: any;
63
-
64
- // Default shouldRetry: don't retry on 4xx errors
65
- const defaultShouldRetry = (error: any): boolean => {
66
- // Don't retry on 4xx errors (client errors)
67
- if (error?.response?.status >= 400 && error?.response?.status < 500) {
90
+
91
+ // Default shouldRetry: don't retry on 4xx errors (client errors).
92
+ // Checks BOTH `error.status` (flat shape from handleHttpError / fetch
93
+ // clients) AND `error.response.status` (axios-style shape) so neither
94
+ // representation can leak a client error into the retry loop.
95
+ const defaultShouldRetry = (error: unknown): boolean => {
96
+ const status = extractHttpStatus(error);
97
+ if (status !== undefined && status >= 400 && status < 500) {
68
98
  return false;
69
99
  }
70
100
  return true;
71
101
  };
72
-
102
+
73
103
  const retryCheck = shouldRetry || defaultShouldRetry;
74
104
 
75
105
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -44,7 +44,7 @@ export class DeviceManager {
44
44
  try {
45
45
  // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
46
46
  const moduleName = '@react-native-async-storage/async-storage';
47
- const asyncStorageModule = await import(moduleName);
47
+ const asyncStorageModule = await import(/* @vite-ignore */ moduleName);
48
48
  const storage = asyncStorageModule.default as unknown as { getItem: (key: string) => Promise<string | null>; setItem: (key: string, value: string) => Promise<void>; removeItem: (key: string) => Promise<void> };
49
49
  return {
50
50
  getItem: storage.getItem.bind(storage),