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

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,9 +20199,13 @@ 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
20210
  if ((!isReconnect || this.clientBound) && this.dpopManager?.initialized) try {
20199
20211
  dpopToken = await this.dpopManager.createRpcProof({ method: "signalwire.connect" });
@@ -20549,7 +20561,7 @@ var DeviceTokenManager = class extends Destroyable {
20549
20561
  await session.reauthenticate(tokenData.token, rpcProof, { clientBound: true });
20550
20562
  updateCredential({ token: tokenData.token });
20551
20563
  logger$7.info("[DeviceToken] Client Bound SAT activated successfully");
20552
- this._currentToken$.next(tokenData);
20564
+ this.emitCurrentToken(tokenData);
20553
20565
  return { activated: true };
20554
20566
  } catch (error) {
20555
20567
  logger$7.error("[DeviceToken] Failed to activate Client Bound SAT:", error);
@@ -20561,6 +20573,21 @@ var DeviceTokenManager = class extends Destroyable {
20561
20573
  }
20562
20574
  }
20563
20575
  /**
20576
+ * Emit a freshly received token to the reactive pipeline, stamping an
20577
+ * absolute `expires_at` when the response carried only `expires_in`.
20578
+ * Resolving the expiry at RECEIVE time (not at read time) is what lets
20579
+ * {@link refreshNowIfDue} detect due-ness on resume: a bare `expires_in`
20580
+ * re-resolved later would always compute a full TTL from "now" and never
20581
+ * cross the refresh buffer.
20582
+ */
20583
+ emitCurrentToken(token) {
20584
+ const stamped = token.expires_at ? token : {
20585
+ ...token,
20586
+ expires_at: resolveExpiresAt(token)
20587
+ };
20588
+ this._currentToken$.next(stamped);
20589
+ }
20590
+ /**
20564
20591
  * Returns true when the cached token has enough headroom before expiry to
20565
20592
  * be safely reused on reactivation. The headroom matches the refresh
20566
20593
  * buffer, so a token within the refresh window is treated as stale (the
@@ -20653,7 +20680,7 @@ var DeviceTokenManager = class extends Destroyable {
20653
20680
  const currentToken = this.getCredential().token;
20654
20681
  if (!currentToken) throw new TokenRefreshError("No current token available for refresh");
20655
20682
  const newTokenData = await this.retryRefresh(session, currentToken, updateCredential);
20656
- this._currentToken$.next(newTokenData);
20683
+ this.emitCurrentToken(newTokenData);
20657
20684
  } catch (error) {
20658
20685
  logger$7.error("[DeviceToken] Automatic Client Bound SAT refresh failed:", error);
20659
20686
  this.errorHandler(error instanceof TokenRefreshError ? error : new TokenRefreshError("Automatic token refresh failed", error));
@@ -20680,6 +20707,22 @@ var DeviceTokenManager = class extends Destroyable {
20680
20707
  throw lastError instanceof Error ? lastError : new TokenRefreshError("All refresh retries exhausted", lastError);
20681
20708
  }
20682
20709
  /**
20710
+ * Force an immediate refresh when the cached Client Bound SAT is already
20711
+ * past its refresh window. Called on resume from suspension where
20712
+ * background-tab throttling can delay the reactive timer past the buffer.
20713
+ * A no-op when no token is cached or it still has headroom; the normal
20714
+ * {@link executeRefresh} guards (paused / in-progress / unauthenticated)
20715
+ * still apply.
20716
+ */
20717
+ refreshNowIfDue() {
20718
+ const token = this._currentToken$.value;
20719
+ if (!token) return;
20720
+ if (resolveExpiresAt(token) * 1e3 - Date.now() <= DEVICE_TOKEN_REFRESH_BUFFER_MS) {
20721
+ logger$7.debug("[DeviceToken] Resume: cached SAT past refresh window; refreshing now");
20722
+ this.executeRefresh();
20723
+ }
20724
+ }
20725
+ /**
20683
20726
  * Stops the reactive refresh pipeline from firing. Use when the underlying
20684
20727
  * session is being torn down (e.g., during {@link SignalWire.disconnect})
20685
20728
  * so a scheduled refresh cannot fire against a destroyed session.
@@ -20730,6 +20773,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20730
20773
  this.deps = deps;
20731
20774
  this._activating = false;
20732
20775
  this._activationGeneration = 0;
20776
+ this._developerRefreshInProgress = false;
20733
20777
  if (dpopManager?.initialized) this._deviceTokenManager = (deps.deviceTokenManagerFactory ?? defaultDeviceTokenManagerFactory)(dpopManager, deps.http, (error) => deps.notifier.onError(error), () => deps.store.read());
20734
20778
  }
20735
20779
  /** True when the Client Bound SAT path is available (DPoP initialized). */
@@ -20748,28 +20792,120 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20748
20792
  * invokes `deps.onRefreshExhausted` so the orchestrator can disconnect.
20749
20793
  */
20750
20794
  scheduleDeveloperRefresh(provider, expiresAt, attempt = 0) {
20795
+ this._activeProvider = provider;
20751
20796
  if (this._developerTimerId !== void 0) clearTimeout(this._developerTimerId);
20752
20797
  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 () => {
20798
+ this._developerTimerId = setTimeout(() => {
20799
+ this._developerTimerId = void 0;
20800
+ this.executeDeveloperRefresh(provider, expiresAt, attempt);
20801
+ }, refreshInterval);
20802
+ }
20803
+ /**
20804
+ * Runs the developer-provided refresh once: mints a new credential, stores
20805
+ * and persists it, reauthenticates the live session (via the notifier), and
20806
+ * reschedules against the new expiry. On failure retries with backoff up to
20807
+ * {@link CREDENTIAL_REFRESH_MAX_RETRIES}, then signals exhaustion.
20808
+ *
20809
+ * Shared by the scheduled timer tick and {@link forceRefreshIfDue}. The
20810
+ * `_developerRefreshInProgress` guard prevents the two from overlapping.
20811
+ */
20812
+ async executeDeveloperRefresh(provider, expiresAt, attempt) {
20813
+ if (this._developerRefreshInProgress) {
20814
+ logger$6.debug("[Coordinator] Developer refresh already in progress; skipping");
20815
+ return;
20816
+ }
20817
+ this._developerRefreshInProgress = true;
20818
+ try {
20819
+ const newCredentials = await this.refreshCredential(provider);
20820
+ this.deps.store.write(newCredentials);
20821
+ this.deps.store.persist(newCredentials);
20754
20822
  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
- }
20823
+ await this.deps.notifier.onCredentialRefreshed(newCredentials);
20824
+ } catch (reauthError) {
20825
+ logger$6.warn("[Coordinator] onCredentialRefreshed rejected (non-fatal):", reauthError);
20771
20826
  }
20772
- }, refreshInterval);
20827
+ logger$6.info("[Coordinator] Credentials refreshed successfully.");
20828
+ if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
20829
+ } catch (error) {
20830
+ const nextAttempt = attempt + 1;
20831
+ logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
20832
+ this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
20833
+ if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
20834
+ else {
20835
+ logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
20836
+ this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
20837
+ this.deps.notifier.onRefreshExhausted();
20838
+ }
20839
+ } finally {
20840
+ this._developerRefreshInProgress = false;
20841
+ }
20842
+ }
20843
+ /**
20844
+ * Force an immediate refresh when the current credential is already past its
20845
+ * scheduled refresh window. Called on resume from suspension, where
20846
+ * background-tab timer throttling can delay the armed refresh well past
20847
+ * expiry, leaving the live session stale.
20848
+ *
20849
+ * Routes to whichever mechanism is armed: the developer timer if armed,
20850
+ * otherwise the Client Bound SAT pipeline. A no-op when nothing is due.
20851
+ */
20852
+ forceRefreshIfDue() {
20853
+ if (this._developerTimerId !== void 0 && this._activeProvider) {
20854
+ const expiry = this.deps.store.read().expiry_at;
20855
+ if (expiry !== void 0 && Date.now() >= expiry - CREDENTIAL_REFRESH_BUFFER_MS) {
20856
+ logger$6.debug("[Coordinator] Resume: credential past refresh window; forcing refresh");
20857
+ clearTimeout(this._developerTimerId);
20858
+ this._developerTimerId = void 0;
20859
+ this.executeDeveloperRefresh(this._activeProvider, expiry, 0);
20860
+ }
20861
+ return;
20862
+ }
20863
+ this._deviceTokenManager?.refreshNowIfDue();
20864
+ }
20865
+ /**
20866
+ * Sync the credential's expiry from the server-provided authorization (the
20867
+ * `signalwire.connect` result). SATs are opaque JWE, so
20868
+ * `fabric_subscriber.expires_at` is the authoritative expiry of the token
20869
+ * the session actually connected with — the provider-reported `expiry_at`
20870
+ * is only a hint (and may be wrong or absent). Corrects the stored
20871
+ * credential and re-arms the developer refresh timer against the real
20872
+ * deadline when the provider supports `refresh()`.
20873
+ */
20874
+ syncExpiryFromAuthorization(authorization, provider) {
20875
+ const expiresAtSec = authorization?.fabric_subscriber?.expires_at;
20876
+ if (!expiresAtSec) return;
20877
+ const expiryAt = expiresAtSec * 1e3;
20878
+ const credential = this.deps.store.read();
20879
+ if (credential.expiry_at === expiryAt) return;
20880
+ logger$6.debug(`[Coordinator] Correcting credential expiry from server authorization: ${new Date(expiryAt).toISOString()}`);
20881
+ const updated = {
20882
+ ...credential,
20883
+ expiry_at: expiryAt
20884
+ };
20885
+ this.deps.store.write(updated);
20886
+ this.deps.store.persist(updated);
20887
+ if (provider?.refresh) this.scheduleDeveloperRefresh(provider, expiryAt);
20888
+ }
20889
+ /**
20890
+ * Invoke `provider.refresh()` deduped against any concurrent developer
20891
+ * refresh. Concurrent callers — the scheduled tick, a resume-forced refresh,
20892
+ * and the orchestrator's -32003 recovery / reconnect re-mint — share one
20893
+ * in-flight promise, so a provider backed by one-time-use rotating refresh
20894
+ * tokens is never invoked twice in parallel.
20895
+ *
20896
+ * The caller owns applying the returned credential (store write, session
20897
+ * reauth, rescheduling); this method only serializes the network call.
20898
+ */
20899
+ async refreshCredential(provider) {
20900
+ if (this._refreshInFlight) return this._refreshInFlight;
20901
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
20902
+ const run = provider.refresh();
20903
+ this._refreshInFlight = run;
20904
+ const clear = () => {
20905
+ if (this._refreshInFlight === run) this._refreshInFlight = void 0;
20906
+ };
20907
+ run.then(clear, clear);
20908
+ return run;
20773
20909
  }
