@oxyhq/core 1.11.11 → 1.11.13

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 (130) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +3 -1
  3. package/dist/cjs/HttpService.js +227 -51
  4. package/dist/cjs/OxyServices.base.js +9 -0
  5. package/dist/cjs/OxyServices.js +8 -3
  6. package/dist/cjs/crypto/index.js +3 -1
  7. package/dist/cjs/crypto/keyManager.js +476 -172
  8. package/dist/cjs/crypto/polyfill.js +14 -65
  9. package/dist/cjs/crypto/recoveryPhrase.js +30 -11
  10. package/dist/cjs/crypto/signatureService.js +25 -60
  11. package/dist/cjs/i18n/locales/en-US.json +46 -1
  12. package/dist/cjs/i18n/locales/es-ES.json +46 -1
  13. package/dist/cjs/i18n/locales/locales/en-US.json +46 -1
  14. package/dist/cjs/i18n/locales/locales/es-ES.json +46 -1
  15. package/dist/cjs/index.js +7 -2
  16. package/dist/cjs/mixins/OxyServices.assets.js +9 -4
  17. package/dist/cjs/mixins/OxyServices.auth.js +27 -0
  18. package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
  19. package/dist/cjs/mixins/OxyServices.features.js +0 -11
  20. package/dist/cjs/mixins/OxyServices.fedcm.js +4 -3
  21. package/dist/cjs/mixins/OxyServices.language.js +5 -36
  22. package/dist/cjs/mixins/OxyServices.redirect.js +6 -2
  23. package/dist/cjs/mixins/OxyServices.security.js +13 -2
  24. package/dist/cjs/mixins/OxyServices.user.js +70 -38
  25. package/dist/cjs/mixins/OxyServices.utility.js +19 -43
  26. package/dist/cjs/mixins/index.js +11 -3
  27. package/dist/cjs/utils/accountUtils.js +71 -2
  28. package/dist/cjs/utils/asyncUtils.js +34 -5
  29. package/dist/cjs/utils/deviceManager.js +5 -36
  30. package/dist/cjs/utils/platformCrypto.js +165 -0
  31. package/dist/cjs/utils/platformCrypto.native.js +123 -0
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/CrossDomainAuth.js +3 -1
  34. package/dist/esm/HttpService.js +228 -52
  35. package/dist/esm/OxyServices.base.js +9 -0
  36. package/dist/esm/OxyServices.js +8 -3
  37. package/dist/esm/crypto/index.js +1 -1
  38. package/dist/esm/crypto/keyManager.js +473 -138
  39. package/dist/esm/crypto/polyfill.js +14 -32
  40. package/dist/esm/crypto/recoveryPhrase.js +30 -11
  41. package/dist/esm/crypto/signatureService.js +25 -27
  42. package/dist/esm/i18n/locales/en-US.json +46 -1
  43. package/dist/esm/i18n/locales/es-ES.json +46 -1
  44. package/dist/esm/i18n/locales/locales/en-US.json +46 -1
  45. package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
  46. package/dist/esm/index.js +2 -2
  47. package/dist/esm/mixins/OxyServices.assets.js +9 -4
  48. package/dist/esm/mixins/OxyServices.auth.js +27 -0
  49. package/dist/esm/mixins/OxyServices.contacts.js +47 -0
  50. package/dist/esm/mixins/OxyServices.features.js +0 -11
  51. package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
  52. package/dist/esm/mixins/OxyServices.language.js +5 -3
  53. package/dist/esm/mixins/OxyServices.redirect.js +6 -2
  54. package/dist/esm/mixins/OxyServices.security.js +13 -2
  55. package/dist/esm/mixins/OxyServices.user.js +70 -38
  56. package/dist/esm/mixins/OxyServices.utility.js +19 -10
  57. package/dist/esm/mixins/index.js +11 -3
  58. package/dist/esm/utils/accountUtils.js +67 -1
  59. package/dist/esm/utils/asyncUtils.js +34 -5
  60. package/dist/esm/utils/deviceManager.js +5 -3
  61. package/dist/esm/utils/platformCrypto.js +125 -0
  62. package/dist/esm/utils/platformCrypto.native.js +80 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/HttpService.d.ts +47 -3
  65. package/dist/types/OxyServices.base.d.ts +7 -0
  66. package/dist/types/OxyServices.d.ts +36 -3
  67. package/dist/types/crypto/index.d.ts +1 -1
  68. package/dist/types/crypto/keyManager.d.ts +110 -9
  69. package/dist/types/crypto/polyfill.d.ts +3 -1
  70. package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
  71. package/dist/types/crypto/signatureService.d.ts +4 -0
  72. package/dist/types/index.d.ts +4 -3
  73. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  74. package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
  75. package/dist/types/mixins/OxyServices.auth.d.ts +16 -0
  76. package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
  77. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  78. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  79. package/dist/types/mixins/OxyServices.features.d.ts +2 -7
  80. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  83. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  90. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.user.d.ts +40 -11
  92. package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
  93. package/dist/types/mixins/index.d.ts +52 -4
  94. package/dist/types/models/interfaces.d.ts +62 -3
  95. package/dist/types/utils/accountUtils.d.ts +41 -1
  96. package/dist/types/utils/asyncUtils.d.ts +6 -2
  97. package/dist/types/utils/platformCrypto.d.ts +87 -0
  98. package/dist/types/utils/platformCrypto.native.d.ts +54 -0
  99. package/package.json +28 -1
  100. package/src/CrossDomainAuth.ts +12 -10
  101. package/src/HttpService.ts +264 -51
  102. package/src/OxyServices.base.ts +10 -0
  103. package/src/OxyServices.ts +9 -4
  104. package/src/crypto/__tests__/keyManager.test.ts +336 -0
  105. package/src/crypto/index.ts +6 -1
  106. package/src/crypto/keyManager.ts +529 -151
  107. package/src/crypto/polyfill.ts +14 -34
  108. package/src/crypto/recoveryPhrase.ts +56 -17
  109. package/src/crypto/signatureService.ts +25 -29
  110. package/src/i18n/locales/en-US.json +46 -1
  111. package/src/i18n/locales/es-ES.json +46 -1
  112. package/src/index.ts +16 -3
  113. package/src/mixins/OxyServices.assets.ts +15 -11
  114. package/src/mixins/OxyServices.auth.ts +28 -0
  115. package/src/mixins/OxyServices.contacts.ts +73 -0
  116. package/src/mixins/OxyServices.features.ts +2 -12
  117. package/src/mixins/OxyServices.fedcm.ts +4 -3
  118. package/src/mixins/OxyServices.language.ts +6 -4
  119. package/src/mixins/OxyServices.redirect.ts +6 -2
  120. package/src/mixins/OxyServices.security.ts +18 -8
  121. package/src/mixins/OxyServices.user.ts +90 -49
  122. package/src/mixins/OxyServices.utility.ts +19 -10
  123. package/src/mixins/index.ts +58 -7
  124. package/src/models/interfaces.ts +65 -3
  125. package/src/utils/__tests__/asyncUtils.test.ts +187 -0
  126. package/src/utils/accountUtils.ts +82 -2
  127. package/src/utils/asyncUtils.ts +39 -9
  128. package/src/utils/deviceManager.ts +7 -4
  129. package/src/utils/platformCrypto.native.ts +101 -0
  130. package/src/utils/platformCrypto.ts +145 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "1.11.11",
