@oxyhq/core 6.0.0 → 7.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.
Files changed (70) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +1 -1
  3. package/dist/cjs/index.js +32 -11
  4. package/dist/cjs/mixins/OxyServices.accounts.js +7 -13
  5. package/dist/cjs/mixins/index.js +0 -5
  6. package/dist/cjs/server/index.js +2 -2
  7. package/dist/cjs/session/SessionClient.js +181 -10
  8. package/dist/cjs/session/accountDialogController.js +541 -0
  9. package/dist/cjs/session/accountProjection.js +131 -0
  10. package/dist/cjs/session/createSessionClient.js +9 -2
  11. package/dist/cjs/shared/utils/debugUtils.js +3 -3
  12. package/dist/cjs/utils/authWebUrl.js +10 -30
  13. package/dist/cjs/utils/coldBoot.js +10 -8
  14. package/dist/cjs/utils/{fapiAutoDetect.js → registrableApex.js} +8 -10
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/HttpService.js +1 -1
  17. package/dist/esm/index.js +26 -8
  18. package/dist/esm/mixins/OxyServices.accounts.js +7 -13
  19. package/dist/esm/mixins/index.js +0 -5
  20. package/dist/esm/server/index.js +1 -1
  21. package/dist/esm/session/SessionClient.js +181 -10
  22. package/dist/esm/session/accountDialogController.js +536 -0
  23. package/dist/esm/session/accountProjection.js +127 -0
  24. package/dist/esm/session/createSessionClient.js +9 -2
  25. package/dist/esm/shared/utils/debugUtils.js +3 -3
  26. package/dist/esm/utils/authWebUrl.js +9 -29
  27. package/dist/esm/utils/coldBoot.js +10 -8
  28. package/dist/esm/utils/{fapiAutoDetect.js → registrableApex.js} +8 -10
  29. package/dist/types/.tsbuildinfo +1 -1
  30. package/dist/types/HttpService.d.ts +1 -1
  31. package/dist/types/index.d.ts +6 -3
  32. package/dist/types/mixins/OxyServices.accounts.d.ts +24 -27
  33. package/dist/types/mixins/index.d.ts +1 -2
  34. package/dist/types/models/session.d.ts +4 -5
  35. package/dist/types/server/index.d.ts +1 -1
  36. package/dist/types/session/SessionClient.d.ts +52 -1
  37. package/dist/types/session/accountDialogController.d.ts +246 -0
  38. package/dist/types/session/accountProjection.d.ts +142 -0
  39. package/dist/types/session/createSessionClient.d.ts +9 -2
  40. package/dist/types/shared/utils/debugUtils.d.ts +3 -3
  41. package/dist/types/utils/accountUtils.d.ts +2 -3
  42. package/dist/types/utils/authWebUrl.d.ts +9 -29
  43. package/dist/types/utils/coldBoot.d.ts +17 -14
  44. package/dist/types/utils/{fapiAutoDetect.d.ts → registrableApex.d.ts} +8 -10
  45. package/package.json +2 -2
  46. package/src/HttpService.ts +1 -1
  47. package/src/index.ts +43 -11
  48. package/src/mixins/OxyServices.accounts.ts +21 -26
  49. package/src/mixins/index.ts +0 -7
  50. package/src/models/session.ts +4 -5
  51. package/src/server/index.ts +1 -1
  52. package/src/session/SessionClient.ts +202 -12
  53. package/src/session/__tests__/SessionClient.signedOut.test.ts +224 -0
  54. package/src/session/__tests__/accountDialogController.test.ts +469 -0
  55. package/src/session/__tests__/accountProjection.test.ts +181 -0
  56. package/src/session/accountDialogController.ts +682 -0
  57. package/src/session/accountProjection.ts +263 -0
  58. package/src/session/createSessionClient.ts +9 -2
  59. package/src/shared/utils/debugUtils.ts +3 -3
  60. package/src/utils/__tests__/authWebUrl.test.ts +5 -16
  61. package/src/utils/__tests__/{fapiAutoDetect.test.ts → registrableApex.test.ts} +1 -1
  62. package/src/utils/accountUtils.ts +2 -3
  63. package/src/utils/authWebUrl.ts +9 -30
  64. package/src/utils/coldBoot.ts +17 -14
  65. package/src/utils/{fapiAutoDetect.ts → registrableApex.ts} +8 -10
  66. package/dist/cjs/mixins/OxyServices.authorizedApps.js +0 -38
  67. package/dist/esm/mixins/OxyServices.authorizedApps.js +0 -35
  68. package/dist/types/mixins/OxyServices.authorizedApps.d.ts +0 -94
  69. package/src/mixins/OxyServices.authorizedApps.ts +0 -75
  70. package/src/mixins/__tests__/authorizedApps.test.ts +0 -63
