@signalwire/js 4.0.0-dev-20260717142935 → 4.0.0-dev-20260721191222

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.
package/dist/browser.mjs CHANGED
@@ -9049,6 +9049,14 @@ const CREDENTIAL_REFRESH_MAX_DELAY_MS = 3e4;
9049
9049
  /** Buffer in milliseconds before token expiry to trigger refresh. */
9050
9050
  const CREDENTIAL_REFRESH_BUFFER_MS = 5e3;
9051
9051
  /**
9052
+ * Clock-skew allowance (ms) for treating an in-memory credential as expired
9053
+ * when deciding whether a fresh (re)connect must re-mint the token before
9054
+ * authenticating. A token within this window of its `expiry_at` is treated as
9055
+ * stale so the reconnect re-mints via the credential provider instead of
9056
+ * replaying a dead token (which the server rejects with -32003).
9057
+ */
9058
+ const CREDENTIAL_EXPIRY_SKEW_MS = 3e4;
9059
+ /**
9052
9060
  * Maximum time the coordinator will wait for `DeviceTokenManager.activate()`
9053
9061
  * to resolve before treating the activation as failed and falling back to
9054
9062
  * the developer-provided refresh path. Prevents a wedged HTTP layer from
@@ -20093,9 +20101,9 @@ var ClientSessionManager = class extends Destroyable {
20093
20101
  }
20094
20102
  async handleAuthenticationError(error) {
20095
20103
  logger$9.error("Authentication error:", error);
20096
- const isRecoverableAuthError = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
20104
+ const isRecoverableAuthError$1 = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
20097
20105
  const hasStoredState = await (0, import_cjs$7.firstValueFrom)(this.authorizationState$.pipe((0, import_cjs$7.take)(1))) !== void 0;
20098
- if (isRecoverableAuthError && hasStoredState) {
20106
+ if (isRecoverableAuthError$1 && hasStoredState) {
20099
20107
  logger$9.debug("[Session] Recoverable auth error — cleaning up stored state and reconnecting fresh");
20100
20108
  try {
20101
20109
  await this.cleanupStoredConnectionParams();
@@ -20191,11 +20199,15 @@ var ClientSessionManager = class extends Destroyable {
20191
20199
  const isReconnect = hasReconnectState && storedToken;
20192
20200
  let dpopToken;
20193
20201
  if (isReconnect) logger$9.debug("[Session] Reconnecting with stored jwt_token + authorization_state");
20194
- else if (this.onBeforeReconnect && this.clientBound) {
20195
- logger$9.debug("[Session] Refreshing credentials before fresh connect");
20196
- await this.onBeforeReconnect();
20202
+ else {
20203
+ const credential = this.getCredential();
20204
+ const credentialExpired = credential.expiry_at !== void 0 && credential.expiry_at <= Date.now() + CREDENTIAL_EXPIRY_SKEW_MS;
20205
+ if (this.onBeforeReconnect && (this.clientBound || credentialExpired)) {
20206
+ logger$9.debug("[Session] Refreshing credentials before fresh connect");
20207
+ await this.onBeforeReconnect();
20208
+ }
20197
20209
  }
20198
- if ((!isReconnect || this.clientBound) && this.dpopManager?.initialized) try {
20210
+ if (this.dpopManager?.initialized) try {
20199
20211
  dpopToken = await this.dpopManager.createRpcProof({ method: "signalwire.connect" });
20200
20212
  } catch (error) {
20201
20213
  if (this.clientBound) throw error;
@@ -20228,6 +20240,7 @@ var ClientSessionManager = class extends Destroyable {
20228
20240
  });
20229
20241
  if (response.protocol) await this.transport.setProtocol(response.protocol);
20230
20242
  this._authorization$.next(response.authorization);
20243
+ if (response.authorization.cnf?.jkt) this._wasClientBound = true;
20231
20244
  this._iceServers$.next(response.ice_servers ?? []);
20232
20245
  this._authState$.next({ kind: "authenticated" });
20233
20246
  logger$9.debug("[Session] Authentication completed successfully");
@@ -20347,6 +20360,14 @@ var ClientSessionWrapper = class {
20347
20360
  get authenticated() {
20348
20361
  return this.clientSessionManager.authenticated;
20349
20362
  }
20363
+ /**
20364
+ * Whether the session is using a Client Bound SAT (DPoP). Sticky — set
20365
+ * when the binding is established or restored from a resumed session's
20366
+ * server authorization.
20367
+ */
20368
+ get clientBound() {
20369
+ return this.clientSessionManager.clientBound;
20370
+ }
20350
20371
  get signalingEvent$() {
20351
20372
  return this.clientSessionManager.signalingEvent$;
20352
20373
  }
