@oxyhq/core 8.1.0 → 9.1.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/README.md +1 -3
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/coldBootV2.js +22 -332
- package/dist/cjs/crypto/keyManager.js +0 -95
- package/dist/cjs/index.js +6 -19
- package/dist/cjs/mixins/OxyServices.auth.js +4 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
- package/dist/cjs/mixins/index.js +2 -3
- package/dist/cjs/session/SessionClient.js +29 -100
- package/dist/cjs/session/accountDialogController.js +0 -13
- package/dist/cjs/session/authStateStore.js +9 -89
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -71
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +23 -330
- package/dist/esm/crypto/keyManager.js +0 -95
- package/dist/esm/index.js +7 -11
- package/dist/esm/mixins/OxyServices.auth.js +4 -7
- package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
- package/dist/esm/mixins/index.js +2 -3
- package/dist/esm/session/SessionClient.js +29 -100
- package/dist/esm/session/accountDialogController.js +0 -13
- package/dist/esm/session/authStateStore.js +8 -88
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -71
- package/dist/esm/utils/registrableApex.js +2 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -3
- package/dist/types/boot/coldBootV2.d.ts +28 -53
- package/dist/types/crypto/keyManager.d.ts +0 -21
- package/dist/types/index.d.ts +3 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
- package/dist/types/models/interfaces.d.ts +15 -0
- package/dist/types/session/SessionClient.d.ts +6 -40
- package/dist/types/session/accountDialogController.d.ts +1 -3
- package/dist/types/session/authStateStore.d.ts +32 -57
- package/dist/types/session/createSessionClient.d.ts +2 -9
- package/dist/types/session/refresh.d.ts +32 -28
- package/dist/types/utils/registrableApex.d.ts +2 -6
- package/package.json +3 -3
- package/src/HttpService.ts +3 -3
- package/src/boot/__tests__/coldBootV2.test.ts +140 -353
- package/src/boot/coldBootV2.ts +35 -391
- package/src/crypto/keyManager.ts +0 -101
- package/src/index.ts +7 -30
- package/src/mixins/OxyServices.auth.ts +5 -9
- package/src/mixins/OxyServices.deviceBoot.ts +19 -142
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
- package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
- package/src/mixins/index.ts +2 -3
- package/src/models/interfaces.ts +15 -0
- package/src/session/SessionClient.ts +31 -122
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
- package/src/session/__tests__/authStateStore.test.ts +29 -42
- package/src/session/__tests__/refresh.test.ts +66 -52
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +32 -126
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -92
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -167
- package/dist/esm/boot/deviceBootReturn.js +0 -161
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
- package/src/boot/deviceBootReturn.ts +0 -210
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
|
@@ -36,8 +36,8 @@ export interface SessionClientOptions {
|
|
|
36
36
|
onUnauthenticated?: () => void;
|
|
37
37
|
/**
|
|
38
38
|
* Statically-injected `socket.io-client` factory (its `io` export).
|
|
39
|
-
* `@oxyhq/services`
|
|
40
|
-
*
|
|
39
|
+
* `@oxyhq/services` lists `socket.io-client` as a real dependency and
|
|
40
|
+
* passes `io` in directly, so realtime session sync never
|
|
41
41
|
* depends on a runtime dynamic `import('socket.io-client')` of a bare
|
|
42
42
|
* specifier — which is bundler-fragile in Metro/Expo-web and Vite when
|
|
43
43
|
* `@oxyhq/core` is consumed as its published dist (the import resolves to
|
|
@@ -46,40 +46,17 @@ export interface SessionClientOptions {
|
|
|
46
46
|
* absent it falls back to `getSocketIO()`.
|
|
47
47
|
*/
|
|
48
48
|
socketFactory?: SocketIOFactory;
|
|
49
|
-
/**
|
|
50
|
-
* Gate + credential for opening the realtime socket while SIGNED OUT (no
|
|
51
|
-
* access token), so an idle tab still joins its `device:<id>` room and can
|
|
52
|
-
* self-acquire the moment a sibling app/tab signs in on the same device.
|
|
53
|
-
* Returns:
|
|
54
|
-
* - `true` → connect and rely on the first-party `oxy_device` cookie
|
|
55
|
-
* riding the same-site handshake (web `*.oxy.so`; the cookie is HttpOnly
|
|
56
|
-
* so JS cannot read it, but the browser sends it automatically).
|
|
57
|
-
* - a string → connect and present it as `deviceToken` in the handshake
|
|
58
|
-
* auth (native shared-keychain device token; RN has no cookie jar).
|
|
59
|
-
* - `false`/`null` → do NOT open a signed-out socket (the default — e.g. a
|
|
60
|
-
* native app with no known device yet).
|
|
61
|
-
* Called at connect time (and each reconnect); may be async (keychain read).
|
|
62
|
-
*/
|
|
63
|
-
signedOutSocketAuth?: () => boolean | string | null | Promise<boolean | string | null>;
|
|
64
|
-
/**
|
|
65
|
-
* Invoked when a `session_state` push (or a same-origin BroadcastChannel wake)
|
|
66
|
-
* arrives while this tab is SIGNED OUT and the pushed device state has at
|
|
67
|
-
* least one account — i.e. a sibling just signed in on this device. The
|
|
68
|
-
* consumer runs its session acquisition (cold boot / `requestWebSession`),
|
|
69
|
-
* which plants a token and flips the tab to signed-in. Guarded + idempotent:
|
|
70
|
-
* only one acquisition runs at a time, and a returned promise gates the next.
|
|
71
|
-
*/
|
|
72
|
-
onSessionAppeared?: () => void | Promise<void>;
|
|
73
49
|
}
|
|
74
50
|
|
|
75
51
|
type StateListener = (state: DeviceSessionState | null) => void;
|
|
76
52
|
|
|
77
53
|
/**
|
|
78
|
-
* Same-origin `BroadcastChannel` name for instant, network-free session
|
|
79
|
-
* across tabs of the SAME origin (e.g. two `accounts.oxy.so` tabs)
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
54
|
+
* Same-origin `BroadcastChannel` name for instant, network-free session-state
|
|
55
|
+
* propagation across tabs of the SAME origin (e.g. two `accounts.oxy.so` tabs):
|
|
56
|
+
* when one authenticated tab commits an account switch / sign-out, siblings
|
|
57
|
+
* re-sync their device state without waiting on the socket. Cross-origin
|
|
58
|
+
* same-apex propagation rides the authenticated device socket; cross-APEX
|
|
59
|
+
* (mention.earth) relies on a reload — the documented limitation.
|
|
83
60
|
*/
|
|
84
61
|
const SESSION_BROADCAST_CHANNEL = 'oxy.session';
|
|
85
62
|
|
|
@@ -96,11 +73,7 @@ export class SessionClient {
|
|
|
96
73
|
protected socket: MinimalSocket | null = null;
|
|
97
74
|
private tokenUnsub: (() => void) | null = null;
|
|
98
75
|
private started = false;
|
|
99
|
-
/**
|
|
100
|
-
private acquiring = false;
|
|
101
|
-
/** True while the live socket is an anonymous (signed-out) device connection. */
|
|
102
|
-
private socketAnonymous = false;
|
|
103
|
-
/** Same-origin cross-tab wake channel; null on platforms without BroadcastChannel. */
|
|
76
|
+
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
104
77
|
private channel: SessionBroadcastChannel | null = null;
|
|
105
78
|
|
|
106
79
|
constructor(
|
|
@@ -248,27 +221,19 @@ export class SessionClient {
|
|
|
248
221
|
if (this.started) return;
|
|
249
222
|
this.started = true;
|
|
250
223
|
this.tokenUnsub = this.host.onTokensChanged((token) => {
|
|
251
|
-
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
this.
|
|
255
|
-
if (!this.socket)
|
|
256
|
-
if (this.socketAnonymous) {
|
|
257
|
-
// The live socket was an anonymous (device-room-only) connection. Force a
|
|
258
|
-
// reconnect so the handshake re-runs authenticated and also joins the
|
|
259
|
-
// `user:<id>` notification room.
|
|
260
|
-
this.socketAnonymous = false;
|
|
261
|
-
this.socket.disconnect();
|
|
262
|
-
this.socket.connect();
|
|
263
|
-
} else if (!this.socket.connected) {
|
|
224
|
+
// A rotated/fresh bearer landed — reconnect a dropped socket so its
|
|
225
|
+
// handshake re-runs with the current token. Sign-out (null token) is
|
|
226
|
+
// handled by the consumer calling `stop()`.
|
|
227
|
+
if (!token || !this.socket) return;
|
|
228
|
+
if (!this.socket.connected) {
|
|
264
229
|
this.socket.connect();
|
|
265
230
|
}
|
|
266
231
|
});
|
|
267
232
|
this.openBroadcastChannel();
|
|
268
|
-
// `bootstrap` (`GET /session/device/state`) is bearer-authenticated
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
233
|
+
// `bootstrap` (`GET /session/device/state`) is bearer-authenticated. A
|
|
234
|
+
// signed-out client opens no socket and runs no bootstrap; a bootstrap
|
|
235
|
+
// failure is non-fatal — the socket still connects so realtime sync
|
|
236
|
+
// survives a transient state-fetch error.
|
|
272
237
|
if (this.host.getAccessToken()) {
|
|
273
238
|
try {
|
|
274
239
|
await this.bootstrap();
|
|
@@ -281,8 +246,6 @@ export class SessionClient {
|
|
|
281
246
|
|
|
282
247
|
stop(): void {
|
|
283
248
|
this.started = false;
|
|
284
|
-
this.acquiring = false;
|
|
285
|
-
this.socketAnonymous = false;
|
|
286
249
|
if (this.tokenUnsub) {
|
|
287
250
|
this.tokenUnsub();
|
|
288
251
|
this.tokenUnsub = null;
|
|
@@ -312,51 +275,22 @@ export class SessionClient {
|
|
|
312
275
|
return;
|
|
313
276
|
}
|
|
314
277
|
if (!this.started) return; // stopped while the dynamic import was in flight
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
// the consumer supplies a device anchor (web cookie → `true`; native token →
|
|
319
|
-
// string). This lets an idle tab receive its device's `session_state` pushes
|
|
320
|
-
// and self-acquire when a sibling signs in.
|
|
321
|
-
let signedOutAuth: boolean | string | null = false;
|
|
322
|
-
if (!hasToken && this.options.signedOutSocketAuth) {
|
|
323
|
-
try {
|
|
324
|
-
signedOutAuth = await this.options.signedOutSocketAuth();
|
|
325
|
-
} catch (error) {
|
|
326
|
-
logger.warn('[SessionClient] signedOutSocketAuth failed', { component: 'SessionClient' }, error);
|
|
327
|
-
signedOutAuth = false;
|
|
328
|
-
}
|
|
329
|
-
if (!this.started) return; // stopped while the async gate was in flight
|
|
330
|
-
}
|
|
331
|
-
const signedOutConnect = signedOutAuth !== false && signedOutAuth != null;
|
|
332
|
-
const signedOutDeviceToken = typeof signedOutAuth === 'string' ? signedOutAuth : undefined;
|
|
333
|
-
this.socketAnonymous = !hasToken && signedOutConnect;
|
|
278
|
+
// Sockets are BEARER-ONLY: the server rejects any handshake without a valid
|
|
279
|
+
// bearer, so a signed-out client never opens a socket.
|
|
280
|
+
if (!this.host.getAccessToken()) return;
|
|
334
281
|
|
|
335
282
|
const socket = io(this.host.getBaseURL(), {
|
|
336
283
|
transports: ['websocket'],
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
autoConnect: hasToken || signedOutConnect,
|
|
341
|
-
auth: (cb: (data: { token: string; deviceToken?: string }) => void) => {
|
|
342
|
-
const token = this.host.getAccessToken() ?? '';
|
|
343
|
-
// Present the native device token only while signed out (no bearer). Once
|
|
344
|
-
// a token is held the bearer path wins and the deviceToken is redundant.
|
|
345
|
-
cb(token || !signedOutDeviceToken ? { token } : { token, deviceToken: signedOutDeviceToken });
|
|
284
|
+
autoConnect: true,
|
|
285
|
+
auth: (cb: (data: { token: string }) => void) => {
|
|
286
|
+
cb({ token: this.host.getAccessToken() ?? '' });
|
|
346
287
|
},
|
|
347
288
|
});
|
|
348
289
|
socket.on('session_state', (payload: unknown) => {
|
|
349
290
|
const applied = this.applyState(payload);
|
|
350
291
|
if (!applied) return;
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
// reconnects the socket on the authenticated path.
|
|
354
|
-
if (!this.host.getAccessToken()) {
|
|
355
|
-
if ((this.state?.accounts.length ?? 0) > 0) {
|
|
356
|
-
this.requestAcquisition();
|
|
357
|
-
}
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
292
|
+
// A push changed the active account on another device/tab — re-fetch state
|
|
293
|
+
// to plant the access token for the newly-active account.
|
|
360
294
|
const active = this.state?.activeAccountId ?? null;
|
|
361
295
|
if (active && active !== this.host.getCurrentAccountId()) {
|
|
362
296
|
void this.bootstrap().catch((error) => {
|
|
@@ -367,34 +301,11 @@ export class SessionClient {
|
|
|
367
301
|
this.socket = socket;
|
|
368
302
|
}
|
|
369
303
|
|
|
370
|
-
/**
|
|
371
|
-
* Run the consumer's session acquisition at most once at a time. A returned
|
|
372
|
-
* promise gates the next attempt (reset on settle), so a failed acquisition
|
|
373
|
-
* can retry on the NEXT push while a burst of identical pushes cannot pile up.
|
|
374
|
-
*/
|
|
375
|
-
private requestAcquisition(): void {
|
|
376
|
-
if (this.acquiring || !this.options.onSessionAppeared) return;
|
|
377
|
-
this.acquiring = true;
|
|
378
|
-
let result: void | Promise<void>;
|
|
379
|
-
try {
|
|
380
|
-
result = this.options.onSessionAppeared();
|
|
381
|
-
} catch (error) {
|
|
382
|
-
this.acquiring = false;
|
|
383
|
-
logger.error('[SessionClient] onSessionAppeared threw', error);
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
void Promise.resolve(result).catch((error) => {
|
|
387
|
-
logger.warn('[SessionClient] onSessionAppeared rejected', { component: 'SessionClient' }, error);
|
|
388
|
-
}).finally(() => {
|
|
389
|
-
this.acquiring = false;
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
|
|
393
304
|
/**
|
|
394
305
|
* Open the same-origin `BroadcastChannel` (web only). A sibling tab that
|
|
395
|
-
* commits
|
|
396
|
-
*
|
|
397
|
-
*
|
|
306
|
+
* commits an account switch / sign-out posts a wake ping; on receipt an
|
|
307
|
+
* authenticated tab re-syncs its device state — instant + network-free for the
|
|
308
|
+
* common "two tabs of the same origin" case, with no state (and no tokens)
|
|
398
309
|
* ever crossing the channel. No-op on native (no BroadcastChannel).
|
|
399
310
|
*/
|
|
400
311
|
private openBroadcastChannel(): void {
|
|
@@ -412,14 +323,12 @@ export class SessionClient {
|
|
|
412
323
|
if (!event || typeof event.data !== 'object' || event.data === null) return;
|
|
413
324
|
if ((event.data as { type?: unknown }).type !== 'commit') return;
|
|
414
325
|
// BroadcastChannel never echoes to the posting context, so this is a
|
|
415
|
-
// sibling's commit.
|
|
416
|
-
//
|
|
326
|
+
// sibling's commit. An authenticated tab re-syncs its device state; the
|
|
327
|
+
// re-sync does not re-post, so there is no cross-tab ping loop.
|
|
417
328
|
if (this.host.getAccessToken()) {
|
|
418
329
|
void this.bootstrap().catch((error) => {
|
|
419
330
|
logger.warn('[SessionClient] broadcast re-sync failed', { component: 'SessionClient' }, error);
|
|
420
331
|
});
|
|
421
|
-
} else {
|
|
422
|
-
this.requestAcquisition();
|
|
423
332
|
}
|
|
424
333
|
};
|
|
425
334
|
this.channel = channel;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionClient same-origin `BroadcastChannel` cross-tab state propagation.
|
|
3
|
+
*
|
|
4
|
+
* This is the AUTHENTICATED-only survivor of the zero-cookie cutover: when one
|
|
5
|
+
* authenticated tab commits an account switch / sign-out, same-origin siblings
|
|
6
|
+
* re-sync their device state instantly without waiting on the socket. The
|
|
7
|
+
* signed-out "self-acquire on sibling sign-in" wake is gone (sockets are now
|
|
8
|
+
* bearer-only), so every case here runs with a bearer present.
|
|
9
|
+
*/
|
|
10
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
11
|
+
|
|
12
|
+
type Handler = (...args: unknown[]) => void;
|
|
13
|
+
class FakeSocket {
|
|
14
|
+
connected = false;
|
|
15
|
+
handlers = new Map<string, Handler[]>();
|
|
16
|
+
on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
|
|
17
|
+
off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
|
|
18
|
+
connect() { this.connected = true; this.trigger('connect'); }
|
|
19
|
+
disconnect() { this.connected = false; }
|
|
20
|
+
trigger(event: string, ...args: unknown[]) { for (const h of this.handlers.get(event) ?? []) h(...args); }
|
|
21
|
+
}
|
|
22
|
+
let fakeSocket: FakeSocket;
|
|
23
|
+
const ioMock = jest.fn((_uri: string, opts?: Record<string, unknown>) => {
|
|
24
|
+
if (!opts || opts.autoConnect !== false) fakeSocket.connected = true;
|
|
25
|
+
return fakeSocket;
|
|
26
|
+
});
|
|
27
|
+
jest.mock('socket.io-client', () => ({ __esModule: true, io: (...args: unknown[]) => ioMock(...(args as [string, Record<string, unknown>?])) }));
|
|
28
|
+
|
|
29
|
+
// A same-name in-process BroadcastChannel bus: postMessage delivers to every
|
|
30
|
+
// OTHER open channel of the same name (never the sender) — matching the spec.
|
|
31
|
+
type BusEntry = { name: string; onmessage: ((event: { data: unknown }) => void) | null };
|
|
32
|
+
const bus = new Set<BusEntry>();
|
|
33
|
+
class FakeBroadcastChannel {
|
|
34
|
+
private entry: BusEntry;
|
|
35
|
+
constructor(public name: string) { this.entry = { name, onmessage: null }; bus.add(this.entry); }
|
|
36
|
+
get onmessage(): ((event: { data: unknown }) => void) | null { return this.entry.onmessage; }
|
|
37
|
+
set onmessage(cb: ((event: { data: unknown }) => void) | null) { this.entry.onmessage = cb; }
|
|
38
|
+
postMessage(data: unknown) {
|
|
39
|
+
for (const e of bus) {
|
|
40
|
+
if (e === this.entry || e.name !== this.name) continue;
|
|
41
|
+
e.onmessage?.({ data });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
close() { bus.delete(this.entry); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
48
|
+
|
|
49
|
+
const STATE = (rev: number, accounts = [{ accountId: 'a1', sessionId: 's1', authuser: 0 }]): DeviceSessionState =>
|
|
50
|
+
({ deviceId: 'd1', accounts, activeAccountId: accounts[0]?.accountId ?? null, revision: rev, updatedAt: 1720000000000 });
|
|
51
|
+
const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
|
|
52
|
+
|
|
53
|
+
function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
|
|
54
|
+
return {
|
|
55
|
+
makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
|
|
56
|
+
getBaseURL: () => 'http://test.invalid',
|
|
57
|
+
getAccessToken: () => 'tok',
|
|
58
|
+
onTokensChanged: () => () => undefined,
|
|
59
|
+
setTokens: jest.fn(),
|
|
60
|
+
getCurrentAccountId: () => 'a1',
|
|
61
|
+
...over,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
fakeSocket = new FakeSocket();
|
|
69
|
+
ioMock.mockClear();
|
|
70
|
+
bus.clear();
|
|
71
|
+
(globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = FakeBroadcastChannel as unknown;
|
|
72
|
+
});
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
(globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = undefined;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('SessionClient BroadcastChannel cross-tab re-sync (authenticated)', () => {
|
|
78
|
+
it('a local mutation wakes an authenticated same-origin sibling to re-sync (bootstrap)', async () => {
|
|
79
|
+
const bMakeRequest = jest.fn().mockResolvedValue(SYNC(1));
|
|
80
|
+
const b = new SessionClient(makeHost({ makeRequest: bMakeRequest, getAccessToken: () => 'tok-b' }), {});
|
|
81
|
+
await b.start();
|
|
82
|
+
bMakeRequest.mockClear();
|
|
83
|
+
const a = new SessionClient(makeHost({ getAccessToken: () => 'tok-a' }), {});
|
|
84
|
+
await a.start();
|
|
85
|
+
await a.addCurrentAccount();
|
|
86
|
+
await flush();
|
|
87
|
+
expect(bMakeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
|
|
88
|
+
a.stop();
|
|
89
|
+
b.stop();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does not re-sync the posting tab from its own commit ping (no self-loop)', async () => {
|
|
93
|
+
const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
|
|
94
|
+
const a = new SessionClient(makeHost({ makeRequest, getAccessToken: () => 'tok-a' }), {});
|
|
95
|
+
await a.start();
|
|
96
|
+
makeRequest.mockClear();
|
|
97
|
+
// `addCurrentAccount` POSTs then posts a commit ping; the ping never echoes
|
|
98
|
+
// to the sender, so this tab must NOT re-fetch its own device state.
|
|
99
|
+
await a.addCurrentAccount();
|
|
100
|
+
await flush();
|
|
101
|
+
expect(makeRequest).not.toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
|
|
102
|
+
a.stop();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('is a no-op on platforms without BroadcastChannel (native)', async () => {
|
|
106
|
+
(globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = undefined;
|
|
107
|
+
const c = new SessionClient(makeHost({ getAccessToken: () => 'tok' }), {});
|
|
108
|
+
await c.start();
|
|
109
|
+
// Mutations must not throw when BroadcastChannel is absent.
|
|
110
|
+
await expect(c.switchAccount('a1')).resolves.toBeUndefined();
|
|
111
|
+
c.stop();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -105,21 +105,21 @@ describe('SessionClient socket', () => {
|
|
|
105
105
|
c.stop();
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
it('does not
|
|
108
|
+
it('does not open a socket at all when signed out (bearer-only)', async () => {
|
|
109
109
|
const c = new SessionClient(makeHost({ getAccessToken: () => null }));
|
|
110
110
|
await c.start();
|
|
111
|
-
|
|
112
|
-
expect(opts?.autoConnect).toBe(false);
|
|
111
|
+
expect(ioMock).not.toHaveBeenCalled();
|
|
113
112
|
c.stop();
|
|
114
113
|
});
|
|
115
114
|
|
|
116
|
-
it('reconnects when a token arrives after
|
|
117
|
-
|
|
118
|
-
const host = makeHost({
|
|
115
|
+
it('reconnects an existing socket when a fresh token arrives after a transient drop', async () => {
|
|
116
|
+
const listeners: Array<(t: string | null) => void> = [];
|
|
117
|
+
const host = makeHost({ onTokensChanged: (l) => { listeners.push(l); return () => undefined; } });
|
|
119
118
|
const c = new SessionClient(host);
|
|
120
119
|
await c.start();
|
|
121
|
-
fakeSocket.connected
|
|
122
|
-
|
|
120
|
+
expect(fakeSocket.connected).toBe(true); // authenticated connect on start
|
|
121
|
+
fakeSocket.connected = false; // simulate a transient socket drop
|
|
122
|
+
listeners.forEach((l) => l('fresh-token'));
|
|
123
123
|
expect(fakeSocket.connected).toBe(true);
|
|
124
124
|
c.stop();
|
|
125
125
|
});
|
|
@@ -3,16 +3,18 @@ import {
|
|
|
3
3
|
createNativeAuthStateStore,
|
|
4
4
|
createMemoryAuthStateStore,
|
|
5
5
|
AUTH_STATE_STORAGE_KEY,
|
|
6
|
-
DEVICE_TOKEN_STORAGE_KEY,
|
|
7
6
|
type PersistedAuthState,
|
|
8
7
|
type NativeKeyValueStorage,
|
|
9
8
|
} from '../authStateStore';
|
|
10
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The zero-cookie persisted shape: `sessionId` + `userId` are the only required
|
|
12
|
+
* fields; `deviceId` / `deviceSecret` (the mint credential) and
|
|
13
|
+
* `accessToken` / `expiresAt` (warm-boot) are all optional round-trip fields.
|
|
14
|
+
*/
|
|
11
15
|
const SAMPLE: PersistedAuthState = {
|
|
12
16
|
sessionId: 's-1',
|
|
13
|
-
refreshToken: 'r-abcdefghijklmnop',
|
|
14
17
|
userId: 'u-1',
|
|
15
|
-
deviceToken: 'd-1234567890',
|
|
16
18
|
accessToken: 'a-jwt',
|
|
17
19
|
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
18
20
|
};
|
|
@@ -65,7 +67,7 @@ describe('createWebAuthStateStore', () => {
|
|
|
65
67
|
expect(await store.load()).toEqual(SAMPLE);
|
|
66
68
|
});
|
|
67
69
|
|
|
68
|
-
it('round-trips the optional
|
|
70
|
+
it('round-trips the optional deviceId + deviceSecret mint credential', async () => {
|
|
69
71
|
installLocalStorage(makeFakeStorage());
|
|
70
72
|
const store = createWebAuthStateStore();
|
|
71
73
|
const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
|
|
@@ -77,53 +79,47 @@ describe('createWebAuthStateStore', () => {
|
|
|
77
79
|
expect(loaded).toEqual(withCreds);
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
it('deserializes a
|
|
82
|
+
it('deserializes a minimal blob with no device credentials (fields absent)', async () => {
|
|
81
83
|
const storage = makeFakeStorage();
|
|
82
84
|
installLocalStorage(storage);
|
|
83
85
|
const store = createWebAuthStateStore();
|
|
84
86
|
|
|
85
|
-
storage.setItem(
|
|
86
|
-
AUTH_STATE_STORAGE_KEY,
|
|
87
|
-
JSON.stringify({ sessionId: 's-1', refreshToken: 'r-abcdefghijklmnop', userId: 'u-1' }),
|
|
88
|
-
);
|
|
87
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: 's-1', userId: 'u-1' }));
|
|
89
88
|
const loaded = await store.load();
|
|
90
|
-
expect(loaded).
|
|
89
|
+
expect(loaded).toEqual({ sessionId: 's-1', userId: 'u-1' });
|
|
91
90
|
expect(loaded && 'deviceId' in loaded).toBe(false);
|
|
92
91
|
expect(loaded && 'deviceSecret' in loaded).toBe(false);
|
|
92
|
+
expect(loaded && 'accessToken' in loaded).toBe(false);
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
it('clear() wipes the session
|
|
95
|
+
it('clear() wipes the persisted session', async () => {
|
|
96
96
|
installLocalStorage(makeFakeStorage());
|
|
97
97
|
const store = createWebAuthStateStore();
|
|
98
98
|
|
|
99
|
-
await store.save(SAMPLE);
|
|
100
|
-
await store.saveDeviceToken('device-token-xyz');
|
|
99
|
+
await store.save({ ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' });
|
|
101
100
|
await store.clear();
|
|
102
101
|
|
|
103
102
|
expect(await store.load()).toBeNull();
|
|
104
|
-
expect(await store.loadDeviceToken()).toBe('device-token-xyz');
|
|
105
103
|
});
|
|
106
104
|
|
|
107
|
-
it('
|
|
105
|
+
it('returns null for malformed JSON or a blob missing a required field', async () => {
|
|
108
106
|
const storage = makeFakeStorage();
|
|
109
107
|
installLocalStorage(storage);
|
|
110
108
|
const store = createWebAuthStateStore();
|
|
111
109
|
|
|
112
|
-
|
|
113
|
-
expect(
|
|
114
|
-
await store.clearDeviceToken();
|
|
115
|
-
expect(await store.loadDeviceToken()).toBeNull();
|
|
116
|
-
});
|
|
110
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, 'not-json');
|
|
111
|
+
expect(await store.load()).toBeNull();
|
|
117
112
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const store = createWebAuthStateStore();
|
|
113
|
+
// Missing userId → invalid.
|
|
114
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: 's' }));
|
|
115
|
+
expect(await store.load()).toBeNull();
|
|
122
116
|
|
|
123
|
-
|
|
117
|
+
// Missing sessionId → invalid.
|
|
118
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ userId: 'u' }));
|
|
124
119
|
expect(await store.load()).toBeNull();
|
|
125
120
|
|
|
126
|
-
|
|
121
|
+
// Empty required strings → invalid.
|
|
122
|
+
storage.setItem(AUTH_STATE_STORAGE_KEY, JSON.stringify({ sessionId: '', userId: '' }));
|
|
127
123
|
expect(await store.load()).toBeNull();
|
|
128
124
|
});
|
|
129
125
|
|
|
@@ -141,23 +137,16 @@ describe('createWebAuthStateStore', () => {
|
|
|
141
137
|
expect(await store.load()).toEqual(SAMPLE);
|
|
142
138
|
});
|
|
143
139
|
|
|
144
|
-
it('swallows a write that throws (quota / private mode) without rejecting', async () => {
|
|
140
|
+
it('swallows a write that throws (quota / private mode) without rejecting, and the mirror keeps the session live', async () => {
|
|
145
141
|
installLocalStorage(makeFakeStorage({ throwOnSet: true }));
|
|
146
142
|
const store = createWebAuthStateStore();
|
|
143
|
+
const withCreds: PersistedAuthState = { ...SAMPLE, deviceId: 'dev-abc', deviceSecret: 'ds-secret-xyz' };
|
|
147
144
|
|
|
148
|
-
await expect(store.save(
|
|
145
|
+
await expect(store.save(withCreds)).resolves.toBeUndefined();
|
|
149
146
|
// The write never reached storage...
|
|
150
147
|
expect(localStorage.getItem(AUTH_STATE_STORAGE_KEY)).toBeNull();
|
|
151
|
-
// ...but the in-memory mirror keeps the session
|
|
152
|
-
expect(await store.load()).toEqual(
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('the mirror keeps the deviceToken live when the write throws', async () => {
|
|
156
|
-
installLocalStorage(makeFakeStorage({ throwOnSet: true }));
|
|
157
|
-
const store = createWebAuthStateStore();
|
|
158
|
-
|
|
159
|
-
await store.saveDeviceToken('dt-mirrored');
|
|
160
|
-
expect(await store.loadDeviceToken()).toBe('dt-mirrored');
|
|
148
|
+
// ...but the in-memory mirror keeps the session (incl. the mint credential) live.
|
|
149
|
+
expect(await store.load()).toEqual(withCreds);
|
|
161
150
|
});
|
|
162
151
|
|
|
163
152
|
it('a cleared session reads null even if storage later holds a stale blob (mirror wins)', async () => {
|
|
@@ -198,13 +187,11 @@ describe('createNativeAuthStateStore', () => {
|
|
|
198
187
|
expect(await store.load()).toEqual(SAMPLE);
|
|
199
188
|
});
|
|
200
189
|
|
|
201
|
-
it('
|
|
190
|
+
it('clear() wipes the persisted session', async () => {
|
|
202
191
|
const store = createNativeAuthStateStore(makeNativeStorage());
|
|
203
|
-
await store.save(SAMPLE);
|
|
204
|
-
await store.saveDeviceToken('dt-native');
|
|
192
|
+
await store.save({ ...SAMPLE, deviceId: 'dev-1', deviceSecret: 'ds-1' });
|
|
205
193
|
await store.clear();
|
|
206
194
|
expect(await store.load()).toBeNull();
|
|
207
|
-
expect(await store.loadDeviceToken()).toBe('dt-native');
|
|
208
195
|
});
|
|
209
196
|
|
|
210
197
|
it('keeps the session live via the mirror when the injected storage throws (locked keychain)', async () => {
|