@oxyhq/services 8.1.1 → 8.2.0

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 (29) hide show
  1. package/lib/commonjs/ui/components/OxyProvider.js +30 -23
  2. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  3. package/lib/commonjs/ui/context/OxyContext.js +334 -79
  4. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  5. package/lib/commonjs/ui/hooks/useSessionManagement.js +7 -3
  6. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  7. package/lib/module/ui/components/OxyProvider.js +31 -24
  8. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  9. package/lib/module/ui/context/OxyContext.js +335 -79
  10. package/lib/module/ui/context/OxyContext.js.map +1 -1
  11. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  12. package/lib/module/ui/hooks/useSessionManagement.js +7 -3
  13. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  14. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  15. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  16. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  17. package/lib/typescript/commonjs/ui/types/navigation.d.ts +0 -3
  18. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  19. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  20. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  21. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  22. package/lib/typescript/module/ui/types/navigation.d.ts +0 -3
  23. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  24. package/package.json +2 -2
  25. package/src/ui/components/OxyProvider.tsx +29 -39
  26. package/src/ui/context/OxyContext.tsx +334 -90
  27. package/src/ui/context/hooks/useAuthOperations.ts +1 -1
  28. package/src/ui/hooks/useSessionManagement.ts +8 -4
  29. package/src/ui/types/navigation.ts +0 -3
@@ -14,6 +14,7 @@ import type { User, ApiError, SessionLoginResponse } from '@oxyhq/core';
14
14
  import type { ManagedAccount, CreateManagedAccountInput } from '@oxyhq/core';
15
15
  import { KeyManager } from '@oxyhq/core';
16
16
  import type { ClientSession } from '@oxyhq/core';
17
+ import { autoDetectAuthWebUrl, runColdBoot } from '@oxyhq/core';
17
18
  import { toast } from '@oxyhq/bloom';
18
19
  import { useAuthStore, type AuthState } from '../stores/authStore';
19
20
  import { useShallow } from 'zustand/react/shallow';
@@ -113,6 +114,72 @@ export interface OxyContextProviderProps {
113
114
  onError?: (error: ApiError) => void;
114
115
  }
115
116
 
117
+ /**
118
+ * Module-level run-once guard for the cold-boot silent SSO steps
119
+ * (`fedcm-silent` and `silent-iframe`).
120
+ *
121
+ * Both steps trigger a one-shot browser credential / iframe handshake that must
122
+ * fire AT MOST ONCE per page load — otherwise a provider remount storm (route
123
+ * churn, StrictMode double-invoke, error-boundary recovery) becomes a credential
124
+ * request storm. A per-instance ref resets on every remount, so the guard must
125
+ * live at module scope. Keyed on `origin|baseURL` so two providers pointed at
126
+ * the same API from the same origin share one attempt; never cleared because
127
+ * only a fresh page load can change the IdP session state, and a fresh page load
128
+ * starts a fresh module scope.
129
+ *
130
+ * This is a NEW, dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
131
+ * (which guards the post-boot INTERACTIVE button path) and never a core
132
+ * module-level singleton (that re-evaluates under Metro web bundling and the
133
+ * guard would not hold).
134
+ */
135
+ const servicesSilentAttempted = new Set<string>();
136
+
137
+ /**
138
+ * Build the `origin|baseURL` signature used as the silent-cold-boot guard key.
139
+ */
140
+ function silentColdBootKey(oxyServices: OxyServices): string {
141
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'no-origin';
142
+ let baseURL = '';
143
+ try {
144
+ baseURL = oxyServices.getBaseURL?.() ?? '';
145
+ } catch {
146
+ baseURL = '';
147
+ }
148
+ return `${origin}|${baseURL}`;
149
+ }
150
+
151
+ /**
152
+ * Whether `idpOrigin` is a same-site, first-party host of the current page —
153
+ * i.e. it shares the page's registrable apex (last two labels), so a "no
154
+ * session" answer from its `/auth/session-check` iframe is authoritative for
155
+ * THIS app and may force a local sign-out.
156
+ *
157
+ * On a cross-site IdP (or any host whose relationship to the page can't be
158
+ * positively established) this returns `false`, so the visibility-driven check
159
+ * may surface a session-ended toast but MUST NOT clear local state — a
160
+ * third-party / undetermined IdP answer can never force logout. Returns `false`
161
+ * off-browser.
162
+ */
163
+ function isSameSiteIdP(idpOrigin: string): boolean {
164
+ if (typeof window === 'undefined') return false;
165
+ let idpHostname: string;
166
+ try {
167
+ idpHostname = new URL(idpOrigin).hostname;
168
+ } catch {
169
+ return false;
170
+ }
171
+ const pageHostname = window.location.hostname;
172
+ if (!idpHostname || !pageHostname) return false;
173
+ if (idpHostname === pageHostname) return true;
174
+ const apexOf = (hostname: string): string => hostname.split('.').slice(-2).join('.');
175
+ const pageApex = apexOf(pageHostname);
176
+ // Require a real registrable apex (≥2 labels) AND an exact apex match AND that
177
+ // the IdP host is the page apex itself or a subdomain of it.
178
+ if (pageHostname.split('.').length < 2) return false;
179
+ if (apexOf(idpHostname) !== pageApex) return false;
180
+ return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
181
+ }
182
+
116
183
  let cachedUseFollowHook: UseFollowHook | null = null;