@@ -20549,7 +20570,7 @@ var DeviceTokenManager = class extends Destroyable {
20549
20570
  await session.reauthenticate(tokenData.token, rpcProof, { clientBound: true });
20550
20571
  updateCredential({ token: tokenData.token });
20551
20572
  logger$7.info("[DeviceToken] Client Bound SAT activated successfully");
20552
- this._currentToken$.next(tokenData);
20573
+ this.emitCurrentToken(tokenData);
20553
20574
  return { activated: true };
20554
20575
  } catch (error) {
20555
20576
  logger$7.error("[DeviceToken] Failed to activate Client Bound SAT:", error);
@@ -20561,6 +20582,21 @@ var DeviceTokenManager = class extends Destroyable {
20561
20582
  }
20562
20583
  }
20563
20584
  /**
20585
+ * Emit a freshly received token to the reactive pipeline, stamping an
20586
+ * absolute `expires_at` when the response carried only `expires_in`.
20587
+ * Resolving the expiry at RECEIVE time (not at read time) is what lets
20588
+ * {@link refreshNowIfDue} detect due-ness on resume: a bare `expires_in`
20589
+ * re-resolved later would always compute a full TTL from "now" and never
20590
+ * cross the refresh buffer.
20591
+ */
20592
+ emitCurrentToken(token) {
20593
+ const stamped = token.expires_at ? token : {
20594
+ ...token,
20595
+ expires_at: resolveExpiresAt(token)
20596
+ };
20597
+ this._currentToken$.next(stamped);
20598
+ }
20599
+ /**
20564
20600
  * Returns true when the cached token has enough headroom before expiry to
20565
20601
  * be safely reused on reactivation. The headroom matches the refresh
20566
20602
  * buffer, so a token within the refresh window is treated as stale (the
@@ -20653,7 +20689,7 @@ var DeviceTokenManager = class extends Destroyable {
20653
20689
  const currentToken = this.getCredential().token;
20654
20690
  if (!currentToken) throw new TokenRefreshError("No current token available for refresh");
20655
20691
  const newTokenData = await this.retryRefresh(session, currentToken, updateCredential);
20656
- this._currentToken$.next(newTokenData);
20692
+ this.emitCurrentToken(newTokenData);
20657
20693
  } catch (error) {
20658
20694
  logger$7.error("[DeviceToken] Automatic Client Bound SAT refresh failed:", error);
20659
20695
  this.errorHandler(error instanceof TokenRefreshError ? error : new TokenRefreshError("Automatic token refresh failed", error));
@@ -20680,6 +20716,22 @@ var DeviceTokenManager = class extends Destroyable {
20680
20716
  throw lastError instanceof Error ? lastError : new TokenRefreshError("All refresh retries exhausted", lastError);
20681
20717
  }
20682
20718
  /**
20719
+ * Force an immediate refresh when the cached Client Bound SAT is already
20720
+ * past its refresh window. Called on resume from suspension where
20721
+ * background-tab throttling can delay the reactive timer past the buffer.
20722
+ * A no-op when no token is cached or it still has headroom; the normal
20723
+ * {@link executeRefresh} guards (paused / in-progress / unauthenticated)
20724
+ * still apply.
20725
+ */
20726
+ refreshNowIfDue() {
20727
+ const token = this._currentToken$.value;
20728
+ if (!token) return;
20729
+ if (resolveExpiresAt(token) * 1e3 - Date.now() <= DEVICE_TOKEN_REFRESH_BUFFER_MS) {
20730
+ logger$7.debug("[DeviceToken] Resume: cached SAT past refresh window; refreshing now");
20731
+ this.executeRefresh();
20732
+ }
20733
+ }
20734
+ /**
20683
20735
  * Stops the reactive refresh pipeline from firing. Use when the underlying
20684
20736
  * session is being torn down (e.g., during {@link SignalWire.disconnect})
20685
20737
  * so a scheduled refresh cannot fire against a destroyed session.
@@ -20730,6 +20782,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20730
20782
  this.deps = deps;
20731
20783
  this._activating = false;
20732
20784
  this._activationGeneration = 0;
20785
+ this._developerRefreshInProgress = false;
20733
20786
  if (dpopManager?.initialized) this._deviceTokenManager = (deps.deviceTokenManagerFactory ?? defaultDeviceTokenManagerFactory)(dpopManager, deps.http, (error) => deps.notifier.onError(error), () => deps.store.read());
20734
20787
  }
20735
20788
  /** True when the Client Bound SAT path is available (DPoP initialized). */
