@oxyhq/auth 2.0.3 → 2.0.5

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 (42) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/WebOxyProvider.js +37 -0
  3. package/dist/cjs/hooks/mutations/useAccountMutations.js +185 -43
  4. package/dist/cjs/hooks/queryClient.js +136 -92
  5. package/dist/cjs/hooks/useFileDownloadUrl.js +12 -36
  6. package/dist/cjs/hooks/useSessionSocket.js +250 -115
  7. package/dist/cjs/index.js +13 -3
  8. package/dist/cjs/stores/accountStore.js +2 -2
  9. package/dist/cjs/utils/sessionHelpers.js +4 -2
  10. package/dist/cjs/utils/storageHelpers.js +37 -11
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/WebOxyProvider.js +38 -1
  13. package/dist/esm/hooks/mutations/useAccountMutations.js +186 -44
  14. package/dist/esm/hooks/queryClient.js +132 -89
  15. package/dist/esm/hooks/useFileDownloadUrl.js +11 -34
  16. package/dist/esm/hooks/useSessionSocket.js +217 -112
  17. package/dist/esm/index.js +4 -1
  18. package/dist/esm/stores/accountStore.js +2 -2
  19. package/dist/esm/utils/sessionHelpers.js +4 -2
  20. package/dist/esm/utils/storageHelpers.js +37 -11
  21. package/dist/types/.tsbuildinfo +1 -1
  22. package/dist/types/WebOxyProvider.d.ts +1 -1
  23. package/dist/types/hooks/mutations/useAccountMutations.d.ts +153 -9
  24. package/dist/types/hooks/queries/useAccountQueries.d.ts +11 -7
  25. package/dist/types/hooks/queries/useSecurityQueries.d.ts +2 -2
  26. package/dist/types/hooks/queries/useServicesQueries.d.ts +7 -5
  27. package/dist/types/hooks/queryClient.d.ts +24 -10
  28. package/dist/types/hooks/useAssets.d.ts +1 -1
  29. package/dist/types/hooks/useFileDownloadUrl.d.ts +2 -6
  30. package/dist/types/index.d.ts +5 -1
  31. package/dist/types/utils/sessionHelpers.d.ts +3 -1
  32. package/package.json +29 -5
  33. package/src/WebOxyProvider.tsx +39 -1
  34. package/src/hooks/mutations/useAccountMutations.ts +230 -57
  35. package/src/hooks/queryClient.ts +140 -83
  36. package/src/hooks/useFileDownloadUrl.ts +15 -39
  37. package/src/hooks/useSessionSocket.ts +273 -112
  38. package/src/index.ts +13 -1
  39. package/src/stores/accountStore.ts +2 -2
  40. package/src/utils/sessionHelpers.ts +4 -2
  41. package/src/utils/storageHelpers.ts +50 -11
  42. package/src/global.d.ts +0 -1
@@ -1,38 +1,16 @@
1
1
  import { useEffect, useState } from 'react';
2
- import { OxyServices } from '@oxyhq/core';
3
- let oxyInstance = null;
4
- export const setOxyFileUrlInstance = (instance) => {
5
- oxyInstance = instance;
6
- };
7
2
  /**
8
3
  * Hook to resolve a file's download URL asynchronously.
9
4
  *
10
- * Prefers the provided `oxyServices` instance, falls back to the module-level
11
- * singleton set via `setOxyFileUrlInstance`.
12
- *
13
5
  * Uses `getFileDownloadUrlAsync` first, falling back to the synchronous
14
6
  * `getFileDownloadUrl` if the async call fails.
15
7
  */