117
184
 
118
185
  const loadUseFollowHook = (): UseFollowHook => {
@@ -159,9 +226,19 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
159
226
  if (providedOxyServices) {
160
227
  oxyServicesRef.current = providedOxyServices;
161
228
  } else if (baseURL) {
229
+ // Auto-detect the FAPI (IdP) origin from the current browser hostname so
230
+ // a consuming RP (mention.earth, homiio.com, alia.onl, …) targets
231
+ // `auth.<rp-apex>` for FedCM + the silent iframe WITHOUT passing
232
+ // `authWebUrl` explicitly — that is what makes both the FedCM config and
233
+ // the `/auth/silent` iframe first-party with the RP (Safari ITP /
234
+ // Firefox TCP need first-party). An explicit `authWebUrl` prop still
235
+ // wins. On native `autoDetectAuthWebUrl()` returns `undefined`
236
+ // (off-browser), leaving the value unchanged. We only auto-detect on the
237
+ // baseURL-only path — a consumer-provided `OxyServices` instance is
238
+ // never mutated.
162
239
  oxyServicesRef.current = new OxyServices({
163
240
  baseURL,
164
- authWebUrl,
241
+ authWebUrl: authWebUrl ?? autoDetectAuthWebUrl(),
165
242
  authRedirectUri,
166
243
  });
167
244
  } else {
@@ -234,7 +311,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
234
311
 
235
312
  const logger = useCallback((message: string, err?: unknown) => {
236
313
  if (__DEV__) {
237
- console.warn(`[OxyContext] ${message}`, err);
314
+ loggerUtil.warn(message, { component: 'OxyContext' }, err);
238
315
  }
239
316
  }, []);
240
317
 
@@ -453,6 +530,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
453
530
  const onAuthStateChangeRef = useRef(onAuthStateChange);
454
531
  onAuthStateChangeRef.current = onAuthStateChange;
455
532
 
533
+ // `handleWebSSOSession` is declared further down (it depends on values that
534
+ // are only available there). The FedCM/iframe cold-boot steps need to commit
535
+ // a recovered session through it, so we route the call through a ref that is
536
+ // populated once the callback exists. The ref is assigned synchronously on
537
+ // every render before the cold-boot effect can fire (the effect is gated on
538
+ // `storage` + `initialized`, both of which settle after first render).
539
+ const handleWebSSOSessionRef = useRef<((session: SessionLoginResponse) => Promise<void>) | null>(null);
540
+
456
541
  // Cold-boot session restore via the secure refresh cookies (web only).
457
542
  //
458
543
  // Calls `oxyServices.refreshAllSessions()` → `POST /auth/refresh-all` with
@@ -546,98 +631,230 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
546
631
  return true;
547
632
  }, [oxyServices, persistSessionDurably]);
548
633
 