@@ -20748,28 +20801,120 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20748
20801
  * invokes `deps.onRefreshExhausted` so the orchestrator can disconnect.
20749
20802
  */
20750
20803
  scheduleDeveloperRefresh(provider, expiresAt, attempt = 0) {
20804
+ this._activeProvider = provider;
20751
20805
  if (this._developerTimerId !== void 0) clearTimeout(this._developerTimerId);
20752
20806
  const refreshInterval = attempt === 0 ? Math.max(expiresAt - Date.now() - CREDENTIAL_REFRESH_BUFFER_MS, 1e3) : Math.min(CREDENTIAL_REFRESH_RETRY_BASE_MS * Math.pow(2, attempt) * (.5 + Math.random() * .5), CREDENTIAL_REFRESH_MAX_DELAY_MS);
20753
- this._developerTimerId = setTimeout(async () => {
20807
+ this._developerTimerId = setTimeout(() => {
20808
+ this._developerTimerId = void 0;
20809
+ this.executeDeveloperRefresh(provider, expiresAt, attempt);
20810
+ }, refreshInterval);
20811
+ }
20812
+ /**
20813
+ * Runs the developer-provided refresh once: mints a new credential, stores
20814
+ * and persists it, reauthenticates the live session (via the notifier), and
20815
+ * reschedules against the new expiry. On failure retries with backoff up to
20816
+ * {@link CREDENTIAL_REFRESH_MAX_RETRIES}, then signals exhaustion.
20817
+ *
20818
+ * Shared by the scheduled timer tick and {@link forceRefreshIfDue}. The
20819
+ * `_developerRefreshInProgress` guard prevents the two from overlapping.
20820
+ */
20821
+ async executeDeveloperRefresh(provider, expiresAt, attempt) {
20822
+ if (this._developerRefreshInProgress) {
20823
+ logger$6.debug("[Coordinator] Developer refresh already in progress; skipping");
20824
+ return;
20825
+ }
20826
+ this._developerRefreshInProgress = true;
20827
+ try {
20828
+ const newCredentials = await this.refreshCredential(provider);
20829
+ this.deps.store.write(newCredentials);
20830
+ this.deps.store.persist(newCredentials);
20754
20831
  try {
20755
- if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
20756
- const newCredentials = await provider.refresh();
20757
- this.deps.store.write(newCredentials);
20758
- this.deps.store.persist(newCredentials);
20759
- logger$6.info("[Coordinator] Credentials refreshed successfully.");
20760
- if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
20761
- } catch (error) {
20762
- const nextAttempt = attempt + 1;
20763
- logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
20764
- this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
20765
- if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
20766
- else {
20767
- logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
20768
- this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
20769
- this.deps.notifier.onRefreshExhausted();
20770
- }
20832
+ await this.deps.notifier.onCredentialRefreshed(newCredentials);
20833
+ } catch (reauthError) {
20834
+ logger$6.warn("[Coordinator] onCredentialRefreshed rejected (non-fatal):", reauthError);
20771
20835
  }
20772
- }, refreshInterval);
20836
+ logger$6.info("[Coordinator] Credentials refreshed successfully.");
20837
+ if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
20838
+ } catch (error) {
20839
+ const nextAttempt = attempt + 1;
20840
+ logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
20841
+ this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
20842
+ if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
20843
+ else {
20844
+ logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
20845
+ this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
20846
+ this.deps.notifier.onRefreshExhausted();
20847
+ }
20848
+ } finally {
20849
+ this._developerRefreshInProgress = false;
20850
+ }
20851
+ }
20852
+ /**
20853
+ * Force an immediate refresh when the current credential is already past its
20854
+ * scheduled refresh window. Called on resume from suspension, where
20855
+ * background-tab timer throttling can delay the armed refresh well past
20856
+ * expiry, leaving the live session stale.
20857
+ *
20858
+ * Routes to whichever mechanism is armed: the developer timer if armed,
20859
+ * otherwise the Client Bound SAT pipeline. A no-op when nothing is due.
20860
+ */
20861
+ forceRefreshIfDue() {
20862
+ if (this._developerTimerId !== void 0 && this._activeProvider) {
20863
+ const expiry = this.deps.store.read().expiry_at;
20864
+ if (expiry !== void 0 && Date.now() >= expiry - CREDENTIAL_REFRESH_BUFFER_MS) {
20865
+ logger$6.debug("[Coordinator] Resume: credential past refresh window; forcing refresh");
20866
+ clearTimeout(this._developerTimerId);
20867
+ this._developerTimerId = void 0;
20868
+ this.executeDeveloperRefresh(this._activeProvider, expiry, 0);
20869
+ }
20870
+ return;
20871
+ }
20872
+ this._deviceTokenManager?.refreshNowIfDue();
20873
+ }
20874
+ /**
20875
+ * Sync the credential's expiry from the server-provided authorization (the
20876
+ * `signalwire.connect` result). SATs are opaque JWE, so
20877
+ * `fabric_subscriber.expires_at` is the authoritative expiry of the token
20878
+ * the session actually connected with — the provider-reported `expiry_at`
20879
+ * is only a hint (and may be wrong or absent). Corrects the stored
20880
+ * credential and re-arms the developer refresh timer against the real
20881
+ * deadline when the provider supports `refresh()`.
20882
+ */
20883
+ syncExpiryFromAuthorization(authorization, provider) {
20884
+ const expiresAtSec = authorization?.fabric_subscriber?.expires_at;
20885
+ if (!expiresAtSec) return;
20886
+ const expiryAt = expiresAtSec * 1e3;
20887
+ const credential = this.deps.store.read();
20888
+ if (credential.expiry_at === expiryAt) return;
20889
+ logger$6.debug(`[Coordinator] Correcting credential expiry from server authorization: ${new Date(expiryAt).toISOString()}`);
20890
+ const updated = {
20891
+ ...credential,
20892
+ expiry_at: expiryAt
20893
+ };
20894
+ this.deps.store.write(updated);
20895
+ this.deps.store.persist(updated);
20896
+ if (provider?.refresh) this.scheduleDeveloperRefresh(provider, expiryAt);
20897
+ }
20898
+ /**
20899
+ * Invoke `provider.refresh()` deduped against any concurrent developer
20900
+ * refresh. Concurrent callers — the scheduled tick, a resume-forced refresh,
20901
+ * and the orchestrator's -32003 recovery / reconnect re-mint — share one
20902
+ * in-flight promise, so a provider backed by one-time-use rotating refresh
20903
+ * tokens is never invoked twice in parallel.
20904
+ *
20905
+ * The caller owns applying the returned credential (store write, session
20906
+ * reauth, rescheduling); this method only serializes the network call.
20907
+ */
20908
+ async refreshCredential(provider) {
20909
+ if (this._refreshInFlight) return this._refreshInFlight;
20910
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
20911
+ const run = provider.refresh();
20912
+ this._refreshInFlight = run;
20913
+ const clear = () => {
20914
+ if (this._refreshInFlight === run) this._refreshInFlight = void 0;
20915
+ };
20916
+ run.then(clear, clear);
20917
+ return run;
20773
20918
  }
