@oxyhq/core 8.0.0 → 9.0.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 (68) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/coldBootV2.js +66 -281
  3. package/dist/cjs/crypto/keyManager.js +0 -95
  4. package/dist/cjs/index.js +6 -19
  5. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  6. package/dist/cjs/mixins/OxyServices.deviceBoot.js +24 -92
  7. package/dist/cjs/mixins/index.js +2 -3
  8. package/dist/cjs/session/SessionClient.js +29 -100
  9. package/dist/cjs/session/accountDialogController.js +0 -13
  10. package/dist/cjs/session/authStateStore.js +14 -88
  11. package/dist/cjs/session/createSessionClient.js +2 -9
  12. package/dist/cjs/session/refresh.js +46 -62
  13. package/dist/cjs/utils/registrableApex.js +2 -6
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/boot/coldBootV2.js +67 -279
  16. package/dist/esm/crypto/keyManager.js +0 -95
  17. package/dist/esm/index.js +7 -11
  18. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  19. package/dist/esm/mixins/OxyServices.deviceBoot.js +25 -93
  20. package/dist/esm/mixins/index.js +2 -3
  21. package/dist/esm/session/SessionClient.js +29 -100
  22. package/dist/esm/session/accountDialogController.js +0 -13
  23. package/dist/esm/session/authStateStore.js +13 -87
  24. package/dist/esm/session/createSessionClient.js +2 -9
  25. package/dist/esm/session/refresh.js +46 -62
  26. package/dist/esm/utils/registrableApex.js +2 -6
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/HttpService.d.ts +3 -3
  29. package/dist/types/boot/coldBootV2.d.ts +28 -53
  30. package/dist/types/crypto/keyManager.d.ts +0 -21
  31. package/dist/types/index.d.ts +3 -5
  32. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  33. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +22 -46
  34. package/dist/types/session/SessionClient.d.ts +4 -38
  35. package/dist/types/session/accountDialogController.d.ts +1 -3
  36. package/dist/types/session/authStateStore.d.ts +38 -43
  37. package/dist/types/session/createSessionClient.d.ts +2 -9
  38. package/dist/types/session/refresh.d.ts +32 -28
  39. package/dist/types/utils/registrableApex.d.ts +2 -6
  40. package/package.json +2 -2
  41. package/src/HttpService.ts +3 -3
  42. package/src/boot/__tests__/coldBootV2.test.ts +237 -236
  43. package/src/boot/coldBootV2.ts +92 -333
  44. package/src/crypto/keyManager.ts +0 -101
  45. package/src/index.ts +7 -30
  46. package/src/mixins/OxyServices.auth.ts +5 -9
  47. package/src/mixins/OxyServices.deviceBoot.ts +30 -115
  48. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
  49. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  50. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  51. package/src/mixins/index.ts +2 -3
  52. package/src/session/SessionClient.ts +29 -120
  53. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  54. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  55. package/src/session/__tests__/authStateStore.test.ts +47 -33
  56. package/src/session/__tests__/refresh.test.ts +72 -44
  57. package/src/session/accountDialogController.ts +4 -18
  58. package/src/session/authStateStore.ts +43 -111
  59. package/src/session/createSessionClient.ts +2 -9
  60. package/src/session/refresh.ts +60 -83
  61. package/src/utils/registrableApex.ts +2 -6
  62. package/dist/cjs/boot/deviceBootReturn.js +0 -152
  63. package/dist/esm/boot/deviceBootReturn.js +0 -146
  64. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  65. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
  66. package/src/boot/deviceBootReturn.ts +0 -195
  67. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  68. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
