@oxyhq/core 10.1.4 → 10.2.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.
@@ -129,6 +129,23 @@ export declare class SessionClient {
129
129
  start(): Promise<void>;
130
130
  stop(): void;
131
131
  private connectSocket;
132
+ /**
133
+ * Handle the token-free `session_accounts_changed` signal (room `user:<userId>`).
134
+ *
135
+ * Unlike `session_state` (device-scoped, carries the new state to APPLY), this
136
+ * reaches ALL of a user's connected sockets across their devices/origins and is
137
+ * a pure SIGNAL: it carries no token, no secret, and no account bodies. The only
138
+ * trustworthy bit is "something changed for this user", so — matching the
139
+ * `session_state` contract's guidance — we re-fetch our OWN authoritative device
140
+ * state (`bootstrap` → `GET /session/device/state`) and let the existing
141
+ * `applyState` revision guard reconcile it. We never trust any field on the event
142
+ * beyond routing it to the current user.
143
+ *
144
+ * The refetch is a private (bearer) call: the socket only joins `user:<userId>`
145
+ * when authenticated, so a signed-out client never receives this — but we guard
146
+ * the bearer anyway so a race at sign-out can't 401.
147
+ */
148
+ private onSessionAccountsChanged;
132
149
  /**
133
150
  * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
134
151
  * commits an account switch / sign-out posts a wake ping; on receipt an
@@ -28,6 +28,7 @@
28
28
  import type { OxyServices } from '../OxyServices';
29
29
  import type { SessionLoginResponse, MinimalUserData } from '../models/session';
30
30
  import type { SessionClient } from './SessionClient';
31
+ import type { SocketIOFactory } from './socketLoader';
31
32
  import { type SwitchableAccount } from './accountProjection';
32
33
  /** The dialog's top-level view. */
33
34
  export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
@@ -103,8 +104,20 @@ export interface AccountDialogControllerOptions {
103
104
  * `location.origin` normalization in {@link openPasswordAtOxyAuth}.
104
105
  */
105
106
  authRedirectUri?: string | null;
106
- /** QR device-flow poll interval in ms (default 3000). */
107
+ /**
108
+ * QR device-flow FALLBACK poll interval in ms (default 12000). The primary
109
+ * approval signal is the `/auth-session` socket's `auth_update` event (instant);
110
+ * this slow poll is only the safety net for when the socket can't connect.
111
+ */
107
112
  pollIntervalMs?: number;
113
+ /**
114
+ * Statically-injected `socket.io-client` factory (its `io` export), same as
115
+ * {@link SessionClient}'s. When provided, the QR flow subscribes to the
116
+ * `/auth-session` namespace for an INSTANT `auth_update` wake instead of relying
117
+ * on the slow fallback poll. Absent on web builds without a bundled `io` and in
118
+ * headless/core usage → the controller silently degrades to poll-only.
119
+ */
120
+ socketFactory?: SocketIOFactory;
108
121
  /**
109
122
  * Optional URL opener. When provided, `openPasswordAtOxyAuth` invokes it with
110
123
  * the built URL in addition to returning it (web: `location.assign`; native:
@@ -135,6 +148,7 @@ export declare class AccountDialogController {
135
148
  private readonly pollIntervalMs;
136
149
  private readonly openUrl?;
137
150
  private readonly canOpenApp?;
151
+ private readonly socketFactory?;
138
152
  private readonly listeners;
139
153
  private view;
140
154
  private graph;
@@ -146,6 +160,18 @@ export declare class AccountDialogController {
146
160
  /** The secret device-flow token of the active QR flow (never surfaced). */
147
161
  private signInToken;
148
162
  private pollTimer;
163
+ /**
164
+ * The `/auth-session` socket for the active QR flow, or null (poll-only). Its
165
+ * `auth_update` event wakes {@link pollOnce} instantly instead of waiting for the
166
+ * slow fallback timer.
167
+ */
168
+ private authSessionSocket;
169
+ /**
170
+ * Guards {@link pollOnce} against re-entrancy: the fallback timer and a socket
171
+ * `auth_update` wake can fire together — without this both could claim the
172
+ * single-use token concurrently.
173
+ */
174
+ private pollInFlight;
149
175
  private unsubscribeSession;
150
176
  private unsubscribeTokens;
151
177
  /** Last-observed SDK auth readiness (a planted bearer). Drives the fetch edge. */
@@ -249,7 +275,7 @@ export declare class AccountDialogController {
249
275
  * probe/open failure is logged and swallowed — the QR/polling fallback remains.
250
276
  */
251
277
  private maybeOpenCommons;
252
- /** Tear down the active sign-in device flow (timers + token) and reset to idle. */
278
+ /** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
253
279
  cancelSignIn(): void;
254
280
  /**
255
281
  * Build (and, when an `openUrl` handler was supplied, open) the auth.oxy.so
@@ -271,6 +297,13 @@ export declare class AccountDialogController {
271
297
  redirectUri?: string;
272
298
  }): Promise<string>;
273
299
  private scheduleNextPoll;
300
+ /**
301
+ * Run one status check + (on approval) claim. Triggered by the fallback timer
302
+ * AND by the `/auth-session` socket's `auth_update` wake, so it is guarded
303
+ * against concurrent entry: whichever fires first claims the single-use token;
304
+ * the other no-ops. The `auth_update` payload is never trusted — this always
305
+ * re-checks the authoritative status via `pollCommonsSignIn`.
306
+ */
274
307
  private pollOnce;
275
308
  private claimAndComplete;
276
309
  /**
@@ -286,6 +319,21 @@ export declare class AccountDialogController {
286
319
  private commitAuthorizedSession;
287
320
  private failSignIn;
288
321
  private clearPollTimer;
322
+ /**
323
+ * Subscribe the active QR flow to the `/auth-session` namespace so the API's
324
+ * `auth_update` event wakes {@link pollOnce} the instant the approval lands.
325
+ *
326
+ * The join is keyed by the secret `sessionToken` (the server's `auth:<token>`
327
+ * room, joined by emitting `join`) and re-issued on every (re)connect so it
328
+ * survives socket drops. `auth_update` is treated as a pure SIGNAL — the payload
329
+ * is never trusted; `pollOnce` re-checks the authoritative status and claims.
330
+ *
331
+ * No-op (poll-only) when no `socketFactory` was injected (web without a bundled
332
+ * `io`, headless/core usage, tests). The namespace needs no auth.
333
+ */
334
+ private openAuthSessionSocket;
335
+ /** Tear down the `/auth-session` socket, if any. Idempotent. */
336
+ private closeAuthSessionSocket;
289
337
  private setSignIn;
290
338
  private computeSnapshot;
291
339
  /** Recompute the snapshot and notify subscribers. */
@@ -2,6 +2,8 @@ export interface MinimalSocket {
2
2
  connected: boolean;
3
3
  on(event: string, handler: (...args: unknown[]) => void): void;
4
4
  off(event: string, handler?: (...args: unknown[]) => void): void;
5
+ /** Client→server emit (e.g. joining the `/auth-session` room for a QR flow). */
6
+ emit(event: string, ...args: unknown[]): void;
5
7
  connect(): void;
6
8
  disconnect(): void;
7
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "10.1.4",
3
+ "version": "10.2.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "dependencies": {
97
- "@oxyhq/contracts": "^0.14.0",
97
+ "@oxyhq/contracts": "^0.14.1",
98
98
  "@oxyhq/protocol": "^0.1.5",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
@@ -26,6 +26,8 @@ interface RateLimitTestRequest extends Request {
26
26
  observedKey?: string;
27
27
  }
28
28
 
29
+ const HEX24 = /^[0-9a-f]{24}$/;
30
+
29
31
  function makeOxy(authHandler: RequestHandler): OxyServices {
30
32
  return {
31
33
  auth: jest.fn(() => authHandler),
@@ -33,17 +35,37 @@ function makeOxy(authHandler: RequestHandler): OxyServices {
33
35
  }
34
36
 
35
37
  function makeRequest(overrides: Partial<RateLimitTestRequest> = {}): RateLimitTestRequest {
38
+ const ip = overrides.ip ?? '203.0.113.9';
36
39
  return {
37
40
  method: 'GET',
38
41
  path: '/api/test',
39
- ip: '203.0.113.9',
40
- socket: { remoteAddress: '203.0.113.9' },
42
+ ip,
43
+ socket: { remoteAddress: ip },
41
44
  ...overrides,
42
45
  } as RateLimitTestRequest;
43
46
  }
44
47
 
48
+ /** Run the anonymous limiter for a bare IP and return the store key it produced. */
49
+ function keyForIp(ip: string): string {
50
+ const oxy = makeOxy((_req: Request, _res: Response, next: NextFunction) => next());
51
+ const req = makeRequest({ ip });
52
+ createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
53
+ if (typeof req.observedKey !== 'string') {
54
+ throw new Error('key generator did not run');
55
+ }
56
+ return req.observedKey;
57
+ }
58
+
45
59
  describe('@oxyhq/core/server rate limiter', () => {
60
+ const originalEnv = {
61
+ IP_HASH_SALT: process.env.IP_HASH_SALT,
62
+ DEVICE_ID_SALT: process.env.DEVICE_ID_SALT,
63
+ };
64
+
46
65
  beforeEach(() => {
66
+ // Isolate salt resolution from any ambient env so key assertions are deterministic.
67
+ delete process.env.IP_HASH_SALT;
68
+ delete process.env.DEVICE_ID_SALT;
47
69
  rateLimitMock.mockImplementation((options: CapturedRateLimitOptions) => {
48
70
  return (req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
49
71
  req.observedMax = options.max(req);
@@ -55,6 +77,10 @@ describe('@oxyhq/core/server rate limiter', () => {
55
77
 
56
78
  afterEach(() => {
57
79
  jest.clearAllMocks();
80
+ if (originalEnv.IP_HASH_SALT === undefined) delete process.env.IP_HASH_SALT;
81
+ else process.env.IP_HASH_SALT = originalEnv.IP_HASH_SALT;
82
+ if (originalEnv.DEVICE_ID_SALT === undefined) delete process.env.DEVICE_ID_SALT;
83
+ else process.env.DEVICE_ID_SALT = originalEnv.DEVICE_ID_SALT;
58
84
  });
59
85
 
60
86
  it('does not trust locally decoded non-session JWT identities for quota or bucket keys', () => {
@@ -73,7 +99,9 @@ describe('@oxyhq/core/server rate limiter', () => {
73
99
  );
74
100
 
75
101
  expect(req.observedMax).toBe(600);
76
- expect(req.observedKey).toBe('203.0.113.9');
102
+ // Anonymous callers are bucketed by a hashed key, NEVER the raw IP.
103
+ expect(req.observedKey).toMatch(HEX24);
104
+ expect(req.observedKey).not.toContain('203.0.113.9');
77
105
  expect(next).toHaveBeenCalledTimes(1);
78
106
  });
79
107
 
@@ -93,6 +121,7 @@ describe('@oxyhq/core/server rate limiter', () => {
93
121
  );
94
122
 
95
123
  expect(req.observedMax).toBe(5000);
124
+ // Authenticated identities are keyed by the user id verbatim — NOT hashed.
96
125
  expect(req.observedKey).toBe('user:validated-user');
97
126
  });
98
127
 
@@ -110,7 +139,72 @@ describe('@oxyhq/core/server rate limiter', () => {
110
139
  );
111
140
 
112
141
  expect(req.observedMax).toBe(600);
113
- expect(req.observedKey).toBe('203.0.113.9');
142
+ expect(req.observedKey).toMatch(HEX24);
143
+ expect(req.observedKey).not.toContain('203.0.113.9');
114
144
  expect(next).toHaveBeenCalledTimes(1);
115
145
  });
146
+
147
+ describe('anonymous key hashing', () => {
148
+ it('produces a deterministic 24-hex key that never contains the raw IP', () => {
149
+ const first = keyForIp('203.0.113.9');
150
+ const second = keyForIp('203.0.113.9');
151
+
152
+ expect(first).toMatch(HEX24);
153
+ expect(first).toBe(second);
154
+ expect(first).not.toContain('203.0.113.9');
155
+ });
156
+
157
+ it('produces different keys for different IPv4 addresses', () => {
158
+ expect(keyForIp('203.0.113.9')).not.toBe(keyForIp('198.51.100.7'));
159
+ });
160
+
161
+ it('buckets IPv6 addresses in the same /56 to the same key', () => {
162
+ // 2001:db8:abcd:ee11 and 2001:db8:abcd:eeff share the /56 prefix (top byte of
163
+ // the 4th hextet is 0xee for both); the differing bits are host bits.
164
+ const a = keyForIp('2001:db8:abcd:ee11::1');
165
+ const b = keyForIp('2001:db8:abcd:eeff::9999');
166
+
167
+ expect(a).toMatch(HEX24);
168
+ expect(a).toBe(b);
169
+ });
170
+
171
+ it('produces different keys for IPv6 addresses in different /56 prefixes', () => {
172
+ const sameFiftySix = keyForIp('2001:db8:abcd:ee11::1');
173
+ const otherFiftySix = keyForIp('2001:db8:abcd:ff11::1');
174
+
175
+ expect(sameFiftySix).not.toBe(otherFiftySix);
176
+ });
177
+
178
+ it('salts the hash with IP_HASH_SALT so keys are not portable across salts', () => {
179
+ const unsalted = keyForIp('203.0.113.9');
180
+
181
+ process.env.IP_HASH_SALT = 'salt-a';
182
+ const saltedA = keyForIp('203.0.113.9');
183
+
184
+ process.env.IP_HASH_SALT = 'salt-b';
185
+ const saltedB = keyForIp('203.0.113.9');
186
+
187
+ expect(saltedA).toMatch(HEX24);
188
+ expect(saltedA).not.toBe(unsalted);
189
+ expect(saltedB).not.toBe(saltedA);
190
+ });
191
+
192
+ it('prefers IP_HASH_SALT over DEVICE_ID_SALT', () => {
193
+ process.env.DEVICE_ID_SALT = 'device-salt';
194
+ const deviceOnly = keyForIp('203.0.113.9');
195
+
196
+ process.env.IP_HASH_SALT = 'ip-salt';
197
+ const ipPreferred = keyForIp('203.0.113.9');
198
+
199
+ expect(ipPreferred).not.toBe(deviceOnly);
200
+ });
201
+
202
+ it('falls back to the literal "unknown" key when no IP is resolvable', () => {
203
+ const oxy = makeOxy((_req: Request, _res: Response, next: NextFunction) => next());
204
+ const req = makeRequest({ ip: undefined, socket: {} as Request['socket'] });
205
+ createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
206
+
207
+ expect(req.observedKey).toBe('unknown');
208
+ });
209
+ });
116
210
  });
@@ -1,3 +1,5 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { isIPv4, isIPv6 } from 'node:net';
1
3
  import type { Request, RequestHandler } from 'express';
2
4
  import rateLimit, { type Store } from 'express-rate-limit';
3
5
  import type { OxyServices } from '../OxyServices';
@@ -99,9 +101,103 @@ function isBuiltInExempt(req: Request): boolean {
99
101
  );
100
102
  }
101
103
 
102
- /** IPv6-safe IP key generator (replaces colons to avoid Redis namespace issues). */
103
- function ipKeyGenerator(ip: string): string {
104
- return ip.replace(/:/g, '_');
104
+ /**
105
+ * Anonymous rate-limit keys must be PRIVACY-PRESERVING: the raw client IP must
106
+ * never reach a store at rest (in-memory or Redis). We therefore HMAC-hash the
107
+ * IP into a short, transient-only bucket key. Two IPv6-specific concerns shape
108
+ * the pre-hash normalization:
109
+ *
110
+ * - IPv6 hosts are typically handed an entire /64 (often a /56), so a single
111
+ * host can rotate through an enormous address space and evade a per-address
112
+ * limit. We bucket IPv6 to its /56 prefix BEFORE hashing.
113
+ * - express-rate-limit only exposes an `ipKeyGenerator` /56 helper from v8
114
+ * onwards; `@oxyhq/core` pins v7 (peer `^7.0.0`), so the masking is
115
+ * implemented here rather than pulling a major-version bump of a
116
+ * security-critical dependency (and its rate-limit-redis compatibility) into
117
+ * an unrelated privacy change. This mirrors `packages/api/src/utils/ipKey.ts`.
118
+ */
119
+ const IPV6_SUBNET_BITS = 56;
120
+
121
+ /** Expand an IPv6 literal (handling `::` and embedded IPv4) to 8 numeric hextets, or null if unparseable. */
122
+ function ipv6Hextets(ip: string): number[] | null {
123
+ let addr = ip;
124
+ const zone = addr.indexOf('%');
125
+ if (zone !== -1) {
126
+ addr = addr.slice(0, zone);
127
+ }
128
+
129
+ // Embedded IPv4 tail (e.g. `::ffff:203.0.113.7`) → fold the dotted quad into two hextets.
130
+ const lastColon = addr.lastIndexOf(':');
131
+ if (lastColon !== -1 && addr.slice(lastColon + 1).includes('.')) {
132
+ const v4 = addr.slice(lastColon + 1);
133
+ if (!isIPv4(v4)) {
134
+ return null;
135
+ }
136
+ const octets = v4.split('.').map((part) => Number.parseInt(part, 10));
137
+ const high = ((octets[0] << 8) | octets[1]).toString(16);
138
+ const low = ((octets[2] << 8) | octets[3]).toString(16);
139
+ addr = `${addr.slice(0, lastColon + 1)}${high}:${low}`;
140
+ }
141
+
142
+ const halves = addr.split('::');
143
+ if (halves.length > 2) {
144
+ return null;
145
+ }
146
+ const head = halves[0] ? halves[0].split(':') : [];
147
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
148
+ let groups: string[];
149
+ if (halves.length === 1) {
150
+ groups = head;
151
+ } else {
152
+ const missing = 8 - (head.length + tail.length);
153
+ if (missing < 0) {
154
+ return null;
155
+ }
156
+ groups = [...head, ...new Array(missing).fill('0'), ...tail];
157
+ }
158
+ if (groups.length !== 8) {
159
+ return null;
160
+ }
161
+ const hextets = groups.map((group) => Number.parseInt(group || '0', 16));
162
+ if (hextets.some((value) => Number.isNaN(value) || value < 0 || value > 0xffff)) {
163
+ return null;
164
+ }
165
+ return hextets;
166
+ }
167
+
168
+ /** Mask an IPv6 address to its /{bits} prefix, returned as a canonical hex string. */
169
+ function maskIPv6(ip: string, bits: number): string {
170
+ const hextets = ipv6Hextets(ip);
171
+ if (!hextets) {
172
+ return ip;
173
+ }
174
+ const masked = hextets.map((hextet, index) => {
175
+ const groupStart = index * 16;
176
+ if (groupStart >= bits) {
177
+ return 0;
178
+ }
179
+ const keepBits = Math.min(16, bits - groupStart);
180
+ const mask = keepBits >= 16 ? 0xffff : (0xffff << (16 - keepBits)) & 0xffff;
181
+ return hextet & mask;
182
+ });
183
+ return `${masked.map((hextet) => hextet.toString(16)).join(':')}/${bits}`;
184
+ }
185
+
186
+ /**
187
+ * Hash a client IP into a privacy-preserving bucket key. IPv6 is bucketed to its
188
+ * /56 prefix first (so a single v6 host can't rotate through its allocation to
189
+ * mint fresh keys), then HMAC'd with the server-side salt. The salt is resolved
190
+ * at CALL time (`IP_HASH_SALT`, else `DEVICE_ID_SALT`, else empty) — an empty
191
+ * salt still hashes, which beats storing a raw IP; backends SHOULD set one of
192
+ * those envs. The `rl|` namespace ensures a rate-limit key can never collide
193
+ * with, or be correlated against, a deviceId derivation that reuses the same
194
+ * salt. The result is a short hex digest with no colons, so it is Redis-safe.
195
+ */
196
+ function hashAnonymousIp(ip: string): string {
197
+ const normalized =
198
+ isIPv6(ip) && !ip.startsWith('::ffff:') ? maskIPv6(ip, IPV6_SUBNET_BITS) : ip;
199
+ const salt = process.env.IP_HASH_SALT || process.env.DEVICE_ID_SALT || '';
200
+ return createHmac('sha256', salt).update(`rl|${normalized}`).digest('hex').slice(0, 24);
105
201
  }
106
202
 
107
203
  /**
@@ -132,14 +228,17 @@ function resolveTrustedAuthenticatedKey(req: OxyAuthedRequest): string | null {
132
228
  return null;
133
229
  }
134
230
 
135
- /** Resolve the rate-limit key: per trusted authenticated identity, else per (IPv6-safe) IP. */
231
+ /** Resolve the rate-limit key: per trusted authenticated identity, else per hashed (IPv6-bucketed) IP. */
136
232
  function resolveKey(req: OxyAuthedRequest): string {
137
233
  const authenticatedKey = resolveTrustedAuthenticatedKey(req);
138
234
  if (authenticatedKey) {
139
235
  return authenticatedKey;
140
236
  }
141
- const ip = req.ip || req.socket.remoteAddress || 'unknown';
142
- return ipKeyGenerator(ip);
237
+ const ip = req.ip || req.socket.remoteAddress;
238
+ if (!ip) {
239
+ return 'unknown';
240
+ }
241
+ return hashAnonymousIp(ip);
143
242
  }
144
243
 
145
244
  /**
@@ -2,6 +2,8 @@ import {
2
2
  deviceSessionStateSchema,
3
3
  deviceSessionSyncSchema,
4
4
  safeParseContract,
5
+ SESSION_ACCOUNTS_CHANGED_EVENT,
6
+ sessionAccountsChangedEventSchema,
5
7
  type DeviceSessionState,
6
8
  } from '@oxyhq/contracts';
7
9
  import { logger } from '../utils/loggerUtils';
@@ -407,6 +409,9 @@ export class SessionClient {
407
409
  });
408
410
  }
409
411
  });
412
+ socket.on(SESSION_ACCOUNTS_CHANGED_EVENT, (payload: unknown) => {
413
+ this.onSessionAccountsChanged(payload);
414
+ });
410
415
  this.socket = socket;
411
416
  // (Re)bind app-facing server-event subscriptions on the fresh socket.
412
417
  this.boundServerEvents.clear();
@@ -415,6 +420,37 @@ export class SessionClient {
415
420
  }
416
421
  }
417
422
 
423
+ /**
424
+ * Handle the token-free `session_accounts_changed` signal (room `user:<userId>`).
425
+ *
426
+ * Unlike `session_state` (device-scoped, carries the new state to APPLY), this
427
+ * reaches ALL of a user's connected sockets across their devices/origins and is
428
+ * a pure SIGNAL: it carries no token, no secret, and no account bodies. The only
429
+ * trustworthy bit is "something changed for this user", so — matching the
430
+ * `session_state` contract's guidance — we re-fetch our OWN authoritative device
431
+ * state (`bootstrap` → `GET /session/device/state`) and let the existing
432
+ * `applyState` revision guard reconcile it. We never trust any field on the event
433
+ * beyond routing it to the current user.
434
+ *
435
+ * The refetch is a private (bearer) call: the socket only joins `user:<userId>`
436
+ * when authenticated, so a signed-out client never receives this — but we guard
437
+ * the bearer anyway so a race at sign-out can't 401.
438
+ */
439
+ private onSessionAccountsChanged(payload: unknown): void {
440
+ const event = safeParseContract(sessionAccountsChangedEventSchema, payload);
441
+ if (!event) {
442
+ logger.warn('[SessionClient] discarded invalid session_accounts_changed', { component: 'SessionClient' });
443
+ return;
444
+ }
445
+ // The socket is in `user:<activeUserId>` for the planted bearer, so this should
446
+ // always be the current user; ignore a foreign id defensively (out-of-band relay).
447
+ if (event.userId !== this.host.getCurrentAccountId()) return;
448
+ if (!this.host.getAccessToken()) return;
449
+ void this.bootstrap().catch((error) => {
450
+ logger.warn('[SessionClient] session_accounts_changed refetch failed', { component: 'SessionClient' }, error);
451
+ });
452
+ }
453
+
418
454
  /**
419
455
  * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
420
456
  * commits an account switch / sign-out posts a wake ping; on receipt an
@@ -8,6 +8,7 @@ class FakeSocket implements MinimalSocket {
8
8
  handlers = new Map<string, Handler[]>();
9
9
  on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
10
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
+ emit(_event: string, ..._args: unknown[]) { /* client→server emit, unused here */ }
11
12
  connect() { this.connected = true; }
12
13
  disconnect() { this.connected = false; }
13
14
  emitServer(event: string, payload: unknown) { for (const h of this.handlers.get(event) ?? []) h(payload); }
@@ -132,6 +132,42 @@ describe('SessionClient socket', () => {
132
132
  expect(fakeSocket.connected).toBe(false);
133
133
  });
134
134
 
135
+ it('session_accounts_changed for the current user refetches device state (GET /session/device/state)', async () => {
136
+ const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
137
+ const host = makeHost({ makeRequest, getCurrentAccountId: () => 'a1' });
138
+ const c = new SessionClient(host);
139
+ await c.start();
140
+ makeRequest.mockClear();
141
+ fakeSocket.trigger('session_accounts_changed', { userId: 'a1', revision: 5, reason: 'add' });
142
+ await Promise.resolve();
143
+ expect(makeRequest).toHaveBeenCalledWith('GET', '/session/device/state', undefined, { cache: false });
144
+ c.stop();
145
+ });
146
+
147
+ it('session_accounts_changed for a DIFFERENT user is ignored (no refetch)', async () => {
148
+ const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
149
+ const host = makeHost({ makeRequest, getCurrentAccountId: () => 'a1' });
150
+ const c = new SessionClient(host);
151
+ await c.start();
152
+ makeRequest.mockClear();
153
+ fakeSocket.trigger('session_accounts_changed', { userId: 'someone-else', revision: 5, reason: 'switch' });
154
+ await Promise.resolve();
155
+ expect(makeRequest).not.toHaveBeenCalled();
156
+ c.stop();
157
+ });
158
+
159
+ it('session_accounts_changed drops a malformed payload without refetching', async () => {
160
+ const makeRequest = jest.fn().mockResolvedValue(SYNC(1));
161
+ const host = makeHost({ makeRequest, getCurrentAccountId: () => 'a1' });
162
+ const c = new SessionClient(host);
163
+ await c.start();
164
+ makeRequest.mockClear();
165
+ fakeSocket.trigger('session_accounts_changed', { userId: 'a1', reason: 'not-a-real-reason' });
166
+ await Promise.resolve();
167
+ expect(makeRequest).not.toHaveBeenCalled();
168
+ c.stop();
169
+ });
170
+
135
171
  it('a socket-pushed empty state fires onUnauthenticated with the PUSH origin (bug #4)', async () => {
136
172
  const onUnauthenticated = jest.fn();
137
173
  const c = new SessionClient(makeHost(), { onUnauthenticated });
@@ -20,6 +20,7 @@ class FakeSocket implements MinimalSocket {
20
20
  handlers = new Map<string, Handler[]>();
21
21
  on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
22
22
  off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
23
+ emit(_event: string, ..._args: unknown[]) { /* no-op: device socket never emits */ }
23
24
  connect() { this.connected = true; }
24
25
  disconnect() { this.connected = false; }
25
26
  }