@oxyhq/core 5.2.0 → 5.3.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 (117) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +32 -42
  3. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  4. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  5. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  6. package/dist/cjs/i18n/locales/en-US.json +1 -0
  7. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  9. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  10. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  11. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  23. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  24. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  25. package/dist/cjs/index.js +24 -2
  26. package/dist/cjs/mixins/OxyServices.sso.js +36 -0
  27. package/dist/cjs/mixins/OxyServices.user.js +24 -0
  28. package/dist/cjs/server/index.js +13 -1
  29. package/dist/cjs/session/SessionClient.js +142 -0
  30. package/dist/cjs/session/createSessionClient.js +26 -0
  31. package/dist/cjs/session/projectSessionState.js +75 -0
  32. package/dist/cjs/session/sessionClientHost.js +30 -0
  33. package/dist/cjs/session/socketLoader.js +55 -0
  34. package/dist/cjs/utils/cache.js +2 -0
  35. package/dist/cjs/utils/ssoBounce.js +9 -9
  36. package/dist/cjs/utils/ssoEstablish.js +110 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/CrossDomainAuth.js +32 -42
  39. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  40. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  41. package/dist/esm/i18n/locales/de-DE.json +1 -0
  42. package/dist/esm/i18n/locales/en-US.json +1 -0
  43. package/dist/esm/i18n/locales/es-ES.json +1 -0
  44. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  45. package/dist/esm/i18n/locales/it-IT.json +1 -0
  46. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  47. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  48. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  49. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  50. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  51. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  52. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  54. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  55. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  56. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  59. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  60. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  61. package/dist/esm/index.js +14 -0
  62. package/dist/esm/mixins/OxyServices.sso.js +36 -0
  63. package/dist/esm/mixins/OxyServices.user.js +24 -0
  64. package/dist/esm/server/index.js +10 -0
  65. package/dist/esm/session/SessionClient.js +138 -0
  66. package/dist/esm/session/createSessionClient.js +23 -0
  67. package/dist/esm/session/projectSessionState.js +69 -0
  68. package/dist/esm/session/sessionClientHost.js +27 -0
  69. package/dist/esm/session/socketLoader.js +19 -0
  70. package/dist/esm/utils/cache.js +2 -0
  71. package/dist/esm/utils/ssoBounce.js +9 -9
  72. package/dist/esm/utils/ssoEstablish.js +107 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/CrossDomainAuth.d.ts +33 -13
  75. package/dist/types/index.d.ts +7 -0
  76. package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
  77. package/dist/types/mixins/OxyServices.user.d.ts +14 -0
  78. package/dist/types/server/index.d.ts +2 -0
  79. package/dist/types/session/SessionClient.d.ts +55 -0
  80. package/dist/types/session/createSessionClient.d.ts +23 -0
  81. package/dist/types/session/projectSessionState.d.ts +43 -0
  82. package/dist/types/session/sessionClientHost.d.ts +18 -0
  83. package/dist/types/session/socketLoader.d.ts +9 -0
  84. package/dist/types/utils/ssoBounce.d.ts +9 -9
  85. package/dist/types/utils/ssoEstablish.d.ts +85 -0
  86. package/package.json +1 -1
  87. package/src/CrossDomainAuth.ts +33 -44
  88. package/src/__tests__/crossDomainAuth.test.ts +33 -16
  89. package/src/i18n/locales/ar-SA.json +1 -0
  90. package/src/i18n/locales/ca-ES.json +1 -0
  91. package/src/i18n/locales/de-DE.json +1 -0
  92. package/src/i18n/locales/en-US.json +1 -0
  93. package/src/i18n/locales/es-ES.json +1 -0
  94. package/src/i18n/locales/fr-FR.json +1 -0
  95. package/src/i18n/locales/it-IT.json +1 -0
  96. package/src/i18n/locales/ja-JP.json +1 -0
  97. package/src/i18n/locales/ko-KR.json +1 -0
  98. package/src/i18n/locales/pt-PT.json +1 -0
  99. package/src/i18n/locales/zh-CN.json +1 -0
  100. package/src/index.ts +24 -0
  101. package/src/mixins/OxyServices.sso.ts +55 -0
  102. package/src/mixins/OxyServices.user.ts +26 -0
  103. package/src/mixins/__tests__/sso.test.ts +41 -0
  104. package/src/server/index.ts +12 -0
  105. package/src/session/SessionClient.ts +175 -0
  106. package/src/session/__tests__/SessionClient.rest.test.ts +79 -0
  107. package/src/session/__tests__/SessionClient.socket.test.ts +132 -0
  108. package/src/session/__tests__/SessionClient.state.test.ts +64 -0
  109. package/src/session/__tests__/sessionIntegration.test.ts +202 -0
  110. package/src/session/createSessionClient.ts +31 -0
  111. package/src/session/projectSessionState.ts +83 -0
  112. package/src/session/sessionClientHost.ts +32 -0
  113. package/src/session/socketLoader.ts +28 -0
  114. package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
  115. package/src/utils/cache.ts +2 -0
  116. package/src/utils/ssoBounce.ts +9 -9
  117. package/src/utils/ssoEstablish.ts +174 -0
