@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.
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +188 -88
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +2 -1
- package/lib/commonjs/ui/components/OxyProvider.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/hooks/queries/useSupervision.js +45 -9
- package/lib/commonjs/ui/hooks/queries/useSupervision.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/components/OxyProvider.js +2 -1
- package/lib/module/ui/components/OxyProvider.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/hooks/queries/useSupervision.js +45 -10
- package/lib/module/ui/hooks/queries/useSupervision.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/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.d.ts +22 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.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/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useSupervision.d.ts +22 -1
- package/lib/typescript/module/ui/hooks/queries/useSupervision.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 +7 -8
- package/src/ui/boot/__tests__/cachedColdBoot.test.ts +187 -0
- package/src/ui/boot/runProviderColdBoot.ts +229 -95
- package/src/ui/components/OxyProvider.tsx +2 -0
- package/src/ui/context/OxyContext.tsx +146 -3
- package/src/ui/hooks/queries/__tests__/useSupervision.test.tsx +73 -0
- package/src/ui/hooks/queries/useSupervision.ts +43 -9
- package/src/ui/session/__tests__/cachedBoot.test.ts +32 -0
- 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
|
|
166
|
-
// offline
|
|
167
|
-
// overlaps the local work below.
|
|
168
|
-
const classification = (
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
classification
|
|
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 (
|
|
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
|
|
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
|
-
}>({
|
|
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
|
+
});
|