16
- export const useFileDownloadUrl = (fileIdOrServices, fileIdOrOptions, maybeOptions) => {
17
- // Support two call signatures:
18
- // 1. useFileDownloadUrl(oxyServices, fileId, options) — preferred
19
- // 2. useFileDownloadUrl(fileId, options) — legacy (uses singleton)
20
- let services;
21
- let fileId;
22
- let options;
23
- if (fileIdOrServices instanceof OxyServices) {
24
- services = fileIdOrServices;
25
- fileId = typeof fileIdOrOptions === 'string' ? fileIdOrOptions : null;
26
- options = maybeOptions;
27
- }
28
- else {
29
- services = oxyInstance;
30
- fileId = typeof fileIdOrServices === 'string' ? fileIdOrServices : null;
31
- options = typeof fileIdOrOptions === 'object' && fileIdOrOptions !== null ? fileIdOrOptions : undefined;
32
- }
8
+ export const useFileDownloadUrl = (oxyServices, fileId, options) => {
33
9
  const [url, setUrl] = useState(null);
34
10
  const [loading, setLoading] = useState(false);
35
11
  const [error, setError] = useState(null);
12
+ const variant = options?.variant;
13
+ const expiresIn = options?.expiresIn;
36
14
  useEffect(() => {
37
15
  if (!fileId) {
38
16
  setUrl(null);
@@ -40,25 +18,25 @@ export const useFileDownloadUrl = (fileIdOrServices, fileIdOrOptions, maybeOptio
40
18
  setError(null);
41
19
  return;
42
20
  }
43
- if (!services) {
21
+ if (!oxyServices) {
44
22
  setUrl(null);
45
23
  setLoading(false);
46
24
  setError(new Error('OxyServices instance not configured for useFileDownloadUrl'));
47
25
  return;
48
26
  }
49
27
  let cancelled = false;
50
- const instance = services;
28
+ const instance = oxyServices;
29
+ const targetFileId = fileId;
51
30
  const load = async () => {
52
31
  setLoading(true);
53
32
  setError(null);
54
33
  try {
55
- const { variant, expiresIn } = options || {};
56
34
  let resolvedUrl = null;
57
35
  if (typeof instance.getFileDownloadUrlAsync === 'function') {
58
- resolvedUrl = await instance.getFileDownloadUrlAsync(fileId, variant, expiresIn);
36
+ resolvedUrl = await instance.getFileDownloadUrlAsync(targetFileId, variant, expiresIn);
59
37
  }
60
38
  if (!resolvedUrl && typeof instance.getFileDownloadUrl === 'function') {
61
- resolvedUrl = instance.getFileDownloadUrl(fileId, variant, expiresIn);
39
+ resolvedUrl = instance.getFileDownloadUrl(targetFileId, variant, expiresIn);
62
40
  }
63
41
  if (!cancelled) {
64
42
  setUrl(resolvedUrl || null);
@@ -68,8 +46,7 @@ export const useFileDownloadUrl = (fileIdOrServices, fileIdOrOptions, maybeOptio
68
46
  // Fallback to sync URL on error where possible
69
47
  try {
70
48
  if (typeof instance.getFileDownloadUrl === 'function') {
71
- const { variant, expiresIn } = options || {};
72
- const fallbackUrl = instance.getFileDownloadUrl(fileId, variant, expiresIn);
49
+ const fallbackUrl = instance.getFileDownloadUrl(targetFileId, variant, expiresIn);
73
50
  if (!cancelled) {
74
51
  setUrl(fallbackUrl || null);
75
52
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -78,7 +55,7 @@ export const useFileDownloadUrl = (fileIdOrServices, fileIdOrOptions, maybeOptio
78
55
  }
79
56
  }
80
57
  catch {
81
- // ignore secondary failure
58
+ // Secondary failure: surface the original error below.
82
59
  }
83
60
  if (!cancelled) {
84
61
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -94,6 +71,6 @@ export const useFileDownloadUrl = (fileIdOrServices, fileIdOrOptions, maybeOptio
94
71
  return () => {
95
72
  cancelled = true;
96
73
  };
97
- }, [fileId, services, options?.variant, options?.expiresIn]);
74
+ }, [fileId, oxyServices, variant, expiresIn]);
98
75
  return { url, loading, error };
99
76
  };
@@ -1,5 +1,4 @@
1
1
  import { useEffect, useRef, useMemo } from 'react';
2
- import io from 'socket.io-client';
3
2
  import { toast } from 'sonner';
4
3
  import { logger } from '@oxyhq/core';
5
4
  import { createDebugLogger } from '@oxyhq/core';
@@ -8,6 +7,47 @@ import { useQueryClient } from '@tanstack/react-query';
8
7
  import { useSessions } from './queries/useServicesQueries';
9
8
  import { invalidateSessionQueries } from './queries/queryKeys';
10
9
  const debug = createDebugLogger('SessionSocket');
10
+ /** localStorage key used by AuthManager for persisting access tokens. */
11
+ const LS_ACCESS_TOKEN_KEY = 'oxy_access_token';
12
+ /** Delay before retrying socket connection after an auth failure (ms). */
13
+ const AUTH_RETRY_DELAY_MS = 2000;
14
+ /** Maximum number of consecutive auth-failure retries. */
15
+ const MAX_AUTH_RETRIES = 3;
16
+ /**
17
+ * Read the access token from localStorage directly.
18
+ * Used as a fallback when the in-memory token is empty (e.g., during a
19
+ * cross-tab token refresh race).
20
+ */
21
+ function readTokenFromStorage() {
22
+ if (typeof window === 'undefined')
23
+ return null;
24
+ try {
25
+ return window.localStorage.getItem(LS_ACCESS_TOKEN_KEY);
26
+ }
27
+ catch (err) {
28
+ console.warn('[oxy.session-socket] localStorage read failed:', err);
29
+ return null;
30
+ }
31
+ }
32
+ let _io = null;
33
+ let _ioLoadAttempted = false;
34
+ async function getSocketIO() {
35
+ if (_io)
36
+ return _io;
37
+ if (_ioLoadAttempted)
38
+ return null;
39
+ _ioLoadAttempted = true;
40
+ try {
41
+ const mod = (await import('socket.io-client'));
42
+ _io = mod.io ?? mod.default ?? null;
43
+ return _io;
44
+ }
45
+ catch (err) {
46
+ console.warn('[oxy.session-socket] socket.io-client import failed:', err);
47
+ debug.warn('socket.io-client is not installed. useSessionSocket will be disabled. Install it with: bun add socket.io-client');
48
+ return null;
49
+ }
50
+ }
11
51
  export function useSessionSocket(options) {
12
52
  const { user, activeSessionId, oxyServices, signOut, clearSessionState } = useWebOxy();
13
53
  const queryClient = useQueryClient();
@@ -52,137 +92,202 @@ export function useSessionSocket(options) {
52
92
  socketRef.current.disconnect();
53
93
  socketRef.current = null;
54
94
  }
55
- // Connect with auth token; use callback so reconnections get a fresh token
56
- socketRef.current = io(baseURL, {
57
- transports: ['websocket'],
58
- auth: (cb) => {
59
- const token = oxyServices.getAccessToken();
60
- cb({ token: token ?? '' });
61
- },
62
- });
63
- const socket = socketRef.current;
64
- // Server auto-joins the user to `user:<userId>` room on connection
65
- const handleConnect = () => {
66
- debug.log('Socket connected:', socket.id);
95
+ let cancelled = false;
96
+ let authRetryCount = 0;
97
+ let authRetryTimer = null;
98
+ /**
99
+ * Resolve the best available access token.
100
+ * Prefers the in-memory token from OxyServices; falls back to
101
+ * localStorage which may have been updated by another tab.
102
+ */
103
+ const resolveToken = () => {
104
+ return oxyServices.getAccessToken() || readTokenFromStorage();
67
105
  };
68
- const refreshSessions = () => {
69
- invalidateSessionQueries(queryClientRef.current);
70
- return Promise.resolve();
71
- };
72
- const handleSessionUpdate = async (data) => {
73
- debug.log('Received session_update:', data);
74
- const currentActiveSessionId = activeSessionIdRef.current;
75
- const deviceId = currentDeviceIdRef.current;
76
- // Handle different event types
77
- if (data.type === 'session_removed') {
78
- // Track removed session
79
- if (data.sessionId && onSessionRemovedRef.current) {
80
- onSessionRemovedRef.current(data.sessionId);
81
- }
82
- // If the removed sessionId matches the current activeSessionId, immediately clear state
83
- if (data.sessionId === currentActiveSessionId) {
84
- if (onRemoteSignOutRef.current) {
85
- onRemoteSignOutRef.current();
86
- }
87
- else {
88
- toast.info('You have been signed out remotely.');
89
- }
90
- try {
91
- await clearSessionStateRef.current();
92
- }
93
- catch (error) {
94
- if (__DEV__) {
95
- logger.error('Failed to clear session state after session_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
106
+ getSocketIO().then((ioFn) => {
107
+ if (cancelled || !ioFn)
108
+ return;
109
+ // Connect with auth token; use callback so reconnections get a fresh token.
110
+ // If no token is available at all, we skip the initial connect and let
111
+ // the storage listener or retry logic connect when a token appears.
112
+ const token = resolveToken();
113
+ const socket = ioFn(baseURL, {
114
+ transports: ['websocket'],
115
+ autoConnect: !!token, // don't auto-connect when there is no token
116
+ auth: (cb) => {
117
+ const resolved = resolveToken();
118
+ if (!resolved) {
119
+ // No token available -- disconnect gracefully instead of sending
120
+ // an empty string that the server will reject.
121
+ debug.warn('No access token available for socket auth; disconnecting.');
122
+ if (socketRef.current) {
123
+ socketRef.current.disconnect();
96
124
  }
125
+ return;
97
126
  }
127
+ cb({ token: resolved });
128
+ },
129
+ });
130
+ socketRef.current = socket;
131
+ // Server auto-joins the user to `user:<userId>` room on connection
132
+ const handleConnect = () => {
133
+ debug.log('Socket connected:', socket.id);
134
+ // Successful connection resets the auth retry counter.
135
+ authRetryCount = 0;
136
+ };
137
+ /**
138
+ * Handle socket disconnection. When the disconnect reason indicates an
139
+ * auth failure (server rejected the token), schedule a short retry so
140
+ * that an in-progress token refresh can complete before the next attempt.
141
+ */
142
+ const handleDisconnect = (reason) => {
143
+ debug.log('Socket disconnected:', reason);
144
+ // "io server disconnect" = server forcibly closed the connection (auth failure).
145
+ // "transport error" can also happen when the auth callback aborted.
146
+ if ((reason === 'io server disconnect' || reason === 'transport error') &&
147
+ authRetryCount < MAX_AUTH_RETRIES &&
148
+ !cancelled) {
149
+ authRetryCount++;
150
+ debug.log(`Auth-related disconnect; scheduling retry ${authRetryCount}/${MAX_AUTH_RETRIES} in ${AUTH_RETRY_DELAY_MS}ms`);
151
+ authRetryTimer = setTimeout(() => {
152
+ if (cancelled)
153
+ return;
154
+ const retryToken = resolveToken();
155
+ if (retryToken && socketRef.current) {
156
+ debug.log('Retrying socket connection with refreshed token');
157
+ socketRef.current.connect();
158
+ }
159
+ }, AUTH_RETRY_DELAY_MS);
160
+ }
161
+ };
162
+ const refreshSessions = () => {
163
+ invalidateSessionQueries(queryClientRef.current);
164
+ return Promise.resolve();
165
+ };
166
+ const triggerLocalSignOut = async (toastMessage, errorContext) => {
167
+ if (onRemoteSignOutRef.current) {
168
+ onRemoteSignOutRef.current();
98
169
  }
99
170
  else {
100
- refreshSessions();
171
+ toast.info(toastMessage);
101
172
  }
102
- }
103
- else if (data.type === 'device_removed') {
104
- // Track all removed sessions from this device
105
- if (data.sessionIds && onSessionRemovedRef.current) {
106
- for (const sessionId of data.sessionIds) {
107
- onSessionRemovedRef.current(sessionId);
108
- }
173
+ // Clear local state since the server has already removed the session.
174
+ // Await so storage cleanup completes before any subsequent navigation.
175
+ try {
176
+ await clearSessionStateRef.current();
109
177
  }
110
- // If the removed deviceId matches the current device, immediately clear state
111
- if (data.deviceId && data.deviceId === deviceId) {
112
- if (onRemoteSignOutRef.current) {
113
- onRemoteSignOutRef.current();
114
- }
115
- else {
116
- toast.info('This device has been removed. You have been signed out.');
117
- }
118
- try {
119
- await clearSessionStateRef.current();
120
- }
121
- catch (error) {
122
- if (__DEV__) {
123
- logger.error('Failed to clear session state after device_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
124
- }
178
+ catch (error) {
179
+ if (process.env.NODE_ENV !== 'production') {
180
+ logger.error(`Failed to clear session state after ${errorContext}`, error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
125
181
  }
126
182
  }
127
- else {
128
- refreshSessions();
129
- }
130
- }
131
- else if (data.type === 'sessions_removed') {
132
- // Track all removed sessions
133
- if (data.sessionIds && onSessionRemovedRef.current) {
134
- for (const sessionId of data.sessionIds) {
135
- onSessionRemovedRef.current(sessionId);
183
+ };
184
+ const handleSessionUpdate = async (data) => {
185
+ debug.log('Received session_update:', data);
186
+ const currentActiveSessionId = activeSessionIdRef.current;
187
+ const deviceId = currentDeviceIdRef.current;
188
+ // Strict whitelist. Every event type that may sign the user out must
189
+ // appear in the switch. Anything unknown falls through to `default`,
190
+ // which only logs in dev. This guards against future server-side event
191
+ // additions (e.g. `session_created` after a successful sign-in)
192
+ // accidentally triggering sign-out via a fallback branch that compares
193
+ // `data.sessionId === currentActiveSessionId` — that branch would match
194
+ // the user's NEW session id and trigger an instant remote sign-out
195
+ // toast on every login.
196
+ switch (data.type) {
197
+ case 'session_removed': {
198
+ if (data.sessionId && onSessionRemovedRef.current) {
199
+ onSessionRemovedRef.current(data.sessionId);
200
+ }
201
+ if (data.sessionId && data.sessionId === currentActiveSessionId) {
202
+ await triggerLocalSignOut('You have been signed out remotely.', 'session_removed');
203
+ }
204
+ else {
205
+ refreshSessions();
206
+ }
207
+ break;
136
208
  }
137
- }
138
- // If the current activeSessionId is in the removed sessionIds list, immediately clear state
139
- if (data.sessionIds && currentActiveSessionId && data.sessionIds.includes(currentActiveSessionId)) {
140
- if (onRemoteSignOutRef.current) {
141
- onRemoteSignOutRef.current();
209
+ case 'device_removed': {
210
+ if (data.sessionIds && onSessionRemovedRef.current) {
211
+ for (const sessionId of data.sessionIds) {
212
+ onSessionRemovedRef.current(sessionId);
213
+ }
214
+ }
215
+ if (data.deviceId && deviceId && data.deviceId === deviceId) {
216
+ await triggerLocalSignOut('This device has been removed. You have been signed out.', 'device_removed');
217
+ }
218
+ else {
219
+ refreshSessions();
220
+ }
221
+ break;
142
222
  }
143
- else {
144
- toast.info('You have been signed out remotely.');
223
+ case 'sessions_removed': {
224
+ if (data.sessionIds && onSessionRemovedRef.current) {
225
+ for (const sessionId of data.sessionIds) {
226
+ onSessionRemovedRef.current(sessionId);
227
+ }
228
+ }
229
+ if (data.sessionIds &&
230
+ currentActiveSessionId &&
231
+ data.sessionIds.includes(currentActiveSessionId)) {
232
+ await triggerLocalSignOut('You have been signed out remotely.', 'sessions_removed');
233
+ }
234
+ else {
235
+ refreshSessions();
236
+ }
237
+ break;
145
238
  }
146
- try {
147
- await clearSessionStateRef.current();
239
+ case 'session_created':
240
+ case 'session_update': {
241
+ // Lifecycle event for the current user. Just resync the sessions
242
+ // list — never sign out.
243
+ refreshSessions();
244
+ break;
148
245
  }
149
- catch (error) {
150
- if (__DEV__) {
151
- logger.error('Failed to clear session state after sessions_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
246
+ default: {
247
+ if (process.env.NODE_ENV !== 'production') {
248
+ logger.warn('Unknown session socket event type', {
249
+ component: 'useSessionSocket',
250
+ type: data.type,
251
+ });
152
252
  }
253
+ break;
153
254
  }
154
255
  }
155
- else {
156
- refreshSessions();
256
+ };
257
+ socket.on('connect', handleConnect);
258
+ socket.on('disconnect', handleDisconnect);
259
+ socket.on('session_update', handleSessionUpdate);
260
+ // Listen for cross-tab token updates via the Storage event.
261
+ // When another tab writes a fresh access token to localStorage,
262
+ // reconnect this tab's socket if it was disconnected.
263
+ const handleStorageEvent = (e) => {
264
+ if (e.key === LS_ACCESS_TOKEN_KEY && e.newValue && socketRef.current?.disconnected) {
265
+ debug.log('Cross-tab token update detected; reconnecting socket');
266
+ authRetryCount = 0; // reset retries since we got a fresh token
267
+ socketRef.current.connect();
157
268
  }
269
+ };
270
+ if (typeof window !== 'undefined') {
271
+ window.addEventListener('storage', handleStorageEvent);
272
+ // Store the handler so cleanup can remove it
273
+ socket.__oxyStorageHandler = handleStorageEvent;
158
274
  }
159
- else {
160
- // For other event types (e.g., session_created), refresh sessions
161
- refreshSessions();
162
- // If the current session was logged out (legacy behavior), handle it specially
163
- if (data.sessionId === currentActiveSessionId) {
164
- if (onRemoteSignOutRef.current) {
165
- onRemoteSignOutRef.current();
166
- }
167
- else {
168
- toast.info('You have been signed out remotely.');
169
- }
170
- try {
171
- await clearSessionStateRef.current();
172
- }
173
- catch (error) {
174
- debug.error('Failed to clear session state after session_update:', error);
175
- }
275
+ });
276
+ return () => {
277
+ cancelled = true;
278
+ if (authRetryTimer) {
279
+ clearTimeout(authRetryTimer);
280
+ }
281
+ const currentSocket = socketRef.current;
282
+ if (currentSocket) {
283
+ // Remove cross-tab storage listener
284
+ const storageHandler = currentSocket.__oxyStorageHandler;
285
+ if (typeof window !== 'undefined' && storageHandler) {
286
+ window.removeEventListener('storage', storageHandler);
176
287
  }
288
+ currentSocket.disconnect();
289
+ socketRef.current = null;
177
290
  }
178
291
  };
179
- socket.on('connect', handleConnect);
180
- socket.on('session_update', handleSessionUpdate);
181
- return () => {
182
- socket.off('connect', handleConnect);
183
- socket.off('session_update', handleSessionUpdate);
184
- socket.disconnect();
185
- socketRef.current = null;
186
- };
187
292
  }, [userId, baseURL]); // Only depend on userId and baseURL - callbacks are in refs
188
293
  }
package/dist/esm/index.js CHANGED
@@ -27,15 +27,18 @@ export { WebOxyProvider, useWebOxy, useAuth } from './WebOxyProvider';
27
27
  // --- Stores ---
28
28
  export { useAuthStore } from './stores/authStore';
29
29
  export { useAssetStore, useAssets as useAssetsStore, useAsset, useUploadProgress, useAssetLoading, useAssetErrors, useAssetsByApp, useAssetsByEntity, useAssetUsageCount, useIsAssetLinked, } from './stores/assetStore';
30
+ export { useAccountStore, useAccounts, useAccountLoading, useAccountError, useAccountLoadingSession, } from './stores/accountStore';
31
+ export { useFollowStore, } from './stores/followStore';
30
32
  // --- Query Hooks ---
31
33
  export { useUserProfile, useUserProfiles, useCurrentUser, useUserById, useUserByUsername, useUsersBySessions, usePrivacySettings, useSessions, useSession, useDeviceSessions, useUserDevices, useSecurityInfo, useSecurityActivity, useRecentSecurityActivity, } from './hooks/queries';
32
34
  // --- Mutation Hooks ---
33
35
  export { useUpdateProfile, useUploadAvatar, useUpdateAccountSettings, useUpdatePrivacySettings, useUploadFile, useSwitchSession, useLogoutSession, useLogoutAll, useUpdateDeviceName, useRemoveDevice, } from './hooks/mutations';
34
36
  export { createProfileMutation, createGenericMutation, } from './hooks/mutations/mutationFactory';
35
37
  // --- Custom Hooks ---
38
+ export { useWebSSO, isWebBrowser } from './hooks/useWebSSO';
36
39
  export { useSessionSocket } from './hooks/useSessionSocket';
37
40
  export { useAssets, setOxyAssetInstance } from './hooks/useAssets';
38
- export { useFileDownloadUrl, setOxyFileUrlInstance } from './hooks/useFileDownloadUrl';
41
+ export { useFileDownloadUrl } from './hooks/useFileDownloadUrl';
39
42
  export { useFollow, useFollowerCounts } from './hooks/useFollow';
40
43
  export { useFileFiltering } from './hooks/useFileFiltering';
41
44
  // --- Error Handlers ---
@@ -161,7 +161,7 @@ export const useAccountStore = create((set, get) => ({
161
161
  }
162
162
  catch (error) {
163
163
  const errorMessage = error instanceof Error ? error.message : 'Failed to load accounts';
164
- if (__DEV__) {
164
+ if (process.env.NODE_ENV !== 'production') {
165
165
  console.error('AccountStore: Failed to load accounts:', error);
166
166
  }
167
167
  set({ error: errorMessage });
@@ -172,7 +172,7 @@ export const useAccountStore = create((set, get) => ({
172
172
  }
173
173
  catch (error) {
174
174
  const errorMessage = error instanceof Error ? error.message : 'Failed to load accounts';
175
- if (__DEV__) {
175
+ if (process.env.NODE_ENV !== 'production') {
176
176
  console.error('AccountStore: Failed to load accounts:', error);
177
177
  }
178
178
  set({ error: errorMessage, loading: false });
@@ -21,7 +21,9 @@ export const mapSessionsToClient = (sessions, fallbackDeviceId, fallbackUserId)
21
21
  }));
22
22
  };
23
23
  /**
24
- * Fetch device sessions with fallback to the legacy session endpoint when needed.
24
+ * Fetch device sessions, falling back to the per-user session endpoint
25
+ * if the device endpoint is unavailable (older API versions or disabled
26
+ * device-grouping feature flag).
25
27
  *
26
28
  * @param oxyServices - Oxy service instance
27
29
  * @param sessionId - Session identifier to fetch
@@ -33,7 +35,7 @@ export const fetchSessionsWithFallback = async (oxyServices, sessionId, { fallba
33
35
  return mapSessionsToClient(deviceSessions, fallbackDeviceId, fallbackUserId);
34
36
  }
35
37
  catch (error) {
36
- if (__DEV__ && logger) {
38
+ if (process.env.NODE_ENV !== 'production' && logger) {
37
39
  logger('Failed to get device sessions, falling back to user sessions', error);
38
40
  }
39
41
  const userSessions = await oxyServices.getSessionsBySessionId(sessionId);
@@ -5,7 +5,8 @@ const MEMORY_STORAGE = () => {
5
5
  const store = new Map();
6
6
  return {
7
7
  async getItem(key) {
8
- return store.has(key) ? store.get(key) : null;
8
+ const value = store.get(key);
9
+ return value === undefined ? null : value;
9
10
  },
10
11
  async setItem(key, value) {
11
12
  store.set(key, value);
@@ -31,7 +32,8 @@ const createWebStorage = () => {
31
32
  try {
32
33
  return window.localStorage.getItem(key);
33
34
  }
34
- catch {
35
+ catch (err) {
36
+ console.warn('[oxy.storage] localStorage.getItem failed:', err);
35
37
  return null;
36
38
  }
37
39
  },
@@ -39,29 +41,44 @@ const createWebStorage = () => {
39
41
  try {
40
42
  window.localStorage.setItem(key, value);
41
43
  }
42
- catch {
43
- // Ignore quota or access issues for now.
44
+ catch (err) {
45
+ // Quota exceeded or storage disabled (e.g., Safari private mode).
46
+ // Surface to logs so it is debuggable, but do not throw so callers
47
+ // can keep functioning with degraded persistence.
48
+ console.warn('[oxy.storage] localStorage.setItem failed:', err);
44
49
  }
45
50
  },
46
51
  async removeItem(key) {
47
52
  try {
48
53
  window.localStorage.removeItem(key);
49
54
  }
50
- catch {
51
- // Ignore failures.
55
+ catch (err) {
56
+ console.warn('[oxy.storage] localStorage.removeItem failed:', err);
52
57
  }
53
58
  },
54
59
  async clear() {
55
60
  try {
56
61
  window.localStorage.clear();
57
62
  }
58
- catch {
59
- // Ignore failures.
63
+ catch (err) {
64
+ console.warn('[oxy.storage] localStorage.clear failed:', err);
60
65
  }
61
66
  },
62
67
  };
63
68
  };
64
69
  let asyncStorageInstance = null;
70
+ /**
71
+ * Type guard verifying that an imported value exposes the AsyncStorage API.
72
+ */
73
+ const isAsyncStorageLike = (value) => {
74
+ if (typeof value !== 'object' || value === null)
75
+ return false;
76
+ const candidate = value;
77
+ return (typeof candidate.getItem === 'function' &&
78
+ typeof candidate.setItem === 'function' &&
79
+ typeof candidate.removeItem === 'function' &&
80
+ typeof candidate.clear === 'function');
81
+ };
65
82
  /**
66
83
  * Lazily import React Native AsyncStorage implementation.
67
84
  */
@@ -72,12 +89,21 @@ const createNativeStorage = async () => {
72
89
  try {
73
90
  // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
74
91
  const moduleName = '@react-native-async-storage/async-storage';
75
- const asyncStorageModule = await import(moduleName);
76
- asyncStorageInstance = asyncStorageModule.default;
92
+ const asyncStorageModule = (await import(moduleName));
93
+ const candidate = asyncStorageModule.default;
94
+ if (!isAsyncStorageLike(candidate)) {
95
+ throw new Error('AsyncStorage default export does not match expected API');
96
+ }
97
+ asyncStorageInstance = {
98
+ getItem: (key) => candidate.getItem(key),
99
+ setItem: (key, value) => candidate.setItem(key, value),
100
+ removeItem: (key) => candidate.removeItem(key),
101
+ clear: () => candidate.clear(),
102
+ };
77
103
  return asyncStorageInstance;
78
104
  }
79
105
  catch (error) {
80
- if (__DEV__) {
106
+ if (process.env.NODE_ENV !== 'production') {
81
107
  console.error('Failed to import AsyncStorage:', error);
82
108
  }
83
109
  throw new Error('AsyncStorage is required in React Native environment');