@oxyhq/services 10.2.1 → 10.2.3

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.
@@ -4,6 +4,7 @@ import {
4
4
  useCallback,
5
5
  useContext,
6
6
  useEffect,
7
+ useLayoutEffect,
7
8
  useMemo,
8
9
  useRef,
9
10
  useState,
@@ -250,6 +251,35 @@ const COOKIE_RESTORE_TIMEOUT = 3000;
250
251
  */
251
252
  const COLD_BOOT_OVERALL_DEADLINE = 20000;
252
253
 
254
+ function getHttpStatus(error: unknown): number | undefined {
255
+ if (!error || typeof error !== 'object') {
256
+ return undefined;
257
+ }
258
+
259
+ if ('status' in error) {
260
+ const status = (error as { status?: unknown }).status;
261
+ if (typeof status === 'number') {
262
+ return status;
263
+ }
264
+ }
265
+
266
+ if ('response' in error) {
267
+ const response = (error as { response?: unknown }).response;
268
+ if (response && typeof response === 'object' && 'status' in response) {
269
+ const status = (response as { status?: unknown }).status;
270
+ if (typeof status === 'number') {
271
+ return status;
272
+ }
273
+ }
274
+ }
275
+
276
+ return undefined;
277
+ }
278
+
279
+ function isUnauthorizedStatus(error: unknown): boolean {
280
+ return getHttpStatus(error) === 401;
281
+ }
282
+
253
283
  /**
254
284
  * Whether `idpOrigin` is a same-site, first-party host of the current page —
255
285
  * i.e. it shares the page's registrable apex (last two labels), so a "no
@@ -272,7 +302,10 @@ function isSameSiteIdP(idpOrigin: string): boolean {
272
302
  let idpHostname: string;
273
303
  try {
274
304
  idpHostname = new URL(idpOrigin).hostname;
275
- } catch {
305
+ } catch (parseError) {
306
+ if (__DEV__) {
307
+ loggerUtil.debug('Invalid IdP origin while checking same-site session status', { component: 'OxyContext' }, parseError as unknown);
308
+ }
276
309
  return false;
277
310
  }
278
311
  const pageHostname = window.location.hostname;
@@ -287,6 +320,12 @@ function isSameSiteIdP(idpOrigin: string): boolean {
287
320
  return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
288
321
  }
289
322
 
323
+ function isOnSsoCallbackPath(): boolean {
324
+ return isWebBrowser() && window.location.pathname === SSO_CALLBACK_PATH;
325
+ }
326
+
327
+ const useBrowserLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
328
+
290
329
  let cachedUseFollowHook: UseFollowHook | null = null;
291
330
 
292
331
  const loadUseFollowHook = (): UseFollowHook => {
@@ -386,7 +425,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
386
425
  // `isAuthResolved` on the context type for the consumer contract.
387
426
  const [authResolved, setAuthResolved] = useState(false);
388
427
  const authResolvedRef = useRef(false);
428
+ const userRef = useRef<User | null>(user);
429
+ const isAuthenticatedRef = useRef(isAuthenticated);
430
+ userRef.current = user;
431
+ isAuthenticatedRef.current = isAuthenticated;
389
432
  const [initialized, setInitialized] = useState(false);
433
+ const [ssoCallbackIntercepting, setSsoCallbackIntercepting] = useState(false);
390
434
  const setAuthState = useAuthStore.setState;
391
435
 
392
436
  // Keep the shared `oxyClient` singleton's token store in lockstep with the
@@ -625,6 +669,42 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
625
669
  updateSessionsRef.current = updateSessions;
626
670
  const clearSessionStateRef = useRef(clearSessionState);
627
671
  clearSessionStateRef.current = clearSessionState;
672
+ const clearingInvalidTokenRef = useRef(false);
673
+
674
+ useEffect(() => {
675
+ const handleTokenChange = (accessToken: string | null) => {
676
+ if (accessToken) {
677
+ setTokenReady(true);
678
+ return;
679
+ }
680
+
681
+ if (userRef.current || isAuthenticatedRef.current) {
682
+ setTokenReady(false);
683
+ if (clearingInvalidTokenRef.current) {
684
+ return;
685
+ }
686
+ clearingInvalidTokenRef.current = true;
687
+ clearSessionStateRef.current()
688
+ .catch((clearError) => {
689
+ logger('Failed to clear invalidated auth session', clearError);
690
+ })
691
+ .finally(() => {
692
+ clearingInvalidTokenRef.current = false;
693
+ if (authResolvedRef.current) {
694
+ setTokenReady(true);
695
+ }
696
+ });
697
+ return;
698
+ }
699
+
700
+ if (authResolvedRef.current) {
701
+ setTokenReady(true);
702
+ }
703
+ };
704
+
705
+ handleTokenChange(oxyServices.getAccessToken());
706
+ return oxyServices.onTokensChanged(handleTokenChange);
707
+ }, [logger, oxyServices]);
628
708
 
629
709
  // Durable, navigation-safe session persistence.
630
710
  //
@@ -641,12 +721,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
641
721
  await readyStorage.setItem(storageKeys.activeSessionId, sessionId);
642
722
  const existingIds = await readyStorage.getItem(storageKeys.sessionIds);
643
723
  let sessionIds: string[] = [];
644
- try { sessionIds = existingIds ? JSON.parse(existingIds) : []; } catch { /* corrupted storage */ }
724
+ try {
725
+ sessionIds = existingIds ? JSON.parse(existingIds) : [];
726
+ } catch (parseError) {
727
+ logger('Failed to parse persisted session ids; replacing corrupted storage value', parseError);
728
+ }
645
729
  if (!sessionIds.includes(sessionId)) {
646
730
  sessionIds.push(sessionId);
647
731
  await readyStorage.setItem(storageKeys.sessionIds, JSON.stringify(sessionIds));
648
732
  }
