@signalwire/js 4.0.0-dev-20260826210511 → 4.0.0-dev-20260908172838

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
@@ -12374,15 +12374,57 @@ const RPCEventAckResponse = (id) => makeRPCResponse({
12374
12374
  result: {}
12375
12375
  });
12376
12376
 
12377
+ //#endregion
12378
+ //#region src/utils/authRecovery.ts
12379
+ /**
12380
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
12381
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
12382
+ * underlying signaling error is nested. Bounded by a visited set to guard
12383
+ * against cyclic causes.
12384
+ */
12385
+ function findJSONRPCError(error) {
12386
+ const seen = /* @__PURE__ */ new Set();
12387
+ let current = error;
12388
+ while (current instanceof Error && !seen.has(current)) {
12389
+ seen.add(current);
12390
+ if (current instanceof JSONRPCError) return current;
12391
+ current = current.error ?? current.cause;
12392
+ }
12393
+ }
12394
+ /**
12395
+ * Whether an error is a session-recoverable authentication failure
12396
+ * (`-32002` authentication failed or `-32003` requester validation failed)
12397
+ * that a credential re-mint + retry can heal.
12398
+ */
12399
+ function isRecoverableAuthError(error) {
12400
+ const rpcError = findJSONRPCError(error);
12401
+ return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
12402
+ }
12403
+ /**
12404
+ * Whether an error is specifically a requester-validation rejection
12405
+ * (`-32003`) — the server refusing the session's credential.
12406
+ *
12407
+ * Narrower than {@link isRecoverableAuthError} on purpose. `-32002` is
12408
+ * overloaded server-side: a rejected reattach arrives as `-32002` with
12409
+ * `cause: INVALID_MSG_UNSPECIFIED` and message `CALL ERROR`, which is a
12410
+ * call-level rejection and says nothing about the credential. Use this where
12411
+ * the decision must not be fooled by that, such as deciding whether retrying
12412
+ * an operation could possibly succeed.
12413
+ */
12414
+ function isRequesterValidationError(error) {
12415
+ return findJSONRPCError(error)?.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED;
12416
+ }
12417
+
12377
12418
  //#endregion
12378
12419
  //#region src/managers/AttachManager.ts
12379
12420
  const logger$25 = getLogger();