@@ -1,158 +0,0 @@
1
- import type { AuthTokenBundle } from '@oxyhq/contracts';
2
- import {
3
- parseDeviceBootFragment,
4
- hashHasBootFragment,
5
- consumeDeviceBootReturn,
6
- type ConsumeDeviceBootReturnDeps,
7
- } from '../deviceBootReturn';
8
- import { createMemoryAuthStateStore } from '../../session/authStateStore';
9
-
10
- const STATE = 'st-1234567890';
11
- const CODE = 'c'.repeat(24);
12
- const DEVICE_TOKEN = 'd'.repeat(24);
13
-
14
- // A loose record (fed to `encodeHash(unknown)` and validated by the schema) so
15
- // tests can build the session arm plus invalid variants (missing/extra `code`,
16
- // `v: 2`) without fighting the discriminated-union type.
17
- function fragmentObject(overrides: Record<string, unknown> = {}): Record<string, unknown> {
18
- return { v: 1, state: STATE, reason: 'session', code: CODE, deviceToken: DEVICE_TOKEN, ...overrides };
19
- }
20
-
21
- function encodeHash(obj: unknown): string {
22
- const b64 = Buffer.from(JSON.stringify(obj), 'utf-8')
23
- .toString('base64')
24
- .replace(/\+/g, '-')
25
- .replace(/\//g, '_')
26
- .replace(/=+$/, '');
27
- return `#oxy_boot=${b64}`;
28
- }
29
-
30
- const BUNDLE: AuthTokenBundle = {
31
- sessionId: 'sess-1',
32
- accessToken: 'access-jwt',
33
- refreshToken: 'refresh-abcdefghijklmnop',
34
- expiresAt: '2030-01-01T00:00:00.000Z',
35
- user: { id: 'user-1', name: {} } as AuthTokenBundle['user'],
36
- };
37
-
38
- /** Build the injectable deps around a memory store, recording call order. */
39
- function makeDeps(
40
- hash: string,
41
- overrides: Partial<ConsumeDeviceBootReturnDeps> & { expectedState?: string | null } = {},
42
- ): { deps: ConsumeDeviceBootReturnDeps; calls: string[]; store: ReturnType<typeof createMemoryAuthStateStore> } {
43
- const calls: string[] = [];
44
- const store = createMemoryAuthStateStore();
45
- const deps: ConsumeDeviceBootReturnDeps = {
46
- hash,
47
- stripFragment: () => calls.push('strip'),
48
- readExpectedState: () => (overrides.expectedState !== undefined ? overrides.expectedState : STATE),
49
- clearExpectedState: () => calls.push('clearState'),
50
- store,
51
- exchangeBootCode: overrides.exchangeBootCode
52
- ?? (async () => {
53
- calls.push('exchange');
54
- return BUNDLE;
55
- }),
56
- plantAccessToken: overrides.plantAccessToken ?? ((token) => calls.push(`plant:${token}`)),
57
- };
58
- return { deps, calls, store };
59
- }
60
-
61
- describe('parseDeviceBootFragment', () => {
62
- it('parses a valid base64url fragment', () => {
63
- expect(parseDeviceBootFragment(encodeHash(fragmentObject()))).toEqual(fragmentObject());
64
- });
65
-
66
- it('returns null when the parameter is absent', () => {
67
- expect(parseDeviceBootFragment('#something=else')).toBeNull();
68
- expect(parseDeviceBootFragment('')).toBeNull();
69
- });
70
-
71
- it('returns null for malformed base64 / non-JSON / wrong shape', () => {
72
- expect(parseDeviceBootFragment('#oxy_boot=!!!not-base64!!!')).toBeNull();
73
- expect(parseDeviceBootFragment(encodeHash({ nope: true }))).toBeNull();
74
- // v must be the literal 1
75
- expect(parseDeviceBootFragment(encodeHash(fragmentObject({ v: 2 })))).toBeNull();
76
- });
77
-
78
- it('returns null for a session fragment missing its code (discriminated union)', () => {
79
- expect(parseDeviceBootFragment(encodeHash(fragmentObject({ code: undefined })))).toBeNull();
80
- });
81
- });
82
-
83
- describe('hashHasBootFragment', () => {
84
- it('detects the fragment regardless of position', () => {
85
- expect(hashHasBootFragment('#oxy_boot=abc')).toBe(true);
86
- expect(hashHasBootFragment('#x=1&oxy_boot=abc')).toBe(true);
87
- expect(hashHasBootFragment('#other=1')).toBe(false);
88
- expect(hashHasBootFragment('')).toBe(false);
89
- });
90
- });
91
-
92
- describe('consumeDeviceBootReturn', () => {
93
- it('returns none and does not touch the URL when no fragment is present', async () => {
94
- const { deps, calls } = makeDeps('#unrelated=1');
95
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'none' });
96
- expect(calls).not.toContain('strip');
97
- });
98
-
99
- it('strips the fragment BEFORE exchanging the code', async () => {
100
- const { deps, calls } = makeDeps(encodeHash(fragmentObject()));
101
- const outcome = await consumeDeviceBootReturn(deps);
102
- expect(outcome.kind).toBe('session');
103
- expect(calls.indexOf('strip')).toBeLessThan(calls.indexOf('exchange'));
104
- });
105
-
106
- it('resolves a session, persists it, and plants the token', async () => {
107
- const { deps, store } = makeDeps(encodeHash(fragmentObject()));
108
- const outcome = await consumeDeviceBootReturn(deps);
109
- expect(outcome).toEqual({
110
- kind: 'session',
111
- session: { sessionId: 'sess-1', userId: 'user-1', accessToken: 'access-jwt' },
112
- });
113
- expect(await store.load()).toMatchObject({
114
- sessionId: 'sess-1',
115
- refreshToken: 'refresh-abcdefghijklmnop',
116
- userId: 'user-1',
117
- deviceToken: DEVICE_TOKEN,
118
- });
119
- expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
120
- });
121
-
122
- it('rejects a state mismatch without persisting or exchanging', async () => {
123
- const { deps, calls, store } = makeDeps(encodeHash(fragmentObject()), { expectedState: 'WRONG' });
124
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
125
- expect(calls).toContain('strip');
126
- expect(calls).not.toContain('exchange');
127
- expect(await store.loadDeviceToken()).toBeNull();
128
- });
129
-
130
- it('rejects when there is no expected state stashed at all', async () => {
131
- const { deps } = makeDeps(encodeHash(fragmentObject()), { expectedState: null });
132
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
133
- });
134
-
135
- it('persists the deviceToken but returns no-session for a signed-out device', async () => {
136
- const { deps, store, calls } = makeDeps(
137
- encodeHash(fragmentObject({ reason: 'new_device', code: undefined })),
138
- );
139
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'no-session', reason: 'new_device' });
140
- expect(calls).not.toContain('exchange');
141
- expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
142
- });
143
-
144
- it('returns no-session when the code exchange fails (burned/expired code)', async () => {
145
- const { deps } = makeDeps(encodeHash(fragmentObject()), {
146
- exchangeBootCode: async () => {
147
- throw new Error('code already burned');
148
- },
149
- });
150
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'no-session', reason: 'no_session' });
151
- });
152
-
153
- it('strips even a malformed fragment and returns none', async () => {
154
- const { deps, calls } = makeDeps('#oxy_boot=!!!malformed!!!');
155
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'none' });
156
- expect(calls).toContain('strip');
157
- });
158
- });
@@ -1,195 +0,0 @@
1
- /**
2
- * Device-boot return-fragment consumption (web cross-apex hop).
3
- *
4
- * After the top-level `GET /auth/device/bootstrap` hop, the API 303s back to the
5
- * RP with a `#oxy_boot=<base64url(JSON)>` fragment. This module parses and
6
- * consumes it: it strips the fragment from the URL FIRST (so the opaque
7
- * deviceToken / code never linger in history or a `Referer`), verifies the
8
- * echoed CSRF `state` against the value the initiator stashed in
9
- * `sessionStorage`, persists the deviceToken, and — when a session resolved —
10
- * exchanges the single-use `code` for a token bundle.
11
- *
12
- * Pure/injectable: all DOM access (hash, `history.replaceState`,
13
- * `sessionStorage`) is passed in as callbacks so the logic is unit-testable
14
- * under the jest `node` environment and reusable by `coldBootV2`.
15
- *
16
- * ESM-safe (no `require()`).
17
- */
18
- import {
19
- deviceBootFragmentSchema,
20
- resolveUserId,
21
- safeParseContract,
22
- type AuthTokenBundle,
23
- type DeviceBootFragment,
24
- type DeviceBootReason,
25
- } from '@oxyhq/contracts';
26
- import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
27
-
28
- /** The `#oxy_boot=` fragment parameter name the API appends on the return hop. */
29
- export const BOOT_FRAGMENT_PARAM = 'oxy_boot';
30
-
31
- /**
32
- * `sessionStorage` key under which the bootstrap-hop initiator stashes the
33
- * 128-bit CSRF `state` before navigating, and which the return step reads back
34
- * (single-use).
35
- */
36
- export const BOOT_STATE_SESSION_KEY = 'oxy.boot.state';
37
-
38
- /**
39
- * Decode a base64url string to UTF-8 text, or `null` on any malformed input.
40
- * Handles both web (`atob` + `TextDecoder`) and Node (`Buffer`) without a
41
- * `require()` — the ESM build stays clean.
42
- */
43
- function base64UrlDecode(input: string): string | null {
44
- try {
45
- let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
46
- while (b64.length % 4 !== 0) {
47
- b64 += '=';
48
- }
49
- if (typeof atob === 'function') {
50
- const binary = atob(b64);
51
- const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
52
- if (typeof TextDecoder !== 'undefined') {
53
- return new TextDecoder().decode(bytes);
54
- }
55
- return binary;
56
- }
57
- if (typeof Buffer !== 'undefined') {
58
- return Buffer.from(b64, 'base64').toString('utf-8');
59
- }
60
- return null;
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- /** True when a location hash carries the `oxy_boot` return fragment. */
67
- export function hashHasBootFragment(hash: string): boolean {
68
- return new RegExp(`(^|[#&])${BOOT_FRAGMENT_PARAM}=`).test(hash);
69
- }
70
-
71
- /**
72
- * Extract + decode + validate the `oxy_boot` fragment from a location hash.
73
- * Returns the parsed {@link DeviceBootFragment}, or `null` when the parameter
74
- * is absent, not valid base64url, not JSON, or fails the contract schema.
75
- */
76
- export function parseDeviceBootFragment(hash: string): DeviceBootFragment | null {
77
- const withoutHash = hash.startsWith('#') ? hash.slice(1) : hash;
78
- const params = new URLSearchParams(withoutHash);
79
- const raw = params.get(BOOT_FRAGMENT_PARAM);
80
- if (!raw) {
81
- return null;
82
- }
83
- const json = base64UrlDecode(raw);
84
- if (!json) {
85
- return null;
86
- }
87
- let parsed: unknown;
88
- try {
89
- parsed = JSON.parse(json);
90
- } catch {
91
- return null;
92
- }
93
- return safeParseContract(deviceBootFragmentSchema, parsed);
94
- }
95
-
96
- /** The winning session shape a cold-boot step reports. */
97
- export interface DeviceBootSession {
98
- sessionId: string;
99
- userId: string;
100
- accessToken: string;
101
- }
102
-
103
- /** Outcome of {@link consumeDeviceBootReturn}. */
104
- export type DeviceBootReturnOutcome =
105
- | { kind: 'none' }
106
- | { kind: 'state-mismatch' }
107
- | { kind: 'session'; session: DeviceBootSession }
108
- | { kind: 'no-session'; reason: DeviceBootReason };
109
-
110
- export interface ConsumeDeviceBootReturnDeps {
111
- /** The current location hash (e.g. `window.location.hash`). */
112
- hash: string;
113
- /** Strip the fragment from the URL (e.g. `history.replaceState`). */
114
- stripFragment: () => void;
115
- /** Read the expected CSRF state (e.g. `sessionStorage.getItem(BOOT_STATE_SESSION_KEY)`). */
116
- readExpectedState: () => string | null;
117
- /** Clear the expected CSRF state (single-use). */
118
- clearExpectedState: () => void;
119
- store: AuthStateStore;
120
- /** Exchange the single-use boot code for a token bundle (`oxy.exchangeBootCode`). */
121
- exchangeBootCode: (code: string) => Promise<AuthTokenBundle>;
122
- /** Plant the freshly-minted access token on the owner client (`oxy.setTokens`). */
123
- plantAccessToken: (accessToken: string) => void;
124
- }
125
-
126
- /**
127
- * Consume the device-boot return fragment.
128
- *
129
- * Order is load-bearing:
130
- * 1. If no fragment is present, return `none` (no URL mutation).
131
- * 2. STRIP the fragment from the URL immediately — before validation or any
132
- * network — so the deviceToken/code never persist in history/referrer.
133
- * 3. Verify the echoed `state` against the stashed (single-use) value; a
134
- * mismatch returns `state-mismatch` without persisting or exchanging.
135
- * 4. Persist the deviceToken (survives sign-out).
136
- * 5. If a session resolved (`reason:'session'` + `code`), exchange the code,
137
- * persist the rotated session, plant the token, and return `session`.
138
- * Otherwise return `no-session` with the reason.
139
- */
140
- export async function consumeDeviceBootReturn(
141
- deps: ConsumeDeviceBootReturnDeps,
142
- ): Promise<DeviceBootReturnOutcome> {
143
- if (!hashHasBootFragment(deps.hash)) {
144
- return { kind: 'none' };
145
- }
146
-
147
- // Strip FIRST — even a forged/malformed fragment must not linger in the URL.
148
- deps.stripFragment();
149
-
150
- const fragment = parseDeviceBootFragment(deps.hash);
151
- if (!fragment) {
152
- return { kind: 'none' };
153
- }
154
-
155
- const expected = deps.readExpectedState();
156
- deps.clearExpectedState();
157
- if (!expected || expected !== fragment.state) {
158
- return { kind: 'state-mismatch' };
159
- }
160
-
161
- await deps.store.saveDeviceToken(fragment.deviceToken);
162
-
163
- // `code` is guaranteed present on the `session` arm (the contract's
164
- // discriminated union requires it; a session fragment without a code fails to
165
- // parse and never reaches here).
166
- if (fragment.reason === 'session') {
167
- try {
168
- const bundle = await deps.exchangeBootCode(fragment.code);
169
- const userId = resolveUserId(bundle.user);
170
- if (!userId) {
171
- return { kind: 'no-session', reason: 'no_session' };
172
- }
173
- const next: PersistedAuthState = {
174
- sessionId: bundle.sessionId,
175
- refreshToken: bundle.refreshToken,
176
- userId,
177
- deviceToken: fragment.deviceToken,
178
- accessToken: bundle.accessToken,
179
- expiresAt: bundle.expiresAt,
180
- };
181
- await deps.store.save(next);
182
- deps.plantAccessToken(bundle.accessToken);
183
- return {
184
- kind: 'session',
185
- session: { sessionId: bundle.sessionId, userId, accessToken: bundle.accessToken },
186
- };
187
- } catch {
188
- // The code burned/expired between hop and exchange — resolve signed-out
189
- // rather than throwing (the once-ever hop already fired; do not retry).
190
- return { kind: 'no-session', reason: 'no_session' };
191
- }
192
- }
193
-
194
- return { kind: 'no-session', reason: fragment.reason };
195
- }
@@ -1,24 +0,0 @@
1
- /**
2
- * Shared device-token contract on web. Under the jest `node` environment
3
- * `getPlatformOS()` resolves to `'web'`, so the shared-keychain device-token
4
- * methods are no-ops (web persists its deviceToken in the per-origin
5
- * AuthStateStore instead of a keychain). The native keychain plumbing is
6
- * exercised via spies in the coldBootV2 suite.
7
- */
8
- import { KeyManager } from '../keyManager';
9
-
10
- describe('KeyManager shared device token (web)', () => {
11
- it('getSharedDeviceToken returns null on web', async () => {
12
- expect(await KeyManager.getSharedDeviceToken()).toBeNull();
13
- });
14
-
15
- it('setSharedDeviceToken is a no-op that does not throw on web', async () => {
16
- await expect(KeyManager.setSharedDeviceToken('dt-web')).resolves.toBeUndefined();
17
- // Still null — nothing was persisted on web.
18
- expect(await KeyManager.getSharedDeviceToken()).toBeNull();
19
- });
20
-
21
- it('clearSharedDeviceToken is a no-op that does not throw on web', async () => {
22
- await expect(KeyManager.clearSharedDeviceToken()).resolves.toBeUndefined();
23
- });
24
- });
@@ -1,224 +0,0 @@
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
- });