649
- }, [getReadyStorage, storageKeys.activeSessionId, storageKeys.sessionIds]);
733
+ }, [getReadyStorage, logger, storageKeys.activeSessionId, storageKeys.sessionIds]);
650
734
 
651
735
  // Refs so the cold-boot restore can plant session state without widening its
652
736
  // dependency array (mirrors the existing ref pattern above).
@@ -1293,13 +1377,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1293
1377
  // +not-found screen before the storage-gated cold-boot `sso-return` step gets
1294
1378
  // a chance to strip the fragment and restore the real destination.
1295
1379
  //
1296
- // This effect fires the SAME `runSsoReturn` kernel the instant we mount ON the
1297
- // callback path, BEFORE the cold boot (which awaits storage init). It restores
1298
- // the pre-bounce destination (and, on `ok`, commits the exchanged session via
1299
- // `handleWebSSOSession`) immediately, so the router re-syncs off the callback
1300
- // path and never lingers on it. Because the SDK owns this interception
1301
- // entirely, NO app needs a `/__oxy/sso-callback` route — it works identically
1302
- // across every consumer with zero per-app code.
1380
+ // This effect fires the SAME `runSsoReturn` kernel the instant we hydrate ON
1381
+ // the callback path, BEFORE the cold boot (which awaits storage init). The
1382
+ // first render intentionally matches the app/router's static HTML; the
1383
+ // browser layout effect then hides the internal route and consumes the
1384
+ // callback before the first visible paint. That keeps SSR/SSG hydration stable
1385
+ // while still ensuring no app needs a `/__oxy/sso-callback` route.
1303
1386
  //
1304
1387
  // It is purely ADDITIVE. The later cold-boot `sso-return` step stays as
1305
1388
  // defense-in-depth for the non-callback-path case; `consumeSsoReturn` strips
@@ -1315,13 +1398,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1315
1398
  // already wired when this fires at eager-mount time. If for any reason it were
1316
1399
  // not yet set, the later cold-boot `sso-return` step would commit it — but the
1317
1400
  // ref IS set during render, so the eager `ok` commit works.
