@oxyhq/services 8.6.0 → 8.7.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 (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 +53 -1
  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 +53 -1
  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 +68 -0
  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
@@ -103,6 +103,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
103
103
  children,
104
104
  onAuthStateChange,
105
105
  storageKeyPrefix,
106
+ appName,
106
107
  baseURL,
107
108
  authWebUrl,
108
109
  authRedirectUri,
@@ -296,6 +297,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
296
297
  authWebUrl={authWebUrl}
297
298
  authRedirectUri={authRedirectUri}
298
299
  storageKeyPrefix={storageKeyPrefix}
300
+ appName={appName}
299
301
  onAuthStateChange={onAuthStateChange as OxyContextProviderProps['onAuthStateChange']}
300
302
  >
301
303
  {children}
@@ -93,7 +93,7 @@ const SignInModal: React.FC = () => {
93
93
 
94
94
  const insets = useSafeAreaInsets();
95
95
  const theme = useTheme();
96
- const { oxyServices, switchSession } = useOxy();
96
+ const { oxyServices, switchSession, appName } = useOxy();
97
97
 
98
98
  const socketRef = useRef<Socket | null>(null);
99
99
  const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -273,7 +273,7 @@ const SignInModal: React.FC = () => {
273
273
  await oxyServices.makeRequest('POST', '/auth/session/create', {
274
274
  sessionToken,
275
275
  expiresAt,
276
- appId: Platform.OS,
276
+ appId: appName,
277
277
  }, { cache: false });
278
278
 
279
279
  setAuthSession({ sessionToken, expiresAt });
@@ -284,7 +284,7 @@ const SignInModal: React.FC = () => {
284
284
  } finally {
285
285
  setIsLoading(false);
286
286
  }
287
- }, [oxyServices, connectSocket]);
287
+ }, [oxyServices, connectSocket, appName]);
288
288
 
289
289
  // Generate a cryptographically random session token.
290
290
  // 16 random bytes -> 32 hex chars (128 bits of entropy) — unguessable.
@@ -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
  }
@@ -204,6 +217,34 @@ const SILENT_IFRAME_TIMEOUT = 2500;
204
217
  */
205
218
  const COOKIE_RESTORE_TIMEOUT = 3000;
206
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
+
207
248
  /**
208
249
  * Whether `idpOrigin` is a same-site, first-party host of the current page —
209
250
  * i.e. it shares the page's registrable apex (last two labels), so a "no
@@ -273,6 +314,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
273
314
  authWebUrl,
274
315
  authRedirectUri,
275
316
  storageKeyPrefix = 'oxy_session',
317
+ appName: appNameProp,
276
318
  onAuthStateChange,
277
319
  onError,
278
320
  }) => {
@@ -382,6 +424,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
382
424
 
383
425
  const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]);
384
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
+
385
435
  // Storage initialization.
386
436
  //
387
437
  // `storage` (state) drives render-time gating (`isStorageReady`) and the
@@ -1144,6 +1194,21 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1144
1194
  );
1145
1195
  }
1146
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
+ },
1147
1212
  });
1148
1213
 
1149
1214
  if (__DEV__ && outcome.kind === 'session') {
@@ -1601,6 +1666,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1601
1666
  clearSessionState,
1602
1667
  clearAllAccountData,
1603
1668
  storageKeyPrefix,
1669
+ appName,
1604
1670
  oxyServices,
1605
1671
  useFollow: useFollowHook,
1606
1672
  showBottomSheet: showBottomSheetForContext,
@@ -1629,6 +1695,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1629
1695
  logoutAllDeviceSessions,
1630
1696
  oxyServices,
1631
1697
  storageKeyPrefix,
1698
+ appName,
1632
1699
  refreshSessionsWithUser,
1633
1700
  sessions,
1634
1701
  setLanguage,
@@ -1706,6 +1773,7 @@ const LOADING_STATE: OxyContextState = {
1706
1773
  clearSessionState: () => rejectMissingProvider<void>(),
1707
1774
  clearAllAccountData: () => rejectMissingProvider<void>(),
1708
1775
  storageKeyPrefix: 'oxy_session',
1776
+ appName: resolveAppDisplayName(undefined, undefined),
1709
1777
  oxyServices: LOADING_STATE_OXY_SERVICES,
1710
1778
  openAvatarPicker: () => {},
1711
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
+ }