@oxyhq/auth 2.0.4 → 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 (38) 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 +81 -94
  7. package/dist/cjs/index.js +1 -2
  8. package/dist/cjs/utils/sessionHelpers.js +3 -1
  9. package/dist/cjs/utils/storageHelpers.js +36 -10
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/WebOxyProvider.js +38 -1
  12. package/dist/esm/hooks/mutations/useAccountMutations.js +186 -44
  13. package/dist/esm/hooks/queryClient.js +132 -89
  14. package/dist/esm/hooks/useFileDownloadUrl.js +11 -34
  15. package/dist/esm/hooks/useSessionSocket.js +81 -94
  16. package/dist/esm/index.js +1 -1
  17. package/dist/esm/utils/sessionHelpers.js +3 -1
  18. package/dist/esm/utils/storageHelpers.js +36 -10
  19. package/dist/types/.tsbuildinfo +1 -1
  20. package/dist/types/WebOxyProvider.d.ts +1 -1
  21. package/dist/types/hooks/mutations/useAccountMutations.d.ts +153 -9
  22. package/dist/types/hooks/queries/useAccountQueries.d.ts +11 -7
  23. package/dist/types/hooks/queries/useSecurityQueries.d.ts +2 -2
  24. package/dist/types/hooks/queries/useServicesQueries.d.ts +7 -5
  25. package/dist/types/hooks/queryClient.d.ts +24 -10
  26. package/dist/types/hooks/useAssets.d.ts +1 -1
  27. package/dist/types/hooks/useFileDownloadUrl.d.ts +2 -6
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/utils/sessionHelpers.d.ts +3 -1
  30. package/package.json +22 -3
  31. package/src/WebOxyProvider.tsx +39 -1
  32. package/src/hooks/mutations/useAccountMutations.ts +230 -57
  33. package/src/hooks/queryClient.ts +140 -83
  34. package/src/hooks/useFileDownloadUrl.ts +15 -39
  35. package/src/hooks/useSessionSocket.ts +123 -91
  36. package/src/index.ts +1 -1
  37. package/src/utils/sessionHelpers.ts +3 -1
  38. package/src/utils/storageHelpers.ts +49 -10
@@ -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
  };