12380
12421
  var AttachManager = class {
12381
- constructor(storage, deviceController, reconnectCallsTimeout, attachKey) {
12422
+ constructor(storage, deviceController, reconnectCallsTimeout, attachKey, credentialRecovered) {
12382
12423
  this.storage = storage;
12383
12424
  this.deviceController = deviceController;
12384
12425
  this.reconnectCallsTimeout = reconnectCallsTimeout;
12385
12426
  this.attachKey = attachKey;
12427
+ this.credentialRecovered = credentialRecovered;
12386
12428
  this.writeQueue = Promise.resolve();
12387
12429
  }
12388
12430
  async detachAll() {
@@ -12462,8 +12504,14 @@ var AttachManager = class {
12462
12504
  * rejecting. Once that fix is deployed, this will work for both
12463
12505
  * page reloads and WebSocket reconnects.
12464
12506
  *
12465
- * Failed reattach attempts are handled gracefully — the stale call
12466
- * reference is cleaned up from storage.
12507
+ * A failed reattach does NOT generally cost the stored reference. It is
12508
+ * discarded only when the server denied the reattach on a session whose
12509
+ * credential it had already accepted — a verified reauthentication followed
12510
+ * by a refusal is the server saying the call is gone, and that is the one
12511
+ * refusal worth acting on. Until then the credential may be what is being
12512
+ * refused, and the record is the only way a later reload can try again;
12513
+ * keeping it costs nothing, since `detachExpired` reaps it once it is older
12514
+ * than `reconnectCallsTimeout`.
12467
12515
  */
12468
12516
  async reattachCalls() {
12469
12517
  const attached = await this.readAttached();
@@ -12472,6 +12520,7 @@ var AttachManager = class {
12472
12520
  const { destination } = attachment;
12473
12521
  const options = this.buildCallOptions(attachment);
12474
12522
  let succeeded = false;
12523
+ let refusedOnCredentials = false;
12475
12524
  for (let attempt = 1; attempt <= 3; attempt++) try {
12476
12525
  await this.session.createOutboundCall(destination, {
12477
12526
  callId,
@@ -12482,15 +12531,20 @@ var AttachManager = class {
12482
12531
  break;
12483
12532
  } catch (error) {
12484
12533
  logger$25.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
12534
+ if (isRequesterValidationError(error)) {
12535
+ refusedOnCredentials = true;
12536
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was refused on credentials; not retrying.`);
12537
+ break;
12538
+ }
12485
12539
  if (attempt < 3) await new Promise((r) => setTimeout(r, (attempt + 1) * 1e3));
12486
12540
  }
12487
- if (!succeeded) {
12488
- logger$25.warn(`[AttachManager] Reattach failed after 3 attempts for call ${callId}, removing reference`);
12541
+ if (!succeeded) if (this.credentialRecovered() && !refusedOnCredentials) {
12542
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was denied after a verified reauthentication, removing reference`);
12489
12543
  await this.detach({
12490
12544
  id: callId,
12491
12545
  mediaDirections: attachment.mediaDirections
12492
12546
  });
12493
- }
12547
+ } else logger$25.warn(`[AttachManager] Reattach failed for call ${callId}; keeping the reference (credential refused or never proven good)`);
12494
12548
  }
12495
12549
  }
12496
12550
  /**
@@ -14508,33 +14562,6 @@ function throwOnRPCError() {
14508
14562
  });
14509
14563
  }
14510
14564
 
14511
- //#endregion
14512
- //#region src/utils/unwrapVertoReply.ts
14513
- /**
14514
- * Unwrap a method's own reply from a `webrtc.verto` envelope.
14515
- *
14516
- * A control verb sent in-dialog comes back nested two levels deep:
14517
- *
14518
- * ```
14519
- * { result: { node_id, code, result: { jsonrpc, id, result: <method payload> } } }
14520
- * ```
14521
- *
14522
- * whereas the routed transport resolves the method payload directly under
14523
- * `.result`. Readers want the latter shape, and the difference is silent when it
14524
- * is wrong — `response.result.layouts` simply evaluates to `undefined` against the
14525
- * envelope, so the data arrives, nothing throws, and the caller sees an empty
14526
- * value. That exact failure produced an empty layout dropdown with a successful
14527
- * request behind it.
14528
- *
14529
- * Accepting both shapes here keeps every reader indifferent to which transport
14530
- * produced the response. Anything that is not a nested envelope (a plain ack, or
14531
- * an already-unwrapped reply) passes through untouched.
14532
- */
14533
- function unwrapVertoReply(response) {
14534
- const inner = getValueFrom(response, "result.result");
14535
- return inner && typeof inner === "object" && "result" in inner ? inner : response;
14536
- }
14537
-
14538
14565
  //#endregion
14539
14566
  //#region src/managers/CallEventsManager.ts
14540
14567
  var import_cjs$20 = require_cjs();
@@ -14727,10 +14754,14 @@ var CallEventsManager = class extends Destroyable {
14727
14754
  updateLayouts() {
14728
14755
  if (!this.selfId) return;
14729
14756
  this.webRtcCallSession.executeMethod(this.selfId, "call.layout.list", {}).then((response) => {
14730
- const { result } = unwrapVertoReply(response);
14757
+ const layouts = response.result?.layouts;
14758
+ if (!layouts) {
14759
+ logger$22.warn("[CallEventsManager] Layout list response carried no layouts; keeping current layouts");
14760
+ return;
14761
+ }
14731
14762
  this._sessionState$.next({
14732
14763
  ...this._sessionState$.value,
14733
- layouts: result.layouts
14764
+ layouts
14734
14765
  });
14735
14766
  }).catch((error) => {
14736
14767
  logger$22.error("[CallEventsManager] Error fetching layouts:", error);
@@ -17125,7 +17156,7 @@ var WebRTCVertoManager = class extends VertoManager {
17125
17156
  }
17126
17157
  }));
17127
17158
  const failure = findNestedVertoFailure(response);
17128
- if (failure) throw new JSONRPCError(Number.parseInt(failure.code, 10) || 0, failure.message ?? `Call control "${method}" failed (code ${failure.code})`, void 0);
17159
+ if (failure) throw new JSONRPCError(Number.parseInt(failure.code, 10) || 0, `Call control "${method}" failed (code ${failure.code})${failure.message ? `: ${failure.message}` : ""}`, void 0);
17129
17160
  return response;
17130
17161
  }
17131
17162
  async executeVerto(message, optionals = {}) {
@@ -18433,6 +18464,33 @@ function mosToQualityLevel(mos) {
18433
18464
  return "critical";
18434
18465
  }
18435
18466
 
18467
+ //#endregion
18468
+ //#region src/utils/unwrapVertoReply.ts
18469
+ /**
18470
+ * Unwrap a method's own reply from a `webrtc.verto` envelope.
18471
+ *
18472
+ * A control verb sent in-dialog comes back nested two levels deep:
18473
+ *
18474
+ * ```
18475
+ * { result: { node_id, code, result: { jsonrpc, id, result: <method payload> } } }
18476
+ * ```
18477
+ *
18478
+ * whereas the routed transport resolves the method payload directly under
18479
+ * `.result`. Readers want the latter shape, and the difference is silent when it
18480
+ * is wrong — `response.result.layouts` simply evaluates to `undefined` against the
18481
+ * envelope, so the data arrives, nothing throws, and the caller sees an empty
18482
+ * value. That exact failure produced an empty layout dropdown with a successful
18483
+ * request behind it.
18484
+ *
18485
+ * Accepting both shapes here keeps every reader indifferent to which transport
18486
+ * produced the response. Anything that is not a nested envelope (a plain ack, or
18487
+ * an already-unwrapped reply) passes through untouched.
18488
+ */
18489
+ function unwrapVertoReply(response) {
18490
+ const inner = getValueFrom(response, "result.result");
18491
+ return inner && typeof inner === "object" && "result" in inner ? inner : response;
18492
+ }
18493
+
18436
18494
  //#endregion
18437
18495
  //#region src/core/entities/Call.ts
18438
18496
  var import_cjs$11 = require_cjs();
@@ -20283,17 +20341,14 @@ var ClientSessionManager = class extends Destroyable {
20283
20341
  const hasStoredState = await (0, import_cjs$7.firstValueFrom)(this.authorizationState$.pipe((0, import_cjs$7.take)(1))) !== void 0;
20284
20342
  if (isRecoverableAuthError$1 && hasStoredState) {
20285
20343
  logger$9.debug("[Session] Recoverable auth error — cleaning up stored state and reconnecting fresh");
20286
- try {
20287
- await this.cleanupStoredConnectionParams();
20288
- } catch (cleanupError) {
20289
- logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20290
- } finally {
20291
- this.transport.reconnect();
20292
- }
20344
+ await this.discardResumeStateAndReconnect();
20293
20345
  } else this._errors$.next(error);
20294
20346
  }
20295
20347
  /**
20296
- * Clear the resume state (authorization_state + protocol) only.
20348
+ * Clear the resume state (authorization_state + protocol) and ask the
20349
+ * transport to reconnect. The `connected` event re-triggers
20350
+ * `authenticate()`, which now has no stored state and so performs a fresh
20351
+ * connect.
20297
20352
  *
20298
20353
  * This is the stale-auth-state recovery helper used by handleAuthError:
20299
20354
  * the server rejected a reconnect, so the resume state is discarded and a
@@ -20301,9 +20356,24 @@ var ClientSessionManager = class extends Destroyable {
20301
20356
  * session lives on through the reconnect and reattachCalls() needs the
20302
20357
  * stored call references afterwards. Do NOT add detachAll() here.
20303
20358
  *
20359
+ * Connect-time recovery only. A *request* refused on an already
20360
+ * authenticated session is never healed here: dropping the resume state
20361
+ * destroys the association between the socket and the previous session,
20362
+ * which is what reattach depends on. That path mints a fresh credential and
20363
+ * reauthenticates instead (see `SignalWire.recoverAndRetry`).
20364
+ *
20304
20365
  * For public teardown (disconnect/destroy), use {@link teardownSessionState}
20305
20366
  * instead, which clears the attach records as well.
20306
20367
  */
20368
+ async discardResumeStateAndReconnect() {
20369
+ try {
20370
+ await this.cleanupStoredConnectionParams();
20371
+ } catch (cleanupError) {
20372
+ logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20373
+ } finally {
20374
+ this.transport.reconnect();
20375
+ }
20376
+ }
20307
20377
  async cleanupStoredConnectionParams() {
20308
20378
  await this.transport.setProtocol(void 0);
20309
20379
  await this.updateAuthorizationStateInStorage(void 0);
@@ -21875,37 +21945,17 @@ var TransportManager = class extends Destroyable {
21875
21945
  }
21876
21946
  };
21877
21947
 
21878
- //#endregion
21879
- //#region src/utils/authRecovery.ts
21880
- /**
21881
- * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
21882
- * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
21883
- * underlying signaling error is nested. Bounded by a visited set to guard
21884
- * against cyclic causes.
21885
- */
21886
- function findJSONRPCError(error) {
21887
- const seen = /* @__PURE__ */ new Set();
21888
- let current = error;
21889
- while (current instanceof Error && !seen.has(current)) {
21890
- seen.add(current);
21891
- if (current instanceof JSONRPCError) return current;
21892
- current = current.error ?? current.cause;
21893
- }
21894
- }
21895
- /**
21896
- * Whether an error is a session-recoverable authentication failure
21897
- * (`-32002` authentication failed or `-32003` requester validation failed)
21898
- * that a credential re-mint + retry can heal.
21899
- */
21900
- function isRecoverableAuthError(error) {
21901
- const rpcError = findJSONRPCError(error);
21902
- return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
21903
- }
21904
-
21905
21948
  //#endregion
21906
21949
  //#region src/clients/SignalWire.ts
21907
21950
  var import_cjs$1 = require_cjs();
21908
21951
  const logger$1 = getLogger();
21952
+ /**
21953
+ * Storage key for the client-bound marker. The SAT and authorization_state are
21954
+ * both opaque to the SDK, so on a page reload the preflight recovery — which
21955
+ * runs before any session exists — has no other way to know the session was
21956
+ * client-bound. See {@link SignalWire.persistClientBoundMarker}.
21957
+ */
21958
+ const CLIENT_BOUND_STORAGE_KEY = "sw:client_bound";
21909
21959
  const buildOptionsFromDestination = (destination) => {
21910
21960
  if (typeof destination === "string") {
21911
21961
  const queryStartIndex = destination.indexOf("?");
@@ -21949,6 +21999,7 @@ var SignalWire = class extends Destroyable {
21949
21999
  this.preferences = new ClientPreferences();
21950
22000
  this._user$ = this.createBehaviorSubject(void 0);
21951
22001
  this._directory$ = this.createBehaviorSubject(void 0);
22002
+ this._credentialRecovered = false;
21952
22003
  this._isConnected$ = this.createBehaviorSubject(false);
21953
22004
  this._isRegistered$ = this.createBehaviorSubject(false);
21954
22005
  this._errors$ = this.createReplaySubject(1);
@@ -22114,43 +22165,76 @@ var SignalWire = class extends Destroyable {
22114
22165
  }
22115
22166
  }
22116
22167
  /**
22117
- * One-shot recovery for a live session that failed with a recoverable auth
22118
- * error (`-32002`/`-32003`) because its token went stale — e.g. a refresh
22119
- * timer that was throttled while the tab was backgrounded.
22168
+ * Recover a session the server is refusing: mint a fresh credential,
22169
+ * reauthenticate the live session with it, and retry the operation.
22120
22170
  *
22121
- * Two steps, first that succeeds wins:
22122
- * 1. Reauthenticate with the in-memory token (a fresh DPoP proof is
22123
- * generated automatically). Heals a session whose token was already
22124
- * refreshed by the coordinator but never applied to the open socket,
22125
- * and a client-bound session whose server-side auth merely drifted.
22126
- * 2. Re-mint via the developer provider's `refresh()` and reauthenticate.
22127
- * Skipped for client-bound sessions (the DeviceTokenManager owns their
22128
- * refresh; re-minting a base SAT here would drop the DPoP binding).
22171
+ * The connection is deliberately kept. A reload authenticates the new socket
22172
+ * against the persisted `authorization_state`, and that handshake is what
22173
+ * associates the socket with the previous session — the association reattach
22174
+ * depends on. `signalwire.reauthenticate` swaps the credential *on that same
22175
+ * session*, so recovery never touches the resume state. Discarding it would
22176
+ * heal the credential by destroying the very thing the caller is trying to
22177
+ * get back to.
22129
22178
  *
22130
- * @param allowRemint - Whether step 2 (provider re-mint) may run. Callers
22131
- * pass `false` for non-auth failures so a transient network error never
22132
- * escalates to a token re-mint; step 1 (in-memory reauth) always runs.
22133
- * @returns `true` if the session was reauthenticated, `false` otherwise.
22179
+ * The operation is still the verdict, never the RPC. Reauthenticating with
22180
+ * the in-memory token is accepted by a resume even while requests stay
22181
+ * refused, because the persisted `authorization_state` short-circuits token
22182
+ * validation — and `signalwire.reauthenticate` with a *freshly minted* token
22183
+ * has also been observed accepted while `subscriber.online` keeps being
22184
+ * refused (staging run 33826974634). Both look like success and are not.
22185
+ *
22186
+ * @returns the operation's value, or the reason recovery could not deliver
22187
+ * one. `error` is undefined when there was no way to mint at all.
22134
22188
  */
22135
- async recoverStaleCredential(allowRemint = true) {
22136
- const currentToken = this._deps.credential.token;
22137
- if (currentToken) try {
22138
- await this._clientSession.reauthenticate(currentToken);
22139
- return true;
22189
+ async recoverAndRetry(operation) {
22190
+ if (!await this.remintAndReauthenticate()) return { ok: false };
22191
+ try {
22192
+ const value = await operation();
22193
+ this._credentialRecovered = true;
22194
+ return {
22195
+ ok: true,
22196
+ value
22197
+ };
22140
22198
  } catch (error) {
22141
- logger$1.debug("[SignalWire] In-memory reauth failed during recovery:", error);
22199
+ logger$1.warn("[SignalWire] Reauthentication was accepted but the operation is still refused:", error);
22200
+ return {
22201
+ ok: false,
22202
+ error
22203
+ };
22204
+ }
22205
+ }
22206
+ /**
22207
+ * Re-mint a credential and adopt it only if the live session accepts it.
22208
+ *
22209
+ * The mechanism follows the binding: a client-bound session re-mints a bound
22210
+ * base SAT through `authenticate()` with the DPoP fingerprint, because the
22211
+ * developer refresh handler would hand back an unbound token and silently
22212
+ * degrade the session. An unbound session uses the refresh handler. Rotation
22213
+ * cost is not a reason to skip this — the only reason is having no mechanism.
22214
+ *
22215
+ * @returns whether the session is now running on a freshly accepted credential.
22216
+ */
22217
+ async remintAndReauthenticate() {
22218
+ const provider = this._credentialProvider;
22219
+ if (!provider) return false;
22220
+ const { clientBound } = this._clientSession;
22221
+ if (!clientBound && !provider.refresh) {
22222
+ logger$1.debug("[SignalWire] [SW-NO-REFRESH-HANDLER] Unbound session with no refresh handler; cannot re-mint.");
22223
+ return false;
22142
22224
  }
22143
- if (!allowRemint || this._clientSession.clientBound || !this._credentialProvider?.refresh) return false;
22144
22225
  try {
22145
- const newCredentials = await this.remintCredential(this._credentialProvider);
22146
- if (!newCredentials.token) return false;
22226
+ const newCredentials = clientBound ? await provider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0) : await this.remintCredential(provider);
22227
+ if (!newCredentials.token) {
22228
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the current one.");
22229
+ return false;
22230
+ }
22231
+ await this._clientSession.reauthenticate(newCredentials.token);
22147
22232
  this._deps.credential = newCredentials;
22148
22233
  this.persistCredential(newCredentials);
22149
- await this._clientSession.reauthenticate(newCredentials.token);
22150
- if (newCredentials.expiry_at) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
22234
+ if (newCredentials.expiry_at && provider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(provider, newCredentials.expiry_at);
22151
22235
  return true;
22152
22236
  } catch (error) {
22153
- logger$1.error("[SignalWire] Credential recovery failed:", error);
22237
+ logger$1.warn("[SignalWire] Re-mint recovery failed:", error);
22154
22238
  return false;
22155
22239
  }
22156
22240
  }
@@ -22185,10 +22269,9 @@ var SignalWire = class extends Destroyable {
22185
22269
  if (!this._credentialProvider) return;
22186
22270
  try {
22187
22271
  let newCredentials;
22188
- if (this._clientSession.clientBound) {
22189
- const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
22272
+ if (this._clientSession?.clientBound ?? await this.wasClientBound()) {
22190
22273
  logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
22191
- newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
22274
+ newCredentials = await this._credentialProvider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0);
22192
22275
  } else if (this._credentialProvider.refresh) {
22193
22276
  logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
22194
22277
  newCredentials = await this.remintCredential(this._credentialProvider);
@@ -22216,6 +22299,26 @@ var SignalWire = class extends Destroyable {
22216
22299
  this._deps.storage.setItem("sw:cached_credential", credential);
22217
22300
  if (this._deps.persistSession) this._deps.storage.setItem("sw:cached_credential", credential, "local");
22218
22301
  }
22302
+ /**
22303
+ * Persist whether the session is client-bound, mirroring the credential's
22304
+ * storage scopes so it survives a reload. The preflight recovery reads it
22305
+ * before any session exists to decide whether to re-bind via `authenticate()`
22306
+ * or refresh an unbound token; the marker tracks the latest binding, so an
22307
+ * unbound reconnect clears a stale marker from an earlier client-bound login.
22308
+ */
22309
+ persistClientBoundMarker(bound) {
22310
+ const scopes = this._deps.persistSession ? ["session", "local"] : ["session"];
22311
+ for (const scope of scopes) if (bound) this._deps.storage.setItem(CLIENT_BOUND_STORAGE_KEY, true, scope);
22312
+ else this._deps.storage.removeItem(CLIENT_BOUND_STORAGE_KEY, scope);
22313
+ }
22314
+ /** Read the persisted client-bound marker (see {@link persistClientBoundMarker}). */
22315
+ async wasClientBound() {
22316
+ const scopes = this._deps.persistSession ? ["local", "session"] : ["session"];
22317
+ for (const scope of scopes) try {
22318
+ if (await this._deps.storage.getItem(CLIENT_BOUND_STORAGE_KEY, scope)) return true;
22319
+ } catch {}
22320
+ return false;
22321
+ }
22219
22322
  async init() {
22220
22323
  this._user$.next(new User(this._deps.http));
22221
22324
  if (!this._options.skipConnection) await this.connect();
@@ -22239,6 +22342,42 @@ var SignalWire = class extends Destroyable {
22239
22342
  } catch (error) {
22240
22343
  logger$1.error("[SignalWire] Failed to reattach calls:", error);
22241
22344
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22345
+ } finally {
22346
+ this._credentialRecovered = false;
22347
+ }
22348
+ }
22349
+ /**
22350
+ * Fetch the authenticated user profile, recovering a stale credential.
22351
+ *
22352
+ * On a reload the persisted credential can be expired. Unlike the WS resume —
22353
+ * which the server accepts against the persisted `authorization_state` even
22354
+ * with an expired token — this REST preflight has no such short-circuit and is
22355
+ * refused (401). There is no session yet to reauthenticate, so recovery
22356
+ * re-mints the credential through the provider ({@link refreshCredentialForReconnect})
22357
+ * and retries with a FRESH {@link User}: Fetchable memoizes its result
22358
+ * (shareReplay), so reusing the instance would replay the 401 instead of
22359
+ * re-fetching with the new token. Without the user id the transport/session —
22360
+ * and the reattach a reload is trying to preserve — cannot even be addressed.
22361
+ */
22362
+ async fetchUserOrRecover() {
22363
+ const fetchUser = async (user$1) => {
22364
+ if (!await (0, import_cjs$1.firstValueFrom)(user$1.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
22365
+ this._deps.user = user$1;
22366
+ };
22367
+ const user = this._user$.value;
22368
+ if (!user) throw new UnexpectedError("User not initialized before connect");
22369
+ try {
22370
+ await fetchUser(user);
22371
+ } catch (firstError) {
22372
+ logger$1.error(`[SignalWire] Failed to fetch user information: ${firstError instanceof Error ? firstError.message : "Unknown error"}. This usually means the user token is invalid or expired. Re-minting the credential and retrying.`);
22373
+ try {
22374
+ await this.refreshCredentialForReconnect();
22375
+ const refetched = new User(this._deps.http);
22376
+ await fetchUser(refetched);
22377
+ this._user$.next(refetched);
22378
+ } catch (retryError) {
22379
+ throw new UnexpectedError("Error fetching user information", { cause: retryError });
22380
+ }
22242
22381
  }
22243
22382
  }
22244
22383
  /**
@@ -22280,20 +22419,12 @@ var SignalWire = class extends Destroyable {
22280
22419
  */
22281
22420
  async connect() {
22282
22421
  await this.teardownTransportAndSession();
22283
- try {
22284
- const user = this._user$.value;
22285
- if (!user) throw new UnexpectedError("User not initialized before connect");
22286
- if (!await (0, import_cjs$1.firstValueFrom)(user.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
22287
- this._deps.user = user;
22288
- } catch (error) {
22289
- logger$1.error(`[SignalWire] Failed to fetch user information: ${error instanceof Error ? error.message : "Unknown error"}. This usually means the user token is invalid or expired.`);
22290
- throw new UnexpectedError("Error fetching user information", { cause: error });
22291
- }
22422
+ await this.fetchUserOrRecover();
22292
22423
  const errorHandler = (error) => {
22293
22424
  this._errors$.next(error);
22294
22425
  };
22295
22426
  this._transport = new TransportManager(this._deps.storage, this._deps.protocolKey, this._deps.WebSocket, PreferencesContainer.instance.relayHost ?? this._deps.relayHost, errorHandler);
22296
- this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
22427
+ this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey, () => this._credentialRecovered);
22297
22428
  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$);
22298
22429
  this._publicSession = new ClientSessionWrapper(this._clientSession);
22299
22430
  this._clientSession.callControl = this._options.callControl ?? "routed";
@@ -22303,6 +22434,7 @@ var SignalWire = class extends Destroyable {
22303
22434
  });
22304
22435
  this.subscribeTo(this._clientSession.authorization$, (authorization) => {
22305
22436
  this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
22437
+ this.persistClientBoundMarker(Boolean(authorization?.cnf?.jkt));
22306
22438
  });
22307
22439
  await this._clientSession.connect();
22308
22440
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
@@ -22556,19 +22688,18 @@ var SignalWire = class extends Destroyable {
22556
22688
  throw error;
22557
22689
  }
22558
22690
  logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
22559
- let failureCause = error;
22560
- if (await this.recoverStaleCredential(isRecoverableAuthError(error))) try {
22561
- logger$1.debug("[SignalWire] Recovery succeeded, retrying register()");
22691
+ const outcome = await this.recoverAndRetry(async () => {
22562
22692
  await this._transport.execute(RPCExecute({
22563
22693
  method: "subscriber.online",
22564
22694
  params: {}
22565
22695
  }));
22696
+ });
22697
+ if (outcome.ok) {
22698
+ logger$1.debug("[SignalWire] Recovery restored registration");
22566
22699
  this._isRegistered$.next(true);
22567
22700
  return;
22568
- } catch (retryError) {
22569
- logger$1.error("[SignalWire] Register retry after recovery failed:", retryError);
22570
- failureCause = retryError;
22571
22701
  }
22702
+ const failureCause = outcome.error ?? error;
22572
22703
  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 }) });
22573
22704
  this._errors$.next(registerError);
22574
22705
  throw registerError;
@@ -22628,10 +22759,13 @@ var SignalWire = class extends Destroyable {
22628
22759
  return await this._clientSession.createOutboundCall(destination, computed_options);
22629
22760
  } catch (error) {
22630
22761
  if (!isRecoverableAuthError(error)) throw error;
22631
- logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering credential and retrying once");
22632
- if (!await this.recoverStaleCredential()) throw error;
22633
- await this.waitAuthentication();
22634
- return this._clientSession.createOutboundCall(destination, computed_options);
22762
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering the session and retrying");
22763
+ const outcome = await this.recoverAndRetry(async () => {
22764
+ await this.waitAuthentication();
22765
+ return this._clientSession.createOutboundCall(destination, computed_options);
22766
+ });
22767
+ if (outcome.ok) return outcome.value;
22768
+ throw outcome.error ?? error;
22635
22769
  }
22636
22770
  }
22637
22771
  /**