3
+ "version": "1.11.13",
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",
@@ -13,24 +13,31 @@
13
13
  "exports": {
14
14
  ".": {
15
15
  "types": "./dist/types/index.d.ts",
16
+ "react-native": "./dist/esm/index.js",
16
17
  "import": "./dist/esm/index.js",
17
18
  "require": "./dist/cjs/index.js",
18
19
  "default": "./dist/esm/index.js"
19
20
  },
20
21
  "./crypto": {
21
22
  "types": "./dist/types/crypto/index.d.ts",
23
+ "react-native": "./dist/esm/crypto/index.js",
22
24
  "import": "./dist/esm/crypto/index.js",
23
25
  "require": "./dist/cjs/crypto/index.js",
24
26
  "default": "./dist/esm/crypto/index.js"
25
27
  },
26
28
  "./shared": {
27
29
  "types": "./dist/types/shared/index.d.ts",
30
+ "react-native": "./dist/esm/shared/index.js",
28
31
  "import": "./dist/esm/shared/index.js",
29
32
  "require": "./dist/cjs/shared/index.js",
30
33
  "default": "./dist/esm/shared/index.js"
31
34
  },
32
35
  "./package.json": "./package.json"
33
36
  },
37
+ "react-native": {
38
+ "./dist/esm/utils/platformCrypto.js": "./dist/esm/utils/platformCrypto.native.js",
39
+ "./dist/cjs/utils/platformCrypto.js": "./dist/cjs/utils/platformCrypto.native.js"
40
+ },
34
41
  "files": [
35
42
  "dist",
36
43
  "src"
@@ -62,6 +69,7 @@
62
69
  "build:types": "tsc -p tsconfig.types.json",
63
70
  "clean": "rm -rf dist",
64
71
  "typescript": "tsc --noEmit",
72
+ "test": "jest --passWithNoTests",
65
73
  "lint": "biome lint --error-on-warnings ./src"
66
74
  },