@@ -0,0 +1,132 @@
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
+ on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
8
+ off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
9
+ connect() { this.connected = true; this.trigger('connect'); }
10
+ disconnect() { this.connected = false; }
11
+ trigger(event: string, ...args: unknown[]) { for (const h of this.handlers.get(event) ?? []) h(...args); }
12
+ }
13
+ let fakeSocket: FakeSocket;
14
+ const ioMock = jest.fn((_uri: string, opts?: Record<string, unknown>) => {
15
+ // honor autoConnect like real socket.io (connect immediately unless autoConnect:false)
16
+ if (!opts || opts.autoConnect !== false) fakeSocket.connected = true;
17
+ return fakeSocket;
18
+ });
19
+ jest.mock('socket.io-client', () => ({ __esModule: true, io: (...args: unknown[]) => ioMock(...(args as [string, Record<string, unknown>?])) }));
20
+
21
+ import { SessionClient, type SessionClientHost } from '../SessionClient';
22
+
23
+ const STATE = (rev: number): DeviceSessionState => ({ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000 });
24
+ // The server wraps the sync payload in a REST `{ data }` envelope; makeRequest does NOT unwrap it.
25
+ const SYNC = (rev: number) => ({ data: { state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } } });
26
+
27
+ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
28
+ return {
29
+ makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
30
+ getBaseURL: () => 'http://test.invalid',
31
+ getAccessToken: () => 'tok',
32
+ onTokensChanged: () => () => undefined,
33
+ setTokens: jest.fn(),
34
+ getCurrentAccountId: () => 'a1',
35
+ ...over,
36
+ };
37
+ }
38
+
39
+ beforeEach(() => { fakeSocket = new FakeSocket(); ioMock.mockClear(); });
40
+
41
+ describe('SessionClient socket', () => {
42
+ it('start() bootstraps then opens ONE socket to the base URL with a token-in-handshake auth callback', async () => {
43
+ const host = makeHost();
44
+ const c = new SessionClient(host);
45
+ await c.start();
46
+ expect(host.makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
47
+ expect(ioMock).toHaveBeenCalledTimes(1);
48
+ const [uri, opts] = ioMock.mock.calls[0];
49
+ expect(uri).toBe('http://test.invalid');
50
+ const authCb = jest.fn();
51
+ (opts?.auth as (cb: (d: { token: string }) => void) => void)(authCb);
52
+ expect(authCb).toHaveBeenCalledWith({ token: 'tok' });
53
+ c.stop();
54
+ });
55
+
56
+ it('applies a pushed session_state event', async () => {
57
+ const c = new SessionClient(makeHost());
58
+ await c.start();
59
+ fakeSocket.trigger('session_state', STATE(9));
60
+ expect(c.getState()?.revision).toBe(9);
61
+ c.stop();
62
+ });
63
+
64
+ it('fetches the active token via bootstrap when a pushed state changes the active account', async () => {
65
+ const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
66
+ const host = makeHost({ makeRequest, getCurrentAccountId: () => 'other-account' });
67
+ const c = new SessionClient(host);
68
+ await c.start();
69
+ makeRequest.mockClear();
70
+ fakeSocket.trigger('session_state', STATE(9));
71
+ await Promise.resolve();
72
+ expect(makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
73
+ c.stop();
74
+ });
75
+
76
+ it('C1 regression: plants the active token on a socket-pushed switch even when the post-push bootstrap returns the SAME revision as the push', async () => {
77
+ const setTokens = jest.fn();
78
+ const makeRequest = jest
79
+ .fn()
80
+ .mockResolvedValueOnce(SYNC(1)) // initial bootstrap in start()
81
+ .mockResolvedValue(SYNC(9)); // post-push bootstrap: same revision as the socket push below
82
+ const host = makeHost({ makeRequest, setTokens, getCurrentAccountId: () => 'other-account' });
83
+ const c = new SessionClient(host);
84
+ await c.start();
85
+ makeRequest.mockClear();
86
+ setTokens.mockClear();
87
+ fakeSocket.trigger('session_state', STATE(9));
88
+ await Promise.resolve();
89
+ await Promise.resolve();
90
+ expect(makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
91
+ expect(setTokens).toHaveBeenCalledWith('jwt-9');
92
+ c.stop();
93
+ });
94
+
95
+ it('does not re-fetch when the pushed active account matches the host-held account', async () => {
96
+ const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
97
+ const host = makeHost({ makeRequest, getCurrentAccountId: () => 'a1' });
98
+ const c = new SessionClient(host);
99
+ await c.start();
100
+ makeRequest.mockClear();
101
+ fakeSocket.trigger('session_state', STATE(9));
102
+ await Promise.resolve();
103
+ expect(makeRequest).not.toHaveBeenCalled();
104
+ c.stop();
105
+ });
106
+
107
+ it('does not connect the socket when there is no token (autoConnect false)', async () => {
108
+ const c = new SessionClient(makeHost({ getAccessToken: () => null }));
109
+ await c.start();
110
+ const [, opts] = ioMock.mock.calls[0];
111
+ expect(opts?.autoConnect).toBe(false);
112
+ c.stop();
113
+ });
114
+
115
+ it('reconnects when a token arrives after being disconnected', async () => {
116
+ let tokenListener: ((t: string | null) => void) | null = null;
117
+ const host = makeHost({ getAccessToken: () => null, onTokensChanged: (l) => { tokenListener = l; return () => undefined; } });
118
+ const c = new SessionClient(host);
119
+ await c.start();
120
+ fakeSocket.connected = false;
121
+ tokenListener?.('fresh-token');
122
+ expect(fakeSocket.connected).toBe(true);
123
+ c.stop();
124
+ });
125
+
126
+ it('stop() disconnects the socket', async () => {
127
+ const c = new SessionClient(makeHost());
128
+ await c.start();
129
+ c.stop();
130
+ expect(fakeSocket.connected).toBe(false);
131
+ });
132
+ });
@@ -0,0 +1,64 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import { SessionClient, type SessionClientHost, type TokenTransport } from '../SessionClient';
3
+
4
+ function makeHost(): SessionClientHost {
5
+ return {
6
+ makeRequest: jest.fn(),
7
+ getBaseURL: () => 'http://test.invalid',
8
+ getAccessToken: () => 't',
9
+ onTokensChanged: () => () => undefined,
10
+ setTokens: jest.fn(),
11
+ getCurrentAccountId: () => null,
12
+ };
13
+ }
14
+ const STATE = (rev: number, active: string | null = 'a1'): DeviceSessionState => ({
15
+ deviceId: 'd1', accounts: active ? [{ accountId: 'a1', sessionId: 's1', authuser: 0 }] : [], activeAccountId: active, revision: rev, updatedAt: 1720000000000,
16
+ });
17
+
18
+ // SessionClient.applyState is protected; a tiny subclass exposes it for the unit test.
19
+ class TestClient extends SessionClient { public apply(raw: unknown): boolean { return this.applyState(raw); } }
20
+
21
+ describe('SessionClient state', () => {
22
+ it('starts with null state', () => {
23
+ expect(new SessionClient(makeHost()).getState()).toBeNull();
24
+ });
25
+
26
+ it('applies a valid state and notifies subscribers', () => {
27
+ const c = new TestClient(makeHost());
28
+ const seen: (DeviceSessionState | null)[] = [];
29
+ c.subscribe((s) => seen.push(s));
30
+ expect(c.apply(STATE(1))).toBe(true);
31
+ expect(c.getState()?.revision).toBe(1);
32
+ expect(seen.at(-1)?.revision).toBe(1);
33
+ });
34
+
35
+ it('ignores a stale or equal revision (last-writer-wins)', () => {
36
+ const c = new TestClient(makeHost());
37
+ c.apply(STATE(5));
38
+ expect(c.apply(STATE(5))).toBe(false);
39
+ expect(c.apply(STATE(4))).toBe(false);
40
+ expect(c.getState()?.revision).toBe(5);
41
+ });
42
+
43
+ it('rejects an invalid (unvalidated) state without applying', () => {
44
+ const c = new TestClient(makeHost());
45
+ expect(c.apply({ deviceId: 'd1', accounts: 'nope', revision: 1 })).toBe(false);
46
+ expect(c.getState()).toBeNull();
47
+ });
48
+
49
+ it('calls transport.ensureActiveToken when a state is applied', () => {
50
+ const transport: TokenTransport = { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
51
+ const c = new TestClient(makeHost(), { transport });
52
+ c.apply(STATE(1));
53
+ expect(transport.ensureActiveToken).toHaveBeenCalledWith(expect.objectContaining({ revision: 1 }));
54
+ });
55
+
56
+ it('unsubscribe stops notifications', () => {
57
+ const c = new TestClient(makeHost());
58
+ const seen: unknown[] = [];
59
+ const off = c.subscribe((s) => seen.push(s));
60
+ off();
61
+ c.apply(STATE(1));
62
+ expect(seen).toHaveLength(0);
63
+ });
64
+ });
@@ -0,0 +1,202 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import type { User } from '../../models/interfaces';
3
+ import { SessionClient, type TokenTransport } from '../SessionClient';
4
+ import { createSessionClientHost } from '../sessionClientHost';
5
+ import { createSessionClient } from '../createSessionClient';
6
+ import {
7
+ accountIdsOf,
8
+ activeSessionIdOf,
9
+ activeUserOf,
10
+ deviceStateToClientSessions,
11
+ } from '../projectSessionState';
12
+
13
+ function fakeOxy() {
14
+ const listeners = new Set<(t: string | null) => void>();
15
+ return {
16
+ makeRequest: jest.fn().mockResolvedValue({ ok: true }),
17
+ getBaseURL: jest.fn().mockReturnValue('https://api.oxy.so'),
18
+ getAccessToken: jest.fn().mockReturnValue('tok'),
19
+ setTokens: jest.fn(),
20
+ onTokensChanged: jest.fn((l: (t: string | null) => void) => {
21
+ listeners.add(l);
22
+ return () => listeners.delete(l);
23
+ }),
24
+ _emit: (t: string | null) => listeners.forEach((l) => l(t)),
25
+ };
26
+ }
27
+
28
+ describe('createSessionClientHost', () => {
29
+ test('delegates REST + token methods to oxyServices', async () => {
30
+ const oxy = fakeOxy();
31
+ const host = createSessionClientHost(oxy as never);
32
+ await host.makeRequest('GET', '/session/device/state', undefined, { cache: false });
33
+ expect(oxy.makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
34
+ expect(host.getBaseURL()).toBe('https://api.oxy.so');
35
+ expect(host.getAccessToken()).toBe('tok');
36
+ host.setTokens('new');
37
+ expect(oxy.setTokens).toHaveBeenCalledWith('new');
38
+ });
39
+
40
+ test('getCurrentAccountId reflects setCurrentAccountId', () => {
41
+ const host = createSessionClientHost(fakeOxy() as never);
42
+ expect(host.getCurrentAccountId()).toBeNull();
43
+ host.setCurrentAccountId('u1');
44
+ expect(host.getCurrentAccountId()).toBe('u1');
45
+ });
46
+
47
+ test('onTokensChanged forwards to oxyServices and unsubscribes', () => {
48
+ const oxy = fakeOxy();
49
+ const host = createSessionClientHost(oxy as never);
50
+ const cb = jest.fn();
51
+ const unsub = host.onTokensChanged(cb);
52
+ oxy._emit(null);
53
+ expect(cb).toHaveBeenCalledWith(null);
54
+ unsub();
55
+ oxy._emit('x');
56
+ expect(cb).toHaveBeenCalledTimes(1);
57
+ });
58
+ });
59
+
60
+ function makeUser(id: string): User {
61
+ return {
62
+ id,
63
+ publicKey: `pk-${id}`,
64
+ username: `user-${id}`,
65
+ name: {},
66
+ };
67
+ }
68
+
69
+ // DeviceSessionState.updatedAt is an epoch-ms number on the wire (see
70
+ // packages/contracts/src/deviceSession.ts: `updatedAt: z.number()`), not an
71
+ // ISO string.
72
+ const UPDATED_AT_MS = Date.UTC(2026, 6, 1, 0, 0, 0, 0);
73
+ const UPDATED_AT_ISO = new Date(UPDATED_AT_MS).toISOString();
74
+
75
+ const state: DeviceSessionState = {
76
+ deviceId: 'device-1',
77
+ accounts: [
78
+ { accountId: 'a1', sessionId: 'sess-a1', authuser: 0 },
79
+ { accountId: 'a2', sessionId: 'sess-a2', authuser: 1 },
80
+ ],
81
+ activeAccountId: 'a2',
82
+ revision: 1,
83
+ updatedAt: UPDATED_AT_MS,
84
+ };
85
+
86
+ const usersById = new Map<string, User>([
87
+ ['a1', makeUser('a1')],
88
+ ['a2', makeUser('a2')],
89
+ ]);
90
+
91
+ describe('projectSessionState', () => {
92
+ describe('activeSessionIdOf', () => {
93
+ test('returns the active account sessionId', () => {
94
+ expect(activeSessionIdOf(state)).toBe('sess-a2');
95
+ });
96
+
97
+ test('returns null for null state', () => {
98
+ expect(activeSessionIdOf(null)).toBeNull();
99
+ });
100
+
101
+ test('returns null when activeAccountId is null', () => {
102
+ expect(activeSessionIdOf({ ...state, activeAccountId: null })).toBeNull();
103
+ });
104
+ });
105
+
106
+ describe('deviceStateToClientSessions', () => {
107
+ test('maps every account in order with isCurrent + authuser', () => {
108
+ const sessions = deviceStateToClientSessions(state, usersById);
109
+ expect(sessions).toHaveLength(2);
110
+ expect(sessions[0]).toEqual({
111
+ sessionId: 'sess-a1',
112
+ deviceId: 'device-1',
113
+ expiresAt: UPDATED_AT_ISO,
114
+ lastActive: UPDATED_AT_ISO,
115
+ userId: 'a1',
116
+ isCurrent: false,
117
+ authuser: 0,
118
+ });
119
+ expect(sessions[1]).toEqual({
120
+ sessionId: 'sess-a2',
121
+ deviceId: 'device-1',
122
+ expiresAt: UPDATED_AT_ISO,
123
+ lastActive: UPDATED_AT_ISO,
124
+ userId: 'a2',
125
+ isCurrent: true,
126
+ authuser: 1,
127
+ });
128
+ });
129
+
130
+ test('still projects a session for an account absent from usersById', () => {
131
+ const sessions = deviceStateToClientSessions(state, new Map());
132
+ expect(sessions).toHaveLength(2);
133
+ expect(sessions.map((session) => session.userId)).toEqual(['a1', 'a2']);
134
+ });
135
+ });
136
+
137
+ describe('activeUserOf', () => {
138
+ test('returns the active account user', () => {
139
+ expect(activeUserOf(state, usersById)).toEqual(makeUser('a2'));
140
+ });
141
+
142
+ test('returns null for null state', () => {
143
+ expect(activeUserOf(null, usersById)).toBeNull();
144
+ });
145
+
146
+ test('returns null when activeAccountId is null', () => {
147
+ expect(activeUserOf({ ...state, activeAccountId: null }, usersById)).toBeNull();
148
+ });
149
+
150
+ test('returns null when the active account id is absent from usersById', () => {
151
+ expect(activeUserOf(state, new Map())).toBeNull();
152
+ });
153
+ });
154
+
155
+ describe('accountIdsOf', () => {
156
+ test('returns every account id in order', () => {
157
+ expect(accountIdsOf(state)).toEqual(['a1', 'a2']);
158
+ });
159
+
160
+ test('returns [] for null state', () => {
161
+ expect(accountIdsOf(null)).toEqual([]);
162
+ });
163
+ });
164
+ });
165
+
166
+ describe('createSessionClient', () => {
167
+ function fakeTransport(): TokenTransport {
168
+ return { ensureActiveToken: jest.fn().mockResolvedValue(undefined) };
169
+ }
170
+
171
+ test('wires a SessionClient instance backed by the host + injected transport', () => {
172
+ const oxy = fakeOxy();
173
+
174
+ const { client, host } = createSessionClient(oxy as never, fakeTransport());
175
+
176
+ expect(client).toBeInstanceOf(SessionClient);
177
+ expect(typeof client.bootstrap).toBe('function');
178
+ expect(client.getState()).toBeNull();
179
+ expect(typeof host.setCurrentAccountId).toBe('function');
180
+ });
181
+
182
+ test('the returned host reflects setCurrentAccountId', () => {
183
+ const oxy = fakeOxy();
184
+
185
+ const { host } = createSessionClient(oxy as never, fakeTransport());
186
+
187
+ expect(host.getCurrentAccountId()).toBeNull();
188
+ host.setCurrentAccountId('u1');
189
+ expect(host.getCurrentAccountId()).toBe('u1');
190
+ });
191
+
192
+ test('uses the injected transport (not a hard-coded one) when the client bootstraps', async () => {
193
+ const oxy = fakeOxy();
194
+ oxy.makeRequest.mockResolvedValue({ data: { state, activeToken: null } });
195
+ const transport = fakeTransport();
196
+
197
+ const { client } = createSessionClient(oxy as never, transport);
198
+ await client.bootstrap();
199
+
200
+ expect(transport.ensureActiveToken).toHaveBeenCalledWith(expect.objectContaining({ revision: state.revision }));
201
+ });
202
+ });
@@ -0,0 +1,31 @@
1
+ import type { OxyServices } from '../OxyServices';
2
+ import { SessionClient, type TokenTransport } from './SessionClient';
3
+ import { createSessionClientHost } from './sessionClientHost';
4
+
5
+ /**
6
+ * Wires a `SessionClient` over the given `OxyServices` instance: builds the
7
+ * `SessionClientHost` adapter and passes it through together with a
8
+ * caller-supplied `TokenTransport`.
9
+ *
10
+ * The transport is a required parameter (not constructed here) because it is
11
+ * the one piece of this integration that is NOT platform-agnostic: `services`
12
+ * branches native (shared-keychain sign-in) vs. web (silent sign-in), while
13
+ * `auth-sdk` is web-only. Each consumer builds its own transport and passes
14
+ * it in; this factory only wires the platform-agnostic parts (host + client)
15
+ * so neither consumer re-implements them.
16
+ *
17
+ * The host is returned alongside the client (not just the client) so the
18
+ * caller can call `host.setCurrentAccountId(...)` as the active account
19
+ * changes.
20
+ */
21
+ export function createSessionClient(
22
+ oxyServices: OxyServices,
23
+ transport: TokenTransport,
24
+ ): {
25
+ client: SessionClient;
26
+ host: ReturnType<typeof createSessionClientHost>;
27
+ } {
28
+ const host = createSessionClientHost(oxyServices);
29
+ const client = new SessionClient(host, { transport });
30
+ return { client, host };
31
+ }
@@ -0,0 +1,83 @@
1
+ import type { DeviceSessionState } from '@oxyhq/contracts';
2
+ import type { ClientSession } from '../models/session';
3
+ import type { User } from '../models/interfaces';
4
+
5
+ /**
6
+ * Pure projection helpers: `DeviceSessionState` (the device-scoped
7
+ * multi-account session-sync state produced by `SessionClient`) -> the
8
+ * shapes consumers (`@oxyhq/services`, `@oxyhq/auth`) render today
9
+ * (`ClientSession[]`, an active session id, an active `User`).
10
+ *
11
+ * No I/O. The caller fetches profiles via
12
+ * `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
13
+ * from the result before calling `deviceStateToClientSessions` /
14
+ * `activeUserOf`.
15
+ */
16
+
17
+ /**
18
+ * Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
19
+ *
20
+ * `DeviceSessionState` carries no per-account `expiresAt` / `lastActive` —
21
+ * both are set to `state.updatedAt` (converted to an ISO-8601 string; the
22
+ * wire value is an epoch-ms number) as a provisional value.
23
+ *
24
+ * `usersById` is accepted for signature symmetry with `activeUserOf` even
25
+ * though `ClientSession` only stores `userId` — a session is still
26
+ * projected for an account whose id is absent from `usersById` (no
27
+ * placeholder user is fabricated).
28
+ */
29
+ export function deviceStateToClientSessions(
30
+ state: DeviceSessionState,
31
+ usersById: Map<string, User>,
32
+ ): ClientSession[] {
33
+ const provisionalTimestamp = new Date(state.updatedAt).toISOString();
34
+ return state.accounts.map((account) => ({
35
+ sessionId: account.sessionId,
36
+ deviceId: state.deviceId,
37
+ // provisional: expiresAt/lastActive are not carried on DeviceSessionState
38
+ expiresAt: provisionalTimestamp,
39
+ lastActive: provisionalTimestamp,
40
+ userId: account.accountId,
41
+ isCurrent: account.accountId === state.activeAccountId,
42
+ authuser: account.authuser,
43
+ }));
44
+ }
45
+
46
+ /**
47
+ * The active account's `sessionId`, or `null` when there is no state or no
48
+ * active account is set.
49
+ */
50
+ export function activeSessionIdOf(state: DeviceSessionState | null): string | null {
51
+ if (state === null || state.activeAccountId === null) {
52
+ return null;
53
+ }
54
+ const activeAccountId = state.activeAccountId;
55
+ const activeAccount = state.accounts.find((account) => account.accountId === activeAccountId);
56
+ return activeAccount?.sessionId ?? null;
57
+ }
58
+
59
+ /**
60
+ * The active account's `User`, resolved from `usersById`. `null` when there
61
+ * is no state, no active account is set, or the active account id is absent
62
+ * from `usersById`.
63
+ */
64
+ export function activeUserOf(
65
+ state: DeviceSessionState | null,
66
+ usersById: Map<string, User>,
67
+ ): User | null {
68
+ if (state === null || state.activeAccountId === null) {
69
+ return null;
70
+ }
71
+ return usersById.get(state.activeAccountId) ?? null;
72
+ }
73
+
74
+ /**
75
+ * All account ids in `state`, suitable for an `oxyServices.getUsersByIds(...)`
76
+ * fetch. `[]` for `null` state.
77
+ */
78
+ export function accountIdsOf(state: DeviceSessionState | null): string[] {
79
+ if (state === null) {
80
+ return [];
81
+ }
82
+ return state.accounts.map((account) => account.accountId);
83
+ }
@@ -0,0 +1,32 @@
1
+ import type { OxyServices } from '../OxyServices';
2
+ import type { SessionClientHost } from './SessionClient';
3
+
4
+ /**
5
+ * Thin `SessionClientHost` adapter over an `OxyServices` instance.
6
+ *
7
+ * `SessionClient` is host-agnostic: it only needs a REST + token surface.
8
+ * `OxyServices` already exposes all of that except `getCurrentAccountId`,
9
+ * which has no direct equivalent — the adapter holds a mutable ref set by
10
+ * the caller (`OxyContext` in `@oxyhq/services`, `WebOxyProvider` in
11
+ * `@oxyhq/auth`) via `setCurrentAccountId`.
12
+ *
13
+ * Shared here (rather than duplicated per consumer) because it is entirely
14
+ * platform-agnostic: every method it calls exists identically on
15
+ * `OxyServices` regardless of host (web, Expo/RN, Node).
16
+ */
17
+ export function createSessionClientHost(
18
+ oxyServices: OxyServices,
19
+ ): SessionClientHost & { setCurrentAccountId(id: string | null): void } {
20
+ let currentAccountId: string | null = null;
21
+ return {
22
+ makeRequest: (method, url, data, options) => oxyServices.makeRequest(method, url, data, options),
23
+ getBaseURL: () => oxyServices.getBaseURL(),
24
+ getAccessToken: () => oxyServices.getAccessToken(),
25
+ onTokensChanged: (listener) => oxyServices.onTokensChanged(listener),
26
+ setTokens: (accessToken) => oxyServices.setTokens(accessToken),
27
+ getCurrentAccountId: () => currentAccountId,
28
+ setCurrentAccountId: (id) => {
29
+ currentAccountId = id;
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,28 @@
1
+ import { logger } from '../utils/loggerUtils';
2
+
3
+ export interface MinimalSocket {
4
+ connected: boolean;
5
+ on(event: string, handler: (...args: unknown[]) => void): void;
6
+ off(event: string, handler?: (...args: unknown[]) => void): void;
7
+ connect(): void;
8
+ disconnect(): void;
9
+ }
10
+
11
+ export type SocketIOFactory = (uri: string, opts?: Record<string, unknown>) => MinimalSocket;
12
+
13
+ let cachedFactory: SocketIOFactory | null = null;
14
+ let loadAttempted = false;
15
+
16
+ export async function getSocketIO(): Promise<SocketIOFactory | null> {
17
+ if (cachedFactory) return cachedFactory;
18
+ if (loadAttempted) return null;
19
+ loadAttempted = true;
20
+ try {
21
+ const mod = (await import('socket.io-client')) as { io?: SocketIOFactory; default?: SocketIOFactory };
22
+ cachedFactory = mod.io ?? mod.default ?? null;
23
+ return cachedFactory;
24
+ } catch (error) {
25
+ logger.warn('[SessionClient] socket.io-client import failed; realtime session sync disabled', { component: 'SessionClient' }, error);
26
+ return null;
27
+ }
28
+ }