@oxy.so/services 12.13.0 → 12.14.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 (28) hide show
  1. package/lib/commonjs/ui/boot/runProviderColdBoot.js +188 -88
  2. package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
  3. package/lib/commonjs/ui/context/OxyContext.js +116 -3
  4. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  5. package/lib/commonjs/ui/session/cachedBoot.js +95 -0
  6. package/lib/commonjs/ui/session/cachedBoot.js.map +1 -0
  7. package/lib/module/ui/boot/runProviderColdBoot.js +188 -88
  8. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  9. package/lib/module/ui/context/OxyContext.js +117 -4
  10. package/lib/module/ui/context/OxyContext.js.map +1 -1
  11. package/lib/module/ui/session/cachedBoot.js +92 -0
  12. package/lib/module/ui/session/cachedBoot.js.map +1 -0
  13. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +24 -0
  14. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  15. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  16. package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts +48 -0
  17. package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts.map +1 -0
  18. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +24 -0
  19. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  20. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  21. package/lib/typescript/module/ui/session/cachedBoot.d.ts +48 -0
  22. package/lib/typescript/module/ui/session/cachedBoot.d.ts.map +1 -0
  23. package/package.json +4 -4
  24. package/src/ui/boot/__tests__/cachedColdBoot.test.ts +187 -0
  25. package/src/ui/boot/runProviderColdBoot.ts +229 -95
  26. package/src/ui/context/OxyContext.tsx +146 -3
  27. package/src/ui/session/__tests__/cachedBoot.test.ts +32 -0
  28. package/src/ui/session/cachedBoot.ts +139 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `runProviderColdBoot` with a session saved on the device: it paints from the
