@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.
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +188 -88
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +116 -3
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/session/cachedBoot.js +95 -0
- package/lib/commonjs/ui/session/cachedBoot.js.map +1 -0
- package/lib/module/ui/boot/runProviderColdBoot.js +188 -88
- package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +117 -4
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/session/cachedBoot.js +92 -0
- package/lib/module/ui/session/cachedBoot.js.map +1 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +24 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts +48 -0
- package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts.map +1 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +24 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/session/cachedBoot.d.ts +48 -0
- package/lib/typescript/module/ui/session/cachedBoot.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/ui/boot/__tests__/cachedColdBoot.test.ts +187 -0
- package/src/ui/boot/runProviderColdBoot.ts +229 -95
- package/src/ui/context/OxyContext.tsx +146 -3
- package/src/ui/session/__tests__/cachedBoot.test.ts +32 -0
- 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
|
-
}>({
|
|
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
|
+
}
|