@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
@@ -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,32 @@
1
+ import type { User } from '@oxy.so/core';
2
+ import type { AuthStateStore, PersistedAuthState } from '@oxy.so/core/session';
3
+ import { createCachedBootStore } from '../cachedBoot';
4
+ import { createMemoryStorage } from '../../utils/storageHelpers';
5
+
6
+ function authStore(state: PersistedAuthState): AuthStateStore {
7
+ return {
8
+ load: async () => state,
9
+ save: async () => true,
10
+ clear: async () => undefined,
11
+ };
12
+ }
13
+
14
+ it('offers a cached session only for the account the saved session belongs to', async () => {
15
+ const cache = createCachedBootStore(createMemoryStorage(), 'oxy_session');
16
+ const saved = { sessionId: 's1', userId: 'u1', deviceId: 'd1', deviceSecret: 'secret' };
17
+ await cache.saveLane('client', 'device');
18
+ await cache.saveAccount({ id: 'u2', username: 'other' } as User);
19
+
20
+ expect(await cache.readSession('client', authStore(saved))).toBeNull();
21
+
22
+ await cache.saveAccount({ id: 'u1', username: 'me' } as User);
23
+ expect(await cache.readSession('client', authStore(saved))).toMatchObject({
24
+ sessionId: 's1',
25
+ user: { id: 'u1' },
26
+ warmToken: null,
27
+ });
28
+ // Another app's classification, or an OAuth-only one, never paints.
29
+ expect(await cache.readSession('another-client', authStore(saved))).toBeNull();
30
+ await cache.saveLane('client', 'oauth');
31
+ expect(await cache.readSession('client', authStore(saved))).toBeNull();
32
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * What a returning session needs for its first paint, kept on this device.
3
+ *
4
+ * A cold boot used to wait on two round trips before it showed anything: the
5
+ * registry's classification of this app and the profile of the signed-in
6
+ * account. With a session saved on the device, the app now paints at once from
7
+ * what the last run left here, and the boot verifies both in the background
8
+ * (`runProviderColdBoot`). This module is that cache:
9
+ *
10
+ * - the registry's last verdict on this app (`device` or `oauth`), tied to the
11
+ * `clientId` it was given for;
12
+ * - the last account profile the runtime held, tied to that account's id.
13
+ *
14
+ * Both live in the provider's ordinary storage (AsyncStorage on native,
15
+ * `localStorage` on web), next to the per-account query cache, which already
16
+ * holds the same profile under the SDK's `users` queries. Neither is a
17
+ * credential: the device secret and the access token stay in the auth store.
18
+ *
19
+ * A cached session is offered only when it matches the auth store: the profile
20
+ * must belong to the account the persisted session is for, so a switch or a
21
+ * sign-out on a previous run can never paint somebody else.
22
+ */
23
+ import type { User } from '@oxy.so/core';
24
+ import { TOKEN_REFRESH_LEAD_MS, type AuthStateStore } from '@oxy.so/core/session';
25
+ import type { StorageInterface } from '../utils/storageHelpers';
26
+
27
+ export type SessionLane = 'device' | 'oauth';
28
+
29
+ /** A persisted session the app can show before the network answers. */
30
+ export interface CachedSession {
31
+ sessionId: string;
32
+ userId: string;
33
+ deviceId: string;
34
+ /** The account profile the last run held for {@link userId}. */
35
+ user: User;
36
+ /** The persisted access token, when it is still valid past the refresh lead. */
37
+ warmToken: string | null;
38
+ }
39
+
40
+ export interface CachedBootStore {
41
+ saveLane(clientId: string, lane: SessionLane): Promise<void>;
42
+ saveAccount(user: User): Promise<void>;
43
+ clearAccount(): Promise<void>;
44
+ /**
45
+ * The cached session for this `clientId`, or `null` when anything a first
46
+ * paint needs is missing or does not match the auth store.
47
+ */
48
+ readSession(clientId: string, authStore: AuthStateStore): Promise<CachedSession | null>;
49
+ }
50
+
51
+ const SCHEMA_VERSION = 1;
52
+
53
+ function parse(raw: string | null): Record<string, unknown> | null {
54
+ if (!raw) return null;
55
+ try {
56
+ const value: unknown = JSON.parse(raw);
57
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function isUser(value: unknown): value is User {
64
+ return (
65
+ Boolean(value) &&
66
+ typeof value === 'object' &&
67
+ typeof (value as { id?: unknown }).id === 'string' &&
68
+ (value as { id: string }).id.length > 0
69
+ );
70
+ }
71
+
72
+ export function createCachedBootStore(
73
+ storage: StorageInterface,
74
+ prefix: string,
75
+ now: () => number = Date.now,
76
+ ): CachedBootStore {
77
+ const laneKey = `${prefix}_boot_lane`;
78
+ const accountKey = `${prefix}_boot_account`;
79
+ // Storage failures are never fatal here: a missing entry only means the next
80
+ // boot waits for the network, as every boot did before this cache existed.
81
+ const write = (key: string, value: unknown) =>
82
+ storage.setItem(key, JSON.stringify({ v: SCHEMA_VERSION, ...(value as object) })).catch(() => {
83
+ /* best effort */
84
+ });
85
+
86
+ return {
87
+ saveLane: (clientId, lane) => write(laneKey, { clientId, lane }),
88
+ saveAccount: (user) => write(accountKey, { user }),
89
+ clearAccount: () =>
90
+ storage.removeItem(accountKey).catch(() => {
91
+ /* best effort */
92
+ }),
93
+
94
+ async readSession(clientId, authStore) {
95
+ const [laneEntry, accountEntry] = await Promise.all([
96
+ storage.getItem(laneKey).then(parse, () => null),
97
+ storage.getItem(accountKey).then(parse, () => null),
98
+ ]);
99
+ if (
100
+ laneEntry?.v !== SCHEMA_VERSION ||
101
+ laneEntry.clientId !== clientId ||
102
+ laneEntry.lane !== 'device'
103
+ ) {
104
+ return null;
105
+ }
106
+ const user = accountEntry?.v === SCHEMA_VERSION ? accountEntry.user : null;
107
+ if (!isUser(user)) return null;
108
+
109
+ const persisted = await authStore.load();
110
+ // Only the app's OWN holder: the background check mints with it, which is
111
+ // the only way the server can confirm or end this session.
112
+ if (
113
+ !persisted?.sessionId ||
114
+ !persisted.deviceId ||
115
+ !persisted.deviceSecret ||
116
+ persisted.userId !== user.id
117
+ ) {
118
+ return null;
119
+ }
120
+ // A recorded sign-out on this device restores nothing automatically.
121
+ if (await authStore.isAutomaticIdentitySignInSuppressed?.()) return null;
122
+
123
+ const expiresAtMs = persisted.expiresAt ? new Date(persisted.expiresAt).getTime() : NaN;
124
+ const warmToken =
125
+ persisted.accessToken &&
126
+ Number.isFinite(expiresAtMs) &&
127
+ expiresAtMs > now() + TOKEN_REFRESH_LEAD_MS
128
+ ? persisted.accessToken
129
+ : null;
130
+ return {
131
+ sessionId: persisted.sessionId,
132
+ userId: persisted.userId,
133
+ deviceId: persisted.deviceId,
134
+ user,
135
+ warmToken,
136
+ };
137
+ },
138
+ };
139
+ }