@oxyhq/services 19.1.2 → 19.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/android/build.gradle +21 -0
  2. package/android/src/main/AndroidManifest.xml +10 -0
  3. package/android/src/main/java/so/oxy/identity/OxyIdentityModule.kt +84 -0
  4. package/android/src/main/java/so/oxy/identity/OxyIdentityProvider.kt +77 -0
  5. package/android/src/main/java/so/oxy/identity/OxyIdentityStore.kt +76 -0
  6. package/expo-module.config.json +9 -0
  7. package/ios/OxyIdentity.podspec +28 -0
  8. package/ios/OxyIdentityModule.swift +33 -0
  9. package/lib/commonjs/index.js +7 -0
  10. package/lib/commonjs/index.js.map +1 -1
  11. package/lib/commonjs/ui/context/OxyContext.js +53 -50
  12. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  13. package/lib/commonjs/ui/context/commitSessionFlow.js +91 -0
  14. package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -0
  15. package/lib/commonjs/ui/hooks/useOxyEvent.js +28 -0
  16. package/lib/commonjs/ui/hooks/useOxyEvent.js.map +1 -0
  17. package/lib/commonjs/ui/screens/FileManagementScreen.js +3 -9
  18. package/lib/commonjs/ui/screens/FileManagementScreen.js.map +1 -1
  19. package/lib/commonjs/ui/session/createSessionClient.js +12 -8
  20. package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
  21. package/lib/commonjs/ui/session/tokenTransport.js +22 -32
  22. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
  23. package/lib/module/index.js +1 -0
  24. package/lib/module/index.js.map +1 -1
  25. package/lib/module/ui/context/OxyContext.js +54 -51
  26. package/lib/module/ui/context/OxyContext.js.map +1 -1
  27. package/lib/module/ui/context/commitSessionFlow.js +87 -0
  28. package/lib/module/ui/context/commitSessionFlow.js.map +1 -0
  29. package/lib/module/ui/hooks/useOxyEvent.js +25 -0
  30. package/lib/module/ui/hooks/useOxyEvent.js.map +1 -0
  31. package/lib/module/ui/screens/FileManagementScreen.js +3 -9
  32. package/lib/module/ui/screens/FileManagementScreen.js.map +1 -1
  33. package/lib/module/ui/session/createSessionClient.js +12 -8
  34. package/lib/module/ui/session/createSessionClient.js.map +1 -1
  35. package/lib/module/ui/session/tokenTransport.js +23 -33
  36. package/lib/module/ui/session/tokenTransport.js.map +1 -1
  37. package/lib/typescript/commonjs/index.d.ts +1 -0
  38. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts +39 -0
  41. package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -0
  42. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +3 -1
  43. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/ui/hooks/useOxyEvent.d.ts +8 -0
  45. package/lib/typescript/commonjs/ui/hooks/useOxyEvent.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/ui/screens/FileManagementScreen.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +12 -8
  48. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +11 -9
  50. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
  51. package/lib/typescript/module/index.d.ts +1 -0
  52. package/lib/typescript/module/index.d.ts.map +1 -1
  53. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  54. package/lib/typescript/module/ui/context/commitSessionFlow.d.ts +39 -0
  55. package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -0
  56. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +3 -1
  57. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  58. package/lib/typescript/module/ui/hooks/useOxyEvent.d.ts +8 -0
  59. package/lib/typescript/module/ui/hooks/useOxyEvent.d.ts.map +1 -0
  60. package/lib/typescript/module/ui/screens/FileManagementScreen.d.ts.map +1 -1
  61. package/lib/typescript/module/ui/session/createSessionClient.d.ts +12 -8
  62. package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
  63. package/lib/typescript/module/ui/session/tokenTransport.d.ts +11 -9
  64. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
  65. package/package.json +29 -17
  66. package/plugins/withSharedIdentityProvider.js +83 -0
  67. package/plugins/withSharedIdentityReader.js +46 -0
  68. package/src/index.ts +1 -0
  69. package/src/ui/context/OxyContext.tsx +51 -49
  70. package/src/ui/context/__tests__/commitSessionFlow.test.ts +192 -0
  71. package/src/ui/context/commitSessionFlow.ts +115 -0
  72. package/src/ui/context/oxyContextTypes.ts +3 -1
  73. package/src/ui/hooks/useOxyEvent.ts +22 -0
  74. package/src/ui/screens/FileManagementScreen.tsx +3 -9
  75. package/src/ui/session/__tests__/createSessionClient.test.ts +5 -5
  76. package/src/ui/session/__tests__/tokenTransport.test.ts +43 -60
  77. package/src/ui/session/createSessionClient.ts +13 -10
  78. package/src/ui/session/tokenTransport.ts +22 -41