20774
20910
  /**
20775
20911
  * Cancels any scheduled developer-provided refresh. Idempotent.
@@ -21549,6 +21685,33 @@ var TransportManager = class extends Destroyable {
21549
21685
  }
21550
21686
  };
21551
21687
 
21688
+ //#endregion
21689
+ //#region src/utils/authRecovery.ts
21690
+ /**
21691
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
21692
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
21693
+ * underlying signaling error is nested. Bounded by a visited set to guard
21694
+ * against cyclic causes.
21695
+ */
21696
+ function findJSONRPCError(error) {
21697
+ const seen = /* @__PURE__ */ new Set();
21698
+ let current = error;
21699
+ while (current instanceof Error && !seen.has(current)) {
21700
+ seen.add(current);
21701
+ if (current instanceof JSONRPCError) return current;
21702
+ current = current.error ?? current.cause;
21703
+ }
21704
+ }
21705
+ /**
21706
+ * Whether an error is a session-recoverable authentication failure
21707
+ * (`-32002` authentication failed or `-32003` requester validation failed)
21708
+ * that a credential re-mint + retry can heal.
21709
+ */
21710
+ function isRecoverableAuthError(error) {
21711
+ const rpcError = findJSONRPCError(error);
21712
+ return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
21713
+ }
21714
+
21552
21715
  //#endregion
