@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
@@ -1020,7 +1020,7 @@ export class HttpService {
1020
1020
  * `clearCacheByPrefix` sweeps and `clearCacheEntry` base-key matching.
1021
1021
  * The `clearCacheEntry` callsites all pass fixed, dataless logical keys
1022
1022
  * (`GET:/users/<id>`, `GET:/session/user/<sessionId>`,
1023
- * `GET:/fedcm/me/authorized-apps`), so this readable suffix can never be
1023
+ * `GET:/auth/grants`), so this readable suffix can never be
1024
1024
  * ambiguous with a serialized request body.
1025
1025
  */
1026
1026
  HttpService.CACHE_IDENTITY_DELIM = ' id=';
package/dist/esm/index.js CHANGED
@@ -23,6 +23,9 @@ import './crypto/polyfill.js';
23
23
  // ---------------------------------------------------------------------------
24
24
  export { OxyServices, OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.js';
25
25
  export { OXY_CLOUD_URL, oxyClient } from './OxyServices.js';
26
+ // ---------------------------------------------------------------------------
27
+ // Authentication
28
+ // ---------------------------------------------------------------------------
26
29
  export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth.js';
27
30
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData.js';
28
31
  // ---------------------------------------------------------------------------
@@ -117,14 +120,18 @@ export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccou
117
120
  // Registrable-domain + central-IdP-apex helpers.
118
121
  //
119
122
  // The client SSO-bounce / silent-iframe / FedCM machinery was removed in the
120
- // device-first cutover. These three symbols survive because the api SSO surface
121
- // and the IdP (both lista B, gated on the ecosystem bump) still import them:
122
- // `registrableApex` (eTLD+1), `CENTRAL_IDP_APEX`, and the `SSO_CALLBACK_PATH`
123
- // constant. `@oxyhq/core/server` re-exports `registrableApex` + `SSO_CALLBACK_PATH`
124
- // for the api; the IdP imports all three from here.
125
- // ---------------------------------------------------------------------------
126
- export { registrableApex } from './utils/fapiAutoDetect.js';
127
- export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX } from './utils/authWebUrl.js';
123
+ // device-first cutover (wave 2, ecosystem-wide bump complete). `registrableApex`
124
+ // (eTLD+1) and `CENTRAL_IDP_APEX` are still genuinely used: `registrableApex`
125
+ // via the `@oxyhq/core/server` re-export consumed by
126
+ // `packages/api/src/utils/sameSite.ts` for same-site origin checks, and
127
+ // `CENTRAL_IDP_APEX` by `server/cors.ts`'s `createOxyCors` (auto-allows
128
+ // `*.oxy.so`). `SSO_CALLBACK_PATH` has no remaining importer outside this
129
+ // module as of wave 2 — kept exported for now rather than removed here (an
130
+ // export deletion is a logic change, out of scope for a comment sweep); flag
131
+ // for a follow-up dead-export cleanup pass.
132
+ // ---------------------------------------------------------------------------
133
+ export { registrableApex } from './utils/registrableApex.js';
134
+ export { CENTRAL_IDP_APEX } from './utils/authWebUrl.js';
128
135
  export { SSO_CALLBACK_PATH } from './utils/ssoBounce.js';
129
136
  export { runColdBoot } from './utils/coldBoot.js';
130
137
  // ---------------------------------------------------------------------------
@@ -139,6 +146,17 @@ export { SessionClient } from './session/SessionClient.js';
139
146
  export { createSessionClientHost } from './session/sessionClientHost.js';
140
147
  export { createSessionClient } from './session/createSessionClient.js';
141
148
  export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState.js';
