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