@oxyhq/services 8.5.0 → 8.6.1

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/lib/commonjs/ui/components/OxyProvider.js +2 -0
  2. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  3. package/lib/commonjs/ui/components/SignInModal.js +4 -3
  4. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  5. package/lib/commonjs/ui/context/OxyContext.js +206 -39
  6. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  7. package/lib/commonjs/ui/screens/OxyAuthScreen.js +3 -3
  8. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  9. package/lib/commonjs/ui/utils/appName.js +62 -0
  10. package/lib/commonjs/ui/utils/appName.js.map +1 -0
  11. package/lib/module/ui/components/OxyProvider.js +2 -0
  12. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  13. package/lib/module/ui/components/SignInModal.js +4 -3
  14. package/lib/module/ui/components/SignInModal.js.map +1 -1
  15. package/lib/module/ui/context/OxyContext.js +206 -39
  16. package/lib/module/ui/context/OxyContext.js.map +1 -1
  17. package/lib/module/ui/screens/OxyAuthScreen.js +3 -3
  18. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  19. package/lib/module/ui/utils/appName.js +59 -0
  20. package/lib/module/ui/utils/appName.js.map +1 -0
  21. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +12 -0
  23. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/ui/types/navigation.d.ts +8 -0
  25. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/ui/utils/appName.d.ts +22 -0
  27. package/lib/typescript/commonjs/ui/utils/appName.d.ts.map +1 -0
  28. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  29. package/lib/typescript/module/ui/context/OxyContext.d.ts +12 -0
  30. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  31. package/lib/typescript/module/ui/types/navigation.d.ts +8 -0
  32. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  33. package/lib/typescript/module/ui/utils/appName.d.ts +22 -0
  34. package/lib/typescript/module/ui/utils/appName.d.ts.map +1 -0
  35. package/package.json +2 -2
  36. package/src/ui/components/OxyProvider.tsx +2 -0
  37. package/src/ui/components/SignInModal.tsx +3 -3
  38. package/src/ui/context/OxyContext.tsx +215 -32
  39. package/src/ui/screens/OxyAuthScreen.tsx +3 -3
  40. package/src/ui/types/navigation.ts +8 -0
  41. package/src/ui/utils/__tests__/appName.test.ts +52 -0
  42. package/src/ui/utils/appName.ts +62 -0
@@ -42,6 +42,7 @@ import { useDeviceManagement } from '../hooks/useDeviceManagement';
42
42
  import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
43
43
  import { isInvalidSessionError, isTimeoutOrNetworkError } from '../utils/errorHandlers';
44
44
  import { readActiveAuthuser, writeActiveAuthuser } from '../utils/activeAuthuser';
45
+ import { resolveAppDisplayName } from '../utils/appName';
45
46
  import type { RouteName } from '../navigation/routes';
46
47
  import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
47
48
  import { useQueryClient } from '@tanstack/react-query';