67
75
  "dependencies": {
@@ -73,11 +81,30 @@
73
81
  "socket.io-client": "^4.8.1",
74
82
  "zod": "^3.25.64"
75
83
  },
84
+ "peerDependencies": {
85
+ "@react-native-async-storage/async-storage": "*",
86
+ "expo-crypto": "*",
87
+ "expo-secure-store": "*"
88
+ },
89
+ "peerDependenciesMeta": {
90
+ "@react-native-async-storage/async-storage": {
91
+ "optional": true
92
+ },
93
+ "expo-crypto": {
94
+ "optional": true
95
+ },
96
+ "expo-secure-store": {
97
+ "optional": true
98
+ }
99
+ },
76
100
  "devDependencies": {
77
101
  "@biomejs/biome": "^1.9.4",
102
+ "@react-native-async-storage/async-storage": "^2.2.0",
78
103
  "@types/elliptic": "^6.4.18",
79
104
  "@types/invariant": "^2.2.34",
80
105
  "@types/node": "^20.19.9",
106
+ "expo-crypto": "~56.0.3",
107
+ "expo-secure-store": "~56.0.4",
81
108
  "typescript": "^5.9.2"
82
109
  }
83
110
  }
@@ -130,7 +130,7 @@ export class CrossDomainAuth {
130
130
  * Best method - browser-native, no popups, Google-like experience
131
131
  */
132
132
  async signInWithFedCM(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse> {
133
- return (this.oxyServices as any).signInWithFedCM({
133
+ return this.oxyServices.signInWithFedCM({
134
134
  context: options.isSignup ? 'signup' : 'signin',
135
135
  });
136
136
  }
@@ -141,7 +141,7 @@ export class CrossDomainAuth {
141
141
  * Good method - preserves app state, no full page reload
142
142
  */
143
143
  async signInWithPopup(options: CrossDomainAuthOptions = {}): Promise<SessionLoginResponse> {
144
- return (this.oxyServices as any).signInWithPopup({
144
+ return this.oxyServices.signInWithPopup({
145
145
  mode: options.isSignup ? 'signup' : 'login',
146
146
  width: options.popupDimensions?.width,
147
147
  height: options.popupDimensions?.height,
@@ -154,7 +154,7 @@ export class CrossDomainAuth {
154
154
  * Fallback method - works everywhere but loses app state
155
155
  */
156
156
  signInWithRedirect(options: CrossDomainAuthOptions = {}): void {
157
- (this.oxyServices as any).signInWithRedirect({
157
+ this.oxyServices.signInWithRedirect({
158
158
  redirectUri: options.redirectUri,
159
159
  mode: options.isSignup ? 'signup' : 'login',
160
160
  });
@@ -166,7 +166,7 @@ export class CrossDomainAuth {
166
166
  * Call this on app startup to check if we're returning from auth redirect
167
167
  */
168
168
  handleRedirectCallback(): SessionLoginResponse | null {
169
- return (this.oxyServices as any).handleAuthCallback();
169
+ return this.oxyServices.handleAuthCallback();
170
170
  }
171
171
 
172
172
  /**
@@ -181,7 +181,7 @@ export class CrossDomainAuth {
181
181
  // Try FedCM silent sign-in first (if supported)
182
182
  if (this.isFedCMSupported()) {
183
183
  try {
184
- const session = await (this.oxyServices as any).silentSignInWithFedCM();
184
+ const session = await this.oxyServices.silentSignInWithFedCM();
185
185
  if (session) {
186
186
  return session;
187
187
  }
@@ -192,7 +192,7 @@ export class CrossDomainAuth {
192
192
 
193
193
  // Fallback to iframe-based silent auth
194
194
  try {
195
- return await (this.oxyServices as any).silentSignIn();
195
+ return await this.oxyServices.silentSignIn();
196
196
  } catch (error) {
197
197
  console.warn('[CrossDomainAuth] Silent sign-in failed:', error);
198
198
  return null;
@@ -205,14 +205,16 @@ export class CrossDomainAuth {
205
205
  * For redirect method - restores previously authenticated session from localStorage
206
206
  */
207
207
  restoreSession(): boolean {
208
- return (this.oxyServices as any).restoreSession?.() || false;
208
+ return this.oxyServices.restoreSession?.() || false;
209
209
  }
210
210
 
211
211
  /**
212
212
  * Check if FedCM is supported in current browser
213
213
  */
214
214
  isFedCMSupported(): boolean {
215
- return (this.oxyServices as any).constructor.isFedCMSupported?.() || false;
215
+ // FedCM support is exposed both as a static and an instance method on
216
+ // OxyServices; the instance method is reliable across mixin composition.
217
+ return this.oxyServices.isFedCMSupported?.() || false;
216
218
  }
217
219
 
218
220
  /**
@@ -263,10 +265,10 @@ export class CrossDomainAuth {
263
265
  if (restored) {
264
266
  // Verify session is still valid by fetching user
265
267
  try {
266
- const user = await (this.oxyServices as any).getCurrentUser();
268
+ const user = await this.oxyServices.getCurrentUser();
267
269
  if (user) {
268
270
  return {
269
- sessionId: (this.oxyServices as any).getStoredSessionId?.() || '',
271
+ sessionId: this.oxyServices.getStoredSessionId?.() || '',
270
272
  deviceId: '',
271
273
  expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
272
274
  user,
@@ -17,9 +17,8 @@ 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
- import { isNative, getPlatformOS } from './utils/platform';
21
+ import { isNative, isReactNative, getPlatformOS } from './utils/platform';
23
22
  import type { OxyConfig } from './models/interfaces';
24
23
 
25
24
  /**
@@ -36,6 +35,52 @@ interface JwtPayload {
36
35
  [key: string]: any;
37
36
  }
38
37
 
38
+ /**
39
+ * Structural type that captures the multipart-write surface every supported
40
+ * FormData implementation exposes (browser, React Native, Node `form-data`
41
+ * polyfill, jsdom, undici, etc). We type-narrow against this in
42
+ * `isFormData()` so callers don't have to know which runtime produced the
43
+ * value.
44
+ *
45
+ * Deliberately mirrored from the lib.dom `FormData` interface — kept as a
46
+ * local type because @types/node and @types/react-native model FormData
47
+ * differently and a single import wouldn't be safe in both bundles.
48
+ */
49
+ interface FormDataLike {
50
+ append(name: string, value: unknown, fileName?: string): void;
51
+ delete(name: string): void;
52
+ get(name: string): unknown;
53
+ getAll(name: string): unknown[];
54
+ has(name: string): boolean;
55
+ }
56
+
57
+ /**
58
+ * FNV-1a 32-bit non-cryptographic hash.
59
+ *
60
+ * Used by the cache-key generator for large payloads where full JSON
61
+ * inclusion would balloon the cache map keys. Content-addressed: every
62
+ * byte of the input contributes to the digest, so two payloads with the
63
+ * same top-level shape but different field values produce different keys
64
+ * (the previous `keys + length` heuristic collided on these).
65
+ *
66
+ * Trade-offs:
67
+ * - 32 bits is ample for an in-process cache (collision risk negligible
68
+ * at our key counts; we also prefix with method + url which further
69
+ * partitions the keyspace).
70
+ * - Not cryptographically secure — never use for security decisions.
71
+ * - Zero dependencies, branch-free hot loop, ~1 GiB/s on V8.
72
+ */
73
+ function fnv1a32(str: string): string {
74
+ let h = 0x811c9dc5;
75
+ for (let i = 0; i < str.length; i++) {
76
+ h ^= str.charCodeAt(i);
77
+ // h * 16777619 mod 2^32, written as shift-and-add for portability and
78
+ // to avoid 53-bit JS number truncation in the intermediate multiply.
79
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
80
+ }
81
+ return h.toString(16).padStart(8, '0');
82
+ }
83
+
39
84
  export interface RequestOptions {
40
85
  cache?: boolean;
41
86
  cacheTTL?: number;
@@ -166,35 +211,59 @@ export class HttpService {
166
211
  }
167
212
 
168
213
  /**
169
- * Robust FormData detection that works in browser and Node.js environments
170
- * Checks multiple conditions to handle different FormData implementations
214
+ * Robust FormData detection that works in browser, React Native, and
215
+ * Node.js polyfill environments.
216
+ *
217
+ * Why we don't use `instanceof FormData` alone:
218
+ * - React Native's FormData is a separate class, not the browser one —
219
+ * `instanceof FormData` is true only inside the JS runtime that
220
+ * instantiated the value (browser-side polyfills also have their own).
221
+ * - The Node.js `form-data` polyfill ships its own constructor.
222
+ *
223
+ * Why we explicitly reject `URLSearchParams`:
224
+ * - `URLSearchParams` ALSO exposes `append` / `get` / `has`, so the
225
+ * duck-type fallback below would have misidentified it as FormData.
226
+ * - We want urlencoded payloads to take the JSON-stringify path so the
227
+ * server receives them as `application/x-www-form-urlencoded` instead
228
+ * of an empty multipart body.
171
229
  */
172
- private isFormData(data: unknown): boolean {
173
- if (!data) {
230
+ private isFormData(data: unknown): data is FormDataLike {
231
+ if (!data || typeof data !== 'object') {
174
232
  return false;
175
233
  }
176
234
 
177
- // Primary check: instanceof FormData (works in browser and Node.js with proper polyfills)
178
- if (data instanceof FormData) {
179
- return true;
235
+ // Reject URLSearchParams up front: it shares the duck-typed surface
236
+ // (append / get / has) but is a fundamentally different content type.
237
+ // The caller routes URLSearchParams through the regular body path.
238
+ if (typeof URLSearchParams !== 'undefined' && data instanceof URLSearchParams) {
239
+ return false;
180
240
  }
181
241
 
182
- // Fallback: Check constructor name (handles Node.js polyfills like form-data)
183
- if (typeof data === 'object' && data !== null) {
184
- const constructorName = data.constructor?.name;
185
- if (constructorName === 'FormData' || constructorName === 'FormDataImpl') {
186
- return true;
187
- }
242
+ // Primary check: instanceof FormData. Works whenever the value was
243
+ // constructed by the same runtime/realm that exposes `FormData`.
244
+ if (typeof FormData !== 'undefined' && data instanceof FormData) {
245
+ return true;
246
+ }
188
247
 
189
- // Additional check: Look for FormData-like methods
190
- if (typeof (data as any).append === 'function' &&
191
- typeof (data as any).get === 'function' &&
192
- typeof (data as any).has === 'function') {
193
- return true;
194
- }
248
+ // Fallback: detect Node / RN polyfills by constructor name. Limited to
249
+ // the small handful of known names so we don't accept arbitrary
250
+ // user-supplied objects with a coincidental `name`.
251
+ const constructorName = data.constructor?.name;
252
+ if (constructorName === 'FormData' || constructorName === 'FormDataImpl') {
253
+ return true;
195
254
  }
196
255
 
197
- return false;
256
+ // Last-resort duck typing — require the full FormData write surface
257
+ // (`append`, `get`, `has`, `getAll`, `delete`) so plain objects with
258
+ // an `append` method don't accidentally match.
259
+ const candidate = data as Partial<Record<keyof FormDataLike, unknown>>;
260
+ return (
261
+ typeof candidate.append === 'function' &&
262
+ typeof candidate.get === 'function' &&
263
+ typeof candidate.has === 'function' &&
264
+ typeof candidate.getAll === 'function' &&
265
+ typeof candidate.delete === 'function'
266
+ );
198
267
  }
199
268
 
200
269
  /**
@@ -281,9 +350,12 @@ export class HttpService {
281
350
  headers['X-Native-App'] = 'true';
282
351
  }
283
352
 
284
- // Debug logging for CSRF issues
285
- if (isStateChangingMethod && isDev()) {
286
- console.log('[HttpService] CSRF Debug:', {
353
+ // Debug logging for CSRF issues — routed through the SimpleLogger so
354
+ // it only fires when consumers opt in via `enableLogging`. Previously
355
+ // this was a bare console.log that leaked noise into every host app's
356
+ // stdout in development.
357
+ if (isStateChangingMethod) {
358
+ this.logger.debug('CSRF Debug:', {
287
359
  url,
288
360
  method,
289
361
  isNativeApp,
@@ -312,17 +384,27 @@ export class HttpService {
312
384
  });
313
385
  }
314
386
 
315
- const bodyValue = method !== 'GET' && data
316
- ? (isFormData ? data : JSON.stringify(data))
387
+ const bodyValue = method !== 'GET' && data
388
+ ? (isFormData ? data : JSON.stringify(data))
317
389
  : undefined;
318
-
319
- const response = await fetch(fullUrl, {
320
- method,
321
- headers,
322
- body: bodyValue as BodyInit | null | undefined,
323
- signal: controller.signal,
324
- credentials: 'include', // Include cookies for cross-origin requests (CSRF, session)
325
- });
390
+
391
+ // React Native FormData workaround:
392
+ // Expo SDK 56's "winter fetch" rejects RN file descriptors `{uri, type, name}`
393
+ // in FormDataPart conversion (`Unsupported FormDataPart implementation`).
394
+ // RN's native XMLHttpRequest handles those descriptors correctly, so we
395
+ // route multipart uploads through XHR on RN only. JSON, text, etc. still
396
+ // use fetch on every platform.
397
+ const useXhrForUpload = isFormData && isReactNative() && typeof XMLHttpRequest !== 'undefined';
398
+
399
+ const response = useXhrForUpload
400
+ ? await this.uploadViaXHR(fullUrl, method, headers, bodyValue as FormData, controller.signal, timeout)
401
+ : await fetch(fullUrl, {
402
+ method,
403
+ headers,
404
+ body: bodyValue as BodyInit | null | undefined,
405
+ signal: controller.signal,
406
+ credentials: 'include', // Include cookies for cross-origin requests (CSRF, session)
407
+ });
326
408
 
327
409
  if (timeoutId) clearTimeout(timeoutId);
328
410
 
@@ -471,28 +553,140 @@ export class HttpService {
471
553
  return result;
472
554
  }
473
555
 
556
+ /**
557
+ * Upload via XMLHttpRequest (React Native FormData workaround).
558
+ *
559
+ * Expo SDK 56's "winter fetch" cannot serialize RN file descriptors
560
+ * (`{uri, type, name}`) — `convertFormDataAsync` rejects them as
561
+ * `Unsupported FormDataPart implementation`. RN's native XHR streams
562
+ * the file from disk correctly, so multipart uploads go through XHR
563
+ * on RN only.
564
+ *
565
+ * Returns a standard `Response` so downstream parsing in `request()`
566
+ * (status checks, 401/403 retries, JSON/blob/text parsing) is identical
567
+ * to the fetch path.
568
+ */
569
+ private uploadViaXHR(
570
+ url: string,
571
+ method: string,
572
+ headers: Record<string, string>,
573
+ body: FormData,
574
+ abortSignal: AbortSignal,
575
+ timeout: number,
576
+ ): Promise<Response> {
577
+ return new Promise<Response>((resolve, reject) => {
578
+ const xhr = new XMLHttpRequest();
579
+ xhr.open(method, url, true);
580
+ // withCredentials mirrors fetch's `credentials: 'include'` so the
581
+ // session cookie and CSRF cookie continue to flow.
582
+ xhr.withCredentials = true;
583
+
584
+ // Forward headers but skip Content-Type — XHR sets the multipart
585
+ // boundary automatically and overriding it breaks the upload.
586
+ for (const [key, value] of Object.entries(headers)) {
587
+ if (key.toLowerCase() === 'content-type') continue;
588
+ try {
589
+ xhr.setRequestHeader(key, value);
590
+ } catch (headerError) {
591
+ // Some headers (e.g. forbidden header names) cannot be set —
592
+ // log and continue rather than failing the whole upload.
593
+ this.logger.warn('XHR setRequestHeader failed:', key, headerError);
594
+ }
595
+ }
596
+
597
+ xhr.responseType = 'text';
598
+ if (timeout > 0) {
599
+ xhr.timeout = timeout;
600
+ }
601
+
602
+ const onAbort = (): void => {
603
+ try { xhr.abort(); } catch { /* xhr already finished */ }
604
+ };
605
+ if (abortSignal.aborted) {
606
+ reject(new DOMException('The user aborted a request.', 'AbortError'));
607
+ return;
608
+ }
609
+ abortSignal.addEventListener('abort', onAbort);
610
+
611
+ const cleanup = (): void => {
612
+ abortSignal.removeEventListener('abort', onAbort);
613
+ };
614
+
615
+ xhr.onload = (): void => {
616
+ cleanup();
617
+ const responseHeaders = HttpService.parseXHRHeaders(xhr.getAllResponseHeaders());
618
+ resolve(new Response(xhr.responseText, {
619
+ status: xhr.status,
620
+ statusText: xhr.statusText,
621
+ headers: responseHeaders,
622
+ }));
623
+ };
624
+ xhr.onerror = (): void => {
625
+ cleanup();
626
+ reject(new TypeError('Network request failed'));
627
+ };
628
+ xhr.ontimeout = (): void => {
629
+ cleanup();
630
+ reject(new DOMException('The request timed out.', 'TimeoutError'));
631
+ };
632
+ xhr.onabort = (): void => {
633
+ cleanup();
634
+ reject(new DOMException('The user aborted a request.', 'AbortError'));
635
+ };
636
+
637
+ xhr.send(body);
638
+ });
639
+ }
640
+
641
+ /**
642
+ * Parse raw header string from `XMLHttpRequest.getAllResponseHeaders()`
643
+ * into a `Headers`-compatible object.
644
+ */
645
+ private static parseXHRHeaders(rawHeaders: string): Headers {
646
+ const headers = new Headers();
647
+ if (!rawHeaders) return headers;
648
+ // RFC 7230 line terminator is CRLF; some XHR implementations use LF only.
649
+ const lines = rawHeaders.trim().split(/\r?\n/);
650
+ for (const line of lines) {
651
+ const colonIndex = line.indexOf(':');
652
+ if (colonIndex <= 0) continue;
653
+ const key = line.slice(0, colonIndex).trim();
654
+ const value = line.slice(colonIndex + 1).trim();
655
+ if (key) {
656
+ try {
657
+ headers.append(key, value);
658
+ } catch {
659
+ // Invalid header name/value — skip.
660
+ }
661
+ }
662
+ }
663
+ return headers;
664
+ }
665
+
474
666
  /**
475
667
  * Generate cache key efficiently
476
- * Uses simple hash for large objects to avoid expensive JSON.stringify
668
+ * Uses a content-addressed hash for large payloads so two requests with
669
+ * the same shape but different values never collide on the same key
670
+ * (which would silently serve stale data — e.g. paginated search results,
671
+ * large object updates).
477
672
  */
478
673
  private generateCacheKey(method: string, url: string, data?: unknown): string {
479
674
  if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
480
675
  return `${method}:${url}`;
481
676
  }
482
677
 
483
- // For small objects, use JSON.stringify
678
+ // For small objects, the full serialization IS the key — fastest and
679
+ // guaranteed to be content-addressed.
484
680
  const dataStr = JSON.stringify(data);
485
681
  if (dataStr.length < 1000) {
486
682
  return `${method}:${url}:${dataStr}`;
487
683
  }
488
684
 
489
- // For large objects, use a simple hash based on keys and values length
490
- // This avoids expensive serialization while still being unique enough
491
- const hash = typeof data === 'object' && data !== null
492
- ? Object.keys(data).sort().join(',') + ':' + dataStr.length
493
- : String(data).substring(0, 100);
494
-
495
- return `${method}:${url}:${hash}`;
685
+ // For large payloads, hash the full serialized string so the key remains
686
+ // content-addressed (any byte change yields a different hash). Previous
687
+ // implementation hashed `keys + length` which collided for any two
688
+ // payloads with the same top-level keys and serialized length.
689
+ return `${method}:${url}:${fnv1a32(dataStr)}`;
496
690
  }
497
691
 
498
692
  /**
@@ -524,14 +718,14 @@ export class HttpService {
524
718
  // Return cached token if available
525
719
  const cachedToken = this.tokenStore.getCsrfToken();
526
720
  if (cachedToken) {
527
- if (isDev()) console.log('[HttpService] Using cached CSRF token');
721
+ this.logger.debug('Using cached CSRF token');
528
722
  return cachedToken;
529
723
  }
530
724
 
531
725
  // Deduplicate concurrent CSRF token fetches
532
726
  const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
533
727
  if (existingPromise) {
534
- if (isDev()) console.log('[HttpService] Waiting for existing CSRF fetch');
728
+ this.logger.debug('Waiting for existing CSRF fetch');
535
729
  return existingPromise;
536
730
  }
537
731
 
@@ -539,7 +733,7 @@ export class HttpService {
539
733
  const maxAttempts = 2;
540
734
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
541
735
  try {
542
- if (isDev()) console.log('[HttpService] Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
736
+ this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
543
737
 
544
738
  // Use AbortController for timeout (more compatible than AbortSignal.timeout)
545
739
  const controller = new AbortController();
@@ -554,11 +748,11 @@ export class HttpService {
554
748
 
555
749
  clearTimeout(timeoutId);
556
750
 
557
- if (isDev()) console.log('[HttpService] CSRF fetch response:', response.status, response.ok);
751
+ this.logger.debug('CSRF fetch response:', response.status, response.ok);
558
752
 
559
753
  if (response.ok) {
560
754
  const data = await response.json() as { csrfToken?: string };
561
- if (isDev()) console.log('[HttpService] CSRF response data:', data);
755
+ this.logger.debug('CSRF response data:', data);
562
756
  const token = data.csrfToken || null;
563
757
  this.tokenStore.setCsrfToken(token);
564
758
  this.logger.debug('CSRF token fetched');
@@ -573,10 +767,10 @@ export class HttpService {
573
767
  return headerToken;
574
768
  }
575
769
 
576
- if (isDev()) console.log('[HttpService] CSRF fetch failed with status:', response.status);
770
+ this.logger.debug('CSRF fetch failed with status:', response.status);
577
771
  this.logger.warn('Failed to fetch CSRF token:', response.status);
578
772
  } catch (error) {
579
- if (isDev()) console.log('[HttpService] CSRF fetch error:', error);
773
+ this.logger.debug('CSRF fetch error:', error);
580
774
  this.logger.warn('CSRF token fetch error:', error);
581
775
  }
582
776
  // Wait before retry (500ms)
@@ -758,6 +952,25 @@ export class HttpService {
758
952
  this.cache.delete(key);
759
953
  }
760
954
 
955
+ /**
956
+ * Delete every cache entry whose key starts with `prefix`.
957
+ *
958
+ * Used by mutations that don't know the exact downstream cache keys —
959
+ * e.g. `updateProfile` invalidating all `GET:/session/user/*` entries
960
+ * without having to track every active session ID. Returns the number of
961
+ * deleted entries (for observability in tests).
962
+ */
963
+ clearCacheByPrefix(prefix: string): number {
964
+ let removed = 0;
965
+ for (const key of this.cache.keys()) {
966
+ if (key.startsWith(prefix)) {
967
+ this.cache.delete(key);
968
+ removed++;
969
+ }
970
+ }
971
+ return removed;
972
+ }
973
+
761
974
  getCacheStats() {
762
975
  const cacheStats = this.cache.getStats();
763
976
  const total = this.requestMetrics.cacheHits + this.requestMetrics.cacheMisses;
@@ -106,6 +106,16 @@ export class OxyServicesBase {
106
106
  this.httpService.clearCacheEntry(key);
107
107
  }
108
108
 
109
+ /**
110
+ * Clear every cache entry whose key starts with `prefix`.
111
+ * Useful for mutations that invalidate a family of GET responses
112
+ * without enumerating each one (e.g. all session-user lookups after
113
+ * a profile update).
114
+ */
115
+ public clearCacheByPrefix(prefix: string): number {
116
+ return this.httpService.clearCacheByPrefix(prefix);
117
+ }
118
+
109
119
  /**
110
120
  * Get cache statistics
111
121
  */
@@ -99,11 +99,16 @@ import { composeOxyServices } from './mixins';
99
99
  * });
100
100
  * ```
101
101
  */
102
- // Compose all mixins into the final OxyServices class
102
+ // Compose all mixins into the final OxyServices class. The composed runtime
103
+ // class augments OxyServicesBase with every mixin's methods (see mixins/index.ts).
104
+ // Statically, TypeScript sees this as a constructor producing OxyServicesBase;
105
+ // the additional methods are exposed via interface merging on `OxyServices` below.
103
106
  const OxyServicesComposed = composeOxyServices();
104
107
 
105
- // Export as a named class to avoid TypeScript issues with anonymous class types
106
- export class OxyServices extends (OxyServicesComposed as any) {
108
+ // Export as a named class to avoid TypeScript issues with anonymous class types.
109
+ // We extend the composed constructor directly — its public surface is broadened
110
+ // to the full mixin set via the interface declaration that follows.
111
+ export class OxyServices extends OxyServicesComposed {
107
112
  constructor(config: OxyConfig) {
108
113
  super(config);
109
114
  }
@@ -150,7 +155,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
150
155
  export { OxyAuthenticationError, OxyAuthenticationTimeoutError };
151
156
 
152
157
  /**
153
- * Export the default Oxy Cloud URL (for backward compatibility)
158
+ * Default Oxy Cloud URL used when no `cloudURL` is provided to OxyServices.
154
159
  */
155
160
  export const OXY_CLOUD_URL = 'https://cloud.oxy.so';
156
161