549
- const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
634
+ // Native (and offline) stored-session restore — the ONLY restore path that
635
+ // runs on React Native, and the web fallback when no cross-domain step won.
636
+ //
637
+ // Verbatim-extracted from the previous `restoreSessionsFromStorage` body: it
638
+ // reads the durable `session_ids` / `active_session_id` slots, validates each
639
+ // stored session in parallel (bearer `validateSession`), and switches to the
640
+ // stored active session via the session-management `switchSession`. This body
641
+ // is platform-agnostic and gated by NO `enabled()` predicate so it runs on
642
+ // every platform — on native it is reached unconditionally (every web-only
643
+ // step ahead of it is disabled by `isWebBrowser()`), so native restore is
644
+ // exactly this and nothing else (no FedCM / iframe / refresh-all /
645
+ // handleAuthCallback).
646
+ const restoreStoredSession = useCallback(async (): Promise<boolean> => {
550
647
  if (!storage) {
551
- return;
648
+ return false;
552
649
  }
553
650
 
554
- setTokenReady(false);
555
-
556
- try {
557
- // Web cold-boot fast path: restore the active session from the secure
558
- // httpOnly refresh cookie before the bearer-protected stored-session
559
- // validation (which 401s on a hard reload). On success we are signed in
560
- // from the cookie alone — no FedCM needed. On failure we fall through to
561
- // the existing stored-session flow below; nothing is cleared.
562
- if (await restoreViaRefreshCookie()) {
563
- return;
564
- }
565
-
566
- const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
567
- const storedSessionIds: string[] = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
568
- const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
569
-
570
- let validSessions: ClientSession[] = [];
571
-
572
- if (storedSessionIds.length > 0) {
573
- // Validate all sessions in parallel (with 8s timeout per session) to avoid
574
- // sequential blocking that freezes the app on startup
575
- const VALIDATION_TIMEOUT = 8000;
576
- const results = await Promise.allSettled(
577
- storedSessionIds.map(async (sessionId) => {
578
- const timeoutPromise = new Promise<null>((resolve) =>
579
- setTimeout(() => resolve(null), VALIDATION_TIMEOUT),
580
- );
581
- const validationPromise = oxyServices
582
- .validateSession(sessionId, { useHeaderValidation: true })
583
- .catch((validationError: unknown) => {
584
- if (!isInvalidSessionError(validationError) && !isTimeoutOrNetworkError(validationError)) {
585
- logger('Session validation failed during init', validationError);
586
- } else if (__DEV__ && isTimeoutOrNetworkError(validationError)) {
587
- loggerUtil.debug('Session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreSessionsFromStorage' }, validationError as unknown);
588
- }
589
- return null;
590
- });
591
-
592
- return Promise.race([validationPromise, timeoutPromise]).then((validation) => {
593
- if (validation?.valid && validation.user) {
594
- const now = new Date();
595
- return {
596
- sessionId,
597
- deviceId: '',
598
- expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
599
- lastActive: now.toISOString(),
600
- userId: validation.user.id?.toString() ?? '',
601
- isCurrent: sessionId === storedActiveSessionId,
602
- } as ClientSession;
651
+ const storedSessionIdsJson = await storage.getItem(storageKeys.sessionIds);
652
+ const storedSessionIds: string[] = storedSessionIdsJson ? JSON.parse(storedSessionIdsJson) : [];
653
+ const storedActiveSessionId = await storage.getItem(storageKeys.activeSessionId);
654
+
655
+ let validSessions: ClientSession[] = [];
656
+
657
+ if (storedSessionIds.length > 0) {
658
+ // Validate all sessions in parallel (with 8s timeout per session) to avoid
659
+ // sequential blocking that freezes the app on startup
660
+ const VALIDATION_TIMEOUT = 8000;
661
+ const results = await Promise.allSettled(
662
+ storedSessionIds.map(async (sessionId) => {
663
+ const timeoutPromise = new Promise<null>((resolve) =>
664
+ setTimeout(() => resolve(null), VALIDATION_TIMEOUT),
665
+ );
666
+ const validationPromise = oxyServices
667
+ .validateSession(sessionId, { useHeaderValidation: true })
668
+ .catch((validationError: unknown) => {
669
+ if (!isInvalidSessionError(validationError) && !isTimeoutOrNetworkError(validationError)) {
670
+ logger('Session validation failed during init', validationError);
671
+ } else if (__DEV__ && isTimeoutOrNetworkError(validationError)) {
672
+ loggerUtil.debug('Session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, validationError as unknown);
603
673
  }
604
674
  return null;
605
675
  });
606
- }),
607
- );
608
676
 
609
- validSessions = results
610
- .filter((r): r is PromiseFulfilledResult<ClientSession | null> => r.status === 'fulfilled')
611
- .map((r) => r.value)
612
- .filter((s): s is ClientSession => s !== null);
677
+ return Promise.race([validationPromise, timeoutPromise]).then((validation) => {
678
+ if (validation?.valid && validation.user) {
679
+ const now = new Date();
680
+ return {
681
+ sessionId,
682
+ deviceId: '',
683
+ expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
684
+ lastActive: now.toISOString(),
685
+ userId: validation.user.id?.toString() ?? '',
686
+ isCurrent: sessionId === storedActiveSessionId,
687
+ } as ClientSession;
688
+ }
689
+ return null;
690
+ });
691
+ }),
692
+ );
613
693
 
614
- // Always persist validated sessions to storage (even empty list)
615
- // to clear stale/expired session IDs that would cause 401 loops on restart
616
- updateSessionsRef.current(validSessions, { merge: false });
694
+ validSessions = results
695
+ .filter((r): r is PromiseFulfilledResult<ClientSession | null> => r.status === 'fulfilled')
696
+ .map((r) => r.value)
697
+ .filter((s): s is ClientSession => s !== null);
698
+
699
+ // Always persist validated sessions to storage (even empty list)
700
+ // to clear stale/expired session IDs that would cause 401 loops on restart
701
+ updateSessionsRef.current(validSessions, { merge: false });
702
+ }
703
+
704
+ if (storedActiveSessionId) {
705
+ try {
706
+ await switchSessionRef.current(storedActiveSessionId);
707
+ return true;
708
+ } catch (switchError) {
709
+ // Silently handle expected errors (invalid sessions, timeouts, network issues)
710
+ if (isInvalidSessionError(switchError)) {
711
+ await storage.removeItem(storageKeys.activeSessionId);
712
+ updateSessionsRef.current(
713
+ validSessions.filter((session) => session.sessionId !== storedActiveSessionId),
714
+ { merge: false },
715
+ );
716
+ // Don't log expected session errors during restoration
717
+ } else if (isTimeoutOrNetworkError(switchError)) {
718
+ // Timeout/network error - non-critical, don't block
719
+ if (__DEV__) {
720
+ loggerUtil.debug('Active session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreStoredSession' }, switchError as unknown);
721
+ }
722
+ } else {
723
+ // Only log unexpected errors
724
+ logger('Active session validation error', switchError);
725
+ }
617
726
  }
727
+ }
728
+
729
+ return false;
730
+ }, [
731
+ logger,
732
+ oxyServices,
733
+ storage,
734
+ storageKeys.activeSessionId,
735
+ storageKeys.sessionIds,
736
+ ]);
618
737
 
619
- if (storedActiveSessionId) {
620
- try {
621
- await switchSessionRef.current(storedActiveSessionId);
622
- } catch (switchError) {
623
- // Silently handle expected errors (invalid sessions, timeouts, network issues)
624
- if (isInvalidSessionError(switchError)) {
625
- await storage.removeItem(storageKeys.activeSessionId);
626
- updateSessionsRef.current(
627
- validSessions.filter((session) => session.sessionId !== storedActiveSessionId),
628
- { merge: false },
738
+ // Cold boot — the single, ordered, short-circuit session-recovery sequence,
739
+ // consuming the SAME `runColdBoot` core primitive as `WebOxyProvider`. The
740
+ // FIRST step that yields a session wins; every later step is skipped. Each
741
+ // web-only step is gated by `isWebBrowser()`, so on native ONLY
742
+ // `stored-session` runs.
743
+ //
744
+ // Order (web): redirect callback → FedCM silent → silent iframe → refresh
745
+ // cookie → stored session. Order (native): stored session only.
746
+ const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
747
+ if (!storage) {
748
+ return;
749
+ }
750
+
751
+ setTokenReady(false);
752
+
753
+ const commitWebSession = handleWebSSOSessionRef.current;
754
+ const silentKey = silentColdBootKey(oxyServices);
755
+ const fedcmSupported = isWebBrowser() && oxyServices.isFedCMSupported?.() === true;
756
+
757
+ try {
758
+ const outcome = await runColdBoot<true>({
759
+ steps: [
760
+ {
761
+ // 1) Redirect callback wins: a popup/redirect sign-in just landed
762
+ // back on this page with `access_token`/`session_id` query params.
763
+ // `handleAuthCallback` plants the token but returns a PLACEHOLDER
764
+ // user (empty id), so we hydrate the REAL user via `getCurrentUser`
765
+ // and commit through `handleWebSSOSession` before claiming a
766
+ // session — never expose a placeholder user (R4).
767
+ id: 'redirect',
768
+ enabled: () => isWebBrowser(),
769
+ run: async () => {
770
+ const callbackSession = oxyServices.handleAuthCallback?.();
771
+ if (!callbackSession || !commitWebSession) {
772
+ return { kind: 'skip' };
773
+ }
774
+ const fullUser = await oxyServices.getCurrentUser();
775
+ await commitWebSession({ ...callbackSession, user: fullUser });
776
+ return { kind: 'session', session: true };
777
+ },
778
+ },
779
+ {
780
+ // 2) FedCM silent reauthn (Chrome). `silentSignInWithFedCM` plants
781
+ // the access token internally; we commit the returned session via
782
+ // `handleWebSSOSession`. Guarded so it fires at most once per page
783
+ // load across remounts.
784
+ id: 'fedcm-silent',
785
+ enabled: () => fedcmSupported && !servicesSilentAttempted.has(silentKey),
786
+ run: async () => {
787
+ servicesSilentAttempted.add(silentKey);
788
+ const session = await oxyServices.silentSignInWithFedCM?.();
789
+ if (!session || !commitWebSession) {
790
+ return { kind: 'skip' };
791
+ }
792
+ await commitWebSession(session);
793
+ return { kind: 'session', session: true };
794
+ },
795
+ },
796
+ {
797
+ // 3) Silent first-party iframe ({authWebUrl}/auth/silent) for
798
+ // browsers without FedCM (Safari / Firefox). After auto-detection
799
+ // `authWebUrl` is `auth.<rp-apex>`, so the iframe + its
800
+ // `fedcm_session` cookie are first-party with the RP. Shares the
801
+ // one-shot guard with the FedCM step.
802
+ id: 'silent-iframe',
803
+ enabled: () =>
804
+ isWebBrowser() &&
805
+ oxyServices.isFedCMSupported?.() !== true &&
806
+ !servicesSilentAttempted.has(silentKey),
807
+ run: async () => {
808
+ servicesSilentAttempted.add(silentKey);
809
+ const session = await oxyServices.silentSignIn?.();
810
+ if (!session || !commitWebSession) {
811
+ return { kind: 'skip' };
812
+ }
813
+ await commitWebSession(session);
814
+ return { kind: 'session', session: true };
815
+ },
816
+ },
817
+ {
818
+ // 4) Refresh-cookie restore (same-site only). On `*.oxy.so` the
819
+ // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
820
+ // device-local slot. On a cross-domain RP (mention.earth, …) the
821
+ // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
822
+ // `refreshAllSessions` returns `{accounts:[]}` and this skips.
823
+ // That is correct; there is deliberately NO `api.<apex>` bridge.
824
+ id: 'cookie-restore',
825
+ enabled: () => isWebBrowser(),
826
+ run: async () => {
827
+ const restored = await restoreViaRefreshCookie();
828
+ return restored ? { kind: 'session', session: true } : { kind: 'skip' };
829
+ },
830
+ },
831
+ {
832
+ // 5) Stored-session bearer restore. NO `enabled` gate — runs on ALL
833
+ // platforms. This is native's ONLY restore path (every web-only
834
+ // step above is disabled off-browser).
835
+ id: 'stored-session',
836
+ run: async () => {
837
+ const restored = await restoreStoredSession();
838
+ return restored ? { kind: 'session', session: true } : { kind: 'skip' };
839
+ },
840
+ },
841
+ ],
842
+ onStepError: (id, error) => {
843
+ if (__DEV__) {
844
+ loggerUtil.debug(
845
+ `Cold-boot step "${id}" errored (non-fatal, falling through)`,
846
+ { component: 'OxyContext', method: 'restoreSessionsFromStorage' },
847
+ error,
629
848
  );
630
- // Don't log expected session errors during restoration
631
- } else if (isTimeoutOrNetworkError(switchError)) {
632
- // Timeout/network error - non-critical, don't block
633
- if (__DEV__) {
634
- loggerUtil.debug('Active session validation timeout (expected when offline)', { component: 'OxyContext', method: 'restoreSessionsFromStorage' }, switchError as unknown);
635
- }
636
- } else {
637
- // Only log unexpected errors
638
- logger('Active session validation error', switchError);
639
849
  }
640
- }
850
+ },
851
+ });
852
+
853
+ if (__DEV__ && outcome.kind === 'session') {
854
+ loggerUtil.debug(
855
+ `Cold boot recovered a session via "${outcome.via}"`,
856
+ { component: 'OxyContext', method: 'restoreSessionsFromStorage' },
857
+ );
641
858
  }
642
859
  } catch (error) {
643
860
  if (__DEV__) {
@@ -648,12 +865,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
648
865
  setTokenReady(true);
649
866
  }
650
867
  }, [
651
- logger,
652
868
  oxyServices,
653
869
  storage,
654
- storageKeys.activeSessionId,
655
- storageKeys.sessionIds,
656
870
  restoreViaRefreshCookie,
871
+ restoreStoredSession,
657
872
  ]);
658
873
 
659
874
  useEffect(() => {
@@ -729,7 +944,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
729
944
  onAuthStateChange?.(fullUser);
730
945
  }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
731
946
 
732
- // Enable web SSO only after local storage check completes and no user found
947
+ // Expose `handleWebSSOSession` to the cold-boot FedCM/iframe/redirect steps,
948
+ // which reference it through a ref because they are declared above this
949
+ // callback. Assigned synchronously on every render so the ref is populated
950
+ // before the cold-boot effect (gated on `storage`/`initialized`) can fire.
951
+ handleWebSSOSessionRef.current = handleWebSSOSession;
952
+
953
+ // Cross-domain silent SSO is now owned by the `fedcm-silent` / `silent-iframe`
954
+ // cold-boot steps above (the ordered `runColdBoot` sequence). `useWebSSO`
955
+ // remains mounted for its module-level run-once guard and its interactive
956
+ // FedCM helpers, and as a bounded post-boot safety net: it can fire at most
957
+ // once per page load (its own module guard), and only AFTER cold boot has
958
+ // finished (`tokenReady`) with no user recovered. We deliberately keep
959
+ // `shouldTryWebSSO` as `tokenReady && !user && initialized` — it is NOT
960
+ // loosened; cold boot runs while `tokenReady` is false, so this never races
961
+ // the cold-boot silent step.
733
962
  const shouldTryWebSSO = isWebBrowser() && tokenReady && !user && initialized;
734
963
 
735
964
  useWebSSO({
@@ -749,9 +978,18 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
749
978
  const lastIdPCheckRef = useRef<number>(0);
750
979
  const pendingIdPCleanupRef = useRef<(() => void) | null>(null);
751
980
 
981
+ // Use the RESOLVED IdP origin (the auto-detected `auth.<rp-apex>` planted on
982
+ // the instance config), not the raw `authWebUrl` prop — on a cross-domain RP
983
+ // the prop is undefined but the instance was constructed with the detected
984
+ // value, so the check must target the same first-party IdP the cold-boot
985
+ // iframe used.
986
+ const resolvedAuthWebUrl = oxyServices.config?.authWebUrl;
987
+
752
988
  useEffect(() => {
753
989
  if (!isWebBrowser() || !user || !initialized) return;
754
990
 
991
+ const idpOrigin = resolvedAuthWebUrl || 'https://auth.oxy.so';
992
+
755
993
  const checkIdPSession = () => {
756
994
  // Debounce: check at most once per 30 seconds
757
995
  const now = Date.now();
@@ -764,7 +1002,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
764
1002
  // Load hidden iframe to check IdP session via postMessage
765
1003
  const iframe = document.createElement('iframe');
766
1004
  iframe.style.cssText = 'display:none;width:0;height:0;border:0';
767
- const idpOrigin = authWebUrl || 'https://auth.oxy.so';
768
1005
  iframe.src = `${idpOrigin}/auth/session-check?client_id=${encodeURIComponent(window.location.origin)}`;
769
1006
 
770
1007
  let cleaned = false;
@@ -781,8 +1018,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
781
1018
  cleanup();
782
1019
 
783
1020
  if (!event.data.hasSession) {
784
- toast.info('Your session has ended. Please sign in again.');
785
- await clearSessionState();
1021
+ // Only a SAME-SITE, first-party IdP answer is authoritative enough to
1022
+ // force a local sign-out. On a cross-site / undetermined IdP the
1023
+ // "no session" answer must never clear local state (a third-party
1024
+ // can't be trusted to end this app's session). Surface the toast in
1025
+ // both cases, but gate the destructive `clearSessionState()`.
1026
+ if (isSameSiteIdP(idpOrigin)) {
1027
+ toast.info('Your session has ended. Please sign in again.');
1028
+ await clearSessionState();
1029
+ }
786
1030
  }
787
1031
  };
788
1032
 
@@ -804,7 +1048,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
804
1048
  pendingIdPCleanupRef.current?.();
805
1049
  pendingIdPCleanupRef.current = null;
806
1050
  };
807
- }, [user, initialized, clearSessionState, authWebUrl]);
1051
+ }, [user, initialized, clearSessionState, resolvedAuthWebUrl]);
808
1052
 
809
1053
  const activeSession = activeSessionId
810
1054
  ? sessions.find((session) => session.sessionId === activeSessionId)
@@ -3,7 +3,7 @@ import type { ApiError, User } from '@oxyhq/core';
3
3
  import type { AuthState } from '../../stores/authStore';
4
4
  import type { ClientSession, SessionLoginResponse } from '@oxyhq/core';
5
5
  import { DeviceManager } from '@oxyhq/core';
6
- import { fetchSessionsWithFallback, mapSessionsToClient } from '../../utils/sessionHelpers';
6
+ import { fetchSessionsWithFallback } from '../../utils/sessionHelpers';
7
7
  import { handleAuthError, isInvalidSessionError } from '../../utils/errorHandlers';
8
8
  import type { StorageInterface } from '../../utils/storageHelpers';
9
9
  import type { OxyServices } from '@oxyhq/core';
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { ApiError, User } from '@oxyhq/core';
3
3
  import type { ClientSession } from '@oxyhq/core';
4
4
  import { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual } from '@oxyhq/core';
5
- import { fetchSessionsWithFallback, mapSessionsToClient, validateSessionBatch } from '../utils/sessionHelpers';
5
+ import { fetchSessionsWithFallback, validateSessionBatch } from '../utils/sessionHelpers';
6
6
  import { getStorageKeys, type StorageInterface } from '../utils/storageHelpers';
7
7
  import { handleAuthError, isInvalidSessionError } from '../utils/errorHandlers';
8
8
  import type { OxyServices } from '@oxyhq/core';
@@ -364,7 +364,11 @@ export const useSessionManagement = ({
364
364
 
365
365
  const refreshSessions = useCallback(
366
366
  async (activeUserId?: string): Promise<void> => {
367
- if (!activeSessionIdRef.current) return;
367
+ // Capture the active session id once so the async closure below uses a
368
+ // narrowed, non-null local instead of re-reading the ref (which the
369
+ // compiler cannot prove stays non-null across awaits).
370
+ const activeSessionId = activeSessionIdRef.current;
371
+ if (!activeSessionId) return;
368
372
 
369
373
  if (refreshInFlightRef.current) {
370
374
  await refreshInFlightRef.current;
@@ -379,7 +383,7 @@ export const useSessionManagement = ({
379
383
 
380
384
  const refreshPromise = (async () => {
381
385
  try {
382
- const deviceSessions = await fetchSessionsWithFallback(oxyServices, activeSessionIdRef.current!, {
386
+ const deviceSessions = await fetchSessionsWithFallback(oxyServices, activeSessionId, {
383
387
  fallbackUserId: activeUserId,
384
388
  logger,
385
389
  });
@@ -389,7 +393,7 @@ export const useSessionManagement = ({
389
393
  const otherSessions = sessionsRef.current
390
394
  .filter(
391
395
  (session) =>
392
- session.sessionId !== activeSessionIdRef.current &&
396
+ session.sessionId !== activeSessionId &&
393
397
  !removedSessionsRef.current.has(session.sessionId),
394
398
  )
395
399
  .map((session) => session.sessionId);
@@ -1,6 +1,5 @@
1
1
  import type { ReactNode, RefObject } from 'react';
2
2
  import type { QueryClient } from '@tanstack/react-query';
3
- import type { ThemeMode, AppColorName } from '@oxyhq/bloom';
4
3
  import type { RouteName } from '../navigation/routes';
5
4
  import type { User } from '@oxyhq/core';
6
5
  import type { ClientSession } from '@oxyhq/core';
@@ -57,8 +56,6 @@ export interface OxyProviderProps {
57
56
  authWebUrl?: string;
58
57
  authRedirectUri?: string;
59
58
  queryClient?: QueryClient;
60
- themeMode?: ThemeMode;
61
- colorPreset?: AppColorName;
62
59
  }
63
60
 
64
61