@@ -9,7 +9,7 @@ import { getSocketIO } from './socketLoader';
9
9
  import type { MinimalSocket, SocketIOFactory } from './socketLoader';
10
10
 
11
11
  export interface TokenTransport {
12
- /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
12
+ /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via the persisted refresh family / shared keychain). Best-effort. */
13
13
  ensureActiveToken(state: DeviceSessionState): Promise<void>;
14
14
  }
15
15
 
@@ -46,16 +46,62 @@ 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>;
49
73
  }
50
74
 
51
75
  type StateListener = (state: DeviceSessionState | null) => void;
52
76
 
77
+ /**
78
+ * Same-origin `BroadcastChannel` name for instant, network-free session wake
79
+ * across tabs of the SAME origin (e.g. two `accounts.oxy.so` tabs). Complements
80
+ * the cookie-authed device socket, which covers same-apex cross-origin
81
+ * (`accounts` ↔ `console` ↔ `inbox`, all `*.oxy.so`). Cross-APEX (mention.earth)
82
+ * is covered by neither and relies on a reload — the documented limitation.
83
+ */
84
+ const SESSION_BROADCAST_CHANNEL = 'oxy.session';
85
+
86
+ /** A minimal, feature-detected `BroadcastChannel` surface (absent on native RN). */
87
+ interface SessionBroadcastChannel {
88
+ postMessage(message: unknown): void;
89
+ close(): void;
90
+ onmessage: ((event: { data: unknown }) => void) | null;
91
+ }
92
+
53
93
  export class SessionClient {
54
94
  private state: DeviceSessionState | null = null;
55
95
  private readonly listeners = new Set<StateListener>();
56
96
  protected socket: MinimalSocket | null = null;
57
97
  private tokenUnsub: (() => void) | null = null;
58
98
  private started = false;
99
+ /** In-flight guard so a burst of pushes triggers at most ONE acquisition. */
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. */
104
+ private channel: SessionBroadcastChannel | null = null;
59
105
 
60
106
  constructor(
61
107
  protected readonly host: SessionClientHost,
@@ -161,16 +207,19 @@ export class SessionClient {
161
207
  async switchAccount(accountId: string): Promise<void> {
162
208
  const res = await this.host.makeRequest<unknown>('POST', '/session/device/switch', { accountId }, { cache: false });
163
209
  this.applySync(res);
210
+ this.postCommitPing();
164
211
  }
165
212
 
166
213
  async signOut(target: { accountId: string } | { all: true }): Promise<void> {
167
214
  const res = await this.host.makeRequest<unknown>('POST', '/session/device/signout', target, { cache: false });
168
215
  this.applySync(res);
216
+ this.postCommitPing();
169
217
  }
170
218
 
171
219
  async addCurrentAccount(): Promise<void> {
172
220
  const res = await this.host.makeRequest<unknown>('POST', '/session/device/add', undefined, { cache: false });
173
221
  this.applySync(res);
222
+ this.postCommitPing();
174
223
  }
175
224
 
176
225
  /**
@@ -199,20 +248,54 @@ export class SessionClient {
199
248
  if (this.started) return;
200
249
  this.started = true;
201
250
  this.tokenUnsub = this.host.onTokensChanged((token) => {
202
- if (token && this.socket && !this.socket.connected) {
251
+ if (!token) return;
252
+ // A bearer just landed: an in-flight acquisition (if any) succeeded — clear
253
+ // the guard so a later sign-out can re-acquire.
254
+ this.acquiring = false;
255
+ if (!this.socket) return;
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) {
203
264
  this.socket.connect();
204
265
  }
205
266
  });
206
- await this.bootstrap();
267
+ this.openBroadcastChannel();
268
+ // `bootstrap` (`GET /session/device/state`) is bearer-authenticated: skip it
269
+ // when signed out (the signed-out socket below still joins the device room to
270
+ // receive pushes). A bootstrap failure is non-fatal — the socket must still
271
+ // connect so realtime sync survives a transient state-fetch error.
272
+ if (this.host.getAccessToken()) {
273
+ try {
274
+ await this.bootstrap();
275
+ } catch (error) {
276
+ logger.warn('[SessionClient] bootstrap during start failed (non-fatal)', { component: 'SessionClient' }, error);
277
+ }
278
+ }
207
279
  await this.connectSocket();
208
280
  }
209
281
 
210
282
  stop(): void {
211
283
  this.started = false;
284
+ this.acquiring = false;
285
+ this.socketAnonymous = false;
212
286
  if (this.tokenUnsub) {
213
287
  this.tokenUnsub();
214
288
  this.tokenUnsub = null;
215
289
  }
290
+ if (this.channel) {
291
+ this.channel.onmessage = null;
292
+ try {
293
+ this.channel.close();
294
+ } catch (error) {
295
+ logger.debug('[SessionClient] BroadcastChannel close failed', { component: 'SessionClient' }, error);
296
+ }
297
+ this.channel = null;
298
+ }
216
299
  if (this.socket) {
217
300
  this.socket.disconnect();
218
301
  this.socket = null;
@@ -230,24 +313,131 @@ export class SessionClient {
230
313
  }
231
314
  if (!this.started) return; // stopped while the dynamic import was in flight
232
315
  const hasToken = Boolean(this.host.getAccessToken());
316
+
317
+ // Signed-out connect gate: when there is no bearer, only open the socket if
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;
334
+
233
335
  const socket = io(this.host.getBaseURL(), {
234
336
  transports: ['websocket'],
235
- autoConnect: hasToken,
236
- auth: (cb: (data: { token: string }) => void) => {
237
- cb({ token: this.host.getAccessToken() ?? '' });
337
+ // Send the first-party `oxy_device` cookie on the same-site handshake so a
338
+ // signed-out browser tab can be resolved to its device room server-side.
339
+ withCredentials: true,
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 });
238
346
  },
239
347
  });
240
348
  socket.on('session_state', (payload: unknown) => {
241
349
  const applied = this.applyState(payload);
242
- if (applied) {
243
- const active = this.state?.activeAccountId ?? null;
244
- if (active && active !== this.host.getCurrentAccountId()) {
245
- void this.bootstrap().catch((error) => {
246
- logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
247
- });
350
+ if (!applied) return;
351
+ // Signed-out tab: a session appeared on this device (a sibling signed in).
352
+ // Acquire it so this tab flips to signed-in; the planted token then
353
+ // reconnects the socket on the authenticated path.
354
+ if (!this.host.getAccessToken()) {
355
+ if ((this.state?.accounts.length ?? 0) > 0) {
356
+ this.requestAcquisition();
248
357
  }
358
+ return;
359
+ }
360
+ const active = this.state?.activeAccountId ?? null;
361
+ if (active && active !== this.host.getCurrentAccountId()) {
362
+ void this.bootstrap().catch((error) => {
363
+ logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
364
+ });
249
365
  }
250
366
  });
251
367
  this.socket = socket;
252
368
  }
369
+
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
+ /**
394
+ * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
395
+ * commits a session posts a wake ping; on receipt a signed-in tab re-syncs its
396
+ * device state and a signed-out tab self-acquires — instant + network-free for
397
+ * the common "two tabs of the same origin" case, with no state (and no tokens)
398
+ * ever crossing the channel. No-op on native (no BroadcastChannel).
399
+ */
400
+ private openBroadcastChannel(): void {
401
+ if (this.channel) return;
402
+ const Ctor = (globalThis as { BroadcastChannel?: new (name: string) => SessionBroadcastChannel }).BroadcastChannel;
403
+ if (typeof Ctor !== 'function') return; // native RN / SSR — feature absent
404
+ let channel: SessionBroadcastChannel;
405
+ try {
406
+ channel = new Ctor(SESSION_BROADCAST_CHANNEL);
407
+ } catch (error) {
408
+ logger.debug('[SessionClient] BroadcastChannel unavailable', { component: 'SessionClient' }, error);
409
+ return;
410
+ }
411
+ channel.onmessage = (event) => {
412
+ if (!event || typeof event.data !== 'object' || event.data === null) return;
413
+ if ((event.data as { type?: unknown }).type !== 'commit') return;
414
+ // BroadcastChannel never echoes to the posting context, so this is a
415
+ // sibling's commit. Re-sync (signed-in) or acquire (signed-out). Neither
416
+ // path re-posts, so there is no cross-tab ping loop.
417
+ if (this.host.getAccessToken()) {
418
+ void this.bootstrap().catch((error) => {
419
+ logger.warn('[SessionClient] broadcast re-sync failed', { component: 'SessionClient' }, error);
420
+ });
421
+ } else {
422
+ this.requestAcquisition();
423
+ }
424
+ };
425
+ this.channel = channel;
426
+ }
427
+
428
+ /**
429
+ * Wake same-origin sibling tabs after a locally-initiated session mutation.
430
+ * Opens the channel lazily: a sign-in registers the account (`addCurrentAccount`
431
+ * / `switchAccount`) BEFORE `start()` runs, so the ping must not depend on
432
+ * `start()` having opened the channel first.
433
+ */
434
+ private postCommitPing(): void {
435
+ this.openBroadcastChannel();
436
+ if (!this.channel) return;
437
+ try {
438
+ this.channel.postMessage({ type: 'commit', at: Date.now() });
439
+ } catch (error) {
440
+ logger.debug('[SessionClient] BroadcastChannel post failed', { component: 'SessionClient' }, error);
441
+ }
442
+ }
253
443
  }
@@ -0,0 +1,224 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+
3
+ type Handler = (...args: unknown[]) => void;
4
+ class FakeSocket {
5
+ connected = false;
6
+ handlers = new Map<string, Handler[]>();
7
+ connectCalls = 0;
8
+ disconnectCalls = 0;
9
+ on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
10
+ off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
11
+ connect() { this.connectCalls += 1; this.connected = true; this.trigger('connect'); }
12
+ disconnect() { this.disconnectCalls += 1; this.connected = false; }
13
+ trigger(event: string, ...args: unknown[]) { for (const h of this.handlers.get(event) ?? []) h(...args); }
14
+ }
15
+ let fakeSocket: FakeSocket;
16
+ let lastOpts: Record<string, unknown> | undefined;
17
+ const ioMock = jest.fn((_uri: string, opts?: Record<string, unknown>) => {
18
+ lastOpts = opts;
19
+ if (!opts || opts.autoConnect !== false) fakeSocket.connected = true;
20
+ return fakeSocket;
21
+ });
22
+ jest.mock('socket.io-client', () => ({ __esModule: true, io: (...args: unknown[]) => ioMock(...(args as [string, Record<string, unknown>?])) }));
23
+
24
+ // A same-name in-process BroadcastChannel bus: postMessage delivers to every
25
+ // OTHER open channel of the same name (never the sender) — matching the spec.
26
+ type BusEntry = { name: string; onmessage: ((event: { data: unknown }) => void) | null };
27
+ const bus = new Set<BusEntry>();
28
+ class FakeBroadcastChannel {
29
+ private entry: BusEntry;
30
+ constructor(public name: string) { this.entry = { name, onmessage: null }; bus.add(this.entry); }
31
+ get onmessage(): ((event: { data: unknown }) => void) | null { return this.entry.onmessage; }
32
+ set onmessage(cb: ((event: { data: unknown }) => void) | null) { this.entry.onmessage = cb; }
33
+ postMessage(data: unknown) {
34
+ for (const e of bus) {
35
+ if (e === this.entry || e.name !== this.name) continue;
36
+ e.onmessage?.({ data });
37
+ }
38
+ }
39
+ close() { bus.delete(this.entry); }
40
+ }
41
+
42
+ import { SessionClient, type SessionClientHost, type SessionClientOptions } from '../SessionClient';
43
+
44
+ const STATE = (rev: number, accounts = [{ accountId: 'a1', sessionId: 's1', authuser: 0 }]): DeviceSessionState =>
45
+ ({ deviceId: 'd1', accounts, activeAccountId: accounts[0]?.accountId ?? null, revision: rev, updatedAt: 1720000000000 });
46
+ const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
47
+
48
+ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
49
+ return {
50
+ makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
51
+ getBaseURL: () => 'http://test.invalid',
52
+ getAccessToken: () => null,
53
+ onTokensChanged: () => () => undefined,
54
+ setTokens: jest.fn(),
55
+ getCurrentAccountId: () => null,
56
+ ...over,
57
+ };
58
+ }
59
+
60
+ const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
61
+
62
+ beforeEach(() => {
63
+ fakeSocket = new FakeSocket();
64
+ lastOpts = undefined;
65
+ ioMock.mockClear();
66
+ bus.clear();
67
+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = FakeBroadcastChannel as unknown;
68
+ });
69
+ afterEach(() => {
70
+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = undefined;
71
+ });
72
+
73
+ describe('SessionClient signed-out socket', () => {
74
+ it('opens the socket while signed-out when signedOutSocketAuth resolves true (web cookie), with credentials', async () => {
75
+ const c = new SessionClient(makeHost(), { signedOutSocketAuth: () => true });
76
+ await c.start();
77
+ expect(ioMock).toHaveBeenCalledTimes(1);
78
+ expect(lastOpts?.autoConnect).toBe(true);
79
+ expect(lastOpts?.withCredentials).toBe(true);
80
+ // No bearer, no native token → handshake presents an empty token, no deviceToken.
81
+ const authCb = jest.fn();
82
+ (lastOpts?.auth as (cb: (d: unknown) => void) => void)(authCb);
83
+ expect(authCb).toHaveBeenCalledWith({ token: '' });
84
+ c.stop();
85
+ });
86
+
87
+ it('presents the native device token in the handshake when signedOutSocketAuth returns a string', async () => {
88
+ const c = new SessionClient(makeHost(), { signedOutSocketAuth: () => 'dt-native' });
89
+ await c.start();
90
+ expect(lastOpts?.autoConnect).toBe(true);
91
+ const authCb = jest.fn();
92
+ (lastOpts?.auth as (cb: (d: unknown) => void) => void)(authCb);
93
+ expect(authCb).toHaveBeenCalledWith({ token: '', deviceToken: 'dt-native' });
94
+ c.stop();
95
+ });
96
+
97
+ it('does NOT open the socket while signed-out when signedOutSocketAuth is absent (default)', async () => {
98
+ const c = new SessionClient(makeHost());
99
+ await c.start();
100
+ expect(lastOpts?.autoConnect).toBe(false);
101
+ c.stop();
102
+ });
103
+
104
+ it('does NOT open the socket while signed-out when signedOutSocketAuth resolves false', async () => {
105
+ const c = new SessionClient(makeHost(), { signedOutSocketAuth: async () => false });
106
+ await c.start();
107
+ expect(lastOpts?.autoConnect).toBe(false);
108
+ c.stop();
109
+ });
110
+
111
+ it('skips the bearer-authenticated bootstrap when signed-out', async () => {
112
+ const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
113
+ const c = new SessionClient(makeHost({ makeRequest }), { signedOutSocketAuth: () => true });
114
+ await c.start();
115
+ expect(makeRequest).not.toHaveBeenCalled();
116
+ c.stop();
117
+ });
118
+
119
+ it('a session_state push while signed-out triggers acquisition exactly once for a burst', async () => {
120
+ const onSessionAppeared = jest.fn(() => new Promise<void>(() => undefined)); // never resolves (in flight)
121
+ const c = new SessionClient(makeHost(), { signedOutSocketAuth: () => true, onSessionAppeared });
122
+ await c.start();
123
+ fakeSocket.trigger('session_state', STATE(9));
124
+ fakeSocket.trigger('session_state', STATE(10));
125
+ await flush();
126
+ expect(onSessionAppeared).toHaveBeenCalledTimes(1);
127
+ c.stop();
128
+ });
129
+
130
+ it('does NOT acquire when the pushed signed-out state has zero accounts', async () => {
131
+ const onSessionAppeared = jest.fn();
132
+ const c = new SessionClient(makeHost(), { signedOutSocketAuth: () => true, onSessionAppeared });
133
+ await c.start();
134
+ fakeSocket.trigger('session_state', STATE(9, []));
135
+ await flush();
136
+ expect(onSessionAppeared).not.toHaveBeenCalled();
137
+ c.stop();
138
+ });
139
+
140
+ it('re-acquires on a LATER push after the prior acquisition settled (no permanent lock)', async () => {
141
+ const onSessionAppeared = jest.fn(() => Promise.resolve());
142
+ const c = new SessionClient(makeHost(), { signedOutSocketAuth: () => true, onSessionAppeared });
143
+ await c.start();
144
+ fakeSocket.trigger('session_state', STATE(9));
145
+ await flush();
146
+ expect(onSessionAppeared).toHaveBeenCalledTimes(1);
147
+ fakeSocket.trigger('session_state', STATE(10));
148
+ await flush();
149
+ expect(onSessionAppeared).toHaveBeenCalledTimes(2);
150
+ c.stop();
151
+ });
152
+
153
+ it('reconnects the anonymous socket authenticated once a token arrives', async () => {
154
+ let tokenListener: ((t: string | null) => void) | null = null;
155
+ let token: string | null = null;
156
+ const host = makeHost({
157
+ getAccessToken: () => token,
158
+ onTokensChanged: (l) => { tokenListener = l; return () => undefined; },
159
+ });
160
+ const c = new SessionClient(host, { signedOutSocketAuth: () => true });
161
+ await c.start();
162
+ expect(fakeSocket.connected).toBe(true); // anonymous connect
163
+ const before = fakeSocket.disconnectCalls;
164
+ token = 'fresh';
165
+ tokenListener?.('fresh');
166
+ // Anonymous → authenticated: force a reconnect so the handshake re-runs.
167
+ expect(fakeSocket.disconnectCalls).toBe(before + 1);
168
+ expect(fakeSocket.connected).toBe(true);
169
+ c.stop();
170
+ });
171
+ });
172
+
173
+ describe('SessionClient BroadcastChannel wake', () => {
174
+ const opts = (over: Partial<SessionClientOptions>): SessionClientOptions => ({ signedOutSocketAuth: () => true, ...over });
175
+
176
+ it('a local mutation wakes a signed-out sibling to acquire', async () => {
177
+ const onSessionAppeared = jest.fn(() => Promise.resolve());
178
+ // Signed-out sibling B listening on the channel.
179
+ const b = new SessionClient(makeHost(), opts({ onSessionAppeared }));
180
+ await b.start();
181
+ // Signed-in tab A commits (switchAccount posts a commit ping).
182
+ const a = new SessionClient(makeHost({ getAccessToken: () => 'tok-a' }), opts({}));
183
+ await a.start();
184
+ await a.switchAccount('a1');
185
+ await flush();
186
+ expect(onSessionAppeared).toHaveBeenCalledTimes(1);
187
+ a.stop();
188
+ b.stop();
189
+ });
190
+
191
+ it('a local mutation wakes a signed-in sibling to re-sync (bootstrap)', async () => {
192
+ const bMakeRequest = jest.fn().mockResolvedValue(SYNC(1));
193
+ const b = new SessionClient(makeHost({ makeRequest: bMakeRequest, getAccessToken: () => 'tok-b' }), opts({}));
194
+ await b.start();
195
+ bMakeRequest.mockClear();
196
+ const a = new SessionClient(makeHost({ getAccessToken: () => 'tok-a' }), opts({}));
197
+ await a.start();
198
+ await a.addCurrentAccount();
199
+ await flush();
200
+ expect(bMakeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
201
+ a.stop();
202
+ b.stop();
203
+ });
204
+
205
+ it('does not deliver a commit ping back to the posting tab (no self-loop)', async () => {
206
+ // A signed-out tab that commits locally must not re-trigger its OWN acquisition.
207
+ const onSessionAppeared = jest.fn();
208
+ const a = new SessionClient(makeHost(), opts({ onSessionAppeared }));
209
+ await a.start();
210
+ await a.addCurrentAccount();
211
+ await flush();
212
+ expect(onSessionAppeared).not.toHaveBeenCalled();
213
+ a.stop();
214
+ });
215
+
216
+ it('is a no-op on platforms without BroadcastChannel (native)', async () => {
217
+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = undefined;
218
+ const c = new SessionClient(makeHost({ getAccessToken: () => 'tok' }), { });
219
+ await c.start();
220
+ // Mutations must not throw when BroadcastChannel is absent.
221
+ await expect(c.switchAccount('a1')).resolves.toBeUndefined();
222
+ c.stop();
223
+ });
224
+ });