@@ -24,7 +24,8 @@ function readTokenFromStorage() {
24
24
  try {
25
25
  return window.localStorage.getItem(LS_ACCESS_TOKEN_KEY);
26
26
  }
27
- catch {
27
+ catch (err) {
28
+ console.warn('[oxy.session-socket] localStorage read failed:', err);
28
29
  return null;
29
30
  }
30
31
  }
@@ -37,12 +38,12 @@ async function getSocketIO() {
37
38
  return null;
38
39
  _ioLoadAttempted = true;
39
40
  try {
40
- // eslint-disable-next-line @typescript-eslint/no-require-imports
41
- const mod = await import('socket.io-client');
42
- _io = (mod.io ?? mod.default);
41
+ const mod = (await import('socket.io-client'));
42
+ _io = mod.io ?? mod.default ?? null;
43
43
  return _io;
44
44
  }
45
- catch {
45
+ catch (err) {
46
+ console.warn('[oxy.session-socket] socket.io-client import failed:', err);
46
47
  debug.warn('socket.io-client is not installed. useSessionSocket will be disabled. Install it with: bun add socket.io-client');
47
48
  return null;
48
49
  }
@@ -109,7 +110,7 @@ export function useSessionSocket(options) {
109
110
  // If no token is available at all, we skip the initial connect and let
110
111
  // the storage listener or retry logic connect when a token appears.
111
112
  const token = resolveToken();
112
- socketRef.current = ioFn(baseURL, {
113
+ const socket = ioFn(baseURL, {
113
114
  transports: ['websocket'],
114
115
  autoConnect: !!token, // don't auto-connect when there is no token
115
116
  auth: (cb) => {
@@ -126,7 +127,7 @@ export function useSessionSocket(options) {
126
127
  cb({ token: resolved });
127
128
  },
128
129
  });
129
- const socket = socketRef.current;
130
+ socketRef.current = socket;
130
131
  // Server auto-joins the user to `user:<userId>` room on connection
131
132
  const handleConnect = () => {
132
133
  debug.log('Socket connected:', socket.id);
@@ -162,110 +163,94 @@ export function useSessionSocket(options) {
162
163
  invalidateSessionQueries(queryClientRef.current);
163
164
  return Promise.resolve();
164
165
  };
166
+ const triggerLocalSignOut = async (toastMessage, errorContext) => {
167
+ if (onRemoteSignOutRef.current) {
168
+ onRemoteSignOutRef.current();
169
+ }
170
+ else {
171
+ toast.info(toastMessage);
172
+ }
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();
177
+ }
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' });
181
+ }
182
+ }
183
+ };
165
184
  const handleSessionUpdate = async (data) => {
166
185
  debug.log('Received session_update:', data);
167
186
  const currentActiveSessionId = activeSessionIdRef.current;
168
187
  const deviceId = currentDeviceIdRef.current;
169
- // Handle different event types
170
- if (data.type === 'session_removed') {
171
- // Track removed session
172
- if (data.sessionId && onSessionRemovedRef.current) {
173
- onSessionRemovedRef.current(data.sessionId);
174
- }
175
- // If the removed sessionId matches the current activeSessionId, immediately clear state
176
- if (data.sessionId === currentActiveSessionId) {
177
- if (onRemoteSignOutRef.current) {
178
- onRemoteSignOutRef.current();
179
- }
180
- else {
181
- toast.info('You have been signed out remotely.');
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);
182
200
  }
183
- try {
184
- await clearSessionStateRef.current();
201
+ if (data.sessionId && data.sessionId === currentActiveSessionId) {
202
+ await triggerLocalSignOut('You have been signed out remotely.', 'session_removed');
185
203
  }
186
- catch (error) {
187
- if (process.env.NODE_ENV !== 'production') {
188
- logger.error('Failed to clear session state after session_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
189
- }
204
+ else {
205
+ refreshSessions();
190
206
  }
207
+ break;
191
208
  }
192
- else {
193
- refreshSessions();
194
- }
195
- }
196
- else if (data.type === 'device_removed') {
197
- // Track all removed sessions from this device
198
- if (data.sessionIds && onSessionRemovedRef.current) {
199
- for (const sessionId of data.sessionIds) {
200
- onSessionRemovedRef.current(sessionId);
209
+ case 'device_removed': {
210
+ if (data.sessionIds && onSessionRemovedRef.current) {
211
+ for (const sessionId of data.sessionIds) {
212
+ onSessionRemovedRef.current(sessionId);
213
+ }
201
214
  }
202
- }
203
- // If the removed deviceId matches the current device, immediately clear state
204
- if (data.deviceId && data.deviceId === deviceId) {
205
- if (onRemoteSignOutRef.current) {
206
- onRemoteSignOutRef.current();
215
+ if (data.deviceId && deviceId && data.deviceId === deviceId) {
216
+ await triggerLocalSignOut('This device has been removed. You have been signed out.', 'device_removed');
207
217
  }
208
218
  else {
209
- toast.info('This device has been removed. You have been signed out.');
210
- }
211
- try {
212
- await clearSessionStateRef.current();
213
- }
214
- catch (error) {
215
- if (process.env.NODE_ENV !== 'production') {
216
- logger.error('Failed to clear session state after device_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
217
- }
219
+ refreshSessions();
218
220
  }
221
+ break;
219
222
  }
220
- else {
221
- refreshSessions();
222
- }
223
- }
224
- else if (data.type === 'sessions_removed') {
225
- // Track all removed sessions
226
- if (data.sessionIds && onSessionRemovedRef.current) {
227
- for (const sessionId of data.sessionIds) {
228
- onSessionRemovedRef.current(sessionId);
223
+ case 'sessions_removed': {
224
+ if (data.sessionIds && onSessionRemovedRef.current) {
225
+ for (const sessionId of data.sessionIds) {
226
+ onSessionRemovedRef.current(sessionId);
227
+ }
229
228
  }
230
- }
231
- // If the current activeSessionId is in the removed sessionIds list, immediately clear state
232
- if (data.sessionIds && currentActiveSessionId && data.sessionIds.includes(currentActiveSessionId)) {
233
- if (onRemoteSignOutRef.current) {
234
- onRemoteSignOutRef.current();
229
+ if (data.sessionIds &&
230
+ currentActiveSessionId &&
231
+ data.sessionIds.includes(currentActiveSessionId)) {
232
+ await triggerLocalSignOut('You have been signed out remotely.', 'sessions_removed');
235
233
  }
236
234
  else {
237
- toast.info('You have been signed out remotely.');
238
- }
239
- try {
240
- await clearSessionStateRef.current();
241
- }
242
- catch (error) {
243
- if (process.env.NODE_ENV !== 'production') {
244
- logger.error('Failed to clear session state after sessions_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
245
- }
235
+ refreshSessions();
246
236
  }
237
+ break;
247
238
  }
248
- else {
239
+ case 'session_created':
240
+ case 'session_update': {
241
+ // Lifecycle event for the current user. Just resync the sessions
242
+ // list — never sign out.
249
243
  refreshSessions();
244
+ break;
250
245
  }
251
- }
252
- else {
253
- // For other event types (e.g., session_created), refresh sessions
254
- refreshSessions();
255
- // If the current session was logged out (legacy behavior), handle it specially
256
- if (data.sessionId === currentActiveSessionId) {
257
- if (onRemoteSignOutRef.current) {
258
- onRemoteSignOutRef.current();
259
- }
260
- else {
261
- toast.info('You have been signed out remotely.');
262
- }
263
- try {
264
- await clearSessionStateRef.current();
265
- }
266
- catch (error) {
267
- debug.error('Failed to clear session state after session_update:', error);
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
+ });
268
252
  }
253
+ break;
269
254
  }
270
255
  }
271
256
  };
@@ -293,12 +278,14 @@ export function useSessionSocket(options) {
293
278
  if (authRetryTimer) {
294
279
  clearTimeout(authRetryTimer);
295
280
  }
296
- if (socketRef.current) {
281
+ const currentSocket = socketRef.current;
282
+ if (currentSocket) {
297
283
  // Remove cross-tab storage listener
298
- if (typeof window !== 'undefined' && socketRef.current.__oxyStorageHandler) {
299
- window.removeEventListener('storage', socketRef.current.__oxyStorageHandler);
284
+ const storageHandler = currentSocket.__oxyStorageHandler;
285
+ if (typeof window !== 'undefined' && storageHandler) {
286
+ window.removeEventListener('storage', storageHandler);
300
287
  }
301
- socketRef.current.disconnect();
288
+ currentSocket.disconnect();
302
289
  socketRef.current = null;
303
290
  }
304
291
  };
package/dist/esm/index.js CHANGED
@@ -38,7 +38,7 @@ export { createProfileMutation, createGenericMutation, } from './hooks/mutations
38
38
  export { useWebSSO, isWebBrowser } from './hooks/useWebSSO';
39
39
  export { useSessionSocket } from './hooks/useSessionSocket';
40
40
  export { useAssets, setOxyAssetInstance } from './hooks/useAssets';
41
- export { useFileDownloadUrl, setOxyFileUrlInstance } from './hooks/useFileDownloadUrl';
41
+ export { useFileDownloadUrl } from './hooks/useFileDownloadUrl';
42
42
  export { useFollow, useFollowerCounts } from './hooks/useFollow';
43
43
  export { useFileFiltering } from './hooks/useFileFiltering';
44
44
  // --- Error Handlers ---
@@ -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
@@ -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,8 +89,17 @@ 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) {