20774
20919
  /**
20775
20920
  * Cancels any scheduled developer-provided refresh. Idempotent.
@@ -21549,6 +21694,33 @@ var TransportManager = class extends Destroyable {
21549
21694
  }
21550
21695
  };
21551
21696
 
21697
+ //#endregion
21698
+ //#region src/utils/authRecovery.ts
21699
+ /**
21700
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
21701
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
21702
+ * underlying signaling error is nested. Bounded by a visited set to guard
21703
+ * against cyclic causes.
21704
+ */
21705
+ function findJSONRPCError(error) {
21706
+ const seen = /* @__PURE__ */ new Set();
21707
+ let current = error;
21708
+ while (current instanceof Error && !seen.has(current)) {
21709
+ seen.add(current);
21710
+ if (current instanceof JSONRPCError) return current;
21711
+ current = current.error ?? current.cause;
21712
+ }
21713
+ }
21714
+ /**
21715
+ * Whether an error is a session-recoverable authentication failure
21716
+ * (`-32002` authentication failed or `-32003` requester validation failed)
21717
+ * that a credential re-mint + retry can heal.
21718
+ */
21719
+ function isRecoverableAuthError(error) {
21720
+ const rpcError = findJSONRPCError(error);
21721
+ return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
21722
+ }
21723
+
21552
21724
  //#endregion