3
+ * cache before any network answers, then checks the classification and the
4
+ * session in the background. A first boot (nothing cached) is unchanged.
5
+ */
6
+ import type { AuthStateStore, PersistedAuthState, SessionClient } from '@oxy.so/core/session';
7
+
8
+ jest.mock('@oxy.so/core/session', () => {
9
+ const actual = jest.requireActual('@oxy.so/core/session');
10
+ return { __esModule: true, ...actual, runSessionColdBoot: jest.fn() };
11
+ });
12
+
13
+ import { runSessionColdBoot } from '@oxy.so/core/session';
14
+ import { runProviderColdBoot, type RunProviderColdBootOptions } from '../runProviderColdBoot';
15
+
16
+ const mockRunSessionColdBoot = runSessionColdBoot as jest.Mock;
17
+ type BootOptions = Parameters<typeof runSessionColdBoot>[0];
18
+
19
+ const OFFICIAL = {
20
+ id: 'app',
21
+ name: 'App',
22
+ type: 'first_party',
23
+ isOfficial: true,
24
+ isInternal: false,
25
+ scopes: [],
26
+ };
27
+
28
+ function credentialStore(): AuthStateStore {
29
+ const state: PersistedAuthState = {
30
+ sessionId: 's1',
31
+ userId: 'u1',
32
+ deviceId: 'd1',
33
+ deviceSecret: 'secret',
34
+ };
35
+ return {
36
+ load: jest.fn(async () => state),
37
+ save: jest.fn(async () => true),
38
+ clear: jest.fn(async () => undefined),
39
+ } as unknown as AuthStateStore;
40
+ }
41
+
42
+ function makeOptions(paints: boolean): RunProviderColdBootOptions & {
43
+ getPublic: jest.Mock;
44
+ release: (app: unknown) => void;
45
+ fail: (error: Error) => void;
46
+ } {
47
+ let release: (app: unknown) => void = () => undefined;
48
+ let fail: (error: Error) => void = () => undefined;
49
+ const getPublic = jest.fn(
50
+ () =>
51
+ new Promise((resolve, reject) => {
52
+ release = resolve;
53
+ fail = reject;
54
+ }),
55
+ );
56
+ const markAuthResolved = jest.fn();
57
+ return {
58
+ oxyServices: { apps: { getPublic } } as unknown as RunProviderColdBootOptions['oxyServices'],
59
+ authStore: credentialStore(),
60
+ clientId: 'oxy_dk_test',
61
+ sessionClient: { start: jest.fn(async () => undefined) } as unknown as SessionClient,
62
+ syncDeviceCredentialToHost: jest.fn(async () => undefined),
63
+ commitSession: jest.fn(async () => undefined),
64
+ markAuthResolved,
65
+ setTokenReady: jest.fn(),
66
+ paintCachedSession: jest.fn(async () => {
67
+ if (paints) markAuthResolved();
68
+ return paints;
69
+ }),
70
+ onCachedSessionRejected: jest.fn(async () => undefined),
71
+ onCachedSessionUnconfirmed: jest.fn(),
72
+ getPublic,
73
+ release: (app) => release(app),
74
+ fail: (error) => fail(error),
75
+ };
76
+ }
77
+
78
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
79
+
80
+ beforeEach(() => {
81
+ mockRunSessionColdBoot.mockReset();
82
+ window.history.replaceState(null, '', '/');
83
+ });
84
+
85
+ it('paints a saved session before the registry or the mint answers, then mints for a verdict', async () => {
86
+ mockRunSessionColdBoot.mockImplementation(async (opts: BootOptions) => {
87
+ const session = { sessionId: 's1', userId: 'u1', accessToken: 'minted', via: 'mint' };
88
+ await opts.onSession?.(session);
89
+ return { kind: 'session', via: session.via, session };
90
+ });
91
+ const options = makeOptions(true);
92
+
93
+ const boot = runProviderColdBoot(options);
94
+ await tick();
95
+
96
+ expect(options.markAuthResolved).toHaveBeenCalled();
97
+ expect(mockRunSessionColdBoot).not.toHaveBeenCalled();
98
+
99
+ options.release(OFFICIAL);
100
+ await boot;
101
+
102
+ expect(mockRunSessionColdBoot).toHaveBeenCalledWith(
103
+ expect.objectContaining({ requireServerVerdict: true }),
104
+ );
105
+ expect(options.commitSession).toHaveBeenCalledWith(
106
+ { sessionId: 's1', userId: 'u1', accessToken: 'minted', deviceState: undefined },
107
+ { activate: false },
108
+ );
109
+ expect(options.onCachedSessionRejected).not.toHaveBeenCalled();
110
+ expect(options.onCachedSessionUnconfirmed).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it.each([
114
+ ['the server ends the holder', OFFICIAL, true],
115
+ [
116
+ 'the registry now classifies the app OAuth-only',
117
+ { ...OFFICIAL, type: 'third_party', isOfficial: false },
118
+ false,
119
+ ],
120
+ ])('signs a painted session out when %s', async (_case, app, mints) => {
121
+ mockRunSessionColdBoot.mockImplementation(async (opts: BootOptions) => {
122
+ opts.onHolderRejected?.();
123
+ await opts.onSignedOut?.('no_session');
124
+ return { kind: 'unauthenticated' };
125
+ });
126
+ const options = makeOptions(true);
127
+
128
+ const boot = runProviderColdBoot(options);
129
+ await tick();
130
+ options.release(app);
131
+ await boot;
132
+
133
+ expect(mockRunSessionColdBoot).toHaveBeenCalledTimes(mints ? 1 : 0);
134
+ expect(options.onCachedSessionRejected).toHaveBeenCalledTimes(1);
135
+ expect(options.onCachedSessionUnconfirmed).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it('keeps a painted session the server could not be asked about, and asks again', async () => {
139
+ mockRunSessionColdBoot.mockImplementationOnce(async (opts: BootOptions) => {
140
+ await opts.onSignedOut?.('no_session');
141
+ return { kind: 'unauthenticated' };
142
+ });
143
+ const options = makeOptions(true);
144
+
145
+ const boot = runProviderColdBoot(options);
146
+ await tick();
147
+ options.fail(new Error('offline'));
148
+ await boot;
149
+
150
+ expect(options.onCachedSessionRejected).not.toHaveBeenCalled();
151
+ const retry = (options.onCachedSessionUnconfirmed as jest.Mock).mock.calls[0][0];
152
+ mockRunSessionColdBoot.mockImplementationOnce(async (opts: BootOptions) => {
153
+ const session = { sessionId: 's1', userId: 'u1', accessToken: 'minted', via: 'mint' };
154
+ await opts.onSession?.(session);
155
+ return { kind: 'session', via: session.via, session };
156
+ });
157
+ const retried = retry();
158
+ await tick();
159
+ options.release(OFFICIAL);
160
+ await retried;
161
+
162
+ expect(options.commitSession).toHaveBeenCalledTimes(1);
163
+ expect(options.onCachedSessionUnconfirmed).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it('a first boot with nothing cached still waits for the verdict and the handoff', async () => {
167
+ mockRunSessionColdBoot.mockImplementation(async (opts: BootOptions) => {
168
+ await opts.onSignedOut?.('no_session');
169
+ return { kind: 'unauthenticated' };
170
+ });
171
+ const options = makeOptions(false);
172
+
173
+ const boot = runProviderColdBoot(options);
174
+ await tick();
175
+
176
+ expect(options.markAuthResolved).not.toHaveBeenCalled();
177
+ expect(mockRunSessionColdBoot).not.toHaveBeenCalled();
178
+
179
+ options.release(OFFICIAL);
180
+ await boot;
181
+
182
+ expect(mockRunSessionColdBoot).toHaveBeenCalledWith(
183
+ expect.objectContaining({ requireServerVerdict: false }),
184
+ );
185
+ expect(options.markAuthResolved).toHaveBeenCalled();
186
+ expect(options.onCachedSessionRejected).not.toHaveBeenCalled();
187
+ });
@@ -121,11 +121,223 @@ export interface RunProviderColdBootOptions {
121
121
  ) => Promise<void>;
122
122
  markAuthResolved: () => void;
123
123
  setTokenReady: (ready: boolean) => void;
124
+ /**
125
+ * Show the session saved on this device before any network: the account from
126
+ * its cached profile, auth resolved. Resolves `true` when it did, `false` when
127
+ * the device holds nothing a first paint can use (then the boot runs as a
128
+ * first boot). Never consulted in `sessionMode: 'identity'` or with an OAuth
129
+ * return on the URL.
130
+ */
131
+ paintCachedSession?: () => Promise<boolean>;
132
+ /**
133
+ * The server ended the painted session: the registry now classifies the app
134
+ * as OAuth-only, or the mint answered this holder `invalid_device_secret` /
135
+ * `no_active_session` and no recovery lane restored one. Sign out now.
136
+ */
137
+ onCachedSessionRejected?: () => Promise<void>;
138
+ /**
139
+ * The painted session could not be checked yet (offline, the registry or the
140
+ * mint unreachable). It stays on screen; call `retry` later to ask again.
141
+ */
142
+ onCachedSessionUnconfirmed?: (retry: () => Promise<void>) => void;
143
+ }
144
+
145
+ type ClassificationLane = 'device' | 'oauth';
146
+
147
+ /** Starts the provider's fresh classification; a rejection is observed, not swallowed. */
148
+ function startClassification(opts: RunProviderColdBootOptions): Promise<ClassificationLane> {
149
+ const classification = (async () =>
150
+ opts.resolveSessionLane?.() ??
151
+ resolveApplicationSessionLane(opts.oxyServices, opts.clientId))();
152
+ // Observed so a rejection that lands before it is awaited is not reported as
153
+ // unhandled; every caller still awaits it and sees the rejection.
154
+ classification.catch(() => undefined);
155
+ return classification;
156
+ }
157
+
158
+ /** What the device lanes made of this holder: a session, a verdict against it, or no answer. */
159
+ type DeviceRestoreOutcome = 'session' | 'rejected' | 'unreachable';
160
+
161
+ /**
162
+ * The device lanes of the boot (`runSessionColdBoot`), for a first boot or for
163
+ * the background check of a painted one.
164
+ *
165
+ * A first boot (`painted: false`) is on the critical path: its committed
166
+ * session resolves auth, bounded by {@link SESSION_HANDOFF_DEADLINE_MS}. A
167
+ * painted boot already resolved auth from the cache, so nothing waits on the
168
+ * handoff and the pure-local warm token is skipped: the check needs the mint's
169
+ * verdict, which a still-valid token cannot give.
170
+ */
171
+ async function restoreDeviceSession(
172
+ opts: RunProviderColdBootOptions,
173
+ painted: boolean,
174
+ ): Promise<DeviceRestoreOutcome> {
175
+ const {
176
+ oxyServices,
177
+ authStore,
178
+ sessionMode = 'account',
179
+ identity,
180
+ sessionClient,
181
+ syncDeviceCredentialToHost,
182
+ commitSession,
183
+ markAuthResolved,
184
+ } = opts;
185
+
186
+ await opts.beforeDeviceBoot?.();
187
+
188
+ // Best-effort connectivity probe up front: an EXPLICIT offline verdict skips
189
+ // the two doomed network steps so routing settles immediately instead of
190
+ // burning the overall deadline on a mint that cannot succeed. Any ambiguity
191
+ // resolves to "online" — the network steps still run.
192
+ const offline = await detectOfflineHint();
193
+
194
+ let outcome: DeviceRestoreOutcome = 'unreachable';
195
+ let holderRejected = false;
196
+ await runSessionColdBoot({
197
+ oxy: oxyServices,
198
+ store: authStore,
199
+ platform: { isWeb: isWebBrowser(), isNative: !isWebBrowser() },
200
+ sessionMode,
201
+ identity,
202
+ // The device-wide shared credential slot, enabling the `shared-device-adopt`
203
+ // lane: a newly installed official app joins this device's existing session
204
+ // instead of falling back to signing a challenge with the Commons identity
205
+ // key. `null` on web, where each origin is its own device by design; the
206
+ // core lane is also skipped outright in `sessionMode: 'identity'`.
207
+ sharedDeviceCredential: createPlatformSharedDeviceCredentialStore() ?? undefined,
208
+ overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE_MS,
209
+ isOffline: () => offline,
210
+ requireServerVerdict: painted,
211
+ onHolderRejected: () => {
212
+ holderRejected = true;
213
+ },
214
+ onStepDeadline: (stepId) => {
215
+ loggerUtil.warn(
216
+ `Cold-boot step "${stepId}" exceeded the ${COLD_BOOT_OVERALL_DEADLINE_MS}ms overall deadline — abandoned; routing proceeds signed-out`,
217
+ { component: 'runProviderColdBoot', method: 'onStepDeadline' },
218
+ );
219
+ },
220
+ onSession: async (session) => {
221
+ outcome = 'session';
222
+ // Mint already persisted `{deviceId, deviceSecret}` to the store; sync the
223
+ // in-memory SessionClient host so sockets + tab-focus re-mint can use it.
224
+ await syncDeviceCredentialToHost();
225
+ const handoff = commitSession(
226
+ {
227
+ sessionId: session.sessionId,
228
+ accessToken: session.accessToken,
229
+ userId: session.userId,
230
+ deviceState: session.state,
231
+ },
232
+ { activate: false },
233
+ );
234
+ if (painted) {
235
+ // The account is already on screen; the commit refreshes its profile.
236
+ await handoff;
237
+ return;
238
+ }
239
+ let handoffDeadlineId: ReturnType<typeof setTimeout> | undefined;
240
+ await Promise.race([
241
+ handoff,
242
+ new Promise<void>((resolve) => {
243
+ handoffDeadlineId = setTimeout(resolve, SESSION_HANDOFF_DEADLINE_MS);
244
+ }),
245
+ ]).finally(() => {
246
+ if (handoffDeadlineId !== undefined) {
247
+ clearTimeout(handoffDeadlineId);
248
+ }
249
+ });
250
+ markAuthResolved();
251
+ },
252
+ onSignedOut: async () => {
253
+ outcome = holderRejected ? 'rejected' : 'unreachable';
254
+ await syncDeviceCredentialToHost();
255
+ // A painted session is the caller's to end or keep (see verifyCachedSession).
256
+ if (painted) return;
257
+ const cred = await loadPersistedDeviceCredential(authStore);
258
+ if (cred) {
259
+ try {
260
+ await sessionClient.start();
261
+ } catch (socketError) {
262
+ if (__DEV__) {
263
+ loggerUtil.debug(
264
+ 'Device socket start failed (non-fatal)',
265
+ { component: 'runProviderColdBoot' },
266
+ socketError,
267
+ );
268
+ }
269
+ }
270
+ }
271
+ markAuthResolved();
272
+ },
273
+ onStepError: (id, error) => {
274
+ if (__DEV__) {
275
+ loggerUtil.debug(
276
+ `Cold-boot step "${id}" errored (non-fatal, falling through)`,
277
+ { component: 'runProviderColdBoot' },
278
+ error,
279
+ );
280
+ }
281
+ },
282
+ });
283
+ await opts.afterDeviceBoot?.();
284
+ return outcome;
285
+ }
286
+
287
+ /**
288
+ * The background check of a painted session: the registry's classification and
289
+ * a mint with this holder's own credential (whose commit refreshes the profile).
290
+ * Each is asked until the server answers it. A verdict against the session
291
+ * signs out at that moment; no answer keeps the cached session on screen and
292
+ * hands the caller a retry.
293
+ */
294
+ async function verifyCachedSession(
295
+ opts: RunProviderColdBootOptions,
296
+ verified: { lane: boolean; session: boolean },
297
+ classification: Promise<ClassificationLane>,
298
+ ): Promise<void> {
299
+ if (!verified.lane) {
300
+ let lane: ClassificationLane | null = null;
301
+ try {
302
+ lane = await classification;
303
+ } catch {
304
+ // The registry could not be asked (offline, timeout, 5xx). That is no
305
+ // verdict on this app: the cached `device` classification stands until it
306
+ // answers.
307
+ }
308
+ if (lane === 'oauth') {
309
+ await opts.onCachedSessionRejected?.();
310
+ return;
311
+ }
312
+ verified.lane = lane === 'device';
313
+ }
314
+ if (!verified.session) {
315
+ const outcome = await restoreDeviceSession(opts, true);
316
+ if (outcome === 'rejected') {
317
+ await opts.onCachedSessionRejected?.();
318
+ return;
319
+ }
320
+ verified.session = outcome === 'session';
321
+ }
322
+ if (!verified.lane || !verified.session) {
323
+ opts.onCachedSessionUnconfirmed?.(() =>
324
+ verifyCachedSession(
325
+ opts,
326
+ verified,
327
+ verified.lane ? Promise.resolve('device') : startClassification(opts),
328
+ ),
329
+ );
330
+ }
124
331
  }
125
332
 
126
333
  /**
127
334
  * Device-first cold boot for `@oxy.so/services` providers.
128
335
  *
336
+ * A returning session saved on this device (account mode, the app last
337
+ * classified `device`, its profile cached) paints first, with no network, and
338
+ * is then checked in the background — see `paintCachedSession`. Otherwise, a
339
+ * first boot:
340
+ *
129
341
  * Ordered pipeline:
130
342
  * 1. Complete an OAuth authorization-code return already on the URL (web)
131
343
  * 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
@@ -148,9 +360,6 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
148
360
  clientId,
149
361
  authRedirectUri,
150
362
  sessionMode = 'account',
151
- identity,
152
- sessionClient,
153
- syncDeviceCredentialToHost,
154
363
  commitSession,
155
364
  markAuthResolved,
156
365
  setTokenReady,
@@ -162,14 +371,21 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
162
371
 
163
372
  try {
164
373
  // Classification precedes every USE of a local secret, the shared credential
165
- // and the identity key. Unavailable registry metadata fails closed, including
166
- // offline boot. It starts now, before anything else, so its round trip
167
- // overlaps the local work below.
168
- const classification = (async () =>
169
- opts.resolveSessionLane?.() ?? resolveApplicationSessionLane(oxyServices, clientId))();
170
- // Observed so a rejection that lands before it is awaited is not reported as
171
- // unhandled; every path below still awaits it and sees the rejection.
172
- classification.catch(() => undefined);
374
+ // and the identity key. Unavailable registry metadata fails closed on a first
375
+ // boot, including offline. It starts now, before anything else, so its round
376
+ // trip overlaps the local work below.
377
+ const classification = startClassification(opts);
378
+
379
+ // A returning session saved on this device paints at once from the cache:
380
+ // the account, its cached content, auth resolved, no network on the way.
381
+ // The classification and the session are then checked in the background,
382
+ // and a verdict against either signs out at that moment. Identity-bound
383
+ // clients and OAuth returns keep the path below.
384
+ const freshLaunch = !identityBound && !hasOAuthReturnOnUrl();
385
+ if (freshLaunch && (await opts.paintCachedSession?.())) {
386
+ await verifyCachedSession(opts, { lane: false, session: false }, classification);
387
+ return;
388
+ }
173
389
 
174
390
  // The verdict decides HOW a session is restored, never WHETHER there is one.
175
391
  // A web origin with nothing to restore and no OAuth return on the URL ends
@@ -177,7 +393,7 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
177
393
  // for an answer that cannot change the outcome. The classification still
178
394
  // completes below and still gates every later sign-in
179
395
  // (`ensureDeviceSessionLane` shares this same in-flight lookup).
180
- if (!identityBound && isWebBrowser() && !hasOAuthReturnOnUrl()) {
396
+ if (freshLaunch && isWebBrowser()) {
181
397
  if (!(await hasRestorableState(authStore))) {
182
398
  markAuthResolved();
183
399
  const lane = await classification;
@@ -215,89 +431,7 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
215
431
  return;
216
432
  }
217
433
 
218
- await opts.beforeDeviceBoot?.();
219
-
220
- // Best-effort connectivity probe up front: an EXPLICIT offline verdict skips
221
- // the two doomed network steps so routing settles immediately instead of
222
- // burning the overall deadline on a mint that cannot succeed. Any ambiguity
223
- // resolves to "online" — the network steps still run.
224
- const offline = await detectOfflineHint();
225
-
226
- await runSessionColdBoot({
227
- oxy: oxyServices,
228
- store: authStore,
229
- platform: { isWeb: isWebBrowser(), isNative: !isWebBrowser() },
230
- sessionMode,
231
- identity,
232
- // The device-wide shared credential slot, enabling the `shared-device-adopt`
233
- // lane: a newly installed official app joins this device's existing session
234
- // instead of falling back to signing a challenge with the Commons identity
235
- // key. `null` on web, where each origin is its own device by design; the
236
- // core lane is also skipped outright in `sessionMode: 'identity'`.
237
- sharedDeviceCredential: createPlatformSharedDeviceCredentialStore() ?? undefined,
238
- overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE_MS,
239
- isOffline: () => offline,
240
- onStepDeadline: (stepId) => {
241
- loggerUtil.warn(
242
- `Cold-boot step "${stepId}" exceeded the ${COLD_BOOT_OVERALL_DEADLINE_MS}ms overall deadline — abandoned; routing proceeds signed-out`,
243
- { component: 'runProviderColdBoot', method: 'onStepDeadline' },
244
- );
245
- },
246
- onSession: async (session) => {
247
- // Mint already persisted `{deviceId, deviceSecret}` to the store; sync the
248
- // in-memory SessionClient host so sockets + tab-focus re-mint can use it.
249
- await syncDeviceCredentialToHost();
250
- const handoff = commitSession(
251
- {
252
- sessionId: session.sessionId,
253
- accessToken: session.accessToken,
254
- userId: session.userId,
255
- deviceState: session.state,
256
- },
257
- { activate: false },
258
- );
259
- let handoffDeadlineId: ReturnType<typeof setTimeout> | undefined;
260
- await Promise.race([
261
- handoff,
262
- new Promise<void>((resolve) => {
263
- handoffDeadlineId = setTimeout(resolve, SESSION_HANDOFF_DEADLINE_MS);
264
- }),
265
- ]).finally(() => {
266
- if (handoffDeadlineId !== undefined) {
267
- clearTimeout(handoffDeadlineId);
268
- }
269
- });
270
- markAuthResolved();
271
- },
272
- onSignedOut: async () => {
273
- await syncDeviceCredentialToHost();
274
- const cred = await loadPersistedDeviceCredential(authStore);
275
- if (cred) {
276
- try {
277
- await sessionClient.start();
278
- } catch (socketError) {
279
- if (__DEV__) {
280
- loggerUtil.debug(
281
- 'Device socket start failed (non-fatal)',
282
- { component: 'runProviderColdBoot' },
283
- socketError,
284
- );
285
- }
286
- }
287
- }
288
- markAuthResolved();
289
- },
290
- onStepError: (id, error) => {
291
- if (__DEV__) {
292
- loggerUtil.debug(
293
- `Cold-boot step "${id}" errored (non-fatal, falling through)`,
294
- { component: 'runProviderColdBoot' },
295
- error,
296
- );
297
- }
298
- },
299
- });
300
- await opts.afterDeviceBoot?.();
434
+ await restoreDeviceSession(opts, false);
301
435
  } catch (error) {
302
436
  if (__DEV__) {
303
437
  loggerUtil.error(