@oxyhq/core 1.11.11 → 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.
@@ -10,6 +10,18 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
10
10
  * Get profile by username
11
11
  */
12
12
  getProfileByUsername(username: string): Promise<User>;
13
+ /**
14
+ * Lightweight username lookup for login flows.
15
+ * Returns minimal public info: exists, color, avatar, displayName.
16
+ * Faster than getProfileByUsername — no stats, no formatting.
17
+ */
18
+ lookupUsername(username: string): Promise<{
19
+ exists: boolean;
20
+ username: string;
21
+ color: string | null;
22
+ avatar: string | null;
23
+ displayName: string;
24
+ }>;
13
25
  /**
14
26
  * Search user profiles
15
27
  */
@@ -13,8 +13,12 @@ export declare function parallelWithErrorHandling<T>(operations: (() => Promise<
13
13
  /**
14
14
  * Retry an async operation with exponential backoff
15
15
  *
16
- * By default, does not retry on 4xx errors (client errors).
17
- * Use shouldRetry callback to customize retry behavior.
16
+ * By default, does not retry on 4xx errors (client errors). The default
17
+ * predicate accepts both the axios-style `error.response.status` and the
18
+ * flat `error.status` shape produced by {@link handleHttpError}, so callers
19
+ * never accidentally retry a deterministic client failure.
20
+ *
21
+ * Use the `shouldRetry` callback to customize retry behavior.
18
22
  */
19
23
  export declare function retryAsync<T>(operation: () => Promise<T>, maxRetries?: number, baseDelay?: number, shouldRetry?: (error: any) => boolean): Promise<T>;
20
24
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.11.11",
3
+ "version": "1.11.12",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -17,7 +17,6 @@ import { TTLCache, registerCacheForCleanup } from './utils/cache';
17
17
  import { RequestDeduplicator, RequestQueue, SimpleLogger } from './utils/requestUtils';
18
18
  import { retryAsync } from './utils/asyncUtils';
19
19
  import { handleHttpError } from './utils/errorUtils';
20
- import { isDev } from './shared/utils/debugUtils';
21
20
  import { jwtDecode } from 'jwt-decode';
22
21
  import { isNative, getPlatformOS } from './utils/platform';
23
22
  import type { OxyConfig } from './models/interfaces';
@@ -281,9 +280,12 @@ export class HttpService {
281
280
  headers['X-Native-App'] = 'true';
282
281
  }
283
282
 
284
- // Debug logging for CSRF issues
285
- if (isStateChangingMethod && isDev()) {
286
- console.log('[HttpService] CSRF Debug:', {
283
+ // Debug logging for CSRF issues — routed through the SimpleLogger so
284
+ // it only fires when consumers opt in via `enableLogging`. Previously
285
+ // this was a bare console.log that leaked noise into every host app's
286
+ // stdout in development.
287
+ if (isStateChangingMethod) {
288
+ this.logger.debug('CSRF Debug:', {
287
289
  url,
288
290
  method,
289
291
  isNativeApp,
@@ -524,14 +526,14 @@ export class HttpService {
524
526
  // Return cached token if available
525
527
  const cachedToken = this.tokenStore.getCsrfToken();
526
528
  if (cachedToken) {
527
- if (isDev()) console.log('[HttpService] Using cached CSRF token');
529
+ this.logger.debug('Using cached CSRF token');
528
530
  return cachedToken;
529
531
  }
530
532
 
531
533
  // Deduplicate concurrent CSRF token fetches
532
534
  const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
533
535
  if (existingPromise) {
534
- if (isDev()) console.log('[HttpService] Waiting for existing CSRF fetch');
536
+ this.logger.debug('Waiting for existing CSRF fetch');
535
537
  return existingPromise;
536
538
  }
537
539
 
@@ -539,7 +541,7 @@ export class HttpService {
539
541
  const maxAttempts = 2;
540
542
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
541
543
  try {
542
- if (isDev()) console.log('[HttpService] Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
544
+ this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
543
545
 
544
546
  // Use AbortController for timeout (more compatible than AbortSignal.timeout)
545
547
  const controller = new AbortController();
@@ -554,11 +556,11 @@ export class HttpService {
554
556
 
555
557
  clearTimeout(timeoutId);
556
558
 
557
- if (isDev()) console.log('[HttpService] CSRF fetch response:', response.status, response.ok);
559
+ this.logger.debug('CSRF fetch response:', response.status, response.ok);
558
560
 
559
561
  if (response.ok) {
560
562
  const data = await response.json() as { csrfToken?: string };
561
- if (isDev()) console.log('[HttpService] CSRF response data:', data);
563
+ this.logger.debug('CSRF response data:', data);
562
564
  const token = data.csrfToken || null;
563
565
  this.tokenStore.setCsrfToken(token);
564
566
  this.logger.debug('CSRF token fetched');
@@ -573,10 +575,10 @@ export class HttpService {
573
575
  return headerToken;
574
576
  }
575
577
 
576
- if (isDev()) console.log('[HttpService] CSRF fetch failed with status:', response.status);
578
+ this.logger.debug('CSRF fetch failed with status:', response.status);
577
579
  this.logger.warn('Failed to fetch CSRF token:', response.status);
578
580
  } catch (error) {
579
- if (isDev()) console.log('[HttpService] CSRF fetch error:', error);
581
+ this.logger.debug('CSRF fetch error:', error);
580
582
  this.logger.warn('CSRF token fetch error:', error);
581
583
  }
582
584
  // Wait before retry (500ms)
@@ -52,7 +52,7 @@ async function initSecureStore(): Promise<typeof import('expo-secure-store')> {
52
52
  try {
53
53
  // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
54
54
  const moduleName = 'expo-secure-store';
55
- SecureStore = await import(moduleName);
55
+ SecureStore = await import(/* @vite-ignore */ moduleName);
56
56
  } catch (error) {
57
57
  const errorMessage = error instanceof Error ? error.message : String(error);
58
58
  throw new Error(`Failed to load expo-secure-store: ${errorMessage}. Make sure expo-secure-store is installed and properly configured.`);
@@ -76,7 +76,7 @@ async function initExpoCrypto(): Promise<typeof import('expo-crypto')> {
76
76
  if (!ExpoCrypto) {
77
77
  // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
78
78
  const moduleName = 'expo-crypto';
79
- ExpoCrypto = await import(moduleName);
79
+ ExpoCrypto = await import(/* @vite-ignore */ moduleName);
80
80
  }
81
81
  return ExpoCrypto!;
82
82
  }
@@ -105,7 +105,7 @@ async function getSecureRandomBytes(length: number): Promise<Uint8Array> {
105
105
  // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
106
106
  try {
107
107
  const cryptoModuleName = 'crypto';
108
- const nodeCrypto = await import(cryptoModuleName);
108
+ const nodeCrypto = await import(/* @vite-ignore */ cryptoModuleName);
109
109
  return new Uint8Array(nodeCrypto.randomBytes(length));
110
110
  } catch (error) {
111
111
  // Fallback to expo-crypto if Node crypto fails
@@ -44,7 +44,7 @@ function startExpoCryptoLoad(): void {
44
44
  expoCryptoLoadPromise = (async () => {
45
45
  try {
46
46
  const moduleName = 'expo-crypto';
47
- expoCryptoModule = await import(moduleName);
47
+ expoCryptoModule = await import(/* @vite-ignore */ moduleName);
48
48
  } catch {
49
49
  // expo-crypto not available — expected in non-RN environments
50
50
  }
@@ -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
@@ -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),
@@ -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
  */
@@ -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),