@oxyhq/services 13.0.3 → 13.1.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 (61) hide show
  1. package/lib/commonjs/index.js +18 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/ProfileButton.js +233 -0
  4. package/lib/commonjs/ui/components/ProfileButton.js.map +1 -0
  5. package/lib/commonjs/ui/components/ProfileMenu.js +374 -0
  6. package/lib/commonjs/ui/components/ProfileMenu.js.map +1 -0
  7. package/lib/commonjs/ui/context/OxyContext.js +99 -18
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +28 -1
  10. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  11. package/lib/commonjs/ui/index.js +14 -0
  12. package/lib/commonjs/ui/index.js.map +1 -1
  13. package/lib/commonjs/ui/utils/activeAuthuser.js +76 -8
  14. package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
  15. package/lib/module/index.js +5 -0
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/components/ProfileButton.js +228 -0
  18. package/lib/module/ui/components/ProfileButton.js.map +1 -0
  19. package/lib/module/ui/components/ProfileMenu.js +370 -0
  20. package/lib/module/ui/components/ProfileMenu.js.map +1 -0
  21. package/lib/module/ui/context/OxyContext.js +100 -19
  22. package/lib/module/ui/context/OxyContext.js.map +1 -1
  23. package/lib/module/ui/context/hooks/useAuthOperations.js +29 -2
  24. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  25. package/lib/module/ui/index.js +2 -0
  26. package/lib/module/ui/index.js.map +1 -1
  27. package/lib/module/ui/utils/activeAuthuser.js +74 -9
  28. package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
  29. package/lib/typescript/commonjs/index.d.ts +4 -0
  30. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts +46 -0
  32. package/lib/typescript/commonjs/ui/components/ProfileButton.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts +39 -0
  34. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/index.d.ts +2 -0
  38. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +24 -0
  40. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
  41. package/lib/typescript/module/index.d.ts +4 -0
  42. package/lib/typescript/module/index.d.ts.map +1 -1
  43. package/lib/typescript/module/ui/components/ProfileButton.d.ts +46 -0
  44. package/lib/typescript/module/ui/components/ProfileButton.d.ts.map +1 -0
  45. package/lib/typescript/module/ui/components/ProfileMenu.d.ts +39 -0
  46. package/lib/typescript/module/ui/components/ProfileMenu.d.ts.map +1 -0
  47. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/index.d.ts +2 -0
  50. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +24 -0
  52. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
  53. package/package.json +3 -3
  54. package/src/index.ts +8 -0
  55. package/src/ui/components/ProfileButton.tsx +270 -0
  56. package/src/ui/components/ProfileMenu.tsx +416 -0
  57. package/src/ui/context/OxyContext.tsx +100 -18
  58. package/src/ui/context/hooks/useAuthOperations.ts +35 -2
  59. package/src/ui/index.ts +2 -0
  60. package/src/ui/utils/__tests__/activeAuthuser.test.ts +75 -0
  61. package/src/ui/utils/activeAuthuser.ts +74 -8