21553
21716
  //#region src/clients/SignalWire.ts
21554
21717
  var import_cjs$1 = require_cjs();
@@ -21664,7 +21827,8 @@ var SignalWire = class extends Destroyable {
21664
21827
  notifier: {
21665
21828
  onError: (error) => this._errors$.next(error),
21666
21829
  onWarning: (warning) => this._warnings$.next(warning),
21667
- onRefreshExhausted: () => void this.disconnect()
21830
+ onRefreshExhausted: () => void this.disconnect(),
21831
+ onCredentialRefreshed: async (credential) => this.reauthenticateLiveSession(credential)
21668
21832
  },
21669
21833
  store: {
21670
21834
  read: () => this._deps.credential,
@@ -21721,14 +21885,123 @@ var SignalWire = class extends Destroyable {
21721
21885
  }
21722
21886
  this._deps.credential = _credentials;
21723
21887
  this.persistCredential(_credentials);
21724
- if (this.isConnected && this._clientSession.authenticated && _credentials.token) try {
21725
- await this._clientSession.reauthenticate(_credentials.token);
21888
+ await this.reauthenticateLiveSession(_credentials);
21889
+ }
21890
+ /**
21891
+ * Reauthenticate the currently-open session with a freshly obtained
21892
+ * credential so the new token takes effect on the live socket immediately —
21893
+ * not just on the next reconnect. No-op when the session is not
21894
+ * connected/authenticated or the credential carries no token (e.g. an
21895
+ * authorization-state-only refresh). Non-fatal: reauth failures surface on
21896
+ * `errors$` without aborting the refresh that triggered this.
21897
+ */
21898
+ async reauthenticateLiveSession(credential) {
21899
+ if (!this.isConnected || !this._clientSession.authenticated || !credential.token) return;
21900
+ try {
21901
+ await this._clientSession.reauthenticate(credential.token);
21726
21902
  logger$1.info("[SignalWire] Session refreshed with new credentials.");
21727
21903
  } catch (error) {
21728
21904
  logger$1.error("[SignalWire] Failed to refresh session with new credentials:", error);
21729
21905
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21730
21906
  }
21731
21907
  }
21908
+ /**
21909
+ * One-shot recovery for a live session that failed with a recoverable auth
21910
+ * error (`-32002`/`-32003`) because its token went stale — e.g. a refresh
21911
+ * timer that was throttled while the tab was backgrounded.
21912
+ *
21913
+ * Two steps, first that succeeds wins:
21914
+ * 1. Reauthenticate with the in-memory token (a fresh DPoP proof is
21915
+ * generated automatically). Heals a session whose token was already
21916
+ * refreshed by the coordinator but never applied to the open socket,
21917
+ * and a client-bound session whose server-side auth merely drifted.
21918
+ * 2. Re-mint via the developer provider's `refresh()` and reauthenticate.
21919
+ * Skipped for client-bound sessions (the DeviceTokenManager owns their
21920
+ * refresh; re-minting a base SAT here would drop the DPoP binding).
21921
+ *
21922
+ * @param allowRemint - Whether step 2 (provider re-mint) may run. Callers
21923
+ * pass `false` for non-auth failures so a transient network error never
21924
+ * escalates to a token re-mint; step 1 (in-memory reauth) always runs.
21925
+ * @returns `true` if the session was reauthenticated, `false` otherwise.
21926
+ */
21927
+ async recoverStaleCredential(allowRemint = true) {
21928
+ const currentToken = this._deps.credential.token;
21929
+ if (currentToken) try {
21930
+ await this._clientSession.reauthenticate(currentToken);
21931
+ return true;
21932
+ } catch (error) {
21933
+ logger$1.debug("[SignalWire] In-memory reauth failed during recovery:", error);
21934
+ }
21935
+ if (!allowRemint || this._clientSession.clientBound || !this._credentialProvider?.refresh) return false;
21936
+ try {
21937
+ const newCredentials = await this.remintCredential(this._credentialProvider);
21938
+ if (!newCredentials.token) return false;
21939
+ this._deps.credential = newCredentials;
21940
+ this.persistCredential(newCredentials);
21941
+ await this._clientSession.reauthenticate(newCredentials.token);
21942
+ if (newCredentials.expiry_at) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
21943
+ return true;
21944
+ } catch (error) {
21945
+ logger$1.error("[SignalWire] Credential recovery failed:", error);
21946
+ return false;
21947
+ }
21948
+ }
21949
+ /**
21950
+ * Re-mint a credential via `provider.refresh()`, routed through the
21951
+ * coordinator's shared in-flight guard so concurrent re-mint paths (a
21952
+ * scheduled/resume refresh, -32003 recovery, and reconnect) never fire a
21953
+ * second `provider.refresh()` in parallel — which rotating one-time-use
21954
+ * refresh tokens reject. Falls back to a direct call only if the coordinator
21955
+ * has not been constructed yet.
21956
+ */
21957
+ async remintCredential(provider) {
21958
+ if (this._refreshCoordinator) return this._refreshCoordinator.refreshCredential(provider);
21959
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
21960
+ return provider.refresh();
21961
+ }
21962
+ /**
21963
+ * Re-mint credentials before a fresh (re)connect (`onBeforeReconnect` hook).
21964
+ * The session invokes this only when it is client-bound OR the in-memory
21965
+ * token is expired. The re-mint mechanism depends on the binding:
21966
+ * - Client-bound: `authenticate()` with the DPoP fingerprint to obtain a
21967
+ * fresh base SAT the upcoming reconnect can re-bind (the
21968
+ * DeviceTokenManager re-activates afterwards).
21969
+ * - Unbound: the developer's non-interactive `refresh()` handler.
21970
+ * `authenticate()` is deliberately NOT used here — it may be interactive
21971
+ * (a login prompt) and must not fire on a background reconnect.
21972
+ *
21973
+ * Rejects on failure so the session aborts the reconnect rather than
21974
+ * replaying a stale token.
21975
+ */
21976
+ async refreshCredentialForReconnect() {
21977
+ if (!this._credentialProvider) return;
21978
+ try {
21979
+ let newCredentials;
21980
+ if (this._clientSession.clientBound) {
21981
+ const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
21982
+ logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
21983
+ newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
21984
+ } else if (this._credentialProvider.refresh) {
21985
+ logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
21986
+ newCredentials = await this.remintCredential(this._credentialProvider);
21987
+ } else {
21988
+ logger$1.warn("[SignalWire] [SW-NO-REFRESH-HANDLER] Token expired on reconnect but no refresh handler; reconnecting with the existing token.");
21989
+ return;
21990
+ }
21991
+ if (!newCredentials.token) {
21992
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the existing credential for reconnect.");
21993
+ return;
21994
+ }
21995
+ this._deps.credential = newCredentials;
21996
+ this.persistCredential(newCredentials);
21997
+ if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
21998
+ logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
21999
+ } catch (error) {
22000
+ logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
22001
+ this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22002
+ throw error;
22003
+ }
22004
+ }
21732
22005
  /** Persist credential to localStorage when persistSession is enabled. */
21733
22006
  persistCredential(credential) {
21734
22007
  if (!credential.token) return;
@@ -21815,24 +22088,13 @@ var SignalWire = class extends Destroyable {
21815
22088
  this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
21816
22089
  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
22090
  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
- };
22091
+ this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
21833
22092
  this.subscribeTo(this._clientSession.errors$, (error) => {
21834
22093
  this._errors$.next(error);
21835
22094
  });
22095
+ this.subscribeTo(this._clientSession.authorization$, (authorization) => {
22096
+ this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
22097
+ });
21836
22098
  await this._clientSession.connect();
21837
22099
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
21838
22100
  this.subscribeTo(this._clientSession.authenticated$.pipe((0, import_cjs$1.skip)(1), (0, import_cjs$1.filter)(Boolean)), async () => {
@@ -21995,6 +22257,13 @@ var SignalWire = class extends Destroyable {
21995
22257
  }
21996
22258
  try {
21997
22259
  this._visibilityController = new VisibilityController();
22260
+ this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible")), () => {
22261
+ try {
22262
+ this._refreshCoordinator?.forceRefreshIfDue();
22263
+ } catch (error) {
22264
+ logger$1.warn("[SignalWire] Resume credential revalidation failed (non-fatal):", error);
22265
+ }
22266
+ });
21998
22267
  this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible" && PreferencesContainer.instance.refreshDevicesOnVisible)), () => {
21999
22268
  logger$1.debug("[SignalWire] Page visible, re-enumerating devices");
22000
22269
  try {
@@ -22077,21 +22346,23 @@ var SignalWire = class extends Destroyable {
22077
22346
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22078
22347
  throw error;
22079
22348
  }
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()");
22349
+ logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
22350
+ let failureCause = error;
22351
+ if (await this.recoverStaleCredential(isRecoverableAuthError(error))) try {
22352
+ logger$1.debug("[SignalWire] Recovery succeeded, retrying register()");
22084
22353
  await this._transport.execute(RPCExecute({
22085
22354
  method: "subscriber.online",
22086
22355
  params: {}
22087
22356
  }));
22088
22357
  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;
22358
+ return;
22359
+ } catch (retryError) {
22360
+ logger$1.error("[SignalWire] Register retry after recovery failed:", retryError);
22361
+ failureCause = retryError;
22094
22362
  }
22363
+ 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 }) });
22364
+ this._errors$.next(registerError);
22365
+ throw registerError;
22095
22366
  }
22096
22367
  }
22097
22368
  /**
@@ -22144,7 +22415,15 @@ var SignalWire = class extends Destroyable {
22144
22415
  };
22145
22416
  await this.waitAuthentication();
22146
22417
  logger$1.debug("[SignalWire] Dialing with options:", computed_options);
22147
- return this._clientSession.createOutboundCall(destination, computed_options);
22418
+ try {
22419
+ return await this._clientSession.createOutboundCall(destination, computed_options);
22420
+ } catch (error) {
22421
+ if (!isRecoverableAuthError(error)) throw error;
22422
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering credential and retrying once");
22423
+ if (!await this.recoverStaleCredential()) throw error;
22424
+ await this.waitAuthentication();
22425
+ return this._clientSession.createOutboundCall(destination, computed_options);
22426
+ }
22148
22427
  }
22149
22428
  /**
22150
22429
  * Runs a multi-phase connectivity test against the given destination.