@@ -114,6 +115,13 @@ export interface OxyContextState {
114
115
  clearSessionState: () => Promise<void>;
115
116
  clearAllAccountData: () => Promise<void>;
116
117
  storageKeyPrefix: string;
118
+ /**
119
+ * Resolved human-readable app display name surfaced on the central Oxy
120
+ * sign-in / consent experience (e.g. "Mention wants to access your Oxy
121
+ * account"). Always non-empty — derived from the `appName` prop, then
122
+ * `storageKeyPrefix`, then `document.title` (web), then the platform.
123
+ */
124
+ appName: string;
117
125
  oxyServices: OxyServices;
118
126
  useFollow?: UseFollowHook;
119
127
  showBottomSheet?: (screenOrConfig: RouteName | { screen: RouteName; props?: Record<string, unknown> }) => void;
@@ -140,6 +148,11 @@ export interface OxyContextProviderProps {
140
148
  authWebUrl?: string;
141
149
  authRedirectUri?: string;
142
150
  storageKeyPrefix?: string;
151
+ /**
152
+ * Human-readable name of the consuming app shown on the central Oxy
153
+ * sign-in / consent experience. See {@link OxyContextState.appName}.
154
+ */
155
+ appName?: string;
143
156
  onAuthStateChange?: (user: User | null) => void;
144
157
  onError?: (error: ApiError) => void;
145
158
  }
@@ -178,6 +191,60 @@ function silentColdBootKey(oxyServices: OxyServices): string {
178
191
  return `${origin}|${baseURL}`;
179
192
  }
180
193
 
194
+ /**
195
+ * Per-step fail-fast budget for the cold-boot silent iframe (`silentSignIn`
196
+ * against the per-apex `/auth/silent` host).
197
+ *
198
+ * This step ONLY succeeds when a durable per-apex `fedcm_session` cookie exists
199
+ * (established by a prior `/sso` bounce). On the common reload of a logged-out
200
+ * tab — or a tab that restores via the now-earlier stored-session step — the
201
+ * iframe never posts a message, so the full wait would be dead latency in front
202
+ * of the terminal `/sso` bounce. `silentSignIn` already fails fast on a load
203
+ * error via `iframe.onerror`; this caps the no-message case. 2.5s is well above
204
+ * a same-origin iframe handshake yet a fraction of the legacy 5s default.
205
+ */
206
+ const SILENT_IFRAME_TIMEOUT = 2500;
207
+
208
+ /**
209
+ * Per-step fail-fast budget for the cold-boot refresh-cookie restore
210
+ * (`refreshAllSessions`).
211
+ *
212
+ * On a cross-domain RP the `Domain=oxy.so` refresh cookie never reaches
213
+ * `api.<apex>`, so this request returns no accounts (or stalls behind a slow
214
+ * endpoint) with no useful answer. As one cold-boot step it must not block the
215
+ * fall-through to the terminal `/sso` bounce. 3s bounds the wait while leaving
216
+ * ample headroom for a genuine first-party `*.oxy.so` rotation round-trip.
217
+ */
218
+ const COOKIE_RESTORE_TIMEOUT = 3000;
219
+
220
+ /**
221
+ * HARD overall deadline (ms) for the entire cold-boot step loop —
222
+ * defense-in-depth so a single non-settling step can NEVER hang auth resolution
223
+ * forever (the production regression: a `navigator.credentials.get()` that
224
+ * ignored its abort signal left the `fedcm-silent` step's promise unsettled, so
225
+ * `runColdBoot` never advanced to the terminal `/sso` bounce and auth hung
226
+ * indefinitely).
227
+ *
228
+ * Every step ALREADY bounds its own network work (the stored-session bearer
229
+ * validation at 8s, the silent iframe at `SILENT_IFRAME_TIMEOUT`, the refresh
230
+ * cookie at `COOKIE_RESTORE_TIMEOUT`, FedCM silent at `FEDCM_SILENT_TIMEOUT`
231
+ * plus its hard settle). On a healthy load the FIRST recovering step wins in a
232
+ * single round-trip (1–3s) and the chain short-circuits long before this fires.
233
+ * This budget only trips when one of those per-step bounds regresses.
234
+ *
235
+ * 20s is the chosen value: comfortably ABOVE the worst-case bounded
236
+ * stored-session path under transient slowness (the 8s parallel validation
237
+ * window plus a `switchSession` round-trip) so a genuinely slow-but-healthy
238
+ * reload is never cut off, yet well BELOW the ~28–30s the previous
239
+ * probe-first ordering took — and, critically, finite, so the user can never
240
+ * sit on an indefinite spinner. When the deadline trips, `runColdBoot` keeps
241
+ * iterating to the terminal `sso-bounce` step (whose navigation side effect
242
+ * runs synchronously), so a genuine no-local-session first visit STILL reaches
243
+ * the cross-domain `/sso` fallback. Native runs only the stored-session step,
244
+ * which is bounded well under this, so the deadline never alters native flow.
245
+ */
246
+ const COLD_BOOT_OVERALL_DEADLINE = 20000;
247
+
181
248
  /**
182
249
  * Whether `idpOrigin` is a same-site, first-party host of the current page —
183
250
  * i.e. it shares the page's registrable apex (last two labels), so a "no
@@ -247,6 +314,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
247
314
  authWebUrl,
248
315
  authRedirectUri,
249
316
  storageKeyPrefix = 'oxy_session',
317
+ appName: appNameProp,
250
318
  onAuthStateChange,
251
319
  onError,
252
320
  }) => {
@@ -300,10 +368,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
300
368
 
301
369
  const [tokenReady, setTokenReady] = useState(true);
302
370
  // 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.
371
+ // (auth undetermined) and flips to `true` exactly once monotonic, never
372
+ // reverts. It now flips the MOMENT a session commits (the common reload case
373
+ // unblocks immediately, without waiting for the rest of the cold-boot chain),
374
+ // with the restore `finally` as the no-session/error backstop. The ref makes
375
+ // the flip idempotent across both sites so the setters fire at most once. See
376
+ // `isAuthResolved` on the context type for the consumer contract.
306
377
  const [authResolved, setAuthResolved] = useState(false);
378
+ const authResolvedRef = useRef(false);
307
379
  const [initialized, setInitialized] = useState(false);
308
380
  const setAuthState = useAuthStore.setState;
309
381
 
@@ -352,6 +424,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
352
424
 
353
425
  const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]);
354
426
 
427
+ // Human-readable app display name for the central sign-in / consent UI.
428
+ // Derived once from the consumer config; never "web" unless the app supplies
429
+ // no name, no custom prefix, and no document title.
430
+ const appName = useMemo(
431
+ () => resolveAppDisplayName(appNameProp, storageKeyPrefix),
432
+ [appNameProp, storageKeyPrefix],
433
+ );
434
+
355
435
  // Storage initialization.
356
436
  //
357
437
  // `storage` (state) drives render-time gating (`isStorageReady`) and the
@@ -565,6 +645,26 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
565
645
  const onAuthStateChangeRef = useRef(onAuthStateChange);
566
646
  onAuthStateChangeRef.current = onAuthStateChange;
567
647
 
648
+ // Flip the auth-resolution gate (`authResolved` + `tokenReady`) the MOMENT a
649
+ // session commits, instead of waiting for the whole cold-boot chain to finish.
650
+ // Idempotent and monotonic via `authResolvedRef`: the first call wins and the
651
+ // setters fire at most once, so the restore `finally` backstop becomes a no-op
652
+ // once a commit site has already marked resolution. Called from EVERY place a
653
+ // user is actually committed (the FedCM/iframe/redirect/SSO path
654
+ // `handleWebSSOSession`, the cookie-restore path, and the stored-session path)
655
+ // so the common reload case unblocks the loading gate without sitting behind
656
+ // the remaining (now-skipped) cold-boot steps.
657
+ const markAuthResolved = useCallback(() => {
658
+ if (authResolvedRef.current) {
659
+ return;
660
+ }
661
+ authResolvedRef.current = true;
662
+ setTokenReady(true);
663
+ setAuthResolved(true);
664
+ }, []);
665
+ const markAuthResolvedRef = useRef(markAuthResolved);
666
+ markAuthResolvedRef.current = markAuthResolved;
667
+
568
668
  // `handleWebSSOSession` is declared further down (it depends on values that
569
669
  // are only available there). The FedCM/iframe cold-boot steps need to commit
570
670
  // a recovered session through it, so we route the call through a ref that is
@@ -598,7 +698,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
598
698
 
599
699
  let snapshot;
600
700
  try {
601
- snapshot = await oxyServices.refreshAllSessions();
701
+ // Bound the refresh so a cross-domain/stalled call cannot hang the cold
702
+ // boot in front of the terminal `/sso` bounce (see COOKIE_RESTORE_TIMEOUT).
703
+ snapshot = await oxyServices.refreshAllSessions({ timeout: COOKIE_RESTORE_TIMEOUT });
602
704
  } catch (fetchError) {
603
705
  // Offline / network error — fall through to the cached/stored-session flow.
604
706
  if (__DEV__) {
@@ -662,6 +764,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
662
764
  await persistSessionDurably(activeAccount.sessionId);
663
765
 
664
766
  loginSuccessRef.current(fullUser);
767
+ // A session is now committed — unblock the auth-resolution gate immediately
768
+ // rather than waiting for `runColdBoot` to return (idempotent).
769
+ markAuthResolvedRef.current();
665
770
  onAuthStateChangeRef.current?.(fullUser);
666
771
  return true;
667
772
  }, [oxyServices, persistSessionDurably]);
@@ -739,6 +844,11 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
739
844
  if (storedActiveSessionId) {
740
845
  try {
741
846
  await switchSessionRef.current(storedActiveSessionId);
847
+ // The stored session is committed (this is native's ONLY restore path
848
+ // and the common web reload winner). Unblock the auth-resolution gate
849
+ // immediately so the loading screen clears without waiting for the
850
+ // remaining cold-boot steps to be evaluated/short-circuited (idempotent).
851
+ markAuthResolvedRef.current();
742
852
  return true;
743
853
  } catch (switchError) {
744
854
  // Silently handle expected errors (invalid sessions, timeouts, network issues)
@@ -841,11 +951,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
841
951
  // web-only step is gated by `isWebBrowser()`, so on native ONLY
842
952
  // `stored-session` runs.
843
953
  //
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).
954
+ // Order (web): redirect callback → SSO return → stored session → FedCM silent
955
+ // (central) → silent iframe (per-apex, the durable reload path) → cookie
956
+ // restore → SSO bounce (terminal).
957
+ //
958
+ // LATENCY (FIX A): `stored-session` runs BEFORE the slow no-redirect probes
959
+ // (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a normal reload the
960
+ // local bearer validates in one round-trip and wins, so `runColdBoot`
961
+ // short-circuits and never sits through those probes' timeouts (the prior
962
+ // serial sum was a ~20-30s stall). `redirect` and `sso-return` MUST stay
963
+ // first — they consume the URL fragment before anything can strip it. On a
964
+ // first visit with no local session, `stored-session` skips and the
965
+ // cross-domain fallback chain (fedcm → iframe → cookie → sso-bounce) runs
966
+ // exactly as before; the per-apex silent iframe still restores a durable
967
+ // cross-domain session on reload WITHOUT a top-level bounce, so when it wins
968
+ // `sso-bounce` never fires (no flash, no loop).
849
969
  // Order (native): stored session only (every web-only step is disabled
850
970
  // off-browser).
851
971
  const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
@@ -859,6 +979,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
859
979
  const silentKey = silentColdBootKey(oxyServices);
860
980
  const fedcmSupported = isWebBrowser() && oxyServices.isFedCMSupported?.() === true;
861
981
 
982
+ // FIX-B precondition flag: set true the instant the (now-earlier)
983
+ // `stored-session` step recovers a local bearer session. The slow web-only
984
+ // probes (`fedcm-silent`, `silent-iframe`) AND `enabled` on `!storedSessionRestored`
985
+ // so they are explicitly skipped once a local session won. `runColdBoot`
986
+ // already short-circuits on the first `{kind:'session'}`, so on a winning
987
+ // reload those `enabled` bodies are never even reached — this flag makes the
988
+ // intent explicit and is redundant-safe. On a first-visit-no-local-session,
989
+ // `stored-session` skips, this stays false, and the probes run as before.
990
+ let storedSessionRestored = false;
991
+
862
992
  try {
863
993
  const outcome = await runColdBoot<true>({
864
994
  steps: [
@@ -895,14 +1025,47 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
895
1025
  },
896
1026
  },
897
1027
  {
898
- // 2) FedCM silent reauthn (Chrome) against the CENTRAL IdP
1028
+ // 2) Stored-session bearer restore. NO `enabled` gate runs on ALL
1029
+ // platforms. This is native's ONLY restore path (every web-only step
1030
+ // is disabled off-browser, so native reaches exactly this) AND the
1031
+ // common WEB reload winner.
1032
+ //
1033
+ // ORDERING (FIX A): this step now runs BEFORE the slow web-only
1034
+ // probes (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a
1035
+ // normal reload the local bearer validates in one round-trip and
1036
+ // wins; `runColdBoot` then short-circuits and never even evaluates
1037
+ // the slow no-redirect probes that would otherwise time out (the
1038
+ // ~20-30s serial stall). The `redirect` and `sso-return` steps stay
1039
+ // AHEAD of this one — they must consume the URL fragment before any
1040
+ // later step (or anything else) strips it. On a first visit with no
1041
+ // local session this step skips and the cross-domain fallback chain
1042
+ // (fedcm → iframe → cookie → sso-bounce) runs exactly as before.
1043
+ id: 'stored-session',
1044
+ run: async () => {
1045
+ const restored = await restoreStoredSession();
1046
+ if (restored) {
1047
+ // FIX-B: record the win so the slow probes below explicitly skip
1048
+ // (belt-and-suspenders; `runColdBoot` already short-circuits).
1049
+ storedSessionRestored = true;
1050
+ return { kind: 'session', session: true };
1051
+ }
1052
+ return { kind: 'skip' };
1053
+ },
1054
+ },
1055
+ {
1056
+ // 3) FedCM silent reauthn (Chrome) against the CENTRAL IdP
899
1057
  // (auth.oxy.so). `silentSignInWithFedCM` plants the access token
900
1058
  // internally; we commit the returned session via
901
1059
  // `handleWebSSOSession`. Guarded so it fires at most once per page
902
1060
  // load across remounts. This is an enhancement layered above the
903
1061
  // opaque-code bounce: when it succeeds the bounce never fires.
1062
+ //
1063
+ // FIX-B: additionally skipped when the earlier `stored-session` step
1064
+ // already recovered a local session — the probe cannot improve on a
1065
+ // valid local bearer, and skipping it avoids the silent round-trip.
904
1066
  id: 'fedcm-silent',
905
- enabled: () => fedcmSupported && !servicesSilentAttempted.has(silentKey),
1067
+ enabled: () =>
1068
+ !storedSessionRestored && fedcmSupported && !servicesSilentAttempted.has(silentKey),
906
1069
  run: async () => {
907
1070
  servicesSilentAttempted.add(silentKey);
908
1071
  const session = await oxyServices.silentSignInWithFedCM?.();
@@ -914,7 +1077,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
914
1077
  },
915
1078
  },
916
1079
  {
917
- // 3) First-party silent iframe at the PER-APEX IdP — the DURABLE
1080
+ // 4) First-party silent iframe at the PER-APEX IdP — the DURABLE
918
1081
  // cross-domain reload-restore path. The durable session lives as a
919
1082
  // first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
920
1083
  // `auth.mention.earth`), established during the `/sso` bounce's
@@ -933,8 +1096,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
933
1096
  // equivalent. When auto-detection bails (localhost/IP/single-label)
934
1097
  // there is no per-apex IdP and the step skips. Web only; on native
935
1098
  // `isWebBrowser()` gates it off, so native never runs an iframe.
1099
+ //
1100
+ // FIX-B: additionally skipped when `stored-session` already won.
1101
+ // FIX-D: bounded by `SILENT_IFRAME_TIMEOUT` (plus `iframe.onerror`
1102
+ // fail-fast in core) so a no-message iframe cannot stall cold boot.
936
1103
  id: 'silent-iframe',
937
- enabled: () => isWebBrowser(),
1104
+ enabled: () => !storedSessionRestored && isWebBrowser(),
938
1105
  run: async () => {
939
1106
  const perApexAuthUrl = autoDetectAuthWebUrl();
940
1107
  if (!perApexAuthUrl || !commitWebSession) {
@@ -942,6 +1109,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
942
1109
  }
943
1110
  const session = await oxyServices.silentSignIn?.({
944
1111
  authWebUrlOverride: perApexAuthUrl,
1112
+ timeout: SILENT_IFRAME_TIMEOUT,
945
1113
  });
946
1114
  if (!session?.user || !session?.sessionId) {
947
1115
  return { kind: 'skip' };
@@ -951,12 +1119,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
951
1119
  },
952
1120
  },
953
1121
  {
954
- // 4) Refresh-cookie restore (first-party only). On `*.oxy.so` the
1122
+ // 5) Refresh-cookie restore (first-party only). On `*.oxy.so` the
955
1123
  // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
956
1124
  // device-local slot. On a cross-domain RP (mention.earth, …) the
957
1125
  // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
958
1126
  // `refreshAllSessions` returns `{accounts:[]}` and this skips. That
959
1127
  // is correct; cross-domain restore is handled by the SSO bounce.
1128
+ // FIX-D: `restoreViaRefreshCookie` bounds the request with
1129
+ // `COOKIE_RESTORE_TIMEOUT` so a cross-domain stall cannot hang here.
960
1130
  id: 'cookie-restore',
961
1131
  enabled: () => isWebBrowser(),
962
1132
  run: async () => {
@@ -964,16 +1134,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
964
1134
  return restored ? { kind: 'session', session: true } : { kind: 'skip' };
965
1135
  },
966
1136
  },
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
1137
  {
978
1138
  // 6) SSO bounce (TERMINAL, web only, at most once). No local session
979
1139
  // was found by any step above. Top-level navigate to the central
@@ -1034,6 +1194,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1034
1194
  );
1035
1195
  }
1036
1196
  },
1197
+ // Defense-in-depth: a single step whose promise never settles (the
1198
+ // production FedCM-silent hang) can no longer block the chain forever.
1199
+ // On expiry the runner keeps iterating to the terminal `sso-bounce`
1200
+ // step so a genuine no-local-session visit still reaches the
1201
+ // cross-domain `/sso` fallback; the `finally` backstop flips
1202
+ // `authResolved` regardless. See `COLD_BOOT_OVERALL_DEADLINE`.
1203
+ overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE,
1204
+ onStepDeadline: (id) => {
1205
+ if (__DEV__) {
1206
+ loggerUtil.debug(
1207
+ `Cold-boot step "${id}" exceeded the overall deadline (abandoned, falling through)`,
1208
+ { component: 'OxyContext', method: 'restoreSessionsFromStorage' },
1209
+ );
1210
+ }
1211
+ },
1037
1212
  });
1038
1213
 
1039
1214
  if (__DEV__ && outcome.kind === 'session') {
@@ -1048,14 +1223,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1048
1223
  }
1049
1224
  await clearSessionStateRef.current();
1050
1225
  } 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);
1226
+ // Backstop: mark auth resolved on EVERY exit path — success, no-session,
1227
+ // AND error→catch→finally and on native (which only runs the
1228
+ // `stored-session` step), so the gate can never hang `false`. Idempotent
1229
+ // via `markAuthResolved`'s ref: when a commit site already flipped it
1230
+ // mid-chain (the common reload case), this is a no-op. When no session was
1231
+ // recovered (the unauthenticated/error path), this is where `tokenReady` +
1232
+ // `authResolved` finally flip. Monotonic — never reverts on later restores.
1233
+ markAuthResolved();
1059
1234
  }
1060
1235
  }, [
1061
1236
  oxyServices,
@@ -1063,6 +1238,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1063
1238
  restoreViaRefreshCookie,
1064
1239
  restoreStoredSession,
1065
1240
  runSsoReturn,
1241
+ markAuthResolved,
1066
1242
  ]);
1067
1243
 
1068
1244
  useEffect(() => {
@@ -1216,6 +1392,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1216
1392
  fullUser = session.user as unknown as User;
1217
1393
  }
1218
1394
  loginSuccess(fullUser);
1395
+ // A session is now committed (FedCM silent / per-apex iframe / redirect /
1396
+ // SSO-return / popup all funnel through here) — unblock the auth-resolution
1397
+ // gate immediately, ahead of the cold-boot chain returning (idempotent).
1398
+ markAuthResolvedRef.current();
1219
1399
  onAuthStateChange?.(fullUser);
1220
1400
  }, [oxyServices, updateSessions, setActiveSessionId, loginSuccess, onAuthStateChange, persistSessionDurably]);
1221
1401
 
@@ -1486,6 +1666,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1486
1666
  clearSessionState,
1487
1667
  clearAllAccountData,
1488
1668
  storageKeyPrefix,
1669
+ appName,
1489
1670
  oxyServices,
1490
1671
  useFollow: useFollowHook,
1491
1672
  showBottomSheet: showBottomSheetForContext,
@@ -1514,6 +1695,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1514
1695
  logoutAllDeviceSessions,
1515
1696
  oxyServices,
1516
1697
  storageKeyPrefix,
1698
+ appName,
1517
1699
  refreshSessionsWithUser,
1518
1700
  sessions,
1519
1701
  setLanguage,
@@ -1591,6 +1773,7 @@ const LOADING_STATE: OxyContextState = {
1591
1773
  clearSessionState: () => rejectMissingProvider<void>(),
1592
1774
  clearAllAccountData: () => rejectMissingProvider<void>(),
1593
1775
  storageKeyPrefix: 'oxy_session',
1776
+ appName: resolveAppDisplayName(undefined, undefined),
1594
1777
  oxyServices: LOADING_STATE_OXY_SERVICES,
1595
1778
  openAvatarPicker: () => {},
1596
1779
  actingAs: null,
@@ -119,7 +119,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
119
119
  theme,
120
120
  }) => {
121
121
  const bloomTheme = useTheme();
122
- const { oxyServices, signIn, switchSession, storageKeyPrefix } = useOxy();
122
+ const { oxyServices, signIn, switchSession, appName } = useOxy();
123
123
 
124
124
  const [authSession, setAuthSession] = useState<AuthSession | null>(null);
125
125
  const [isLoading, setIsLoading] = useState(true);
@@ -273,7 +273,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
273
273
  await oxyServices.makeRequest('POST', '/auth/session/create', {
274
274
  sessionToken,
275
275
  expiresAt,
276
- appId: storageKeyPrefix ? storageKeyPrefix.charAt(0).toUpperCase() + storageKeyPrefix.slice(1) : Platform.OS,
276
+ appId: appName,
277
277
  }, { cache: false });
278
278
 
279
279
  setAuthSession({ sessionToken, expiresAt });
@@ -286,7 +286,7 @@ const OxyAuthScreen: React.FC<BaseScreenProps> = ({
286
286
  } finally {
287
287
  setIsLoading(false);
288
288
  }
289
- }, [oxyServices, connectSocket]);
289
+ }, [oxyServices, connectSocket, appName]);
290
290
 
291
291
  // Generate a random session token
292
292
  const generateSessionToken = (): string => {
@@ -52,6 +52,14 @@ export interface OxyProviderProps {
52
52
  children?: ReactNode;
53
53
  onAuthStateChange?: (user: unknown) => void;
54
54
  storageKeyPrefix?: string;
55
+ /**
56
+ * Human-readable name of the consuming app (e.g. "Mention", "Homiio").
57
+ * Surfaced on the central Oxy sign-in / consent experience as
58
+ * "{appName} wants to access your Oxy account". When omitted, the SDK
59
+ * derives a name from `storageKeyPrefix`, then `document.title` (web),
60
+ * falling back to the platform. Set this to guarantee correct branding.
61
+ */
62
+ appName?: string;
55
63
  baseURL?: string;
56
64
  authWebUrl?: string;
57
65
  authRedirectUri?: string;
@@ -0,0 +1,52 @@
1
+ import { resolveAppDisplayName } from '../appName';
2
+
3
+ // The shared react-native mock pins `Platform.OS` to 'web', which is exactly
4
+ // the platform on which the historical "web wants to access your Oxy account"
5
+ // regression occurred. These tests assert the resolution order that prevents it.
6
+
7
+ describe('resolveAppDisplayName', () => {
8
+ const originalTitle = typeof document !== 'undefined' ? document.title : '';
9
+
10
+ afterEach(() => {
11
+ if (typeof document !== 'undefined') {
12
+ document.title = originalTitle;
13
+ }
14
+ });
15
+
16
+ it('prefers an explicit appName, trimmed', () => {
17
+ expect(resolveAppDisplayName(' Mention ', 'oxy_session')).toBe('Mention');
18
+ });
19
+
20
+ it('explicit appName wins over a custom storageKeyPrefix', () => {
21
+ expect(resolveAppDisplayName('Mention', 'homiio')).toBe('Mention');
22
+ });
23
+
24
+ it('capitalizes a custom storageKeyPrefix when no appName is given', () => {
25
+ expect(resolveAppDisplayName(undefined, 'mention')).toBe('Mention');
26
+ });
27
+
28
+ it('ignores the default storageKeyPrefix (never surfaces "Oxy_session")', () => {
29
+ if (typeof document !== 'undefined') {
30
+ document.title = '';
31
+ }
32
+ expect(resolveAppDisplayName(undefined, 'oxy_session')).toBe('web');
33
+ });
34
+
35
+ it('falls back to document.title on web when no name or custom prefix is set', () => {
36
+ if (typeof document !== 'undefined') {
37
+ document.title = 'Homiio';
38
+ }
39
+ expect(resolveAppDisplayName(undefined, 'oxy_session')).toBe('Homiio');
40
+ });
41
+
42
+ it('falls back to the platform only when nothing else is available', () => {
43
+ if (typeof document !== 'undefined') {
44
+ document.title = '';
45
+ }
46
+ expect(resolveAppDisplayName(undefined, undefined)).toBe('web');
47
+ });
48
+
49
+ it('treats a whitespace-only appName as absent', () => {
50
+ expect(resolveAppDisplayName(' ', 'mention')).toBe('Mention');
51
+ });
52
+ });
@@ -0,0 +1,62 @@
1
+ import { Platform } from 'react-native';
2
+
3
+ /**
4
+ * The `storageKeyPrefix` default applied by `OxyContextProvider`. When the
5
+ * consumer never overrides it, the prefix carries no app-identity signal and
6
+ * must NOT be used to derive a display name (it would surface "Oxy_session").
7
+ */
8
+ const DEFAULT_STORAGE_KEY_PREFIX = 'oxy_session';
9
+
10
+ /**
11
+ * Capitalize the first character of a non-empty string. Used to turn a lower
12
+ * case `storageKeyPrefix` (e.g. `"mention"`) into a presentable label
13
+ * (`"Mention"`). Pure; leaves the remainder untouched so multi-word or already
14
+ * capitalized values are preserved.
15
+ */
16
+ function capitalizeFirst(value: string): string {
17
+ return value.charAt(0).toUpperCase() + value.slice(1);
18
+ }
19
+
20
+ /**
21
+ * Resolve a human-readable application display name for the consent / sign-in
22
+ * UI shown by the central Oxy auth experience (e.g. "Mention wants to access
23
+ * your Oxy account"). This is sent as the `appId` field on
24
+ * `POST /auth/session/create` and rendered verbatim by the auth consent page.
25
+ *
26
+ * Resolution order (first non-empty wins):
27
+ * 1. An explicit `appName` declared by the consumer on `OxyProvider`.
28
+ * 2. The capitalized `storageKeyPrefix` — but only when the consumer actually
29
+ * overrode the default. Apps already pass a brand-shaped prefix
30
+ * (`"mention"`, `"homiio"`, …) so this gives most apps a correct name with
31
+ * zero extra config.
32
+ * 3. On web only, a meaningful `document.title` (trimmed). This rescues
33
+ * zero-config web apps that set a page title but no prefix.
34
+ * 4. `Platform.OS` as the terminal fallback. On web this yields the historical
35
+ * `"web"` value — now reached ONLY when an app supplies neither an explicit
36
+ * name, a custom prefix, nor a document title.
37
+ *
38
+ * The result is never empty.
39
+ */
40
+ export function resolveAppDisplayName(
41
+ appName: string | undefined,
42
+ storageKeyPrefix: string | undefined,
43
+ ): string {
44
+ const explicit = appName?.trim();
45
+ if (explicit) {
46
+ return explicit;
47
+ }
48
+
49
+ const prefix = storageKeyPrefix?.trim();
50
+ if (prefix && prefix !== DEFAULT_STORAGE_KEY_PREFIX) {
51
+ return capitalizeFirst(prefix);
52
+ }
53
+
54
+ if (Platform.OS === 'web' && typeof document !== 'undefined') {
55
+ const title = document.title?.trim();
56
+ if (title) {
57
+ return title;
58
+ }
59
+ }
60
+
61
+ return Platform.OS;
62
+ }