@oxyhq/services 8.5.0 → 8.6.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.
@@ -178,6 +178,32 @@ function silentColdBootKey(oxyServices: OxyServices): string {
178
178
  return `${origin}|${baseURL}`;
179
179
  }
180
180
 
181
+ /**
182
+ * Per-step fail-fast budget for the cold-boot silent iframe (`silentSignIn`
183
+ * against the per-apex `/auth/silent` host).
184
+ *
185
+ * This step ONLY succeeds when a durable per-apex `fedcm_session` cookie exists
186
+ * (established by a prior `/sso` bounce). On the common reload of a logged-out
187
+ * tab — or a tab that restores via the now-earlier stored-session step — the
188
+ * iframe never posts a message, so the full wait would be dead latency in front
189
+ * of the terminal `/sso` bounce. `silentSignIn` already fails fast on a load
190
+ * error via `iframe.onerror`; this caps the no-message case. 2.5s is well above
191
+ * a same-origin iframe handshake yet a fraction of the legacy 5s default.
192
+ */
193
+ const SILENT_IFRAME_TIMEOUT = 2500;
194
+
195
+ /**
196
+ * Per-step fail-fast budget for the cold-boot refresh-cookie restore
197
+ * (`refreshAllSessions`).
198
+ *
199
+ * On a cross-domain RP the `Domain=oxy.so` refresh cookie never reaches
200
+ * `api.<apex>`, so this request returns no accounts (or stalls behind a slow
201
+ * endpoint) with no useful answer. As one cold-boot step it must not block the
202
+ * fall-through to the terminal `/sso` bounce. 3s bounds the wait while leaving
203
+ * ample headroom for a genuine first-party `*.oxy.so` rotation round-trip.
204
+ */
205
+ const COOKIE_RESTORE_TIMEOUT = 3000;
206
+
181
207
  /**
182
208
  * Whether `idpOrigin` is a same-site, first-party host of the current page —
183
209
  * i.e. it shares the page's registrable apex (last two labels), so a "no
@@ -300,10 +326,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
300
326
 
301
327
  const [tokenReady, setTokenReady] = useState(true);
302
328
  // Whether the FIRST cold-boot auth restore has concluded. Starts `false`
303
- // (auth undetermined) and flips to `true` exactly once in the restore
304
- // `finally` monotonic, never reverts. See `isAuthResolved` on the context
305
- // type for the consumer contract.
329
+ // (auth undetermined) and flips to `true` exactly once monotonic, never
330
+ // reverts. It now flips the MOMENT a session commits (the common reload case
331
+ // unblocks immediately, without waiting for the rest of the cold-boot chain),
332
+ // with the restore `finally` as the no-session/error backstop. The ref makes
333
+ // the flip idempotent across both sites so the setters fire at most once. See
334
+ // `isAuthResolved` on the context type for the consumer contract.
306
335
  const [authResolved, setAuthResolved] = useState(false);
336
+ const authResolvedRef = useRef(false);
307
337
  const [initialized, setInitialized] = useState(false);
308
338
  const setAuthState = useAuthStore.setState;
309
339
 
@@ -565,6 +595,26 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
565
595
  const onAuthStateChangeRef = useRef(onAuthStateChange);
566
596
  onAuthStateChangeRef.current = onAuthStateChange;
567
597
 
598
+ // Flip the auth-resolution gate (`authResolved` + `tokenReady`) the MOMENT a
599
+ // session commits, instead of waiting for the whole cold-boot chain to finish.
600
+ // Idempotent and monotonic via `authResolvedRef`: the first call wins and the
601
+ // setters fire at most once, so the restore `finally` backstop becomes a no-op
602
+ // once a commit site has already marked resolution. Called from EVERY place a
603
+ // user is actually committed (the FedCM/iframe/redirect/SSO path
604
+ // `handleWebSSOSession`, the cookie-restore path, and the stored-session path)
605
+ // so the common reload case unblocks the loading gate without sitting behind
606
+ // the remaining (now-skipped) cold-boot steps.
607
+ const markAuthResolved = useCallback(() => {
608
+ if (authResolvedRef.current) {
609
+ return;
610
+ }
611
+ authResolvedRef.current = true;
612
+ setTokenReady(true);
613
+ setAuthResolved(true);
614
+ }, []);
615
+ const markAuthResolvedRef = useRef(markAuthResolved);
616
+ markAuthResolvedRef.current = markAuthResolved;
617
+
568
618
  // `handleWebSSOSession` is declared further down (it depends on values that
569
619
  // are only available there). The FedCM/iframe cold-boot steps need to commit
570
620
  // a recovered session through it, so we route the call through a ref that is
@@ -598,7 +648,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
598
648
 
599
649
  let snapshot;
600
650
  try {
601
- snapshot = await oxyServices.refreshAllSessions();
651
+ // Bound the refresh so a cross-domain/stalled call cannot hang the cold
652
+ // boot in front of the terminal `/sso` bounce (see COOKIE_RESTORE_TIMEOUT).
653
+ snapshot = await oxyServices.refreshAllSessions({ timeout: COOKIE_RESTORE_TIMEOUT });
602
654
  } catch (fetchError) {
603
655
  // Offline / network error — fall through to the cached/stored-session flow.
604
656
  if (__DEV__) {
@@ -662,6 +714,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
662
714
  await persistSessionDurably(activeAccount.sessionId);
663
715
 
664
716
  loginSuccessRef.current(fullUser);
717
+ // A session is now committed — unblock the auth-resolution gate immediately
718
+ // rather than waiting for `runColdBoot` to return (idempotent).
719
+ markAuthResolvedRef.current();
665
720
  onAuthStateChangeRef.current?.(fullUser);
666
721
  return true;
667
722
  }, [oxyServices, persistSessionDurably]);
@@ -739,6 +794,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
739
794
  if (storedActiveSessionId) {
740
795
  try {
741
796
  await switchSessionRef.current(storedActiveSessionId);
797
+ // The stored session is committed (this is native's ONLY restore path
798
+ // and the common web reload winner). Unblock the auth-resolution gate
799
+ // immediately so the loading screen clears without waiting for the
800
+ // remaining cold-boot steps to be evaluated/short-circuited (idempotent).
801
+ markAuthResolvedRef.current();
742
802
  return true;
743
803
  } catch (switchError) {
744
804
  // Silently handle expected errors (invalid sessions, timeouts, network issues)
@@ -841,11 +901,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
841
901
  // web-only step is gated by `isWebBrowser()`, so on native ONLY
842
902
  // `stored-session` runs.
843
903
  //
844
- // Order (web): redirect callback → SSO return → FedCM silent (central) →
845
- // silent iframe (per-apex, the durable reload path) → cookie restore →
846
- // stored session → SSO bounce (terminal). The per-apex silent iframe is what
847
- // restores a durable cross-domain session on reload WITHOUT a top-level
848
- // bounce, so when it wins `sso-bounce` never fires (no flash, no loop).
904
+ // Order (web): redirect callback → SSO return → stored session → FedCM silent
905
+ // (central) → silent iframe (per-apex, the durable reload path) → cookie
906
+ // restore → SSO bounce (terminal).
907
+ //
908
+ // LATENCY (FIX A): `stored-session` runs BEFORE the slow no-redirect probes
909
+ // (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a normal reload the
910
+ // local bearer validates in one round-trip and wins, so `runColdBoot`
911
+ // short-circuits and never sits through those probes' timeouts (the prior
912
+ // serial sum was a ~20-30s stall). `redirect` and `sso-return` MUST stay
913
+ // first — they consume the URL fragment before anything can strip it. On a
914
+ // first visit with no local session, `stored-session` skips and the
915
+ // cross-domain fallback chain (fedcm → iframe → cookie → sso-bounce) runs
916
+ // exactly as before; the per-apex silent iframe still restores a durable
917
+ // cross-domain session on reload WITHOUT a top-level bounce, so when it wins
918
+ // `sso-bounce` never fires (no flash, no loop).
849
919
  // Order (native): stored session only (every web-only step is disabled
850
920
  // off-browser).
851
921
  const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
@@ -859,6 +929,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
859
929
  const silentKey = silentColdBootKey(oxyServices);
860
930
  const fedcmSupported = isWebBrowser() && oxyServices.isFedCMSupported?.() === true;
861
931
 
932
+ // FIX-B precondition flag: set true the instant the (now-earlier)
933
+ // `stored-session` step recovers a local bearer session. The slow web-only
934
+ // probes (`fedcm-silent`, `silent-iframe`) AND `enabled` on `!storedSessionRestored`
935
+ // so they are explicitly skipped once a local session won. `runColdBoot`
936
+ // already short-circuits on the first `{kind:'session'}`, so on a winning
937
+ // reload those `enabled` bodies are never even reached — this flag makes the
938
+ // intent explicit and is redundant-safe. On a first-visit-no-local-session,
939
+ // `stored-session` skips, this stays false, and the probes run as before.
940
+ let storedSessionRestored = false;
941
+
862
942
  try {
863
943
  const outcome = await runColdBoot<true>({
864
944
  steps: [
@@ -895,14 +975,47 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
895
975
  },
896
976
  },
897
977
  {
898
- // 2) FedCM silent reauthn (Chrome) against the CENTRAL IdP
978
+ // 2) Stored-session bearer restore. NO `enabled` gate runs on ALL
979
+ // platforms. This is native's ONLY restore path (every web-only step
980
+ // is disabled off-browser, so native reaches exactly this) AND the
981
+ // common WEB reload winner.
982
+ //
983
+ // ORDERING (FIX A): this step now runs BEFORE the slow web-only
984
+ // probes (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a
985
+ // normal reload the local bearer validates in one round-trip and
986
+ // wins; `runColdBoot` then short-circuits and never even evaluates
987
+ // the slow no-redirect probes that would otherwise time out (the
988
+ // ~20-30s serial stall). The `redirect` and `sso-return` steps stay
989
+ // AHEAD of this one — they must consume the URL fragment before any
990
+ // later step (or anything else) strips it. On a first visit with no
991
+ // local session this step skips and the cross-domain fallback chain
992
+ // (fedcm → iframe → cookie → sso-bounce) runs exactly as before.
993
+ id: 'stored-session',
994
+ run: async () => {
995
+ const restored = await restoreStoredSession();
996
+ if (restored) {
997
+ // FIX-B: record the win so the slow probes below explicitly skip
998
+ // (belt-and-suspenders; `runColdBoot` already short-circuits).
999
+ storedSessionRestored = true;
1000
+ return { kind: 'session', session: true };
1001
+ }
1002
+ return { kind: 'skip' };
1003
+ },
1004
+ },
1005
+ {
1006
+ // 3) FedCM silent reauthn (Chrome) against the CENTRAL IdP
899
1007
  // (auth.oxy.so). `silentSignInWithFedCM` plants the access token
900
1008
  // internally; we commit the returned session via
901
1009
  // `handleWebSSOSession`. Guarded so it fires at most once per page
902
1010
  // load across remounts. This is an enhancement layered above the
903
1011
  // opaque-code bounce: when it succeeds the bounce never fires.
1012
+ //
1013
+ // FIX-B: additionally skipped when the earlier `stored-session` step
1014
+ // already recovered a local session — the probe cannot improve on a
1015
+ // valid local bearer, and skipping it avoids the silent round-trip.
904
1016
  id: 'fedcm-silent',
905
- enabled: () => fedcmSupported && !servicesSilentAttempted.has(silentKey),
1017
+ enabled: () =>
1018
+ !storedSessionRestored && fedcmSupported && !servicesSilentAttempted.has(silentKey),
906
1019
  run: async () => {
907
1020
  servicesSilentAttempted.add(silentKey);
908
1021
  const session = await oxyServices.silentSignInWithFedCM?.();
@@ -914,7 +1027,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
914
1027
  },
915
1028
  },
916
1029
  {
917
- // 3) First-party silent iframe at the PER-APEX IdP — the DURABLE
1030
+ // 4) First-party silent iframe at the PER-APEX IdP — the DURABLE
918
1031
  // cross-domain reload-restore path. The durable session lives as a
919
1032
  // first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
920
1033
  // `auth.mention.earth`), established during the `/sso` bounce's
@@ -933,8 +1046,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
933
1046
  // equivalent. When auto-detection bails (localhost/IP/single-label)
934
1047
  // there is no per-apex IdP and the step skips. Web only; on native
935
1048
  // `isWebBrowser()` gates it off, so native never runs an iframe.
1049
+ //
1050
+ // FIX-B: additionally skipped when `stored-session` already won.
1051
+ // FIX-D: bounded by `SILENT_IFRAME_TIMEOUT` (plus `iframe.onerror`
1052
+ // fail-fast in core) so a no-message iframe cannot stall cold boot.
936
1053
  id: 'silent-iframe',
937
- enabled: () => isWebBrowser(),
1054
+ enabled: () => !storedSessionRestored && isWebBrowser(),
938
1055
  run: async () => {
939
1056
  const perApexAuthUrl = autoDetectAuthWebUrl();
940
1057
  if (!perApexAuthUrl || !commitWebSession) {
@@ -942,6 +1059,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
942
1059
  }
943
1060
  const session = await oxyServices.silentSignIn?.({
944
1061
  authWebUrlOverride: perApexAuthUrl,
1062
+ timeout: SILENT_IFRAME_TIMEOUT,
945
1063
  });
946
1064
  if (!session?.user || !session?.sessionId) {
947
1065
  return { kind: 'skip' };
@@ -951,12 +1069,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
951
1069
  },
952
1070
  },
953
1071
  {
954
- // 4) Refresh-cookie restore (first-party only). On `*.oxy.so` the
1072
+ // 5) Refresh-cookie restore (first-party only). On `*.oxy.so` the
955
1073
  // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
956
1074
  // device-local slot. On a cross-domain RP (mention.earth, …) the
957
1075
  // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
958
1076
  // `refreshAllSessions` returns `{accounts:[]}` and this skips. That
959
1077
  // is correct; cross-domain restore is handled by the SSO bounce.
1078
+ // FIX-D: `restoreViaRefreshCookie` bounds the request with
1079
+ // `COOKIE_RESTORE_TIMEOUT` so a cross-domain stall cannot hang here.
960
1080
  id: 'cookie-restore',
961
1081
  enabled: () => isWebBrowser(),
962
1082
  run: async () => {
@@ -964,16 +1084,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
964
1084
  return restored ? { kind: 'session', session: true } : { kind: 'skip' };
965
1085
  },
966
1086
  },
967
- {
968
- // 5) Stored-session bearer restore. NO `enabled` gate — runs on ALL
969
- // platforms. This is native's ONLY restore path (every web-only step
970
- // is disabled off-browser, so native reaches exactly this).
971
- id: 'stored-session',
972
- run: async () => {
973
- const restored = await restoreStoredSession();
974
- return restored ? { kind: 'session', session: true } : { kind: 'skip' };
975
- },
976
- },
977
1087
  {
978
1088
  // 6) SSO bounce (TERMINAL, web only, at most once). No local session
979
1089
  // was found by any step above. Top-level navigate to the central
@@ -1048,14 +1158,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1048
1158
  }
1049
1159
  await clearSessionStateRef.current();
1050
1160
  } finally {
1051
- setTokenReady(true);
1052
- // Auth determination is now complete (session committed, none found, or
1053
- // the error path ran clear-state). Monotonic: `setAuthResolved` is a no-op
1054
- // on subsequent restores since it is already `true`. Runs on every exit
1055
- // path success, no-session, AND error→catch→finally and on native
1056
- // (which only runs the `stored-session` step), so it can never hang
1057
- // `false`.
1058
- setAuthResolved(true);
1161
+ // Backstop: mark auth resolved on EVERY exit path — success, no-session,
1162
+ // AND error→catch→finally and on native (which only runs the
1163
+ // `stored-session` step), so the gate can never hang `false`. Idempotent
1164
+ // via `markAuthResolved`'s ref: when a commit site already flipped it
1165
+ // mid-chain (the common reload case), this is a no-op. When no session was
1166
+ // recovered (the unauthenticated/error path), this is where `tokenReady` +
1167
+ // `authResolved` finally flip. Monotonic — never reverts on later restores.
1168
+ markAuthResolved();
1059
1169
  }
1060
1170
  }, [
1061
1171
  oxyServices,
@@ -1063,6 +1173,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1063
1173
  restoreViaRefreshCookie,
1064
1174
  restoreStoredSession,
1065
1175
  runSsoReturn,
1176
+ markAuthResolved,
1066
1177
  ]);
1067
1178
 
1068
1179
  useEffect(() => {
@@ -1216,6 +1327,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1216
1327
  fullUser = session.user as unknown as User;
1217
1328
  }
1218
1329
  loginSuccess(fullUser);
1330
+ // A session is now committed (FedCM silent / per-apex iframe / redirect /
1331
+ // SSO-return / popup all funnel through here) — unblock the auth-resolution
1332
+ // gate immediately, ahead of the cold-boot chain returning (idempotent).
1333
+ markAuthResolvedRef.current();
1219
1334
  onAuthStateChange?.(fullUser);
1220
1335
  }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
1221
1336