149
+ // Unified account-list projection (THE single source of truth for the account
150
+ // chooser: device sign-ins ∪ account graph, deduped by accountId). Pure +
151
+ // I/O-free — the caller hydrates profiles via `getUsersByIds`. Shared by
152
+ // `@oxyhq/services`, `@oxyhq/auth`, and auth.oxy.so so the list can't diverge.
153
+ export { projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection.js';
154
+ // Headless controller for the unified account dialog. Framework-agnostic
155
+ // state machine + subscribe/getSnapshot store (bind via `useSyncExternalStore`)
156
+ // — no password/2FA logic (that lives at the IdP; `openPasswordAtOxyAuth` only
157
+ // hands off). Reuses `SessionClient.switchAccount` / `oxyServices.switchToAccount`
158
+ // for the uniform switch and the existing device-flow methods for sign-in.
159
+ export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController.js';
142
160
  // ---------------------------------------------------------------------------
143
161
  // Device-first session machinery (auth centralization, wave 1) — additive.
144
162
  // Persisted auth-state store, the unified refresh handler + scheduler, the
@@ -49,24 +49,18 @@ export function OxyServicesAccountsMixin(Base) {
49
49
  * target, directly or inherited — else 403; 404 if missing/archived; 403 if
50
50
  * the target is a personal account), then mints a REAL session for the
51
51
  * target account and returns it in the canonical login / `claimSessionByToken`
52
- * shape (`{ sessionId, deviceId, expiresAt, accessToken, user, authuser }`).
52
+ * shape (`{ sessionId, deviceId, expiresAt, accessToken, user }`).
53
53
  *
54
54
  * Unlike the removed `X-Acting-As` delegation header, the returned session
55
55
  * IS the new identity: this plants `accessToken` as the active token —
56
56
  * exactly like `claimSessionByToken` / `verifyChallenge` — so every
57
57
  * subsequent request authenticates as the target account.
58
58
  *
59
- * Joining the device multi-account set (so the switch survives a reload and
60
- * propagates cross-domain via `/auth/refresh-all`) requires a SECOND call, to
61
- * `POST /auth/session`, made here after the token is planted. The switch route
62
- * lives at `/accounts/*`, OUTSIDE the `oxy_rt_<authuser>` cookie's `Path=/auth`
63
- * scope, so the server never sees the device's existing slots from it and
64
- * would clobber slot 0 (destroying the operator's own session). `/auth/session`
65
- * runs where those cookies ARE visible, so the server allocates a NEW slot that
66
- * coexists with the operator's and returns its `authuser`. This step is
67
- * web-only (native multi-account uses stored sessions, not cookies) and
68
- * best-effort — a failure leaves the in-session switch intact; the switched
69
- * account simply won't survive a reload until the cookie is next established.
59
+ * A single call is all that's needed: the server registers the switched
60
+ * session into the operator's `DeviceSession` set directly, inheriting the
61
+ * operator's central `deviceId` so the switch survives a reload and syncs
62
+ * cross-domain via the same device-first session model as a normal login —
63
+ * there is no separate client-side cookie/slot step to make it stick.
70
64
  *
71
65
  * After planting, the SDK's identity-scoped GET cache is fully cleared so
72
66
  * every cached read re-fetches as the new account. (The consuming
@@ -76,7 +70,7 @@ export function OxyServicesAccountsMixin(Base) {
76
70
  * same-user silent refreshes, so the sweep here is explicit.)
77
71
  *
78
72
  * @param accountId - The target account's Mongo `_id`.
79
- * @returns The minted session (planted) plus the device `authuser` slot.
73
+ * @returns The minted session, already planted as the active session.
80
74
  */
81
75
  async switchToAccount(accountId) {
82
76
  try {
@@ -6,7 +6,6 @@
6
6
  */
7
7
  import { OxyServicesBase } from '../OxyServices.base.js';
8
8
  import { OxyServicesAuthMixin } from './OxyServices.auth.js';
9
- import { OxyServicesAuthorizedAppsMixin } from './OxyServices.authorizedApps.js';
10
9
  import { OxyServicesUserMixin } from './OxyServices.user.js';
11
10
  import { OxyServicesIdentityMixin } from './OxyServices.identity.js';
12
11
  import { OxyServicesPrivacyMixin } from './OxyServices.privacy.js';
@@ -43,10 +42,6 @@ import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot.js';
43
42
  const MIXIN_PIPELINE = [
44
43
  // Base authentication
45
44
  OxyServicesAuthMixin,
46
- // Legacy FedCM "Connected apps" management (list/revoke authorized RP grants).
47
- // The FedCM sign-in surface was removed in the device-first cutover; only
48
- // this management pair survives until the AppGrant migration.
49
- OxyServicesAuthorizedAppsMixin,
50
45
  // User management (requires auth)
51
46
  OxyServicesUserMixin,
52
47
  // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
@@ -26,7 +26,7 @@ export { verifySecret } from './verifySecret.js';
26
26
  // SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
27
27
  // Pure host handling (no browser deps), so it is safe on the server subpath and
28
28
  // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
29
- export { registrableApex } from '../utils/fapiAutoDetect.js';
29
+ export { registrableApex } from '../utils/registrableApex.js';
30
30
  // The single RP callback path the IdP redirects back to. A pure wire-contract
31
31
  // constant (no browser deps at module top level), re-used server-side so the
32
32
  // `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
@@ -1,6 +1,14 @@
1
1
  import { deviceSessionStateSchema, deviceSessionSyncSchema, safeParseContract, } from '@oxyhq/contracts';
2
2
  import { logger } from '../utils/loggerUtils.js';
3
3
  import { getSocketIO } from './socketLoader.js';
4
+ /**
5
+ * Same-origin `BroadcastChannel` name for instant, network-free session wake
6
+ * across tabs of the SAME origin (e.g. two `accounts.oxy.so` tabs). Complements
7
+ * the cookie-authed device socket, which covers same-apex cross-origin
8
+ * (`accounts` ↔ `console` ↔ `inbox`, all `*.oxy.so`). Cross-APEX (mention.earth)
9
+ * is covered by neither and relies on a reload — the documented limitation.
10
+ */
11
+ const SESSION_BROADCAST_CHANNEL = 'oxy.session';
4
12
  export class SessionClient {
5
13
  constructor(host, options = {}) {
6
14
  this.host = host;
@@ -10,6 +18,12 @@ export class SessionClient {
10
18
  this.socket = null;
11
19
  this.tokenUnsub = null;
12
20
  this.started = false;
21
+ /** In-flight guard so a burst of pushes triggers at most ONE acquisition. */
22
+ this.acquiring = false;
23
+ /** True while the live socket is an anonymous (signed-out) device connection. */
24
+ this.socketAnonymous = false;
25
+ /** Same-origin cross-tab wake channel; null on platforms without BroadcastChannel. */
26
+ this.channel = null;
13
27
  }
14
28
  getState() {
15
29
  return this.state;
@@ -102,14 +116,17 @@ export class SessionClient {
102
116
  async switchAccount(accountId) {
103
117
  const res = await this.host.makeRequest('POST', '/session/device/switch', { accountId }, { cache: false });
104
118
  this.applySync(res);
119
+ this.postCommitPing();
105
120
  }
106
121
  async signOut(target) {
107
122
  const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
108
123
  this.applySync(res);
124
+ this.postCommitPing();
109
125
  }
110
126
  async addCurrentAccount() {
111
127
  const res = await this.host.makeRequest('POST', '/session/device/add', undefined, { cache: false });
112
128
  this.applySync(res);
129
+ this.postCommitPing();
113
130
  }
114
131
  /**
115
132
  * Register the just-signed-in account into the device set AND make it the
@@ -137,19 +154,58 @@ export class SessionClient {
137
154
  return;
138
155
  this.started = true;
139
156
  this.tokenUnsub = this.host.onTokensChanged((token) => {
140
- if (token && this.socket && !this.socket.connected) {
157
+ if (!token)
158
+ return;
159
+ // A bearer just landed: an in-flight acquisition (if any) succeeded — clear
160
+ // the guard so a later sign-out can re-acquire.
161
+ this.acquiring = false;
162
+ if (!this.socket)
163
+ return;
164
+ if (this.socketAnonymous) {
165
+ // The live socket was an anonymous (device-room-only) connection. Force a
166
+ // reconnect so the handshake re-runs authenticated and also joins the
167
+ // `user:<id>` notification room.
168
+ this.socketAnonymous = false;
169
+ this.socket.disconnect();
170
+ this.socket.connect();
171
+ }
172
+ else if (!this.socket.connected) {
141
173
  this.socket.connect();
142
174
  }
143
175
  });
144
- await this.bootstrap();
176
+ this.openBroadcastChannel();
177
+ // `bootstrap` (`GET /session/device/state`) is bearer-authenticated: skip it
178
+ // when signed out (the signed-out socket below still joins the device room to
179
+ // receive pushes). A bootstrap failure is non-fatal — the socket must still
180
+ // connect so realtime sync survives a transient state-fetch error.
181
+ if (this.host.getAccessToken()) {
182
+ try {
183
+ await this.bootstrap();
184
+ }
185
+ catch (error) {
186
+ logger.warn('[SessionClient] bootstrap during start failed (non-fatal)', { component: 'SessionClient' }, error);
187
+ }
188
+ }
145
189
  await this.connectSocket();
146
190
  }
147
191
  stop() {
148
192
  this.started = false;
193
+ this.acquiring = false;
194
+ this.socketAnonymous = false;
149
195
  if (this.tokenUnsub) {
150
196
  this.tokenUnsub();
151
197
  this.tokenUnsub = null;
152
198
  }
199
+ if (this.channel) {
200
+ this.channel.onmessage = null;
201
+ try {
202
+ this.channel.close();
203
+ }
204
+ catch (error) {
205
+ logger.debug('[SessionClient] BroadcastChannel close failed', { component: 'SessionClient' }, error);
206
+ }
207
+ this.channel = null;
208
+ }
153
209
  if (this.socket) {
154
210
  this.socket.disconnect();
155
211
  this.socket = null;
@@ -167,24 +223,139 @@ export class SessionClient {
167
223
  if (!this.started)
168
224
  return; // stopped while the dynamic import was in flight
169
225
  const hasToken = Boolean(this.host.getAccessToken());
226
+ // Signed-out connect gate: when there is no bearer, only open the socket if
227
+ // the consumer supplies a device anchor (web cookie → `true`; native token →
228
+ // string). This lets an idle tab receive its device's `session_state` pushes
229
+ // and self-acquire when a sibling signs in.
230
+ let signedOutAuth = false;
231
+ if (!hasToken && this.options.signedOutSocketAuth) {
232
+ try {
233
+ signedOutAuth = await this.options.signedOutSocketAuth();
234
+ }
235
+ catch (error) {
236
+ logger.warn('[SessionClient] signedOutSocketAuth failed', { component: 'SessionClient' }, error);
237
+ signedOutAuth = false;
238
+ }
239
+ if (!this.started)
240
+ return; // stopped while the async gate was in flight
241
+ }
242
+ const signedOutConnect = signedOutAuth !== false && signedOutAuth != null;
243
+ const signedOutDeviceToken = typeof signedOutAuth === 'string' ? signedOutAuth : undefined;
244
+ this.socketAnonymous = !hasToken && signedOutConnect;
170
245
  const socket = io(this.host.getBaseURL(), {
171
246
  transports: ['websocket'],
172
- autoConnect: hasToken,
247
+ // Send the first-party `oxy_device` cookie on the same-site handshake so a
248
+ // signed-out browser tab can be resolved to its device room server-side.
249
+ withCredentials: true,
250
+ autoConnect: hasToken || signedOutConnect,
173
251
  auth: (cb) => {
174
- cb({ token: this.host.getAccessToken() ?? '' });
252
+ const token = this.host.getAccessToken() ?? '';
253
+ // Present the native device token only while signed out (no bearer). Once
254
+ // a token is held the bearer path wins and the deviceToken is redundant.
255
+ cb(token || !signedOutDeviceToken ? { token } : { token, deviceToken: signedOutDeviceToken });
175
256
  },
176
257
  });
177
258
  socket.on('session_state', (payload) => {
178
259
  const applied = this.applyState(payload);
179
- if (applied) {
180
- const active = this.state?.activeAccountId ?? null;
181
- if (active && active !== this.host.getCurrentAccountId()) {
182
- void this.bootstrap().catch((error) => {
183
- logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
184
- });
260
+ if (!applied)
261
+ return;
262
+ // Signed-out tab: a session appeared on this device (a sibling signed in).
263
+ // Acquire it so this tab flips to signed-in; the planted token then
264
+ // reconnects the socket on the authenticated path.
265
+ if (!this.host.getAccessToken()) {
266
+ if ((this.state?.accounts.length ?? 0) > 0) {
267
+ this.requestAcquisition();
185
268
  }
269
+ return;
270
+ }
271
+ const active = this.state?.activeAccountId ?? null;
272
+ if (active && active !== this.host.getCurrentAccountId()) {
273
+ void this.bootstrap().catch((error) => {
274
+ logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
275
+ });
186
276
  }
187
277
  });
188
278
  this.socket = socket;
189
279
  }
280
+ /**
281
+ * Run the consumer's session acquisition at most once at a time. A returned
282
+ * promise gates the next attempt (reset on settle), so a failed acquisition
283
+ * can retry on the NEXT push while a burst of identical pushes cannot pile up.
284
+ */
285
+ requestAcquisition() {
286
+ if (this.acquiring || !this.options.onSessionAppeared)
287
+ return;
288
+ this.acquiring = true;
289
+ let result;
290
+ try {
291
+ result = this.options.onSessionAppeared();
292
+ }
293
+ catch (error) {
294
+ this.acquiring = false;
295
+ logger.error('[SessionClient] onSessionAppeared threw', error);
296
+ return;
297
+ }
298
+ void Promise.resolve(result).catch((error) => {
299
+ logger.warn('[SessionClient] onSessionAppeared rejected', { component: 'SessionClient' }, error);
300
+ }).finally(() => {
301
+ this.acquiring = false;
302
+ });
303
+ }
304
+ /**
305
+ * Open the same-origin `BroadcastChannel` (web only). A sibling tab that
306
+ * commits a session posts a wake ping; on receipt a signed-in tab re-syncs its
307
+ * device state and a signed-out tab self-acquires — instant + network-free for
308
+ * the common "two tabs of the same origin" case, with no state (and no tokens)
309
+ * ever crossing the channel. No-op on native (no BroadcastChannel).
310
+ */
311
+ openBroadcastChannel() {
312
+ if (this.channel)
313
+ return;
314
+ const Ctor = globalThis.BroadcastChannel;
315
+ if (typeof Ctor !== 'function')
316
+ return; // native RN / SSR — feature absent
317
+ let channel;
318
+ try {
319
+ channel = new Ctor(SESSION_BROADCAST_CHANNEL);
320
+ }
321
+ catch (error) {
322
+ logger.debug('[SessionClient] BroadcastChannel unavailable', { component: 'SessionClient' }, error);
323
+ return;
324
+ }
325
+ channel.onmessage = (event) => {
326
+ if (!event || typeof event.data !== 'object' || event.data === null)
327
+ return;
328
+ if (event.data.type !== 'commit')
329
+ return;
330
+ // BroadcastChannel never echoes to the posting context, so this is a
331
+ // sibling's commit. Re-sync (signed-in) or acquire (signed-out). Neither
332
+ // path re-posts, so there is no cross-tab ping loop.
333
+ if (this.host.getAccessToken()) {
334
+ void this.bootstrap().catch((error) => {
335
+ logger.warn('[SessionClient] broadcast re-sync failed', { component: 'SessionClient' }, error);
336
+ });
337
+ }
338
+ else {
339
+ this.requestAcquisition();
340
+ }
341
+ };
342
+ this.channel = channel;
343
+ }
344
+ /**
345
+ * Wake same-origin sibling tabs after a locally-initiated session mutation.
346
+ * Opens the channel lazily: a sign-in registers the account (`addCurrentAccount`
347
+ * / `switchAccount`) BEFORE `start()` runs, so the ping must not depend on
348
+ * `start()` having opened the channel first.
349
+ */
350
+ postCommitPing() {
351
+ this.openBroadcastChannel();
352
+ if (!this.channel)
353
+ return;
354
+ try {
355
+ this.channel.postMessage({ type: 'commit', at: Date.now() });
356
+ }
357
+ catch (error) {
358
+ logger.debug('[SessionClient] BroadcastChannel post failed', { component: 'SessionClient' }, error);
359
+ }
360
+ }
190
361
  }