21553
21725
  //#region src/clients/SignalWire.ts
21554
21726
  var import_cjs$1 = require_cjs();
@@ -21664,7 +21836,8 @@ var SignalWire = class extends Destroyable {
21664
21836
  notifier: {
21665
21837
  onError: (error) => this._errors$.next(error),
21666
21838
  onWarning: (warning) => this._warnings$.next(warning),
21667
- onRefreshExhausted: () => void this.disconnect()
21839
+ onRefreshExhausted: () => void this.disconnect(),
21840
+ onCredentialRefreshed: async (credential) => this.reauthenticateLiveSession(credential)
21668
21841
  },
21669
21842
  store: {
21670
21843
  read: () => this._deps.credential,
@@ -21676,6 +21849,7 @@ var SignalWire = class extends Destroyable {
21676
21849
  ...this._deps.credential,
21677
21850
  ...partial
21678
21851
  };
21852
+ this.persistCredential(this._deps.credential);
21679
21853
  },
21680
21854
  persist: (credential) => this.persistCredential(credential)
21681
21855
  }
@@ -21721,14 +21895,123 @@ var SignalWire = class extends Destroyable {
21721
21895
  }
21722
21896
  this._deps.credential = _credentials;
21723
21897
  this.persistCredential(_credentials);
21724
- if (this.isConnected && this._clientSession.authenticated && _credentials.token) try {
21725
- await this._clientSession.reauthenticate(_credentials.token);
21898
+ await this.reauthenticateLiveSession(_credentials);
21899
+ }
21900
+ /**
21901
+ * Reauthenticate the currently-open session with a freshly obtained
21902
+ * credential so the new token takes effect on the live socket immediately —
21903
+ * not just on the next reconnect. No-op when the session is not
21904
+ * connected/authenticated or the credential carries no token (e.g. an
21905
+ * authorization-state-only refresh). Non-fatal: reauth failures surface on
21906
+ * `errors$` without aborting the refresh that triggered this.
21907
+ */
21908
+ async reauthenticateLiveSession(credential) {
21909
+ if (!this.isConnected || !this._clientSession.authenticated || !credential.token) return;
21910
+ try {
21911
+ await this._clientSession.reauthenticate(credential.token);
21726
21912
  logger$1.info("[SignalWire] Session refreshed with new credentials.");
21727
21913
  } catch (error) {
21728
21914
  logger$1.error("[SignalWire] Failed to refresh session with new credentials:", error);
21729
21915
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21730
21916
  }
21731
21917
  }