@@ -23,7 +23,6 @@ import {
23
23
  installAuthRefreshHandler,
24
24
  startTokenRefreshScheduler,
25
25
  createAccountDialogController,
26
- refreshPersistedSession,
27
26
  logger as loggerUtil,
28
27
  syncHubAfterSignIn,
29
28
  } from '@oxyhq/core';
@@ -64,6 +63,7 @@ import {
64
63
  type CommitInput,
65
64
  } from './oxyContextTypes';
66
65
  import { DEFAULT_SESSION_VALIDITY_MS, loadUseFollowHook } from './oxyContextHelpers';
66
+ import { commitDeviceSetAndResolve } from './commitSessionFlow';
67
67
  import { useOxyAccountGraph } from './useOxyAccountGraph';
68
68
 
69
69
  export type { OxyContextState, PasswordSignInResult, OxyContextProviderProps } from './oxyContextTypes';
@@ -281,9 +281,16 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
281
281
  if (!sessionClientPairRef.current) {
282
282
  sessionClientPairRef.current = createSessionClient(
283
283
  oxyServices,
284
- authStore,
285
- () => {
286
- clearPersistedAuthSafe(authStore, logger);
284
+ (origin) => {
285
+ // Erase the DURABLE device credential only on a `request`-origin verdict
286
+ // (a REST sign-out / revocation). A `push`-origin empty state is a socket
287
+ // broadcast that may be a transient reconnect artifact — clearing the
288
+ // credential on it would strand the user signed out with no way back, so
289
+ // we clear only the local UI session and keep the credential (a dead one
290
+ // re-mints to `no_active_session` and resolves signed-out on next boot).
291
+ if (origin === 'request') {
292
+ clearPersistedAuthSafe(authStore, logger);
293
+ }
287
294
  void clearSessionStateRef.current().catch((clearError) => {
288
295
  logger('Failed to clear local state on remote sign-out', clearError);
289
296
  });
@@ -301,8 +308,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
301
308
  return;
302
309
  }
303
310
  if (state.accounts.length === 0) {
311
+ // Clear the LOCAL UI session on any applied empty state, but NEVER the
312
+ // durable device credential here: `syncFromClient` runs for socket-pushed
313
+ // (possibly transient) states too, and wiping the credential on one would
314
+ // strand the user signed out. The credential wipe is gated on a
315
+ // `request`-origin verdict in `onUnauthenticated` above.
304
316
  sessionClientHost.setCurrentAccountId(null);
305
- clearPersistedAuthSafe(authStore, logger);
306
317
  await clearSessionState();
307
318
  return;
308
319
  }
@@ -333,7 +344,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
333
344
  oxyServices,
334
345
  sessionClient,
335
346
  sessionClientHost,
336
- authStore,
337
347
  updateSessions,
338
348
  setActiveSessionId,
339
349
  loginSuccess,
@@ -526,47 +536,27 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
526
536
  setActiveSessionId(input.sessionId);
527
537
  }
528
538
 
529
- // Register into the device set. A deliberate sign-in ACTIVATES the account
530
- // (`registerAndActivate`); the cold boot only ensures membership and lets
531
- // the server's own `activeAccountId` win (`addCurrentAccount`). Both then
532
- // start the socket + project. Best-effort a failure never fails the
533
- // sign-in (cold boot re-registers on the next load).
534
- try {
535
- if (options.activate) {
536
- await sessionClient.registerAndActivate(input.userId);
537
- } else {
538
- await sessionClient.addCurrentAccount();
539
- }
540
- await sessionClient.start();
541
- await syncFromClient();
542
- } catch (registrationError) {
543
- loggerUtil.warn(
544
- 'commitSession: device-set registration failed',
545
- { component: 'OxyContext', method: 'commitSession' },
546
- registrationError as unknown,
547
- );
548
- }
549
-
550
- // Hydrate the full user (the commit input carries only minimal data). Fall
551
- // back to the minimal shape if the profile fetch fails.
552
- let fullUser: User | null = null;
553
- try {
554
- fullUser = await oxyServices.getCurrentUser();
555
- } catch (profileError) {
556
- if (__DEV__) {
557
- loggerUtil.debug(
558
- 'Failed to fetch full user on commit; using minimal fallback',
559
- { component: 'OxyContext', method: 'commitSession' },
560
- profileError as unknown,
561
- );
562
- }
563
- fullUser = (input.user as unknown as User) ?? null;
564
- }
565
- if (fullUser) {
566
- loginSuccess(fullUser);
567
- onAuthStateChangeRef.current?.(fullUser);
568
- }
569
- markAuthResolvedRef.current();
539
+ // Register into the device set, hydrate the full user, and flip the
540
+ // auth-resolution gate. A deliberate sign-in ACTIVATES the account and
541
+ // BLOCKS on the reconcile (register + socket + sessions projection) before
542
+ // resolving unchanged ordering. The cold boot resolves auth from the
543
+ // profile fetch FIRST and reconciles the device set in the background, so
544
+ // first paint is not held behind those extra round-trips. Reconcile is
545
+ // best-effort — a failure never fails the sign-in (cold boot re-registers
546
+ // on the next load).
547
+ await commitDeviceSetAndResolve({
548
+ activate: options.activate,
549
+ userId: input.userId,
550
+ fallbackUser: (input.user as unknown as User) ?? null,
551
+ registerAndActivate: (userId) => sessionClient.registerAndActivate(userId),
552
+ addCurrentAccount: () => sessionClient.addCurrentAccount(),
553
+ startSocket: () => sessionClient.start(),
554
+ syncFromClient,
555
+ getCurrentUser: () => oxyServices.getCurrentUser(),
556
+ loginSuccess,
557
+ onAuthStateChange: onAuthStateChangeRef.current,
558
+ markAuthResolved: markAuthResolvedRef.current,
559
+ });
570
560
 
571
561
  if (options.activate && options.hubSync && hubSync && isWebBrowser()) {
572
562
  try {
@@ -636,6 +626,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
636
626
  }
637
627
  void Linking.openURL(url);
638
628
  },
629
+ // Native-only probe so the controller can detect an installed Commons and
630
+ // deep-link straight into its approve screen (keeping the QR/polling live
631
+ // as fallback). `undefined` on web mirrors the `openUrl` split — the
632
+ // controller short-circuits, so `redirectToAuthorize` never runs for the
633
+ // `oxycommons://` payload.
634
+ canOpenApp: isWebBrowser() ? undefined : (url) => Linking.canOpenURL(url),
639
635
  });