1318
- useEffect(() => {
1319
- if (!isWebBrowser()) {
1320
- return;
1321
- }
1322
- if (window.location.pathname !== SSO_CALLBACK_PATH) {
1401
+ useBrowserLayoutEffect(() => {
1402
+ if (!isOnSsoCallbackPath()) {
1403
+ setSsoCallbackIntercepting(false);
1323
1404
  return;
1324
1405
  }
1406
+ let mounted = true;
1407
+ setSsoCallbackIntercepting(true);
1325
1408
  runSsoReturnRef.current().catch((error) => {
1326
1409
  if (__DEV__) {
1327
1410
  loggerUtil.debug(
@@ -1330,7 +1413,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1330
1413
  error,
1331
1414
  );
1332
1415
  }
1416
+ }).finally(() => {
1417
+ if (mounted) {
1418
+ setSsoCallbackIntercepting(false);
1419
+ }
1333
1420
  });
1421
+
1422
+ return () => {
1423
+ mounted = false;
1424
+ };
1334
1425
  }, []);
1335
1426
 
1336
1427
  // Web SSO: automatically check for cross-domain session on web platforms.
@@ -1382,7 +1473,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1382
1473
  let fullUser: User;
1383
1474
  try {
1384
1475
  fullUser = await oxyServices.getCurrentUser();
1385
- } catch {
1476
+ } catch (profileError) {
1477
+ if (__DEV__) {
1478
+ loggerUtil.debug('Failed to fetch full user after web session; using session user fallback', { component: 'OxyContext', method: 'handleWebSSOSession' }, profileError as unknown);
1479
+ }
1386
1480
  // If the profile fetch fails, fall back to the minimal data from the session
1387
1481
  // so the user is still logged in (the store accepts User, but the shapes overlap at runtime).
1388
1482
  fullUser = session.user as unknown as User;
@@ -1600,16 +1694,25 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1600
1694
 
1601
1695
  // Load managed accounts when authenticated
1602
1696
  const refreshManagedAccounts = useCallback(async (): Promise<void> => {
1603
- if (!isAuthenticated) return;
1697
+ if (!isAuthenticated || !tokenReady || !oxyServices.getAccessToken()) {
1698
+ setManagedAccounts([]);
1699
+ return;
1700
+ }
1701
+
1604
1702
  try {
1605
1703
  const accounts = await oxyServices.getManagedAccounts();
1606
1704
  setManagedAccounts(accounts);
1607
1705
  } catch (err) {
1706
+ if (isUnauthorizedStatus(err)) {
1707
+ setManagedAccounts([]);
1708
+ await clearSessionStateRef.current();
1709
+ return;
1710
+ }
1608
1711
  if (__DEV__) {
1609
1712
  loggerUtil.debug('Failed to load managed accounts', { component: 'OxyContext' }, err as unknown);
1610
1713
  }
1611
1714
  }
1612
- }, [isAuthenticated, oxyServices]);
1715
+ }, [isAuthenticated, oxyServices, tokenReady]);
1613
1716
 
1614
1717
  useEffect(() => {
1615
1718
  if (isAuthenticated && initialized && tokenReady) {
@@ -1623,9 +1726,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1623
1726
  // Persist to storage
1624
1727
  if (storage) {
1625
1728
  if (userId) {
1626
- storage.setItem(`${storageKeyPrefix}_acting_as`, userId).catch(() => {});
1729
+ storage.setItem(`${storageKeyPrefix}_acting_as`, userId).catch((persistError) => {
1730
+ loggerUtil.debug('Failed to persist acting-as account', { component: 'OxyContext' }, persistError as unknown);
1731
+ });
1627
1732
  } else {
1628
- storage.removeItem(`${storageKeyPrefix}_acting_as`).catch(() => {});
1733
+ storage.removeItem(`${storageKeyPrefix}_acting_as`).catch((persistError) => {
1734
+ loggerUtil.debug('Failed to clear acting-as account', { component: 'OxyContext' }, persistError as unknown);
1735
+ });
1629
1736
  }
1630
1737
  }
1631
1738
  }, [oxyServices, storage, storageKeyPrefix]);
@@ -1718,7 +1825,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1718
1825
 
1719
1826
  return (
1720
1827
  <OxyContext.Provider value={contextValue}>
1721
- {children}
1828
+ {ssoCallbackIntercepting ? null : children}
1722
1829
  </OxyContext.Provider>
1723
1830
  );
1724
1831
  };
@@ -194,7 +194,7 @@ export function useAuth(): UseAuthReturn {
194
194
  // State
195
195
  user,
196
196
  isAuthenticated,
197
- isLoading,
197
+ isLoading: isLoading || !isAuthResolved,
198
198
  isReady: isTokenReady,
199
199
  isAuthResolved,
200
200
  error,
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useMemo, useEffect } from 'react';
2
2
  import { useFollowStore } from '../stores/followStore';
3
3
  import { useOxy } from '../context/OxyContext';
4
- import type { OxyServices } from '@oxyhq/core';
4
+ import { logger as loggerUtil, type OxyServices } from '@oxyhq/core';
5
5
  import { useShallow } from 'zustand/react/shallow';
6
6
 
7
7
  /**
@@ -18,7 +18,8 @@ import { useShallow } from 'zustand/react/shallow';
18
18
  * them in selectors would cause unnecessary selector recalculations).
19
19
  */
20
20
  export const useFollow = (userId?: string | string[]) => {
21
- const { oxyServices } = useOxy();
21
+ const { oxyServices, isAuthenticated, isAuthResolved, isTokenReady } = useOxy();
22
+ const canUsePrivateApi = isAuthResolved && isTokenReady && isAuthenticated;
22
23
  const userIds = useMemo(() => (Array.isArray(userId) ? userId : userId ? [userId] : []), [userId]);
23
24
  const isSingleUser = typeof userId === 'string';
24
25
 
@@ -75,9 +76,10 @@ export const useFollow = (userId?: string | string[]) => {
75
76
  // Store actions are accessed via getState() to avoid subscribing to them.
76
77
  const toggleFollow = useCallback(async () => {
77
78
  if (!isSingleUser || !userId) throw new Error('toggleFollow is only available for single user mode');
79
+ if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
78
80
  const currentlyFollowing = useFollowStore.getState().followingUsers[userId] ?? false;
79
81
  await useFollowStore.getState().toggleFollowUser(userId, oxyServices, currentlyFollowing);
80
- }, [isSingleUser, userId, oxyServices]);
82
+ }, [isSingleUser, userId, canUsePrivateApi, oxyServices]);
81
83
 
82
84
  const setFollowStatus = useCallback((following: boolean) => {
83
85
  if (!isSingleUser || !userId) throw new Error('setFollowStatus is only available for single user mode');
@@ -86,8 +88,9 @@ export const useFollow = (userId?: string | string[]) => {
86
88
 
87
89
  const fetchStatus = useCallback(async () => {
88
90
  if (!isSingleUser || !userId) throw new Error('fetchStatus is only available for single user mode');
91
+ if (!canUsePrivateApi) return;
89
92
  await useFollowStore.getState().fetchFollowStatus(userId, oxyServices);
90
- }, [isSingleUser, userId, oxyServices]);
93
+ }, [isSingleUser, userId, canUsePrivateApi, oxyServices]);
91
94
 
92
95
  const clearError = useCallback(() => {
93
96
  if (!isSingleUser || !userId) throw new Error('clearError is only available for single user mode');
@@ -114,28 +117,33 @@ export const useFollow = (userId?: string | string[]) => {
114
117
  if (!isSingleUser || !userId) return;
115
118
 
116
119
  if ((followerCount === null || followingCount === null) && !isLoadingCounts) {
117
- fetchUserCounts().catch((err: unknown) => console.warn('useFollow: fetchUserCounts failed', err));
120
+ fetchUserCounts().catch((error: unknown) => {
121
+ loggerUtil.warn('useFollow: fetchUserCounts failed', { component: 'useFollow' }, error);
122
+ });
118
123
  }
119
124
  }, [isSingleUser, userId, followerCount, followingCount, isLoadingCounts, fetchUserCounts]);
120
125
 
121
126
  // Multi-user callbacks
122
127
  const toggleFollowForUser = useCallback(async (targetUserId: string) => {
128
+ if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
123
129
  const currentState = useFollowStore.getState().followingUsers[targetUserId] ?? false;
124
130
  await useFollowStore.getState().toggleFollowUser(targetUserId, oxyServices, currentState);
125
- }, [oxyServices]);
131
+ }, [canUsePrivateApi, oxyServices]);
126
132
 
127
133
  const setFollowStatusForUser = useCallback((targetUserId: string, following: boolean) => {
128
134
  useFollowStore.getState().setFollowingStatus(targetUserId, following);
129
135
  }, []);
130
136
 
131
137
  const fetchStatusForUser = useCallback(async (targetUserId: string) => {
138
+ if (!canUsePrivateApi) return;
132
139
  await useFollowStore.getState().fetchFollowStatus(targetUserId, oxyServices);
133
- }, [oxyServices]);
140
+ }, [canUsePrivateApi, oxyServices]);
134
141
 
135
142
  const fetchAllStatuses = useCallback(async () => {
143
+ if (!canUsePrivateApi) return;
136
144
  const store = useFollowStore.getState();
137
145
  await Promise.all(userIds.map(uid => store.fetchFollowStatus(uid, oxyServices)));
138
- }, [userIds, oxyServices]);
146
+ }, [canUsePrivateApi, userIds, oxyServices]);
139
147
 
140
148
  const clearErrorForUser = useCallback((targetUserId: string) => {
141
149
  useFollowStore.getState().clearFollowError(targetUserId);