@oxy.so/services 12.12.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 (45) 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/components/OxyProvider.js +2 -1
  4. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  5. package/lib/commonjs/ui/context/OxyContext.js +116 -3
  6. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  7. package/lib/commonjs/ui/hooks/queries/useSupervision.js +45 -9
  8. package/lib/commonjs/ui/hooks/queries/useSupervision.js.map +1 -1
  9. package/lib/commonjs/ui/session/cachedBoot.js +95 -0
  10. package/lib/commonjs/ui/session/cachedBoot.js.map +1 -0
  11. package/lib/module/ui/boot/runProviderColdBoot.js +188 -88
  12. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  13. package/lib/module/ui/components/OxyProvider.js +2 -1
  14. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  15. package/lib/module/ui/context/OxyContext.js +117 -4
  16. package/lib/module/ui/context/OxyContext.js.map +1 -1
  17. package/lib/module/ui/hooks/queries/useSupervision.js +45 -10
  18. package/lib/module/ui/hooks/queries/useSupervision.js.map +1 -1
  19. package/lib/module/ui/session/cachedBoot.js +92 -0
  20. package/lib/module/ui/session/cachedBoot.js.map +1 -0
  21. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +24 -0
  22. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.d.ts +22 -1
  26. package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts +48 -0
  28. package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts.map +1 -0
  29. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +24 -0
  30. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  31. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  32. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  33. package/lib/typescript/module/ui/hooks/queries/useSupervision.d.ts +22 -1
  34. package/lib/typescript/module/ui/hooks/queries/useSupervision.d.ts.map +1 -1
  35. package/lib/typescript/module/ui/session/cachedBoot.d.ts +48 -0
  36. package/lib/typescript/module/ui/session/cachedBoot.d.ts.map +1 -0
  37. package/package.json +7 -8
  38. package/src/ui/boot/__tests__/cachedColdBoot.test.ts +187 -0
  39. package/src/ui/boot/runProviderColdBoot.ts +229 -95
  40. package/src/ui/components/OxyProvider.tsx +2 -0
  41. package/src/ui/context/OxyContext.tsx +146 -3
  42. package/src/ui/hooks/queries/__tests__/useSupervision.test.tsx +73 -0
  43. package/src/ui/hooks/queries/useSupervision.ts +43 -9
  44. package/src/ui/session/__tests__/cachedBoot.test.ts +32 -0
  45. package/src/ui/session/cachedBoot.ts +139 -0
@@ -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(
@@ -24,6 +24,7 @@ import { LanguageBridge } from './LanguageBridge';
24
24
  import { BloomLocaleBridge } from './BloomLocaleBridge';
25
25
  import { useOxy } from '../context/oxyRuntimeContext';
26
26
  import { useCurrentUser } from '../hooks/queries/useAccountQueries';
27
+ import { SupervisionStateObserver } from '../hooks/queries/useSupervision';
27
28
 
28
29
  /** Hydrate owner preferences after the bearer is ready, including on cold boot. */
29
30
  function AccountProfileObserver() {
@@ -283,6 +284,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
283
284
  onAuthStateChange={onAuthStateChange as OxyRuntimeProviderProps['onAuthStateChange']}
284
285
  >
285
286
  <AccountProfileObserver />
287
+ <SupervisionStateObserver />
286
288
  {productAnalytics ? <ProductAnalyticsObserver analytics={productAnalytics} /> : null}
287
289
  {language ? <LanguageBridge {...language} /> : null}
288
290
  <BloomLocaleBridge>
@@ -59,7 +59,8 @@ import {
59
59
  hasPersistedSessionCredential,
60
60
  loadPersistedDeviceCredential,
61
61
  } from '../utils/deviceCredential';
62
- import { createTokenLossRecovery } from '../session/tokenLossRecovery';
62
+ import { createTokenLossRecovery, TOKEN_RECOVERY_BACKOFF_MS } from '../session/tokenLossRecovery';
63
+ import { createCachedBootStore, type CachedBootStore } from '../session/cachedBoot';
63
64
  import { bindAuthStoreToRuntime } from '../stores/authStore';
64
65
  import { useLanguageManagement } from '../hooks/useLanguageManagement';
65
66
  import { useSessionManagement } from '../hooks/useSessionManagement';
@@ -222,8 +223,19 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
222
223
  clientId: string | undefined;
223
224
  services: OxyServices;
224
225
  lane: 'device' | 'oauth' | null;
226
+ /**
227
+ * `lane` is this device's cached verdict, standing in until the registry
228
+ * answers (a painted cold boot). A fresh lookup keeps it meanwhile.
229
+ */
230
+ provisional: boolean;
225
231
  pending: Promise<'device' | 'oauth'> | null;
226
- }>({ clientId: clientIdProp, services: oxyServices, lane: null, pending: null });
232
+ }>({
233
+ clientId: clientIdProp,
234
+ services: oxyServices,
235
+ lane: null,
236
+ provisional: false,
237
+ pending: null,
238
+ });
227
239
  if (
228
240
  registeredLaneRef.current.clientId !== clientIdProp ||
229
241
  registeredLaneRef.current.services !== oxyServices
@@ -232,6 +244,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
232
244
  clientId: clientIdProp,
233
245
  services: oxyServices,
234
246
  lane: null,
247
+ provisional: false,
235
248
  pending: null,
236
249
  };