21918
+ /**
21919
+ * One-shot recovery for a live session that failed with a recoverable auth
21920
+ * error (`-32002`/`-32003`) because its token went stale — e.g. a refresh
21921
+ * timer that was throttled while the tab was backgrounded.
21922
+ *
21923
+ * Two steps, first that succeeds wins:
21924
+ * 1. Reauthenticate with the in-memory token (a fresh DPoP proof is
21925
+ * generated automatically). Heals a session whose token was already
21926
+ * refreshed by the coordinator but never applied to the open socket,
21927
+ * and a client-bound session whose server-side auth merely drifted.
21928
+ * 2. Re-mint via the developer provider's `refresh()` and reauthenticate.
21929
+ * Skipped for client-bound sessions (the DeviceTokenManager owns their
21930
+ * refresh; re-minting a base SAT here would drop the DPoP binding).
21931
+ *
21932
+ * @param allowRemint - Whether step 2 (provider re-mint) may run. Callers
21933
+ * pass `false` for non-auth failures so a transient network error never
21934
+ * escalates to a token re-mint; step 1 (in-memory reauth) always runs.
21935
+ * @returns `true` if the session was reauthenticated, `false` otherwise.
21936
+ */
21937
+ async recoverStaleCredential(allowRemint = true) {
21938
+ const currentToken = this._deps.credential.token;
21939
+ if (currentToken) try {
21940
+ await this._clientSession.reauthenticate(currentToken);
21941
+ return true;
21942
+ } catch (error) {
21943
+ logger$1.debug("[SignalWire] In-memory reauth failed during recovery:", error);
21944
+ }
21945
+ if (!allowRemint || this._clientSession.clientBound || !this._credentialProvider?.refresh) return false;
21946
+ try {
21947
+ const newCredentials = await this.remintCredential(this._credentialProvider);
21948
+ if (!newCredentials.token) return false;
21949
+ this._deps.credential = newCredentials;
21950
+ this.persistCredential(newCredentials);
21951
+ await this._clientSession.reauthenticate(newCredentials.token);
21952
+ if (newCredentials.expiry_at) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
21953
+ return true;
21954
+ } catch (error) {
21955
+ logger$1.error("[SignalWire] Credential recovery failed:", error);
21956
+ return false;
21957
+ }
21958
+ }
21959
+ /**
21960
+ * Re-mint a credential via `provider.refresh()`, routed through the
21961
+ * coordinator's shared in-flight guard so concurrent re-mint paths (a
21962
+ * scheduled/resume refresh, -32003 recovery, and reconnect) never fire a
21963
+ * second `provider.refresh()` in parallel — which rotating one-time-use
21964
+ * refresh tokens reject. Falls back to a direct call only if the coordinator
21965
+ * has not been constructed yet.
21966
+ */
21967
+ async remintCredential(provider) {
21968
+ if (this._refreshCoordinator) return this._refreshCoordinator.refreshCredential(provider);
21969
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
21970
+ return provider.refresh();
21971
+ }
21972
+ /**
21973
+ * Re-mint credentials before a fresh (re)connect (`onBeforeReconnect` hook).
21974
+ * The session invokes this only when it is client-bound OR the in-memory
21975
+ * token is expired. The re-mint mechanism depends on the binding:
21976
+ * - Client-bound: `authenticate()` with the DPoP fingerprint to obtain a
21977
+ * fresh base SAT the upcoming reconnect can re-bind (the
21978
+ * DeviceTokenManager re-activates afterwards).
21979
+ * - Unbound: the developer's non-interactive `refresh()` handler.
21980
+ * `authenticate()` is deliberately NOT used here — it may be interactive
21981
+ * (a login prompt) and must not fire on a background reconnect.
21982
+ *
21983
+ * Rejects on failure so the session aborts the reconnect rather than
21984
+ * replaying a stale token.
21985
+ */
21986
+ async refreshCredentialForReconnect() {
21987
+ if (!this._credentialProvider) return;
21988
+ try {
21989
+ let newCredentials;
21990
+ if (this._clientSession.clientBound) {
21991
+ const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
21992
+ logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
21993
+ newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
21994
+ } else if (this._credentialProvider.refresh) {
21995
+ logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
21996
+ newCredentials = await this.remintCredential(this._credentialProvider);
21997
+ } else {
21998
+ logger$1.warn("[SignalWire] [SW-NO-REFRESH-HANDLER] Token expired on reconnect but no refresh handler; reconnecting with the existing token.");
21999
+ return;
22000
+ }
22001
+ if (!newCredentials.token) {
22002
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the existing credential for reconnect.");
22003
+ return;
22004
+ }
22005
+ this._deps.credential = newCredentials;
22006
+ this.persistCredential(newCredentials);
22007
+ if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
22008
+ logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
22009
+ } catch (error) {
22010
+ logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
22011
+ this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22012
+ throw error;
22013
+ }
22014
+ }
21732
22015
  /** Persist credential to localStorage when persistSession is enabled. */