640
636
  }
641
637
  const accountDialogController = accountDialogControllerRef.current;
@@ -782,7 +778,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
782
778
  if (document.visibilityState !== 'visible') return;
783
779
  const reconcile = async (): Promise<void> => {
784
780
  if (!oxyServices.getAccessToken() && sessionClientHost.getDeviceCredential()) {
785
- await refreshPersistedSession({ oxy: oxyServices, store: authStore });
781
+ // Route through the ONE shared single-flight the scheduler/preflight/401
782
+ // use — never a private mint lane — so a tab-focus reconcile can't
783
+ // double-rotate the device secret against them.
784
+ await oxyServices.httpService.refreshAccessToken('preflight');
786
785
  }
787
786
  if (!oxyServices.getAccessToken() && !sessionClientHost.getDeviceCredential()) {
788
787
  return;
@@ -794,7 +793,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
794
793
  };
795
794
  document.addEventListener('visibilitychange', onVisibility);
796
795
  return () => document.removeEventListener('visibilitychange', onVisibility);
797
- }, [oxyServices, sessionClient, sessionClientHost, authStore, syncFromClient]);
796
+ }, [oxyServices, sessionClient, sessionClientHost, syncFromClient]);
798
797
 
799
798
  // Exposed `refreshSessions`: re-bootstrap the server-authoritative device
800
799
  // state and reproject — the manual counterpart to the realtime socket.
