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