21733
22016
  persistCredential(credential) {
21734
22017
  if (!credential.token) return;
@@ -21815,24 +22098,13 @@ var SignalWire = class extends Destroyable {
21815
22098
  this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
21816
22099
  this._clientSession = new ClientSessionManager(() => this._deps.credential, this._transport, this._deps.storage, this._deps.authorizationStateKey, this._deps.deviceController, this._attachManager, this._deps.webRTCApiProvider, this._dpopManager, this._networkMonitor?.networkChange$);
21817
22100
  this._publicSession = new ClientSessionWrapper(this._clientSession);
21818
- this._clientSession.onBeforeReconnect = async () => {
21819
- if (!this._credentialProvider) return;
21820
- try {
21821
- const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
21822
- logger$1.debug("[SignalWire] Credential expired, refreshing before reconnect");
21823
- const newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
21824
- this._deps.credential = newCredentials;
21825
- if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
21826
- logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
21827
- } catch (error) {
21828
- logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
21829
- this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21830
- throw error;
21831
- }
21832
- };
22101
+ this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
21833
22102
  this.subscribeTo(this._clientSession.errors$, (error) => {
21834
22103
  this._errors$.next(error);
21835
22104
  });
22105
+ this.subscribeTo(this._clientSession.authorization$, (authorization) => {
22106
+ this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
22107
+ });
21836
22108
  await this._clientSession.connect();
21837
22109
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
21838
22110
  this.subscribeTo(this._clientSession.authenticated$.pipe((0, import_cjs$1.skip)(1), (0, import_cjs$1.filter)(Boolean)), async () => {
@@ -21995,6 +22267,13 @@ var SignalWire = class extends Destroyable {
21995
22267
  }
21996
22268
  try {
21997
22269
  this._visibilityController = new VisibilityController();
22270
+ this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible")), () => {
22271
+ try {
22272
+ this._refreshCoordinator?.forceRefreshIfDue();
22273
+ } catch (error) {
22274
+ logger$1.warn("[SignalWire] Resume credential revalidation failed (non-fatal):", error);
22275
+ }
22276
+ });
21998
22277
  this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible" && PreferencesContainer.instance.refreshDevicesOnVisible)), () => {
21999
22278
  logger$1.debug("[SignalWire] Page visible, re-enumerating devices");
22000
22279
  try {
@@ -22077,21 +22356,23 @@ var SignalWire = class extends Destroyable {
22077
22356
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22078
22357
  throw error;
22079
22358
  }
22080
- logger$1.debug("[SignalWire] Failed to register user, trying reauthentication...");
22081
- try {
22082
- await this._clientSession.reauthenticate(this._deps.credential.token);
22083
- logger$1.debug("[SignalWire] Reauthentication successful, retrying register()");
22359
+ logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
22360
+ let failureCause = error;
22361
+ if (await this.recoverStaleCredential(isRecoverableAuthError(error))) try {
22362
+ logger$1.debug("[SignalWire] Recovery succeeded, retrying register()");
22084
22363
  await this._transport.execute(RPCExecute({
22085
22364
  method: "subscriber.online",
22086
22365
  params: {}
22087
22366
  }));
22088
22367
  this._isRegistered$.next(true);
22089
- } catch (reauthError) {
22090
- logger$1.error("[SignalWire] Reauthentication failed during register():", reauthError);
22091
- const registerError = new InvalidCredentialsError("Failed to register user, and reauthentication attempt also failed. Please check your credentials.", { cause: reauthError instanceof Error ? reauthError : new Error(String(reauthError), { cause: reauthError }) });
22092
- this._errors$.next(registerError);
22093
- throw registerError;
22368
+ return;
22369
+ } catch (retryError) {
22370
+ logger$1.error("[SignalWire] Register retry after recovery failed:", retryError);
22371
+ failureCause = retryError;
22094
22372
  }
22373
+ const registerError = new InvalidCredentialsError("Failed to register user, and credential recovery also failed. Please check your credentials.", { cause: failureCause instanceof Error ? failureCause : new Error(String(failureCause), { cause: failureCause }) });
22374
+ this._errors$.next(registerError);
22375
+ throw registerError;
22095
22376
  }
22096
22377
  }
22097
22378
  /**
@@ -22144,7 +22425,15 @@ var SignalWire = class extends Destroyable {
22144
22425
  };
22145
22426
  await this.waitAuthentication();
22146
22427
  logger$1.debug("[SignalWire] Dialing with options:", computed_options);
22147
- return this._clientSession.createOutboundCall(destination, computed_options);
22428
+ try {
22429
+ return await this._clientSession.createOutboundCall(destination, computed_options);
22430
+ } catch (error) {
22431
+ if (!isRecoverableAuthError(error)) throw error;
22432
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering credential and retrying once");
22433
+ if (!await this.recoverStaleCredential()) throw error;
22434
+ await this.waitAuthentication();
22435
+ return this._clientSession.createOutboundCall(destination, computed_options);
22436
+ }
22148
22437
  }
22149
22438
  /**
22150
22439
  * Runs a multi-phase connectivity test against the given destination.
@@ -22426,9 +22715,10 @@ var SignalWire = class extends Destroyable {
22426
22715
  this._refreshCoordinator?.destroy();
22427
22716
  this._refreshCoordinator = void 0;
22428
22717
  this._dpopManager?.destroy();
22429
- this._clientSession.teardownSessionState();
22430
- this._transport.destroy();
22431
- this._clientSession.destroy();
22718
+ const session = this._clientSession;
22719
+ session?.teardownSessionState();
22720
+ this._transport?.destroy();
22721
+ session?.destroy();
22432
22722
  try {
22433
22723
  this._networkMonitor?.destroy();
22434
22724
  } catch {}