@@ -0,0 +1,416 @@
1
+ import type React from 'react';
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import {
4
+ View,
5
+ Pressable,
6
+ Modal,
7
+ ScrollView,
8
+ ActivityIndicator,
9
+ Platform,
10
+ type ViewStyle,
11
+ } from 'react-native';
12
+ import { MaterialCommunityIcons } from '@expo/vector-icons';
13
+ import { Avatar } from '@oxyhq/bloom/avatar';
14
+ import { Text } from '@oxyhq/bloom/typography';
15
+ import { useTheme } from '@oxyhq/bloom/theme';
16
+ import { toast, useDialogControl, Dialog } from '@oxyhq/bloom';
17
+ import { Divider } from '@oxyhq/bloom/divider';
18
+ import { isDev, logger as loggerUtil } from '@oxyhq/core';
19
+ import { useOxy } from '../context/OxyContext';
20
+ import { useI18n } from '../hooks/useI18n';
21
+ import { useDeviceAccounts } from '../hooks/useDeviceAccounts';
22
+
23
+ const isWeb = Platform.OS === 'web';
24
+
25
+ /** Fixed popover width on web. `ProfileButton` mirrors this when anchoring. */
26
+ const MENU_WIDTH = 300;
27
+
28
+ /**
29
+ * Web-only anchor. `ProfileButton` measures its trigger and anchors the panel's
30
+ * BOTTOM-LEFT corner so the menu opens UPWARD from the sidebar footer. Native
31
+ * ignores the anchor and docks the panel to the bottom as a sheet.
32
+ */
33
+ export interface ProfileMenuAnchor {
34
+ /** Distance from the viewport left, for the panel's LEFT edge. */
35
+ left: number;
36
+ /** Distance from the viewport bottom, for the panel's BOTTOM edge (opens upward). */
37
+ bottom: number;
38
+ }
39
+
40
+ export interface ProfileMenuProps {
41
+ open: boolean;
42
+ onClose: () => void;
43
+ /** Web-only trigger anchor. Native ignores this (bottom-sheet style). */
44
+ anchor?: ProfileMenuAnchor | null;
45
+ /** Navigate to the "Manage account" surface (settings). */
46
+ onNavigateManage: () => void;
47
+ /** Start the add-account / sign-in flow for an additional account. */
48
+ onAddAccount: () => void;
49
+ /** Optional: navigate to the signed-in user's own profile. */
50
+ onNavigateProfile?: () => void;
51
+ /** Called before the active identity changes so apps can clear scoped state. */
52
+ onBeforeSessionChange?: () => void | Promise<void>;
53
+ }
54
+
55
+ /**
56
+ * Clean device-account switcher, modeled on the inbox `AccountMenu` but written
57
+ * fresh with NativeWind classNames + Bloom primitives. Lists every account
58
+ * signed in on this device (from {@link useDeviceAccounts}); tapping a row
59
+ * switches, and each inactive row carries a sign-out icon. Below the list:
60
+ * Add account, Manage account, optional View profile, and Sign out of all.
61
+ *
62
+ * Renders as an upward-opening popover anchored to the trigger on web, and a
63
+ * bottom-sheet style modal on native.
64
+ */
65
+ const ProfileMenu: React.FC<ProfileMenuProps> = ({
66
+ open,
67
+ onClose,
68
+ anchor,
69
+ onNavigateManage,
70
+ onAddAccount,
71
+ onNavigateProfile,
72
+ onBeforeSessionChange,
73
+ }) => {
74
+ const {
75
+ activeSessionId,
76
+ switchSession,
77
+ logoutAll,
78
+ removeSession,
79
+ } = useOxy();
80
+ const { t } = useI18n();
81
+ const { colors } = useTheme();
82
+
83
+ const { accounts } = useDeviceAccounts();
84
+
85
+ const [busySessionId, setBusySessionId] = useState<string | null>(null);
86
+ const [removingSessionId, setRemovingSessionId] = useState<string | null>(null);
87
+ const [signingOutAll, setSigningOutAll] = useState(false);
88
+
89
+ const signOutAllDialog = useDialogControl();
90
+
91
+ const isSwitching = busySessionId !== null;
92
+ const actionDisabled = isSwitching || removingSessionId !== null || signingOutAll;
93
+
94
+ // Switch to a non-active account through the SDK's canonical switch path.
95
+ const handleSwitch = useCallback(async (sessionId: string) => {
96
+ if (sessionId === activeSessionId || busySessionId) {
97
+ return;
98
+ }
99
+ setBusySessionId(sessionId);
100
+ try {
101
+ await onBeforeSessionChange?.();
102
+ await switchSession(sessionId);
103
+ toast.success(t('accountSwitcher.toasts.switchSuccess') || 'Switched account');
104
+ onClose();
105
+ } catch (error) {
106
+ if (!isDev()) {
107
+ loggerUtil.warn('Switch account failed', { component: 'ProfileMenu' }, error as unknown);
108
+ }
109
+ toast.error(t('accountSwitcher.toasts.switchFailed') || 'Failed to switch account');
110
+ } finally {
111
+ setBusySessionId(null);
112
+ }
113
+ }, [activeSessionId, busySessionId, switchSession, t, onClose, onBeforeSessionChange]);
114
+
115
+ // Sign out a specific inactive account without switching/clearing the active
116
+ // one. The menu stays open so the user can keep managing accounts.
117
+ const handleRemove = useCallback(async (sessionId: string) => {
118
+ if (sessionId === activeSessionId || removingSessionId) {
119
+ return;
120
+ }
121
+ setRemovingSessionId(sessionId);
122
+ try {
123
+ await removeSession(sessionId);
124
+ toast.success(t('common.actions.signedOut') || 'Signed out');
125
+ } catch (error) {
126
+ loggerUtil.warn('Remove account failed', { component: 'ProfileMenu' }, error as unknown);
127
+ toast.error(t('common.errors.signOutFailed') || 'Failed to sign out');
128
+ } finally {
129
+ setRemovingSessionId(null);
130
+ }
131
+ }, [activeSessionId, removingSessionId, removeSession, t]);
132
+
133
+ const performSignOutAll = useCallback(async () => {
134
+ if (signingOutAll) {
135
+ return;
136
+ }
137
+ setSigningOutAll(true);
138
+ try {
139
+ await onBeforeSessionChange?.();
140
+ await logoutAll();
141
+ toast.success(t('accountSwitcher.toasts.signOutAllSuccess') || 'Signed out of all accounts');
142
+ onClose();
143
+ } catch (error) {
144
+ loggerUtil.warn('Sign out all failed', { component: 'ProfileMenu' }, error as unknown);
145
+ toast.error(t('common.errors.signOutAllFailed') || 'Failed to sign out of all accounts');
146
+ } finally {
147
+ setSigningOutAll(false);
148
+ }
149
+ }, [signingOutAll, logoutAll, t, onClose, onBeforeSessionChange]);
150
+
151
+ // Escape-to-close (web only).
152
+ useEffect(() => {
153
+ if (!open || !isWeb || typeof document === 'undefined') {
154
+ return undefined;
155
+ }
156
+ const onKey = (event: KeyboardEvent) => {
157
+ if (event.key === 'Escape') {
158
+ event.stopPropagation();
159
+ onClose();
160
+ }
161
+ };
162
+ document.addEventListener('keydown', onKey, true);
163
+ return () => document.removeEventListener('keydown', onKey, true);
164
+ }, [open, onClose]);
165
+
166
+ if (!open) {
167
+ return null;
168
+ }
169
+
170
+ // Web: anchor the panel's bottom-left corner to the measured trigger, falling
171
+ // back to a bottom-left placement when no anchor was captured. Native ignores
172
+ // this (the panel docks to the bottom via the overlay's flex-end).
173
+ const panelAnchorStyle: ViewStyle | undefined = isWeb
174
+ ? {
175
+ position: 'absolute',
176
+ width: MENU_WIDTH,
177
+ left: anchor?.left ?? 8,
178
+ bottom: anchor?.bottom ?? 8,
179
+ }
180
+ : undefined;
181
+
182
+ const content = (
183
+ <Pressable
184
+ // Swallow taps inside the panel so they never reach the overlay's
185
+ // outside-tap-to-close handler.
186
+ onPress={() => undefined}
187
+ className={
188
+ isWeb
189
+ ? 'overflow-hidden rounded-2xl border border-border bg-background'
190
+ : 'overflow-hidden rounded-t-3xl bg-background pb-3'
191
+ }
192
+ style={[
193
+ panelAnchorStyle,
194
+ !isWeb ? { maxHeight: '85%' } : { maxHeight: '85%' },
195
+ styles.shadow,
196
+ ]}
197
+ accessibilityRole="menu"
198
+ accessibilityLabel={t('accountMenu.label') || 'Account menu'}
199
+ >
200
+ <ScrollView
201
+ className="grow-0"
202
+ contentContainerClassName="py-1"
203
+ showsVerticalScrollIndicator={false}
204
+ >
205
+ {/* 1) Device accounts — active first (checkmark), others switchable. */}
206
+ {accounts.map((account) => {
207
+ const isActive = account.isCurrent;
208
+ const isBusy = busySessionId === account.sessionId;
209
+ const isRemoving = removingSessionId === account.sessionId;
210
+ return (
211
+ <Pressable
212
+ key={`account-${account.sessionId}`}
213
+ accessibilityRole="menuitem"
214
+ accessibilityLabel={account.displayName}
215
+ accessibilityState={{ selected: isActive }}
216
+ onPress={() => handleSwitch(account.sessionId)}
217
+ disabled={isActive || isBusy || isSwitching}
218
+ className={`flex-row items-center gap-3 px-4 py-2.5 ${
219
+ isActive ? 'bg-secondary' : ''
220
+ } ${isSwitching && !isActive ? 'opacity-40' : ''}`}
221
+ >
222
+ <Avatar
223
+ source={account.user.avatar ?? undefined}
224
+ uri={account.avatarUrl}
225
+ variant="thumb"
226
+ name={account.displayName}
227
+ size={isActive ? 40 : 32}
228
+ />
229
+ <View className="min-w-0 flex-1">
230
+ <Text
231
+ className={`text-foreground ${isActive ? 'font-semibold' : 'font-medium'}`}
232
+ numberOfLines={1}
233
+ >
234
+ {account.displayName}
235
+ </Text>
236
+ {account.email ? (
237
+ <Text
238
+ className="text-xs text-muted-foreground"
239
+ numberOfLines={1}
240
+ >
241
+ {account.email}
242
+ </Text>
243
+ ) : null}
244
+ </View>
245
+ {isBusy ? (
246
+ <ActivityIndicator color={colors.primary} size="small" />
247
+ ) : isActive ? (
248
+ <MaterialCommunityIcons name="check" size={20} color={colors.primary} />
249
+ ) : isRemoving ? (
250
+ <ActivityIndicator color={colors.textSecondary} size="small" />
251
+ ) : (
252
+ <Pressable
253
+ accessibilityRole="button"
254
+ accessibilityLabel={
255
+ t('accountMenu.signOutAccount', { name: account.displayName })
256
+ || `Sign out ${account.displayName}`
257
+ }
258
+ onPress={() => handleRemove(account.sessionId)}
259
+ disabled={actionDisabled}
260
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
261
+ className="h-7 w-7 items-center justify-center rounded-full opacity-60"
262
+ >
263
+ <MaterialCommunityIcons
264
+ name="logout"
265
+ size={18}
266
+ color={colors.textSecondary}
267
+ />
268
+ </Pressable>
269
+ )}
270
+ </Pressable>
271
+ );
272
+ })}
273
+
274
+ {/* 2) Switching indicator. */}
275
+ {isSwitching ? (
276
+ <View className="flex-row items-center justify-center gap-2 py-2">
277
+ <ActivityIndicator color={colors.textSecondary} size="small" />
278
+ <Text className="text-xs font-medium text-muted-foreground">
279
+ {t('accountMenu.switching') || 'Switching account…'}
280
+ </Text>
281
+ </View>
282
+ ) : null}
283
+
284
+ <Divider color={colors.border} spacing={4} />
285
+
286
+ {/* 3) Add another account. */}
287
+ <ActionRow
288
+ icon="account-plus-outline"
289
+ iconColor={colors.icon}
290
+ label={t('accountMenu.addAnother') || 'Add another account'}
291
+ disabled={actionDisabled}
292
+ onPress={() => {
293
+ onClose();
294
+ onAddAccount();
295
+ }}
296
+ />
297
+
298
+ {/* 4) Manage account. */}
299
+ <ActionRow
300
+ icon="cog-outline"
301
+ iconColor={colors.icon}
302
+ label={t('accountMenu.manage') || 'Manage your Oxy Account'}
303
+ disabled={actionDisabled}
304
+ onPress={() => {
305
+ onClose();
306
+ onNavigateManage();
307
+ }}
308
+ />
309
+
310
+ {/* 5) View profile (optional). */}
311
+ {onNavigateProfile ? (
312
+ <ActionRow
313
+ icon="account-outline"
314
+ iconColor={colors.icon}
315
+ label={t('accountMenu.viewProfile') || 'View profile'}
316
+ disabled={actionDisabled}
317
+ onPress={() => {
318
+ onClose();
319
+ onNavigateProfile();
320
+ }}
321
+ />
322
+ ) : null}
323
+
324
+ {/* 6) Sign out of all accounts. */}
325
+ <Divider color={colors.border} spacing={4} />
326
+ <Pressable
327
+ accessibilityRole="menuitem"
328
+ accessibilityLabel={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
329
+ onPress={() => signOutAllDialog.open()}
330
+ disabled={actionDisabled}
331
+ className={`flex-row items-center gap-3 px-4 py-3 ${actionDisabled ? 'opacity-40' : ''}`}
332
+ >
333
+ {signingOutAll ? (
334
+ <ActivityIndicator color={colors.error} size="small" />
335
+ ) : (
336
+ <MaterialCommunityIcons name="logout" size={18} color={colors.error} />
337
+ )}
338
+ <Text className="font-medium" style={{ color: colors.error }}>
339
+ {t('accountMenu.signOutAll') || 'Sign out of all accounts'}
340
+ </Text>
341
+ </Pressable>
342
+ </ScrollView>
343
+ </Pressable>
344
+ );
345
+
346
+ return (
347
+ <Modal
348
+ visible={open}
349
+ transparent
350
+ animationType={isWeb ? 'fade' : 'slide'}
351
+ onRequestClose={onClose}
352
+ >
353
+ <Pressable
354
+ accessibilityRole="button"
355
+ accessibilityLabel={t('common.actions.close') || 'Close'}
356
+ onPress={onClose}
357
+ className={isWeb ? 'flex-1 relative' : 'flex-1 justify-end'}
358
+ style={!isWeb ? styles.nativeScrim : undefined}
359
+ >
360
+ {content}
361
+ </Pressable>
362
+
363
+ <Dialog
364
+ control={signOutAllDialog}
365
+ title={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
366
+ description={t('common.confirms.signOutAll') || 'Are you sure you want to sign out of all accounts?'}
367
+ actions={[
368
+ {
369
+ label: t('accountMenu.signOutAll') || 'Sign out of all accounts',
370
+ color: 'destructive',
371
+ onPress: performSignOutAll,
372
+ },
373
+ { label: t('common.cancel') || 'Cancel', color: 'cancel' },
374
+ ]}
375
+ />
376
+ </Modal>
377
+ );
378
+ };
379
+
380
+ /** Bottom-section action row (Add account / Manage / View profile). */
381
+ const ActionRow: React.FC<{
382
+ icon: string;
383
+ iconColor: string;
384
+ label: string;
385
+ disabled: boolean;
386
+ onPress: () => void;
387
+ }> = ({ icon, iconColor, label, disabled, onPress }) => (
388
+ <Pressable
389
+ accessibilityRole="menuitem"
390
+ accessibilityLabel={label}
391
+ onPress={onPress}
392
+ disabled={disabled}
393
+ className={`flex-row items-center gap-3 px-4 py-3 ${disabled ? 'opacity-40' : ''}`}
394
+ >
395
+ <MaterialCommunityIcons name={icon} size={20} color={iconColor} />
396
+ <Text className="font-medium text-foreground">{label}</Text>
397
+ </Pressable>
398
+ );
399
+
400
+ // The panel's drop shadow and the native scrim are the only values with no
401
+ // NativeWind class equivalent in this package (dynamic elevation + rgba scrim),
402
+ // so they stay as small inline objects rather than raw class-replaceable styles.
403
+ const styles = {
404
+ shadow: {
405
+ shadowColor: '#000',
406
+ shadowOpacity: 0.18,
407
+ shadowRadius: 24,
408
+ shadowOffset: { width: 0, height: 8 },
409
+ elevation: 12,
410
+ } satisfies ViewStyle,
411
+ nativeScrim: {
412
+ backgroundColor: 'rgba(0,0,0,0.32)',
413
+ } satisfies ViewStyle,
414
+ };
415
+
416
+ export default ProfileMenu;
@@ -43,7 +43,7 @@ import { useAuthOperations } from './hooks/useAuthOperations';
43
43
  import { useDeviceManagement } from '../hooks/useDeviceManagement';
44
44
  import { getStorageKeys, createPlatformStorage, type StorageInterface } from '../utils/storageHelpers';
45
45
  import { isInvalidSessionError, isTimeoutOrNetworkError } from '../utils/errorHandlers';
46
- import { readActiveAuthuser, writeActiveAuthuser } from '../utils/activeAuthuser';
46
+ import { readActiveAuthuser, writeActiveAuthuser, clearSignedOut, isSilentRestoreSuppressed } from '../utils/activeAuthuser';
47
47
  import type { RouteName } from '../navigation/routes';
48
48
  import { showBottomSheet as globalShowBottomSheet } from '../navigation/bottomSheetManager';
49
49
  import { useQueryClient } from '@tanstack/react-query';
@@ -1322,16 +1322,41 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1322
1322
  // run cookie-restore BEFORE those probes (and disable the later duplicate).
1323
1323
  //
1324
1324
  // Read ONCE here (synchronously usable by the step `enabled` gates below).
1325
- // It is non-null ONLY on first-party `*.oxy.so` web apps that persist the
1326
- // slot — a cross-apex RP never writes it (its restore funnels through
1327
- // `handleWebSSOSession`, which does not touch the authuser), and native has
1328
- // no localStorage, so both keep their existing order untouched. The
1329
- // earlier `stored-session` step still runs first (FIX-A latency), and when
1330
- // a slot exists but its cookies have all lapsed, cookie-restore simply
1331
- // returns no accounts and the chain falls through to the cross-domain
1332
- // fallbacks exactly as before.
1325
+ // It is non-null ONLY on first-party web apps that persist the slot. Both the
1326
+ // device sign-in/switch paths AND `handleWebSSOSession` (FedCM/SSO/credentials
1327
+ // commit funnel) now persist it — but only when the session genuinely joined
1328
+ // the device set: `establishDeviceRefreshSlot` returns an `authuser` ONLY on
1329
+ // first-party web (same registrable apex as the API), so a cross-apex RP never
1330
+ // writes a phantom slot and native has no localStorage. Both therefore keep
1331
+ // their existing order untouched. The earlier `stored-session` step still runs
1332
+ // first (FIX-A latency), and when a slot exists but its cookies have all
1333
+ // lapsed, cookie-restore simply returns no accounts and the chain falls through
1334
+ // to the cross-domain fallbacks exactly as before.
1333
1335
  const prioritizeMultiAccount = isWebBrowser() && readActiveAuthuser() !== null;
1334
1336
 
1337
+ // DELIBERATELY-SIGNED-OUT gate (web): when the user pressed "Sign out", any
1338
+ // credential that can silently re-mint a session may still be live on the
1339
+ // next reload — the central IdP session (FedCM credential association /
1340
+ // per-apex `fedcm_session` cookie) AND the device refresh cookies. Since
1341
+ // PR #455 the PRIMARY web session also joins the `oxy_rt_<authuser>` device
1342
+ // set, so the un-gated cookie-restore steps would `refresh-all` that still-
1343
+ // present cookie and sign the user back in without intent — exactly the
1344
+ // "sign-out is silently undone" regression. So this flag gates EVERY
1345
+ // AUTOMATIC silent-restore cold-boot step: `stored-session`,
1346
+ // `cookie-restore-active`, `fedcm-silent`, `silent-iframe`, and
1347
+ // `cookie-restore`. The gate — not a cookie wipe — is the authority: the
1348
+ // account may stay "known" for a fast deliberate re-sign-in, but no step
1349
+ // restores it silently while the flag is set. Read the durable flag ONCE here
1350
+ // (synchronously usable by every step `enabled` gate). Any deliberate sign-in
1351
+ // (password, FedCM, account switch, device claim) clears it, so there is no
1352
+ // "stuck signed out" state. The `sso-bounce` step needs no extra gate: it is
1353
+ // already self-suppressed after sign-out (its `hasPriorSession` hint is
1354
+ // cleared). `sso-return` is NOT gated — it commits the result of a deliberate
1355
+ // top-level `/sso` bounce the user just initiated. `shared-key-signin` is
1356
+ // native-only and the flag never sets on native (`markSignedOut` no-ops
1357
+ // off-web), so its gate would be moot; it is left untouched.
1358
+ const silentRestoreBlocked = isSilentRestoreSuppressed();
1359
+
1335
1360
  try {
1336
1361
  const outcome = await runColdBoot<true>({
1337
1362
  steps: [
@@ -1349,12 +1374,17 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1349
1374
  },
1350
1375
  },
1351
1376
  {
1352
- // 2) Stored-session bearer restore. NO `enabled` gate runs on ALL
1353
- // platforms. This is native's ONLY restore path (every web-only step
1354
- // is disabled off-browser, so native reaches exactly this) AND the
1355
- // common WEB reload winner.
1377
+ // 2) Stored-session bearer restore. Runs on ALL platforms EXCEPT when
1378
+ // the deliberately-signed-out flag is set (web only it never sets on
1379
+ // native, so native still always reaches exactly this step, its ONLY
1380
+ // restore path). This is also the common WEB reload winner.
1381
+ //
1382
+ // SIGNED-OUT GATE: after a deliberate full sign-out the stored session
1383
+ // state is cleared, so this would normally skip anyway — but the gate
1384
+ // makes that authoritative rather than incidental, so no residual
1385
+ // stored token can silently restore while the user is signed out.
1356
1386
  //
1357
- // ORDERING (FIX A): this step now runs BEFORE the slow web-only
1387
+ // ORDERING (FIX A): this step runs BEFORE the slow web-only
1358
1388
  // probes (`fedcm-silent`, `silent-iframe`, `cookie-restore`). On a
1359
1389
  // normal reload the local bearer validates in one round-trip and
1360
1390
  // wins; `runColdBoot` then short-circuits and never even evaluates
@@ -1365,6 +1395,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1365
1395
  // local session this step skips and the cross-domain fallback chain
1366
1396
  // (fedcm → iframe → cookie → sso-bounce) runs exactly as before.
1367
1397
  id: 'stored-session',
1398
+ enabled: () => !silentRestoreBlocked,
1368
1399
  run: async () => {
1369
1400
  const restored = await restoreStoredSession();
1370
1401
  if (restored) {
@@ -1441,8 +1472,14 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1441
1472
  // When no slot is persisted this is disabled and the original order is
1442
1473
  // unchanged; when the slot's cookies have lapsed it returns no accounts
1443
1474
  // and the chain falls through to the cross-domain fallbacks below.
1475
+ //
1476
+ // SIGNED-OUT GATE: since PR #455 the primary's `oxy_rt` slot survives a
1477
+ // deliberate sign-out, so without this gate `refresh-all` would re-mint
1478
+ // and silently re-log-in the user on reload. The flag is cleared by any
1479
+ // deliberate sign-in (including an account SWITCH, which also writes the
1480
+ // new active slot), so the switch-survives-reload path is unaffected.
1444
1481
  id: 'cookie-restore-active',
1445
- enabled: () => prioritizeMultiAccount,
1482
+ enabled: () => prioritizeMultiAccount && !silentRestoreBlocked,
1446
1483
  run: async () => {
1447
1484
  const restored = await restoreViaRefreshCookie();
1448
1485
  return restored ? { kind: 'session', session: true } : { kind: 'skip' };
@@ -1461,7 +1498,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1461
1498
  // valid local bearer, and skipping it avoids the silent round-trip.
1462
1499
  id: 'fedcm-silent',
1463
1500
  enabled: () =>
1464
- !storedSessionRestored && fedcmSupported && !servicesSilentAttempted.has(silentKey),
1501
+ !storedSessionRestored &&
1502
+ !silentRestoreBlocked &&
1503
+ fedcmSupported &&
1504
+ !servicesSilentAttempted.has(silentKey),
1465
1505
  run: async () => {
1466
1506
  servicesSilentAttempted.add(silentKey);
1467
1507
  const session = await oxyServices.silentSignInWithFedCM?.();
@@ -1496,7 +1536,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1496
1536
  // FIX-D: bounded by `SILENT_IFRAME_TIMEOUT` (plus `iframe.onerror`
1497
1537
  // fail-fast in core) so a no-message iframe cannot stall cold boot.
1498
1538
  id: 'silent-iframe',
1499
- enabled: () => !storedSessionRestored && isWebBrowser(),
1539
+ enabled: () => !storedSessionRestored && !silentRestoreBlocked && isWebBrowser(),
1500
1540
  run: async () => {
1501
1541
  if (!commitWebSession) {
1502
1542
  return { kind: 'skip' };
@@ -1529,8 +1569,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1529
1569
  // would be a redundant second `refreshAllSessions`. It still runs in the
1530
1570
  // common no-persisted-slot case (e.g. first visit to a first-party app
1531
1571
  // whose central refresh cookies already exist).
1572
+ //
1573
+ // SIGNED-OUT GATE (same rationale as `cookie-restore-active`): the
1574
+ // primary's `oxy_rt` slot survives a deliberate sign-out (PR #455), so
1575
+ // this must be skipped while the signed-out flag is set or it would
1576
+ // silently re-restore the primary on reload.
1532
1577
  id: 'cookie-restore',
1533
- enabled: () => isWebBrowser() && !prioritizeMultiAccount,
1578
+ enabled: () => isWebBrowser() && !prioritizeMultiAccount && !silentRestoreBlocked,
1534
1579
  run: async () => {
1535
1580
  const restored = await restoreViaRefreshCookie();
1536
1581
  return restored ? { kind: 'session', session: true } : { kind: 'skip' };
@@ -1779,6 +1824,38 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1779
1824
  }
1780
1825
  oxyServices.httpService.setTokens(session.accessToken);
1781
1826
 
1827
+ // A committed web session re-enables automatic silent restore: clear the
1828
+ // durable "deliberately signed out" flag. This funnel is reached by deliberate
1829
+ // sign-ins (password, interactive FedCM, `/sso` return) AND by the silent
1830
+ // cold-boot steps — but those are GATED on the flag, so when it is set they
1831
+ // never run and never reach here; clearing is therefore only hit on a genuine
1832
+ // (re-)sign-in or when restore was already permitted, both correct.
1833
+ clearSignedOut();
1834
+
1835
+ // Register this primary session in the device's first-party multi-account
1836
+ // refresh-cookie set (web only). Every web primary-session restore funnels
1837
+ // through here — FedCM silent (`/fedcm/exchange`), per-apex `/auth/silent`
1838
+ // iframe, central `/sso` return, and keyless password sign-in. Of those, only
1839
+ // a same-apex `/fedcm/exchange` plants an `oxy_rt_<authuser>` slot as a side
1840
+ // effect; the cross-origin/credential-less restores (`/sso/exchange`,
1841
+ // `/auth/silent` postMessage) cannot set an `api.oxy.so` cookie at all. So
1842
+ // WITHOUT this call a web primary never joins the device set: `refresh-all`
1843
+ // returns zero accounts, and account-switch persistence + the
1844
+ // `cookie-restore-active` cold-boot step have no foundation. Calling the shared
1845
+ // `POST /auth/session` primitive here makes EVERY primary participate in the
1846
+ // set, re-plants the rotated token, and records the active `authuser` so the
1847
+ // next cold boot's `cookie-restore-active` reconciles the persisted active
1848
+ // (possibly switched) account instead of `fedcm-silent` clobbering it back to
1849
+ // the primary. Best-effort: a `null` result (native / transient failure)
1850
+ // leaves the in-session token untouched.
1851
+ const primaryAuthuser = await oxyServices.establishDeviceRefreshSlot();
1852
+ // A non-null slot is returned ONLY on first-party web (the helper gates on
1853
+ // platform + same registrable apex), so recording the active slot here is safe
1854
+ // and never writes a phantom authuser for a cross-apex RP or native.
1855
+ if (typeof primaryAuthuser === 'number') {
1856
+ writeActiveAuthuser(primaryAuthuser);
1857
+ }
1858
+
1782
1859
  const clientSession = {
1783
1860
  sessionId: session.sessionId,
1784
1861
  deviceId: session.deviceId || '',
@@ -1786,6 +1863,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1786
1863
  lastActive: new Date().toISOString(),
1787
1864
  userId: session.user.id?.toString() ?? '',
1788
1865
  isCurrent: true,
1866
+ ...(typeof primaryAuthuser === 'number' ? { authuser: primaryAuthuser } : null),
1789
1867
  };
1790
1868
 
1791
1869
  updateSessions([clientSession], { merge: true });
@@ -2099,6 +2177,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
2099
2177
  throw new Error('Account switch did not return a valid session');
2100
2178
  }
2101
2179
 
2180
+ // A switch is a deliberate sign-in into an account: re-enable automatic silent
2181
+ // restore by clearing any prior "deliberately signed out" flag.
2182
+ clearSignedOut();
2183
+
2102
2184
  // `oxyServices.switchToAccount` already planted `result.accessToken` as the
2103
2185
  // active token; mirror the minted session into the multi-account store and
2104
2186
  // mark it current, recording the device `authuser` slot so web silent-switch