@@ -898,6 +897,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
898
897
  storageKeyPrefix,
899
898
  clientId,
900
899
  oxyServices,
900
+ sessionClient,
901
901
  useFollow: useFollowHook,
902
902
  showBottomSheet: showBottomSheetForContext,
903
903
  openAvatarPicker,
@@ -947,6 +947,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
947
947
  storageKeyPrefix,
948
948
  clientId,
949
949
  oxyServices,
950
+ sessionClient,
950
951
  useFollowHook,
951
952
  showBottomSheetForContext,
952
953
  openAvatarPicker,
@@ -1016,6 +1017,7 @@ const LOADING_STATE: OxyContextState = {
1016
1017
  storageKeyPrefix: 'oxy_session',
1017
1018
  clientId: null,
1018
1019
  oxyServices: LOADING_STATE_OXY_SERVICES,
1020
+ sessionClient: null,
1019
1021
  openAvatarPicker: () => {},
1020
1022
  accountDialogController: null,
1021
1023
  isAccountDialogOpen: false,
@@ -0,0 +1,192 @@
1
+ import type { User } from '@oxyhq/core';
2
+ import { logger } from '@oxyhq/core';
3
+ import {
4
+ commitDeviceSetAndResolve,
5
+ type CommitDeviceSetAndResolveDeps,
6
+ } from '../commitSessionFlow';
7
+
8
+ // A resolved promise chain (addCurrentAccount -> start -> syncFromClient) settles
9
+ // entirely within the microtask queue, which is fully drained before a macrotask
10
+ // (`setTimeout`) runs — so one tick flushes a detached reconcile task.
11
+ const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
12
+
13
+ const makeUser = (id: string): User => ({ id, username: id } as unknown as User);
14
+
15
+ interface Deferred<T> {
16
+ promise: Promise<T>;
17
+ resolve: (value: T) => void;
18
+ reject: (reason?: unknown) => void;
19
+ }
20
+
21
+ const deferred = <T,>(): Deferred<T> => {
22
+ let resolve: (value: T) => void = () => undefined;
23
+ let reject: (reason?: unknown) => void = () => undefined;
24
+ const promise = new Promise<T>((res, rej) => {
25
+ resolve = res;
26
+ reject = rej;
27
+ });
28
+ return { promise, resolve, reject };
29
+ };
30
+
31
+ /**
32
+ * Build a deps object whose every step appends to a shared `order` log, so tests
33
+ * can assert both WHICH steps ran and the RELATIVE ordering between the
34
+ * auth-resolution gate and the device-set reconcile.
35
+ */
36
+ const buildDeps = (
37
+ order: string[],
38
+ overrides: Partial<CommitDeviceSetAndResolveDeps> = {},
39
+ ): CommitDeviceSetAndResolveDeps => ({
40
+ activate: false,
41
+ userId: 'u1',
42
+ fallbackUser: makeUser('fallback'),
43
+ registerAndActivate: jest.fn(async () => {
44
+ order.push('registerAndActivate');
45
+ }),
46
+ addCurrentAccount: jest.fn(async () => {
47
+ order.push('addCurrentAccount');
48
+ }),
49
+ startSocket: jest.fn(async () => {
50
+ order.push('startSocket');
51
+ }),
52
+ syncFromClient: jest.fn(async () => {
53
+ order.push('syncFromClient');
54
+ }),
55
+ getCurrentUser: jest.fn(async () => {
56
+ order.push('getCurrentUser');
57
+ return makeUser('u1');
58
+ }),
59
+ loginSuccess: jest.fn(() => {
60
+ order.push('loginSuccess');
61
+ }),
62
+ onAuthStateChange: jest.fn(() => {
63
+ order.push('onAuthStateChange');
64
+ }),
65
+ markAuthResolved: jest.fn(() => {
66
+ order.push('markAuthResolved');
67
+ }),
68
+ ...overrides,
69
+ });
70
+
71
+ describe('commitDeviceSetAndResolve — cold boot (activate: false)', () => {
72
+ it('resolves auth from getCurrentUser BEFORE the device-set reconcile', async () => {
73
+ const order: string[] = [];
74
+ const deps = buildDeps(order);
75
+
76
+ await commitDeviceSetAndResolve(deps);
77
+ await flush();
78
+
79
+ // loginSuccess + onAuthStateChange + markAuthResolved fire after getCurrentUser.
80
+ expect(deps.loginSuccess).toHaveBeenCalledWith(makeUser('u1'));
81
+ expect(deps.onAuthStateChange).toHaveBeenCalledWith(makeUser('u1'));
82
+ expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
83
+
84
+ expect(order.indexOf('getCurrentUser')).toBeLessThan(order.indexOf('loginSuccess'));
85
+ expect(order.indexOf('loginSuccess')).toBeLessThan(order.indexOf('markAuthResolved'));
86
+
87
+ // The reconcile ran (via addCurrentAccount, NOT registerAndActivate) but only
88
+ // AFTER the auth gate flipped — proving it did not block the resolve.
89
+ expect(deps.addCurrentAccount).toHaveBeenCalledTimes(1);
90
+ expect(deps.registerAndActivate).not.toHaveBeenCalled();
91
+ expect(order.indexOf('markAuthResolved')).toBeLessThan(order.indexOf('addCurrentAccount'));
92
+ expect(order.indexOf('addCurrentAccount')).toBeLessThan(order.indexOf('startSocket'));
93
+ expect(order.indexOf('startSocket')).toBeLessThan(order.indexOf('syncFromClient'));
94
+ });
95
+
96
+ it('still resolves auth even when the device-set reconcile hangs forever', async () => {
97
+ const order: string[] = [];
98
+ const hang = deferred<void>(); // addCurrentAccount never settles
99
+ const deps = buildDeps(order, {
100
+ addCurrentAccount: jest.fn(() => {
101
+ order.push('addCurrentAccount');
102
+ return hang.promise;
103
+ }),
104
+ });
105
+
106
+ // Must resolve without waiting on the hung reconcile.
107
+ await commitDeviceSetAndResolve(deps);
108
+
109
+ expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
110
+ expect(deps.loginSuccess).toHaveBeenCalledWith(makeUser('u1'));
111
+ expect(deps.addCurrentAccount).toHaveBeenCalledTimes(1);
112
+ // The hung step gates the rest of the chain — never reached.
113
+ expect(deps.startSocket).not.toHaveBeenCalled();
114
+ expect(deps.syncFromClient).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it('falls back to the minimal commit-input user when the profile fetch fails', async () => {
118
+ const order: string[] = [];
119
+ const deps = buildDeps(order, {
120
+ getCurrentUser: jest.fn(async () => {
121
+ throw new Error('offline');
122
+ }),
123
+ });
124
+
125
+ await commitDeviceSetAndResolve(deps);
126
+ await flush();
127
+
128
+ expect(deps.loginSuccess).toHaveBeenCalledWith(makeUser('fallback'));
129
+ expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
130
+ });
131
+
132
+ it('logs (never swallows) a failing background reconcile and still resolves', async () => {
133
+ const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
134
+ const order: string[] = [];
135
+ const failure = new Error('device-set boom');
136
+ const deps = buildDeps(order, {
137
+ addCurrentAccount: jest.fn(async () => {
138
+ throw failure;
139
+ }),
140
+ });
141
+
142
+ await commitDeviceSetAndResolve(deps);
143
+ await flush();
144
+
145
+ expect(deps.markAuthResolved).toHaveBeenCalledTimes(1);
146
+ expect(warnSpy).toHaveBeenCalledWith(
147
+ 'commitSession: device-set registration failed',
148
+ { component: 'OxyContext', method: 'commitSession' },
149
+ failure,
150
+ );
151
+ });
152
+ });
153
+
154
+ describe('commitDeviceSetAndResolve — deliberate sign-in (activate: true)', () => {
155
+ it('BLOCKS on the device-set reconcile before resolving auth (unchanged ordering)', async () => {
156
+ const order: string[] = [];
157
+ const gate = deferred<void>(); // registerAndActivate stays pending
158
+ const deps = buildDeps(order, {
159
+ activate: true,
160
+ registerAndActivate: jest.fn(() => {
161
+ order.push('registerAndActivate');
162
+ return gate.promise;
163
+ }),
164
+ });
165
+
166
+ const settled = jest.fn();
167
+ const pending = commitDeviceSetAndResolve(deps).then(settled);
168
+
169
+ // While the reconcile is pending, auth must NOT resolve yet.
170
+ await flush();
171
+ expect(deps.registerAndActivate).toHaveBeenCalledTimes(1);
172
+ expect(deps.addCurrentAccount).not.toHaveBeenCalled();
173
+ expect(deps.getCurrentUser).not.toHaveBeenCalled();
174
+ expect(deps.markAuthResolved).not.toHaveBeenCalled();
175
+ expect(settled).not.toHaveBeenCalled();
176
+
177
+ // Complete the reconcile → hydrate + resolve now runs.
178
+ gate.resolve();
179
+ await pending;
180
+
181
+ expect(order).toEqual([
182
+ 'registerAndActivate',
183
+ 'startSocket',
184
+ 'syncFromClient',
185
+ 'getCurrentUser',
186
+ 'loginSuccess',
187
+ 'onAuthStateChange',
188
+ 'markAuthResolved',
189
+ ]);
190
+ expect(settled).toHaveBeenCalledTimes(1);
191
+ });
192
+ });
@@ -0,0 +1,115 @@
1
+ import type { User } from '@oxyhq/core';
2
+ import { logger as loggerUtil } from '@oxyhq/core';
3
+
4
+ const LOG_CONTEXT = { component: 'OxyContext', method: 'commitSession' } as const;
5
+
6
+ export interface CommitDeviceSetAndResolveDeps {
7
+ /**
8
+ * Whether this commit is a deliberate sign-in on THIS device (activates the
9
+ * account and BLOCKS on the device-set reconcile before resolving) vs. a
10
+ * cold-boot restore (resolves auth from the profile fetch FIRST, then
11
+ * reconciles the device set in the background).
12
+ */
13
+ activate: boolean;
14
+ /** The committing account id (only used by `registerAndActivate`). */
15
+ userId?: string;
16
+ /** Minimal user carried by the commit input; used if the profile fetch fails. */
17
+ fallbackUser: User | null;
18
+ /** Deliberate sign-in: register + activate the account in the device set. */
19
+ registerAndActivate: (userId?: string) => Promise<void>;
20
+ /** Cold boot: ensure membership only; the server's `activeAccountId` wins. */
21
+ addCurrentAccount: () => Promise<void>;
22
+ /** Start the device-scoped socket. */
23
+ startSocket: () => Promise<void>;
24
+ /** Project the server-authoritative device state onto local sessions. */
25
+ syncFromClient: () => Promise<void>;
26
+ /** Hydrate the full profile (the commit input carries only minimal data). */
27
+ getCurrentUser: () => Promise<User | null>;
28
+ loginSuccess: (user: User) => void;
29
+ onAuthStateChange?: (user: User | null) => void;
30
+ markAuthResolved: () => void;
31
+ }
32
+
33
+ /**
34
+ * Device-set registration + full-user hydration + auth-resolution gate flip.
35
+ *
36
+ * On a deliberate sign-in (`activate: true`) the device-set reconcile is awaited
37
+ * BEFORE hydrating and resolving — unchanged legacy ordering. On a cold boot
38
+ * (`activate: false`) auth resolves from the profile fetch FIRST so the app can
39
+ * paint an authenticated shell without waiting on device-set registration, the
40
+ * socket connect, and the authoritative sessions projection — those reconcile in
41
+ * a DETACHED background task a moment later.
42
+ */
43
+ export async function commitDeviceSetAndResolve(
44
+ deps: CommitDeviceSetAndResolveDeps,
45
+ ): Promise<void> {
46
+ const {
47
+ activate,
48
+ userId,
49
+ fallbackUser,
50
+ registerAndActivate,
51
+ addCurrentAccount,
52
+ startSocket,
53
+ syncFromClient,
54
+ getCurrentUser,
55
+ loginSuccess,
56
+ onAuthStateChange,
57
+ markAuthResolved,
58
+ } = deps;
59
+
60
+ // Register into the device set, start the socket, then project server truth. A
61
+ // deliberate sign-in ACTIVATES the account (`registerAndActivate`); the cold
62
+ // boot only ensures membership and lets the server's own `activeAccountId` win
63
+ // (`addCurrentAccount`).
64
+ const reconcileDeviceSet = async (): Promise<void> => {
65
+ if (activate) {
66
+ await registerAndActivate(userId);
67
+ } else {
68
+ await addCurrentAccount();
69
+ }
70
+ await startSocket();
71
+ await syncFromClient();
72
+ };
73
+
74
+ const logReconcileError = (registrationError: unknown): void => {
75
+ loggerUtil.warn('commitSession: device-set registration failed', LOG_CONTEXT, registrationError);
76
+ };
77
+
78
+ // Hydrate the full user then flip the auth-resolution gate. Falls back to the
79
+ // minimal commit-input shape if the profile fetch fails — never leaves auth
80
+ // unresolved.
81
+ const hydrateAndResolve = async (): Promise<void> => {
82
+ let fullUser: User | null = null;
83
+ try {
84
+ fullUser = await getCurrentUser();
85
+ } catch (profileError) {
86
+ if (__DEV__) {
87
+ loggerUtil.debug('Failed to fetch full user on commit; using minimal fallback', LOG_CONTEXT, profileError);
88
+ }
89
+ fullUser = fallbackUser;
90
+ }
91
+ if (fullUser) {
92
+ loginSuccess(fullUser);
93
+ onAuthStateChange?.(fullUser);
94
+ }
95
+ markAuthResolved();
96
+ };
97
+
98
+ if (activate) {
99
+ // Deliberate sign-in: block on the device-set reconcile (unchanged ordering)
100
+ // before hydrating + resolving, so the account is fully registered/active.
101
+ try {
102
+ await reconcileDeviceSet();
103
+ } catch (registrationError) {
104
+ logReconcileError(registrationError);
105
+ }
106
+ await hydrateAndResolve();
107
+ return;
108
+ }
109
+
110
+ // Cold boot: resolve auth from the profile fetch FIRST; reconcile the device
111
+ // set (membership + socket + sessions projection) in a DETACHED background
112
+ // task so first paint is not blocked behind those round-trips.
113
+ await hydrateAndResolve();
114
+ void reconcileDeviceSet().catch(logReconcileError);
115
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import type { OxyServices, User, SessionLoginResponse, AccountNode, CreateAccountInput, ClientSession, AccountDialogController, AccountDialogView, ApiError } from '@oxyhq/core';
2
+ import type { OxyServices, User, SessionLoginResponse, AccountNode, CreateAccountInput, ClientSession, AccountDialogController, AccountDialogView, ApiError, SessionClient } from '@oxyhq/core';
3
3
  import type { SecurityAlert } from '@oxyhq/contracts';
4
4
  import type { UseFollowHook } from '../hooks/useFollow.types';
5
5
  import type { useLanguageManagement } from '../hooks/useLanguageManagement';
@@ -76,6 +76,8 @@ export interface OxyContextState {
76
76
  storageKeyPrefix: string;
77
77
  clientId: string | null;
78
78
  oxyServices: OxyServices;
79
+ /** Server-authoritative device session client. `null` before an `OxyProvider` is mounted. */
80
+ sessionClient: SessionClient | null;
79
81
  useFollow?: UseFollowHook;
80
82
  showBottomSheet?: (screenOrConfig: RouteName | { screen: RouteName; props?: Record<string, unknown> }) => void;
81
83
  openAvatarPicker: () => void;
@@ -0,0 +1,22 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ import { useOxy } from '../context/OxyContext';
4
+
5
+ /**
6
+ * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`)
7
+ * for the lifetime of the component. Payloads arrive as `unknown` — callers
8
+ * validate shape. Handler identity may change between renders; the latest one
9
+ * is always invoked.
10
+ */
11
+ export function useOxyEvent(event: string, handler: (payload: unknown) => void): void {
12
+ const { sessionClient } = useOxy();
13
+ const handlerRef = useRef(handler);
14
+ handlerRef.current = handler;
15
+
16
+ useEffect(() => {
17
+ if (!sessionClient) return;
18
+ return sessionClient.onServerEvent(event, (payload) => {
19
+ handlerRef.current(payload);
20
+ });
21
+ }, [sessionClient, event]);
22
+ }
@@ -242,7 +242,6 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
242
242
  const [lastSelectedFileId, setLastSelectedFileId] = useState<string | null>(null);
243
243
  const scrollViewRef = useRef<ScrollView>(null);
244
244
  const photoScrollViewRef = useRef<ScrollView>(null);
245
- const itemRefs = useRef<Map<string, number>>(new Map()); // Track item positions
246
245
  const containerRef = useRef<View>(null);
247
246
  useEffect(() => {
248
247
  if (initialSelectedIds?.length) {
@@ -1003,11 +1002,6 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1003
1002
  // filteredFiles is already sorted, so just use it directly
1004
1003
  const sortedFiles = filteredFiles;
1005
1004
 
1006
- // Store file positions for scrolling
1007
- sortedFiles.forEach((file, index) => {
1008
- itemRefs.current.set(file.id, index);
1009
- });
1010
-
1011
1005
  return sortedFiles.map((file) => {
1012
1006
  const isImage = file.contentType.startsWith('image/');
1013
1007
  const isPDF = file.contentType.includes('pdf');
@@ -1114,10 +1108,10 @@ const FileManagementScreen: React.FC<FileManagementScreenProps> = ({
1114
1108
  useEffect(() => {
1115
1109
  if (lastSelectedFileId && selectMode) {
1116
1110
  if (viewMode === 'all' && scrollViewRef.current) {
1117
- // Find the index of the selected file
1118
- const itemIndex = itemRefs.current.get(lastSelectedFileId);
1111
+ // Find the index of the selected file (filteredFiles is already sorted)
1112
+ const itemIndex = filteredFiles.findIndex(file => file.id === lastSelectedFileId);
1119
1113
 
1120
- if (itemIndex !== undefined && itemIndex >= 0) {
1114
+ if (itemIndex >= 0) {
1121
1115
  // Estimate item height (GroupedItem with dense mode is approximately 60-70px)
1122
1116
  // Account for description rows which add extra height
1123
1117
  const baseItemHeight = 65;
@@ -1,4 +1,4 @@
1
- import { SessionClient, createMemoryAuthStateStore } from '@oxyhq/core';
1
+ import { SessionClient } from '@oxyhq/core';
2
2
 
3
3
  type Handler = (...args: unknown[]) => void;
4
4
  class FakeSocket {
@@ -36,7 +36,7 @@ describe('createSessionClient', () => {
36
36
  test('wires a SessionClient instance backed by the host + device-first token transport', () => {
37
37
  const oxy = fakeOxy();
38
38
 
39
- const { client, host } = createSessionClient(oxy as never, createMemoryAuthStateStore());
39
+ const { client, host } = createSessionClient(oxy as never);
40
40
 
41
41
  expect(client).toBeInstanceOf(SessionClient);
42
42
  expect(typeof client.bootstrap).toBe('function');
@@ -47,7 +47,7 @@ describe('createSessionClient', () => {
47
47
  test('the returned host reflects setCurrentAccountId', () => {
48
48
  const oxy = fakeOxy();
49
49
 
50
- const { host } = createSessionClient(oxy as never, createMemoryAuthStateStore());
50
+ const { host } = createSessionClient(oxy as never);
51
51
 
52
52
  expect(host.getCurrentAccountId()).toBeNull();
53
53
  host.setCurrentAccountId('u1');
@@ -58,7 +58,7 @@ describe('createSessionClient', () => {
58
58
  ioMock.mockClear();
59
59
  const oxy = fakeOxy();
60
60
 
61
- const { client } = createSessionClient(oxy as never, createMemoryAuthStateStore());
61
+ const { client } = createSessionClient(oxy as never);
62
62
  await client.start();
63
63
 
64
64
  // The injected `io` was used to open the session socket at the base URL.
@@ -72,7 +72,7 @@ describe('createSessionClient', () => {
72
72
  const oxy = fakeOxy();
73
73
  const onUnauthenticated = jest.fn();
74
74
 
75
- const { client } = createSessionClient(oxy as never, createMemoryAuthStateStore(), onUnauthenticated);
75
+ const { client } = createSessionClient(oxy as never, onUnauthenticated);
76
76
 
77
77
  // The client was constructed with the callback (no direct getter is exposed;
78
78
  // constructing without throwing + wiring the socket factory is the contract