237
250
  }
@@ -372,6 +385,15 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
372
385
  }
373
386
  const storageReady = storageReadyRef.current;
374
387
 
388
+ // What a returning session's first paint needs (the app's classification and
389
+ // the account's profile), kept in the same storage as the account query cache.
390
+ const cachedBoot = useMemo(
391
+ () => (storage ? createCachedBootStore(storage, storageKeyPrefix) : null),
392
+ [storage, storageKeyPrefix],
393
+ );
394
+ const cachedBootRef = useRef<CachedBootStore | null>(cachedBoot);
395
+ cachedBootRef.current = cachedBoot;
396
+
375
397
  useEffect(() => {
376
398
  // `null` means the public provider owns initialization and has not resolved
377
399
  // it yet. `undefined` means this internal provider was mounted directly and
@@ -535,11 +557,17 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
535
557
  if (selected.clientId !== clientIdProp || selected.services !== oxyServices)
536
558
  throw new Error('Provider classification was superseded');
537
559
  if (selected.pending) return selected.pending;
538
- selected.lane = null;
560
+ if (!selected.provisional) selected.lane = null;
539
561
  const pending = (async () => {
540
562
  const lane = await resolveApplicationSessionLane(oxyServices, clientIdProp);
541
563
  if (registeredLaneRef.current !== selected)
542
564
  throw new Error('Provider classification was superseded');
565
+ if (clientIdProp) void cachedBootRef.current?.saveLane(clientIdProp, lane);
566
+ if (selected.provisional) {
567
+ // The registry has answered: its verdict replaces the cached one now.
568
+ selected.provisional = false;
569
+ selected.lane = lane;
570
+ }
543
571
  if (lane === 'oauth' && !hasIsolatedOAuthSession(runtime)) {
544
572
  sessionClient.resetLocalState();
545
573
  sessionClientHost.setDeviceCredential(null);
@@ -1284,6 +1312,115 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1284
1312
  if (!accountDialogOpen) clearSignInFlows(accountDialogControllerRef.current);
1285
1313
  }, [accountDialogOpen]);
1286
1314
 
1315
+ // ── Cached first paint ───────────────────────────────────────────────────
1316
+ // Keep the profile the runtime holds for the signed-in account, so the next
1317
+ // cold boot can paint it before the network answers. Only for a device-lane
1318
+ // account session: an identity-bound client and an isolated OAuth session
1319
+ // always boot through the network. A sign-out deletes it.
1320
+ const cachedAccountJsonRef = useRef<string | null>(null);
1321
+ useEffect(() => {
1322
+ if (!cachedBoot || identity) return;
1323
+ if (user === null) {
1324
+ if (cachedAccountJsonRef.current !== null) {
1325
+ cachedAccountJsonRef.current = null;
1326
+ void cachedBoot.clearAccount();
1327
+ }
1328
+ return;
1329
+ }
1330
+ if (registeredLaneRef.current.lane !== 'device' || hasIsolatedOAuthSession(runtime)) return;
1331
+ const json = JSON.stringify(user);
1332
+ if (json === cachedAccountJsonRef.current) return;
1333
+ cachedAccountJsonRef.current = json;
1334
+ void cachedBoot.saveAccount(user);
1335
+ }, [cachedBoot, identity, user, runtime]);
1336
+
1337
+ // Paint the session saved on this device: the bearer it left (when still
1338
+ // valid), the session mirror, the account from its cached profile, auth
1339
+ // resolved. Local reads only; the boot checks it all in the background.
1340
+ const paintCachedSession = useCallback(async (): Promise<boolean> => {
1341
+ const cache = cachedBootRef.current;
1342
+ if (identity || !clientIdProp || !cache) return false;
1343
+ const cached = await cache.readSession(clientIdProp, authStore);
1344
+ const selected = registeredLaneRef.current;
1345
+ if (
1346
+ !cached ||
1347
+ selected.clientId !== clientIdProp ||
1348
+ selected.services !== oxyServices ||
1349
+ selected.lane === 'oauth'
1350
+ ) {
1351
+ return false;
1352
+ }
1353
+ if (selected.lane === null) {
1354
+ // The cached verdict stands in for the registry until it answers, so the
1355
+ // refresh handler, lost-token recovery and sign-in treat this app as the
1356
+ // device lane it was on last run.
1357
+ selected.lane = 'device';
1358
+ selected.provisional = true;
1359
+ }
1360
+ if (cached.warmToken) oxyServices.session.setAccessToken(cached.warmToken);
1361
+ await syncDeviceCredentialToHost();
1362
+ const now = new Date();
1363
+ runtime.batch(() => {
1364
+ runtime.mergeSessions(
1365
+ [
1366
+ {
1367
+ sessionId: cached.sessionId,
1368
+ deviceId: cached.deviceId,
1369
+ expiresAt: new Date(now.getTime() + DEFAULT_SESSION_VALIDITY_MS).toISOString(),
1370
+ lastActive: now.toISOString(),
1371
+ userId: cached.userId,
1372
+ isCurrent: true,
1373
+ },
1374
+ ],
1375
+ { merge: true },
1376
+ );
1377
+ runtime.setActiveSessionId(cached.sessionId);
1378
+ runtime.setAccount(cached.user);
1379
+ });
1380
+ onAuthStateChangeRef.current?.(cached.user);
1381
+ markAuthResolvedRef.current();
1382
+ return true;
1383
+ }, [identity, clientIdProp, authStore, oxyServices, runtime, syncDeviceCredentialToHost]);
1384
+
1385
+ // A painted session the server could not be asked about yet stays on screen
1386
+ // and is asked again: on the lost-token backoff, and at once when the device
1387
+ // comes back online. Signed out meanwhile, there is nothing left to ask.
1388
+ const cachedSessionCheckRef = useRef<{
1389
+ timer: ReturnType<typeof setTimeout> | null;
1390
+ attempt: number;
1391
+ runNow: (() => void) | null;
1392
+ }>({ timer: null, attempt: 0, runNow: null });
1393
+ const scheduleCachedSessionCheck = useCallback(
1394
+ (retry: () => Promise<void>): void => {
1395
+ const check = cachedSessionCheckRef.current;
1396
+ if (check.timer !== null) clearTimeout(check.timer);
1397
+ const run = (): void => {
1398
+ if (check.timer !== null) clearTimeout(check.timer);
1399
+ check.timer = null;
1400
+ check.runNow = null;
1401
+ if (runtime.getSnapshot().account === null) return;
1402
+ retry().catch((retryError) => {
1403
+ logger('Failed to check the cached session', retryError);
1404
+ });
1405
+ };
1406
+ const delay =
1407
+ TOKEN_RECOVERY_BACKOFF_MS[Math.min(check.attempt, TOKEN_RECOVERY_BACKOFF_MS.length - 1)];
1408
+ check.attempt += 1;
1409
+ check.runNow = run;
1410
+ check.timer = setTimeout(run, delay);
1411
+ },
1412
+ [runtime, logger],
1413
+ );
1414
+ useEffect(
1415
+ () => () => {
1416
+ const check = cachedSessionCheckRef.current;
1417
+ if (check.timer !== null) clearTimeout(check.timer);
1418
+ check.timer = null;
1419
+ check.runNow = null;
1420
+ },
1421
+ [],
1422
+ );
1423
+
1287
1424
  // ── Cold boot ────────────────────────────────────────────────────────────
1288
1425
  // Device-first session restore via `runProviderColdBoot` (see boot/runProviderColdBoot.ts).
1289
1426
  const runColdBoot = useCallback(async (): Promise<void> => {
@@ -1314,6 +1451,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1314
1451
  commitSession: (input, options) => commitSessionRef.current(input, options),
1315
1452
  markAuthResolved: () => markAuthResolvedRef.current(),
1316
1453
  setTokenReady,
1454
+ paintCachedSession,
1455
+ onCachedSessionRejected: () => clearSessionStateRef.current(),
1456
+ onCachedSessionUnconfirmed: scheduleCachedSessionCheck,
1317
1457
  });
1318
1458
  // The boot may have (re-)established the identity session or cleared a pin
1319
1459
  // whose key no longer exists, so re-resolve rather than trusting the memo
@@ -1329,6 +1469,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1329
1469
  syncDeviceCredentialToHost,
1330
1470
  sessionClient,
1331
1471
  setTokenReady,
1472
+ paintCachedSession,
1473
+ scheduleCachedSessionCheck,
1332
1474
  ]);
1333
1475
 
1334
1476
  useEffect(() => {
@@ -1427,6 +1569,7 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1427
1569
  if (!online || !wasOffline) {
1428
1570
  return;
1429
1571
  }
1572
+ cachedSessionCheckRef.current.runNow?.();
1430
1573
  void (async () => {
1431
1574
  if (hasIsolatedOAuthSession(runtime)) return;
1432
1575
  if (oxyServices.session.accessToken || !sessionClientHost.getDeviceCredential()) {
@@ -0,0 +1,73 @@
1
+ import type { ReactNode } from 'react';
2
+ import { act, render, renderHook, waitFor } from '@testing-library/react';
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { SUPERVISION_CHANGED_EVENT } from '@oxy.so/contracts/client';
5
+ import { SupervisionStateObserver, useSupervision } from '../useSupervision';
6
+
7
+ const mockSupervisionState = jest.fn();
8
+ const mockServerEvents = new Map<string, (payload: unknown) => void>();
9
+ jest.mock('../../../context/oxyRuntimeContext', () => ({
10
+ useOxy: () => ({
11
+ oxyServices: { safety: { supervisionState: mockSupervisionState } },
12
+ user: { id: 'child', kind: 'personal' },
13
+ canUsePrivateApi: true,
14
+ activeSessionId: 'session',
15
+ sessionClient: {
16
+ onServerEvent: (event: string, listener: (payload: unknown) => void) => {
17
+ mockServerEvents.set(event, listener);
18
+ return () => mockServerEvents.delete(event);
19
+ },
20
+ },
21
+ }),
22
+ }));
23
+
24
+ const state = (status: 'active' | 'ended') => ({
25
+ self: {
26
+ childAccountId: 'child',
27
+ guardianAccountId: 'guardian',
28
+ status,
29
+ policyVersion: '2',
30
+ consentVersion: '1',
31
+ createdAt: '2026-10-23T00:00:00.000Z',
32
+ },
33
+ children: [],
34
+ invitations: [],
35
+ });
36
+
37
+ function setup() {
38
+ const client = new QueryClient();
39
+ const wrapper = ({ children }: { children: ReactNode }) => (
40
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
41
+ );
42
+ render(<SupervisionStateObserver />, { wrapper });
43
+ return wrapper;
44
+ }
45
+
46
+ beforeEach(() => {
47
+ mockSupervisionState.mockReset();
48
+ mockServerEvents.clear();
49
+ });
50
+
51
+ it('serves the session state to a new screen from the cache, without a request', async () => {
52
+ mockSupervisionState.mockResolvedValue(state('active'));
53
+ const wrapper = setup();
54
+ await waitFor(() => expect(mockSupervisionState).toHaveBeenCalledTimes(1));
55
+ await act(async () => {});
56
+
57
+ const { result } = renderHook(() => useSupervision(), { wrapper });
58
+
59
+ expect(result.current.data?.self?.status).toBe('active');
60
+ expect(mockSupervisionState).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ it('refetches when the session socket signals a supervision change', async () => {
64
+ mockSupervisionState.mockResolvedValueOnce(state('active')).mockResolvedValue(state('ended'));
65
+ const wrapper = setup();
66
+ const { result } = renderHook(() => useSupervision(), { wrapper });
67
+ await waitFor(() => expect(result.current.data?.self?.status).toBe('active'));
68
+
69
+ act(() => mockServerEvents.get(SUPERVISION_CHANGED_EVENT)?.(undefined));
70
+
71
+ await waitFor(() => expect(result.current.data?.self?.status).toBe('ended'));
72
+ expect(mockSupervisionState).toHaveBeenCalledTimes(2);
73
+ });