@bounded-sh/core 0.0.58 → 0.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/{index-B30-XpaX.js → index-BIdD2yUB.js} +957 -312
  2. package/dist/index-BIdD2yUB.js.map +1 -0
  3. package/dist/{index-DYbzK8YT.mjs → index-BQ6e4MeP.mjs} +952 -313
  4. package/dist/index-BQ6e4MeP.mjs.map +1 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.js +7 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1 -1
  9. package/dist/{realtime-store-BpQsQYhl.js → realtime-store-Bz4Ob6K6.js} +2 -2
  10. package/dist/{realtime-store-BpQsQYhl.js.map → realtime-store-Bz4Ob6K6.js.map} +1 -1
  11. package/dist/{realtime-store-BSuV-Vyr.mjs → realtime-store-DKngqviG.mjs} +2 -2
  12. package/dist/{realtime-store-BSuV-Vyr.mjs.map → realtime-store-DKngqviG.mjs.map} +1 -1
  13. package/dist/{solana-message-DKOPPJwk.js → solana-message-B0NoE4nC.js} +2 -2
  14. package/dist/{solana-message-DKOPPJwk.js.map → solana-message-B0NoE4nC.js.map} +1 -1
  15. package/dist/{solana-message-C2_VvNX7.mjs → solana-message-QUfJi1Qm.mjs} +2 -2
  16. package/dist/{solana-message-C2_VvNX7.mjs.map → solana-message-QUfJi1Qm.mjs.map} +1 -1
  17. package/dist/{solana-operations-CuYFYlyN.mjs → solana-operations-B2Oy9T7D.mjs} +2 -2
  18. package/dist/{solana-operations-CuYFYlyN.mjs.map → solana-operations-B2Oy9T7D.mjs.map} +1 -1
  19. package/dist/{solana-operations-BOKHbEuy.js → solana-operations-ngqIkN9W.js} +2 -2
  20. package/dist/{solana-operations-BOKHbEuy.js.map → solana-operations-ngqIkN9W.js.map} +1 -1
  21. package/dist/utils/auth-token-freshness.d.ts +79 -0
  22. package/dist/utils/rn-session-manager.d.ts +19 -2
  23. package/dist/utils/session-events.d.ts +31 -0
  24. package/dist/utils/session-mutation.d.ts +124 -0
  25. package/dist/utils/utils.d.ts +18 -1
  26. package/dist/utils/web-session-manager.d.ts +43 -2
  27. package/package.json +2 -2
  28. package/dist/index-B30-XpaX.js.map +0 -1
  29. package/dist/index-DYbzK8YT.mjs.map +0 -1
@@ -2909,19 +2909,19 @@ async function createSessionWithSignature(address, message, signature, authMetho
2909
2909
  // Note: module-level state is shared across requests in SSR/Node — acceptable
2910
2910
  // since there is only one active session per server instance (same pattern as
2911
2911
  // refreshInFlight in api.ts).
2912
- let refreshInFlight$1 = null;
2912
+ let refreshInFlight = null;
2913
2913
  let refreshInFlightToken = null;
2914
2914
  async function refreshSession(refreshToken, issuer) {
2915
2915
  // Dedupe per (issuer, token): the rotating token is single-use, so two
2916
2916
  // concurrent refreshes of the SAME token must share one in-flight request or
2917
2917
  // the second would look like reuse and revoke the family.
2918
2918
  const dedupeKey = `${issuer || ''}::${refreshToken}`;
2919
- if (refreshInFlight$1 && refreshInFlightToken === dedupeKey) {
2920
- return refreshInFlight$1;
2919
+ if (refreshInFlight && refreshInFlightToken === dedupeKey) {
2920
+ return refreshInFlight;
2921
2921
  }
2922
2922
  refreshInFlightToken = dedupeKey;
2923
2923
  let currentFlight;
2924
- currentFlight = refreshInFlight$1 = (async () => {
2924
+ currentFlight = refreshInFlight = (async () => {
2925
2925
  try {
2926
2926
  const config = await getConfig();
2927
2927
  const appId = config.appId;
@@ -2938,13 +2938,13 @@ async function refreshSession(refreshToken, issuer) {
2938
2938
  finally {
2939
2939
  // Only clear if we're still the active in-flight request.
2940
2940
  // A second call with a different token may have replaced us.
2941
- if (refreshInFlight$1 === currentFlight) {
2942
- refreshInFlight$1 = null;
2941
+ if (refreshInFlight === currentFlight) {
2942
+ refreshInFlight = null;
2943
2943
  refreshInFlightToken = null;
2944
2944
  }
2945
2945
  }
2946
2946
  })();
2947
- return refreshInFlight$1;
2947
+ return refreshInFlight;
2948
2948
  }
2949
2949
  class SessionRevokeError extends Error {
2950
2950
  constructor(message, cause) {
@@ -3029,7 +3029,248 @@ function refreshedTokenMayReplaceStored(newIdToken, storedAddress, decodeBase64U
3029
3029
  return tokenWallet === storedAddress;
3030
3030
  }
3031
3031
 
3032
+ /**
3033
+ * The one signal core sends upward when a stored session stops existing.
3034
+ *
3035
+ * `tarobase-core` cannot import `js-sdk` (that would be a cycle), and core has no
3036
+ * event bus, so a session dying inside a session manager used to be invisible to
3037
+ * the SDK: `setCurrentUser(null)` was only ever called by `logout()` and `init()`.
3038
+ * The result was a UI that kept showing a connected wallet long after the session
3039
+ * it was showing had been wiped, which is exactly what let the Solana lab freeze a
3040
+ * review against a wallet the server no longer knew about.
3041
+ *
3042
+ * `js-sdk` registers a handler at init; core fires it ONLY when storage was
3043
+ * actually cleared, never on a failed-but-preserved refresh. Firing on a
3044
+ * preserved session would log people out on a transient network blip.
3045
+ */
3046
+ let handlers = [];
3047
+ /**
3048
+ * Register a listener for "the stored session is gone".
3049
+ * Returns an unsubscribe function.
3050
+ */
3051
+ function onSessionCleared(handler) {
3052
+ handlers.push(handler);
3053
+ return () => {
3054
+ handlers = handlers.filter((h) => h !== handler);
3055
+ };
3056
+ }
3057
+ /**
3058
+ * Announce that session storage was cleared.
3059
+ *
3060
+ * Call this ONLY after a removal actually happened. Handlers are best-effort: a
3061
+ * throwing listener must not turn a successful clear into a failed one, because
3062
+ * the storage mutation has already committed by this point.
3063
+ */
3064
+ function notifySessionCleared() {
3065
+ for (const handler of handlers) {
3066
+ try {
3067
+ handler();
3068
+ }
3069
+ catch (_a) {
3070
+ // A listener's failure cannot un-clear storage; keep notifying the rest.
3071
+ }
3072
+ }
3073
+ }
3074
+
3075
+ /**
3076
+ * The one protocol every session-storage mutation goes through.
3077
+ *
3078
+ * WHY THIS EXISTS
3079
+ *
3080
+ * `localStorage` is shared by every tab on an origin but offers no atomic
3081
+ * compare-and-set spanning a read and a write, and each tab is a separate JS
3082
+ * realm, so an in-memory guard cannot serialize them. The old code relied on
3083
+ * doing its read → check → write inside one synchronous turn, which is only
3084
+ * atomic *within* a realm.
3085
+ *
3086
+ * That is not a theoretical gap. `REFRESH_REUSE_GRACE_MS` on the issuer is 60s,
3087
+ * so two tabs presenting the same refresh token inside that window BOTH succeed
3088
+ * and BOTH receive the same successor refresh token — while each response mints
3089
+ * its own distinct idToken. Concurrent cross-tab commits are a designed-for
3090
+ * occurrence, not an edge case.
3091
+ *
3092
+ * Two failures follow from an unlocked read-then-write, and both are closed here:
3093
+ *
3094
+ * 1. Lost update / resurrection. Tab A reads, tab B logs in (or logs out), tab A
3095
+ * writes its stale blob back. B's login is silently replaced, or a logged-out
3096
+ * session comes back to life.
3097
+ * 2. Spurious "the user changed". Tab B's commit finds tab A's blob, which
3098
+ * matches neither B's snapshot nor B's own result because the idTokens
3099
+ * differ — even though it is the same user and the same login.
3100
+ *
3101
+ * THE CRITICAL SECTION IS THE COMMIT, NOT THE NETWORK CALL
3102
+ *
3103
+ * The lock covers read → compare → write only. The refresh POST happens outside
3104
+ * it: holding a cross-tab lock across a 30s network call would serialize every
3105
+ * tab behind the slowest one. This is safe because the snapshot captured before
3106
+ * the network call is used only for COMPARISON. What actually gets written is
3107
+ * decided against storage re-read inside the lock, so a login or logout that lands
3108
+ * during the network call is always observed.
3109
+ *
3110
+ * NO BEST-EFFORT FALLBACK
3111
+ *
3112
+ * A `localStorage` lease was considered and rejected: its own acquire sequence is
3113
+ * a non-atomic read-then-write across realms, and a backgrounded tab can be frozen
3114
+ * past any lease timeout and then resume and write. Both leave exactly the windows
3115
+ * this module exists to close, so a browser without Web Locks fails closed instead.
3116
+ * Absence of `navigator.locks` does NOT imply a single realm — such a browser still
3117
+ * has tabs. Single realm is determined by the absence of `localStorage`.
3118
+ */
3119
+ const SESSION_LOCK_NAME = "bounded_session_mutation";
3120
+ const UNSUPPORTED_ENVIRONMENT_CODE = "unsupported_environment";
3121
+ /**
3122
+ * Thrown when authenticated session work is attempted in a browser that shares
3123
+ * `localStorage` across tabs but cannot serialize access to it. Reads and
3124
+ * anonymous requests are unaffected; only session mutation is refused.
3125
+ */
3126
+ class SessionEnvironmentError extends Error {
3127
+ constructor(message) {
3128
+ super(message);
3129
+ this.code = UNSUPPORTED_ENVIRONMENT_CODE;
3130
+ this.name = "SessionEnvironmentError";
3131
+ }
3132
+ }
3133
+ /**
3134
+ * True when nothing outside this realm can touch session storage.
3135
+ *
3136
+ * Keyed on `localStorage`, not on `window`: React Native defines `window` but
3137
+ * stores sessions through a per-process StorageAdapter, and Node/SSR has neither.
3138
+ * A browser is the only multi-realm case, and `localStorage` is what makes it one.
3139
+ */
3140
+ function isSingleRealm() {
3141
+ return typeof localStorage === "undefined";
3142
+ }
3143
+ function hasWebLocks() {
3144
+ var _a;
3145
+ return typeof navigator !== "undefined"
3146
+ && typeof ((_a = navigator.locks) === null || _a === void 0 ? void 0 : _a.request) === "function";
3147
+ }
3148
+ /**
3149
+ * Run `fn` with exclusive access to session storage across every tab on this origin.
3150
+ *
3151
+ * Single-realm runtimes execute directly. Browsers serialize through Web Locks.
3152
+ * A browser without Web Locks throws rather than running unserialized.
3153
+ */
3154
+ async function withSessionLock(fn) {
3155
+ if (isSingleRealm()) {
3156
+ return await fn();
3157
+ }
3158
+ if (!hasWebLocks()) {
3159
+ throw new SessionEnvironmentError("This browser cannot safely coordinate a Bounded session across tabs " +
3160
+ "(the Web Locks API is unavailable). Authenticated operations are disabled; " +
3161
+ "anonymous reads still work.");
3162
+ }
3163
+ return await navigator.locks.request(SESSION_LOCK_NAME, { mode: "exclusive" }, async () => await fn());
3164
+ }
3165
+ /**
3166
+ * Opaque per-login identifier stamped into the stored session.
3167
+ *
3168
+ * Minted on every `storeSession` (login, guest upgrade, wallet switch) and
3169
+ * PRESERVED across refresh commits. That is the whole point: it is what makes
3170
+ * "S1 refreshed into S1'" provable and distinguishable from "someone logged in
3171
+ * again", which token equality and address equality both get wrong — token
3172
+ * equality rejects a valid post-refresh retry, and address equality accepts a
3173
+ * same-wallet re-login as if it were the same session.
3174
+ *
3175
+ * The issuer has a server-side `familyId` that would serve, but `/session/refresh`
3176
+ * does not return it, so this is client-side.
3177
+ */
3178
+ function mintSessionGeneration() {
3179
+ const c = typeof crypto !== "undefined" ? crypto : undefined;
3180
+ if (typeof (c === null || c === void 0 ? void 0 : c.randomUUID) === "function")
3181
+ return c.randomUUID();
3182
+ if (typeof (c === null || c === void 0 ? void 0 : c.getRandomValues) === "function") {
3183
+ const bytes = c.getRandomValues(new Uint8Array(16));
3184
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
3185
+ }
3186
+ // Last resort only; every runtime the SDK targets provides one of the above.
3187
+ return `g${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
3188
+ }
3189
+ /**
3190
+ * Decide a commit against what storage ACTUALLY holds right now.
3191
+ *
3192
+ * Must be called inside `withSessionLock`, with `current` read inside that same
3193
+ * critical section — comparing against a value read before the network call is
3194
+ * exactly the race this exists to prevent.
3195
+ */
3196
+ function decideCommit(current, expected, next) {
3197
+ var _a;
3198
+ // Absence is SUPERSEDED, never "free to write". A logout that lands between a
3199
+ // refresh's read and its write must not be undone by that write. This is the
3200
+ // backstop that makes session resurrection impossible.
3201
+ if (!current)
3202
+ return "superseded";
3203
+ // A caller with no captured generation cannot prove lineage, so it must not
3204
+ // write. Reads mint a generation for legacy sessions before returning them, so
3205
+ // in practice this only catches a request that was in flight across that
3206
+ // upgrade. Fail closed: the caller retries and gets a provable session.
3207
+ if (expected.generation === null)
3208
+ return "superseded";
3209
+ // A different generation means a different LOGIN, which the address-based guard
3210
+ // cannot see when the same wallet signs in again.
3211
+ if (((_a = current.sessionGeneration) !== null && _a !== void 0 ? _a : null) !== expected.generation) {
3212
+ return "superseded";
3213
+ }
3214
+ if (current.idToken === next.idToken && current.refreshToken === next.refreshToken) {
3215
+ return "already-equal";
3216
+ }
3217
+ // Storage is exactly what this caller read before it went to the network, so
3218
+ // nothing has moved underneath and this write is the legitimate one.
3219
+ if (expected.idToken === undefined || current.idToken === expected.idToken) {
3220
+ return "applied";
3221
+ }
3222
+ // Same login, but storage has moved on: another caller's refresh of this same
3223
+ // session already landed. Theirs is the current truth — its rotated refresh
3224
+ // token is the live one — so defer rather than overwrite. Reporting a principal
3225
+ // change here would fail ordinary concurrent requests at expiry, which is the
3226
+ // common case, not a rare one.
3227
+ return "same-generation-winner";
3228
+ }
3229
+
3032
3230
  class WebSessionManager {
3231
+ /** Raw read of the stored blob. Callers inside the lock use this, never a cached copy. */
3232
+ static readRaw() {
3233
+ if (typeof window === "undefined")
3234
+ return null;
3235
+ const raw = localStorage.getItem(this.BOUNDED_SESSION_STORAGE_KEY);
3236
+ if (!raw)
3237
+ return null;
3238
+ try {
3239
+ return JSON.parse(raw);
3240
+ }
3241
+ catch (_a) {
3242
+ return null;
3243
+ }
3244
+ }
3245
+ static writeRaw(sessionObj) {
3246
+ localStorage.setItem(this.BOUNDED_SESSION_STORAGE_KEY, JSON.stringify(sessionObj));
3247
+ }
3248
+ /**
3249
+ * Read the session, stamping a `sessionGeneration` if it predates them.
3250
+ *
3251
+ * Sessions written before generations existed have no lineage marker, so a
3252
+ * later refresh could not prove it descended from them. Minting on read
3253
+ * upgrades them in place; a request already in flight across that mint carries
3254
+ * no generation and fails closed at commit rather than writing unprovably.
3255
+ */
3256
+ static async readSessionWithGeneration() {
3257
+ const existing = this.readRaw();
3258
+ if (!existing)
3259
+ return null;
3260
+ if (existing.sessionGeneration)
3261
+ return existing;
3262
+ return await withSessionLock(() => {
3263
+ // Re-read inside the lock: another tab may have minted one already.
3264
+ const current = this.readRaw();
3265
+ if (!current)
3266
+ return null;
3267
+ if (current.sessionGeneration)
3268
+ return current;
3269
+ const upgraded = Object.assign(Object.assign({}, current), { sessionGeneration: mintSessionGeneration() });
3270
+ this.writeRaw(upgraded);
3271
+ return upgraded;
3272
+ });
3273
+ }
3033
3274
  /**
3034
3275
  * Decode a base64url-encoded string (used by JWT payloads).
3035
3276
  * Normalises base64url → standard base64 before calling browser atob().
@@ -3078,28 +3319,38 @@ class WebSessionManager {
3078
3319
  // the RIGHT /session/refresh. Email overrides with humanAuthApiUrl; all
3079
3320
  // wallet/guest/signature flows default to authApiUrl (the bounded-auth issuer).
3080
3321
  const sessionIssuer = (issuer || config.authApiUrl || "").replace(/\/$/, "");
3081
- localStorage.setItem(this.BOUNDED_SESSION_STORAGE_KEY, JSON.stringify(Object.assign({ address,
3082
- accessToken,
3083
- idToken,
3084
- refreshToken, appId: currentAppId }, (sessionIssuer ? { issuer: sessionIssuer } : {}))));
3322
+ // A login always starts a NEW generation. Refresh commits preserve it, so
3323
+ // "same generation" means "same login", which is what lets a post-refresh
3324
+ // retry be told apart from a re-login by the same wallet.
3325
+ await withSessionLock(() => {
3326
+ this.writeRaw(Object.assign({ address,
3327
+ accessToken,
3328
+ idToken,
3329
+ refreshToken, appId: currentAppId, sessionGeneration: mintSessionGeneration() }, (sessionIssuer ? { issuer: sessionIssuer } : {})));
3330
+ });
3085
3331
  }
3086
3332
  /* ------------------------------------------------------------------ */
3087
3333
  /* GET */
3088
3334
  /* ------------------------------------------------------------------ */
3089
3335
  static async getSession() {
3336
+ var _a;
3090
3337
  if (typeof window === "undefined")
3091
3338
  return null;
3092
- const session = localStorage.getItem(this.BOUNDED_SESSION_STORAGE_KEY);
3093
- if (!session)
3339
+ const sessionObj = await this.readSessionWithGeneration();
3340
+ if (!sessionObj)
3094
3341
  return null;
3095
- const sessionObj = JSON.parse(session);
3342
+ const generation = (_a = sessionObj.sessionGeneration) !== null && _a !== void 0 ? _a : null;
3096
3343
  /* ---------- validate app-id ---------- */
3097
3344
  const config = await getConfig();
3098
3345
  if (sessionObj.appId && sessionObj.appId !== config.appId) {
3099
- this.clearSession();
3346
+ await this.clearSessionIfGeneration(generation);
3100
3347
  return null;
3101
3348
  }
3102
3349
  /* ---------- check JWT expiration ---------- */
3350
+ // Deliberately NO skew buffer here, unlike the per-request freshness helper.
3351
+ // This is the restore path: at boot a token already past `exp` is unusable
3352
+ // either way, so clearing it is honest. Preserving it would only make
3353
+ // isAuthenticated() lie about a session that cannot authenticate anything.
3103
3354
  try {
3104
3355
  // Check expiry on the ID TOKEN — it is ALWAYS a JWT. The OIDC access_token for
3105
3356
  // email/social (hosted) logins is an OPAQUE string (not a JWT), so decoding IT
@@ -3113,29 +3364,39 @@ class WebSessionManager {
3113
3364
  // (mirrors the refresh-failed branch below) so isAuthenticated()
3114
3365
  // does not stay true and every request does not 401 + retry forever.
3115
3366
  if (!refreshToken) {
3116
- this.clearSession();
3367
+ await this.clearSessionIfGeneration(generation);
3117
3368
  return null;
3118
3369
  }
3119
3370
  const refreshed = await refreshSession(refreshToken, sessionObj.issuer);
3120
3371
  if ((refreshed === null || refreshed === void 0 ? void 0 : refreshed.idToken) && (refreshed === null || refreshed === void 0 ? void 0 : refreshed.accessToken)) {
3121
- await this.updateIdTokenAndAccessToken(refreshed.idToken, refreshed.accessToken,
3372
+ const committed = await this.commitRefreshedTokens({ generation, idToken: sessionObj.idToken }, refreshed.idToken, refreshed.accessToken,
3122
3373
  // Persist the ROTATED refresh token — the old one is now
3123
3374
  // single-use spent; reusing it would revoke the family.
3124
3375
  refreshed.refreshToken);
3125
- const newSession = localStorage.getItem(this.BOUNDED_SESSION_STORAGE_KEY);
3126
- if (!newSession)
3376
+ // Superseded means a login or logout landed while we were on the
3377
+ // network. Returning our own stale null here would sign the UI out
3378
+ // of a session that is now perfectly valid, because init() applies
3379
+ // whatever restoreSession() returns. Re-read once and report THAT.
3380
+ if (committed.outcome === "superseded") {
3381
+ const current = this.readRaw();
3382
+ if (!current)
3383
+ return null;
3384
+ return { address: current.address, session: current };
3385
+ }
3386
+ const newObj = committed.session;
3387
+ if (!newObj)
3127
3388
  return null;
3128
- const newObj = JSON.parse(newSession);
3129
3389
  return { address: newObj.address, session: newObj };
3130
3390
  }
3131
- // Refresh failed — clear stale session to prevent retry loops
3132
- this.clearSession();
3391
+ // Refresh failed — clear stale session to prevent retry loops, but only
3392
+ // if it is still the session we set out to refresh.
3393
+ await this.clearSessionIfGeneration(generation);
3133
3394
  return null;
3134
3395
  }
3135
3396
  }
3136
3397
  catch (err) {
3137
3398
  // Token decode or refresh failed — clear stale session to prevent retry loops
3138
- this.clearSession();
3399
+ await this.clearSessionIfGeneration(generation);
3139
3400
  return null;
3140
3401
  }
3141
3402
  return { address: sessionObj.address, session: sessionObj };
@@ -3143,10 +3404,55 @@ class WebSessionManager {
3143
3404
  /* ------------------------------------------------------------------ */
3144
3405
  /* CLEAR */
3145
3406
  /* ------------------------------------------------------------------ */
3146
- static clearSession() {
3407
+ /**
3408
+ * Remove the session unconditionally (logout, app-id mismatch, dead refresh).
3409
+ *
3410
+ * ASYNC AND LOCKED, and callers MUST await it. Two failure modes hang on this:
3411
+ *
3412
+ * - Removing outside the lock lets a refresh that read before the logout write
3413
+ * the session back afterwards, resurrecting a logged-out session.
3414
+ * - Not awaiting lets `logout()` return while the credential is still in
3415
+ * storage, so the very next request authenticates as the user who just left.
3416
+ */
3417
+ static async clearSession() {
3418
+ if (typeof window === "undefined")
3419
+ return;
3420
+ const removed = await withSessionLock(() => {
3421
+ if (localStorage.getItem(this.BOUNDED_SESSION_STORAGE_KEY) === null)
3422
+ return false;
3423
+ localStorage.removeItem(this.BOUNDED_SESSION_STORAGE_KEY);
3424
+ return true;
3425
+ });
3426
+ // Only announce a clear that actually happened, so a no-op cannot sign out
3427
+ // a UI that was already signed out.
3428
+ if (removed)
3429
+ notifySessionCleared();
3430
+ }
3431
+ /**
3432
+ * Remove the session only if it is still the one identified by `generation`.
3433
+ *
3434
+ * Used by every failure path that wants to drop a dead session without
3435
+ * trampling a newer login that landed while the failure was in flight.
3436
+ */
3437
+ static async clearSessionIfGeneration(generation) {
3147
3438
  if (typeof window === "undefined")
3148
3439
  return;
3149
- localStorage.removeItem(this.BOUNDED_SESSION_STORAGE_KEY);
3440
+ const removed = await withSessionLock(() => {
3441
+ var _a;
3442
+ const current = this.readRaw();
3443
+ if (!current)
3444
+ return false;
3445
+ // No captured generation means no proof this is the session we meant to
3446
+ // drop. Leave it alone rather than risk removing a replacement.
3447
+ if (generation === null)
3448
+ return false;
3449
+ if (((_a = current.sessionGeneration) !== null && _a !== void 0 ? _a : null) !== generation)
3450
+ return false;
3451
+ localStorage.removeItem(this.BOUNDED_SESSION_STORAGE_KEY);
3452
+ return true;
3453
+ });
3454
+ if (removed)
3455
+ notifySessionCleared();
3150
3456
  }
3151
3457
  /* ------------------------------------------------------------------ */
3152
3458
  /* IS-AUTH */
@@ -3181,39 +3487,63 @@ class WebSessionManager {
3181
3487
  /* ------------------------------------------------------------------ */
3182
3488
  /* UPDATE TOKENS */
3183
3489
  /* ------------------------------------------------------------------ */
3184
- static async updateIdTokenAndAccessToken(idToken, accessToken, refreshToken) {
3185
- var _a;
3490
+ /**
3491
+ * Commit refreshed tokens, bound to the session generation they were minted for.
3492
+ *
3493
+ * Returns WHICH of the four outcomes happened, and the session the caller must
3494
+ * actually use. The old version returned void and silently dropped a refused
3495
+ * write, so `getFreshAuthToken` handed back the refreshed token regardless — a
3496
+ * request could then execute as the previous user while storage and the UI had
3497
+ * already moved to a new one.
3498
+ *
3499
+ * Never hand back `idToken` directly: use `result.session`.
3500
+ */
3501
+ static async commitRefreshedTokens(expected, idToken, accessToken, refreshToken) {
3186
3502
  if (typeof window === "undefined")
3187
- return;
3188
- // Resolve config FIRST so the read → check → write below is one synchronous
3189
- // turn (an atomic CAS): no `await` can let a concurrent login interleave a
3190
- // different session between the read and the write (#221).
3503
+ return { outcome: "superseded", session: null };
3504
+ // Resolve config BEFORE taking the lock: no awaits are allowed inside the
3505
+ // critical section, or another realm could interleave between read and write.
3191
3506
  const config = await getConfig();
3192
- const session = localStorage.getItem(this.BOUNDED_SESSION_STORAGE_KEY);
3193
- if (!session)
3194
- return;
3195
- const sessionObj = JSON.parse(session);
3196
- /* ---------- app-id guard ---------- */
3197
- if (sessionObj.appId && sessionObj.appId !== config.appId) {
3198
- this.clearSession();
3199
- return;
3200
- }
3201
- /* ---------- cross-principal guard (#221) ---------- */
3202
- // Refuse to graft a refreshed token onto a DIFFERENT user's stored session
3203
- // (a stale refresh completing after a login/upgrade replaced storage, incl.
3204
- // a cross-tab login). Also re-runs the wallet-binding check storeSession does.
3205
- if (!refreshedTokenMayReplaceStored(idToken, sessionObj.address, (s) => this.decodeBase64Url(s))) {
3206
- console.warn("[WebSessionManager] updateIdTokenAndAccessToken: refreshed token wallet does not match the stored session address — discarding stale refresh");
3207
- return;
3208
- }
3209
- sessionObj.idToken = idToken;
3210
- sessionObj.accessToken = accessToken;
3211
- // Persist the rotated refresh token when the issuer returns a new one.
3212
- if (typeof refreshToken === "string" && refreshToken.length > 0) {
3213
- sessionObj.refreshToken = refreshToken;
3214
- }
3215
- (_a = sessionObj.appId) !== null && _a !== void 0 ? _a : (sessionObj.appId = config.appId);
3216
- localStorage.setItem(this.BOUNDED_SESSION_STORAGE_KEY, JSON.stringify(sessionObj));
3507
+ const result = await withSessionLock(() => {
3508
+ var _a;
3509
+ // Read INSIDE the lock. The snapshot the caller captured before its
3510
+ // network refresh is only a comparison key; what gets written is decided
3511
+ // against what storage holds right now.
3512
+ const current = this.readRaw();
3513
+ /* ---------- app-id guard ---------- */
3514
+ if ((current === null || current === void 0 ? void 0 : current.appId) && current.appId !== config.appId) {
3515
+ localStorage.removeItem(this.BOUNDED_SESSION_STORAGE_KEY);
3516
+ return { outcome: "superseded", session: null, cleared: true };
3517
+ }
3518
+ const next = Object.assign(Object.assign(Object.assign({}, (current !== null && current !== void 0 ? current : {})), { idToken,
3519
+ accessToken }), (typeof refreshToken === "string" && refreshToken.length > 0
3520
+ ? { refreshToken }
3521
+ : {}));
3522
+ const outcome = decideCommit(current, expected, next);
3523
+ if (outcome === "superseded")
3524
+ return { outcome, session: null };
3525
+ // Another caller's refresh of this same login already landed. Theirs is
3526
+ // the truth; reporting a principal change here would fail every ordinary
3527
+ // concurrent request at expiry, which is the common case.
3528
+ if (outcome === "same-generation-winner")
3529
+ return { outcome, session: current };
3530
+ if (outcome === "already-equal")
3531
+ return { outcome, session: current };
3532
+ /* ---------- cross-principal guard (#221) ---------- */
3533
+ // Belt-and-braces alongside the generation check: refuse to graft a
3534
+ // refreshed token onto a DIFFERENT wallet's stored session. Generations
3535
+ // catch a same-wallet re-login, which this address check cannot.
3536
+ if (!refreshedTokenMayReplaceStored(idToken, current.address, (s) => this.decodeBase64Url(s))) {
3537
+ console.warn("[WebSessionManager] commitRefreshedTokens: refreshed token wallet does not match the stored session address — discarding stale refresh");
3538
+ return { outcome: "superseded", session: null };
3539
+ }
3540
+ (_a = next.appId) !== null && _a !== void 0 ? _a : (next.appId = config.appId);
3541
+ this.writeRaw(next);
3542
+ return { outcome, session: next };
3543
+ });
3544
+ if (result.cleared)
3545
+ notifySessionCleared();
3546
+ return { outcome: result.outcome, session: result.session };
3217
3547
  }
3218
3548
  }
3219
3549
  WebSessionManager.BOUNDED_SESSION_STORAGE_KEY = "bounded_session_storage";
@@ -3310,26 +3640,65 @@ class ReactNativeSessionManager {
3310
3640
  // Issuer base for silent refresh (email overrides w/ humanAuthApiUrl; wallet/
3311
3641
  // guest default to authApiUrl). See WebSessionManager for the rationale.
3312
3642
  const sessionIssuer = (issuer || config.authApiUrl || "").replace(/\/$/, "");
3313
- this.getStorage().setItem(this.BOUNDED_SESSION_STORAGE_KEY, JSON.stringify(Object.assign({ address,
3314
- accessToken,
3315
- idToken,
3316
- refreshToken, appId: currentAppId }, (sessionIssuer ? { issuer: sessionIssuer } : {}))));
3643
+ // A login always starts a NEW generation; refresh commits preserve it.
3644
+ // See WebSessionManager for why lineage cannot be inferred from tokens or
3645
+ // address. RN is single-realm, so the lock resolves to direct execution.
3646
+ await withSessionLock(() => {
3647
+ this.writeRaw(Object.assign({ address,
3648
+ accessToken,
3649
+ idToken,
3650
+ refreshToken, appId: currentAppId, sessionGeneration: mintSessionGeneration() }, (sessionIssuer ? { issuer: sessionIssuer } : {})));
3651
+ });
3652
+ }
3653
+ /** Raw read of the stored blob. Callers inside the lock use this, never a cached copy. */
3654
+ static readRaw() {
3655
+ try {
3656
+ const raw = this.getStorage().getItem(this.BOUNDED_SESSION_STORAGE_KEY);
3657
+ return raw ? JSON.parse(raw) : null;
3658
+ }
3659
+ catch (_a) {
3660
+ return null;
3661
+ }
3662
+ }
3663
+ static writeRaw(sessionObj) {
3664
+ this.getStorage().setItem(this.BOUNDED_SESSION_STORAGE_KEY, JSON.stringify(sessionObj));
3665
+ }
3666
+ /** Mirror of WebSessionManager.readSessionWithGeneration. */
3667
+ static async readSessionWithGeneration() {
3668
+ const existing = this.readRaw();
3669
+ if (!existing)
3670
+ return null;
3671
+ if (existing.sessionGeneration)
3672
+ return existing;
3673
+ return await withSessionLock(() => {
3674
+ const current = this.readRaw();
3675
+ if (!current)
3676
+ return null;
3677
+ if (current.sessionGeneration)
3678
+ return current;
3679
+ const upgraded = Object.assign(Object.assign({}, current), { sessionGeneration: mintSessionGeneration() });
3680
+ this.writeRaw(upgraded);
3681
+ return upgraded;
3682
+ });
3317
3683
  }
3318
3684
  /* ------------------------------------------------------------------ */
3319
3685
  /* GET */
3320
3686
  /* ------------------------------------------------------------------ */
3321
3687
  static async getSession() {
3322
- const session = this.getStorage().getItem(this.BOUNDED_SESSION_STORAGE_KEY);
3323
- if (!session)
3688
+ var _a;
3689
+ const sessionObj = await this.readSessionWithGeneration();
3690
+ if (!sessionObj)
3324
3691
  return null;
3325
- const sessionObj = JSON.parse(session);
3692
+ const generation = (_a = sessionObj.sessionGeneration) !== null && _a !== void 0 ? _a : null;
3326
3693
  /* ---------- validate app-id ---------- */
3327
3694
  const config = await getConfig();
3328
3695
  if (sessionObj.appId && sessionObj.appId !== config.appId) {
3329
- this.clearSession();
3696
+ await this.clearSessionIfGeneration(generation);
3330
3697
  return null;
3331
3698
  }
3332
3699
  /* ---------- check JWT expiration ---------- */
3700
+ // No skew buffer, deliberately: this is the restore path, and at boot a token
3701
+ // already past `exp` cannot authenticate anything. See WebSessionManager.
3333
3702
  try {
3334
3703
  // Expiry on the ID TOKEN (always a JWT). The OIDC access_token for email/social
3335
3704
  // logins is opaque, so decoding it threw → the catch wiped a valid session.
@@ -3341,26 +3710,35 @@ class ReactNativeSessionManager {
3341
3710
  // (mirrors the refresh-failed branch below) so isAuthenticated()
3342
3711
  // does not stay true and every request does not 401 + retry forever.
3343
3712
  if (!refreshToken) {
3344
- this.clearSession();
3713
+ await this.clearSessionIfGeneration(generation);
3345
3714
  return null;
3346
3715
  }
3347
3716
  const refreshed = await refreshSession(refreshToken, sessionObj.issuer);
3348
3717
  if ((refreshed === null || refreshed === void 0 ? void 0 : refreshed.idToken) && (refreshed === null || refreshed === void 0 ? void 0 : refreshed.accessToken)) {
3349
- await this.updateIdTokenAndAccessToken(refreshed.idToken, refreshed.accessToken, refreshed.refreshToken);
3350
- const newSession = this.getStorage().getItem(this.BOUNDED_SESSION_STORAGE_KEY);
3351
- if (!newSession)
3718
+ const committed = await this.commitRefreshedTokens({ generation, idToken: sessionObj.idToken }, refreshed.idToken, refreshed.accessToken, refreshed.refreshToken);
3719
+ // Superseded means a login or logout landed mid-refresh. Returning
3720
+ // our stale null would sign the UI out of a session that is now
3721
+ // valid, since init() applies whatever restoreSession() returns.
3722
+ if (committed.outcome === "superseded") {
3723
+ const current = this.readRaw();
3724
+ if (!current)
3725
+ return null;
3726
+ return { address: current.address, session: current };
3727
+ }
3728
+ const newObj = committed.session;
3729
+ if (!newObj)
3352
3730
  return null;
3353
- const newObj = JSON.parse(newSession);
3354
3731
  return { address: newObj.address, session: newObj };
3355
3732
  }
3356
- // Refresh failed — clear stale session to prevent retry loops
3357
- this.clearSession();
3733
+ // Refresh failed — clear stale session to prevent retry loops, but only
3734
+ // if it is still the session we set out to refresh.
3735
+ await this.clearSessionIfGeneration(generation);
3358
3736
  return null;
3359
3737
  }
3360
3738
  }
3361
3739
  catch (err) {
3362
3740
  // Token decode or refresh failed — clear stale session to prevent retry loops
3363
- this.clearSession();
3741
+ await this.clearSessionIfGeneration(generation);
3364
3742
  return null;
3365
3743
  }
3366
3744
  return { address: sessionObj.address, session: sessionObj };
@@ -3368,8 +3746,37 @@ class ReactNativeSessionManager {
3368
3746
  /* ------------------------------------------------------------------ */
3369
3747
  /* CLEAR */
3370
3748
  /* ------------------------------------------------------------------ */
3371
- static clearSession() {
3372
- this.getStorage().removeItem(this.BOUNDED_SESSION_STORAGE_KEY);
3749
+ /**
3750
+ * Remove the session unconditionally. Async and awaited for the same reason as
3751
+ * the web manager: a caller that does not await lets logout() return while the
3752
+ * credential is still readable.
3753
+ */
3754
+ static async clearSession() {
3755
+ const removed = await withSessionLock(() => {
3756
+ if (this.getStorage().getItem(this.BOUNDED_SESSION_STORAGE_KEY) === null)
3757
+ return false;
3758
+ this.getStorage().removeItem(this.BOUNDED_SESSION_STORAGE_KEY);
3759
+ return true;
3760
+ });
3761
+ if (removed)
3762
+ notifySessionCleared();
3763
+ }
3764
+ /** Remove only if the stored session is still the one identified by `generation`. */
3765
+ static async clearSessionIfGeneration(generation) {
3766
+ const removed = await withSessionLock(() => {
3767
+ var _a;
3768
+ const current = this.readRaw();
3769
+ if (!current)
3770
+ return false;
3771
+ if (generation === null)
3772
+ return false;
3773
+ if (((_a = current.sessionGeneration) !== null && _a !== void 0 ? _a : null) !== generation)
3774
+ return false;
3775
+ this.getStorage().removeItem(this.BOUNDED_SESSION_STORAGE_KEY);
3776
+ return true;
3777
+ });
3778
+ if (removed)
3779
+ notifySessionCleared();
3373
3780
  }
3374
3781
  /* ------------------------------------------------------------------ */
3375
3782
  /* IS-AUTH */
@@ -3412,36 +3819,44 @@ class ReactNativeSessionManager {
3412
3819
  /* ------------------------------------------------------------------ */
3413
3820
  /* UPDATE TOKENS */
3414
3821
  /* ------------------------------------------------------------------ */
3415
- static async updateIdTokenAndAccessToken(idToken, accessToken, refreshToken) {
3416
- var _a;
3417
- // Resolve config FIRST so the read → check → write below is one synchronous
3418
- // turn (an atomic CAS): no `await` can let a concurrent login interleave a
3419
- // different session between the read and the write (#220).
3822
+ /**
3823
+ * Commit refreshed tokens bound to the generation they were minted for.
3824
+ * Mirror of WebSessionManager.commitRefreshedTokens; see it for the rationale.
3825
+ */
3826
+ static async commitRefreshedTokens(expected, idToken, accessToken, refreshToken) {
3827
+ // Resolve config BEFORE the lock: no awaits inside the critical section.
3420
3828
  const config = await getConfig();
3421
- const session = this.getStorage().getItem(this.BOUNDED_SESSION_STORAGE_KEY);
3422
- if (!session)
3423
- return;
3424
- const sessionObj = JSON.parse(session);
3425
- /* ---------- app-id guard ---------- */
3426
- if (sessionObj.appId && sessionObj.appId !== config.appId) {
3427
- this.clearSession();
3428
- return;
3429
- }
3430
- /* ---------- cross-principal guard (#220) ---------- */
3431
- // Refuse to graft a refreshed token onto a DIFFERENT user's stored session
3432
- // (a stale refresh completing after a login/upgrade replaced storage). Also
3433
- // re-runs the wallet-binding check storeSession does.
3434
- if (!refreshedTokenMayReplaceStored(idToken, sessionObj.address, (s) => this.decodeBase64Url(s))) {
3435
- console.warn("[ReactNativeSessionManager] updateIdTokenAndAccessToken: refreshed token wallet does not match the stored session address — discarding stale refresh");
3436
- return;
3437
- }
3438
- sessionObj.idToken = idToken;
3439
- sessionObj.accessToken = accessToken;
3440
- if (typeof refreshToken === "string" && refreshToken.length > 0) {
3441
- sessionObj.refreshToken = refreshToken;
3442
- }
3443
- (_a = sessionObj.appId) !== null && _a !== void 0 ? _a : (sessionObj.appId = config.appId);
3444
- this.getStorage().setItem(this.BOUNDED_SESSION_STORAGE_KEY, JSON.stringify(sessionObj));
3829
+ const result = await withSessionLock(() => {
3830
+ var _a;
3831
+ const current = this.readRaw();
3832
+ /* ---------- app-id guard ---------- */
3833
+ if ((current === null || current === void 0 ? void 0 : current.appId) && current.appId !== config.appId) {
3834
+ this.getStorage().removeItem(this.BOUNDED_SESSION_STORAGE_KEY);
3835
+ return { outcome: "superseded", session: null, cleared: true };
3836
+ }
3837
+ const next = Object.assign(Object.assign(Object.assign({}, (current !== null && current !== void 0 ? current : {})), { idToken,
3838
+ accessToken }), (typeof refreshToken === "string" && refreshToken.length > 0
3839
+ ? { refreshToken }
3840
+ : {}));
3841
+ const outcome = decideCommit(current, expected, next);
3842
+ if (outcome === "superseded")
3843
+ return { outcome, session: null };
3844
+ if (outcome === "same-generation-winner")
3845
+ return { outcome, session: current };
3846
+ if (outcome === "already-equal")
3847
+ return { outcome, session: current };
3848
+ /* ---------- cross-principal guard (#220) ---------- */
3849
+ if (!refreshedTokenMayReplaceStored(idToken, current.address, (s) => this.decodeBase64Url(s))) {
3850
+ console.warn("[ReactNativeSessionManager] commitRefreshedTokens: refreshed token wallet does not match the stored session address — discarding stale refresh");
3851
+ return { outcome: "superseded", session: null };
3852
+ }
3853
+ (_a = next.appId) !== null && _a !== void 0 ? _a : (next.appId = config.appId);
3854
+ this.writeRaw(next);
3855
+ return { outcome, session: next };
3856
+ });
3857
+ if (result.cleared)
3858
+ notifySessionCleared();
3859
+ return { outcome: result.outcome, session: result.session };
3445
3860
  }
3446
3861
  }
3447
3862
  ReactNativeSessionManager.BOUNDED_SESSION_STORAGE_KEY = "bounded_session_storage";
@@ -3474,11 +3889,6 @@ function getActiveSessionManager() {
3474
3889
  : WebSessionManager;
3475
3890
  }
3476
3891
 
3477
- var sessionManager = /*#__PURE__*/Object.freeze({
3478
- __proto__: null,
3479
- getActiveSessionManager: getActiveSessionManager
3480
- });
3481
-
3482
3892
  const NO_AMBIENT_SERVER_SESSION = "Server sessions are not process-global. Use createWalletClient({ keypair }) and call that wallet client's methods.";
3483
3893
  /* ------------------------------------------------------------------ */
3484
3894
  /* SESSION MANAGER */
@@ -3536,7 +3946,7 @@ class ServerSessionManager {
3536
3946
  const address = kp.publicKey.toBase58();
3537
3947
  /* fetch nonce from auth API */
3538
3948
  const nonce = await genAuthNonce();
3539
- const { genSolanaMessage } = await Promise.resolve().then(function () { return require('./solana-message-DKOPPJwk.js'); });
3949
+ const { genSolanaMessage } = await Promise.resolve().then(function () { return require('./solana-message-B0NoE4nC.js'); });
3540
3950
  const message = await genSolanaMessage(address, nonce);
3541
3951
  /* sign the message */
3542
3952
  const sigBytes = nacl.sign.detached(new TextEncoder().encode(message), kp.secretKey);
@@ -3766,16 +4176,262 @@ function normalizeEvmAddress(raw) {
3766
4176
  return /^0x[0-9a-f]{40}$/.test(v) ? v : null;
3767
4177
  }
3768
4178
 
3769
- async function createBearerToken(isServer) {
4179
+ /**
4180
+ * The one place a caller turns "I need a credential" into a usable token.
4181
+ *
4182
+ * Both transports go through this. The WebSocket path grew a good version of this
4183
+ * logic first; the HTTP path had none at all and simply sent whatever was in
4184
+ * storage, which is what let an expired token reach the server and come back as a
4185
+ * misleading policy denial.
4186
+ *
4187
+ * THE CONTRACT, which callers depend on:
4188
+ *
4189
+ * returns null -> there is no session. Send the request anonymously; public
4190
+ * reads must keep working for logged-out visitors.
4191
+ * returns token -> use exactly this token.
4192
+ * throws -> there IS a session and it is unusable. NEVER fall back to an
4193
+ * anonymous request, or the caller lands in policy as an
4194
+ * anonymous user and gets the same misleading 403 this whole
4195
+ * change exists to remove.
4196
+ *
4197
+ * Four corrections over the WebSocket original, each of which was a real defect:
4198
+ *
4199
+ * - No sticky module-global failure. The original latched any refresh failure
4200
+ * into `ambientAuthFailure` and rethrew it on every later call, so ONE
4201
+ * transient network blip disabled refresh until an unrelated auth-state
4202
+ * change reset it.
4203
+ * - "Expiring soon" and "already expired" are different. The original collapsed
4204
+ * both behind a 60s buffer, so a transient refresh failure threw away a token
4205
+ * that was still valid for another 59 seconds.
4206
+ * - A missing or non-numeric `exp` is unusable, not fresh. `payload.exp * 1000`
4207
+ * is NaN, and `now > NaN` is false, so such a token read as perfectly fresh.
4208
+ * - Storage is cleared only on a DEFINITIVE rejection, never on a transient one.
4209
+ */
4210
+ /**
4211
+ * Stable machine code for "there is a session and it cannot be used".
4212
+ *
4213
+ * The VALUE is deliberately unchanged from when this lived in subscription-v2 as
4214
+ * `WS_AUTH_EXPIRED_CODE`: it is public surface that consumers branch on
4215
+ * (`e.code === 'auth_expired'`), so renaming it would break them for no gain. Only
4216
+ * the identifier lost its `WS_` prefix, now that HTTP raises it too.
4217
+ *
4218
+ * The realtime worker's 401 body uses `invalid_or_expired_session` for the same
4219
+ * condition on the wire; this is the client-side counterpart.
4220
+ */
4221
+ const AUTH_EXPIRED_CODE = 'auth_expired';
4222
+ /** The principal changed under a request that was already in flight. */
4223
+ const AUTH_CHANGED_CODE = 'auth_changed';
4224
+ function makeAuthExpiredError(message, status) {
4225
+ const err = new Error(`${AUTH_EXPIRED_CODE}: ${message}`);
4226
+ err.code = AUTH_EXPIRED_CODE;
4227
+ if (status !== undefined)
4228
+ err.status = status;
4229
+ return err;
4230
+ }
4231
+ function makeAuthChangedError(message) {
4232
+ const err = new Error(`${AUTH_CHANGED_CODE}: ${message}`);
4233
+ err.code = AUTH_CHANGED_CODE;
4234
+ return err;
4235
+ }
4236
+ function isAuthExpiredError(error) {
4237
+ const code = error === null || error === void 0 ? void 0 : error.code;
4238
+ return !!error && typeof error === 'object'
4239
+ && (code === AUTH_EXPIRED_CODE || code === AUTH_CHANGED_CODE);
4240
+ }
4241
+ function normalizeAuthExpiredError(error, fallbackMessage) {
4242
+ var _a, _b;
4243
+ if (isAuthExpiredError(error))
4244
+ return error;
4245
+ const status = (_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : error === null || error === void 0 ? void 0 : error.code;
4246
+ return makeAuthExpiredError(fallbackMessage, status);
4247
+ }
4248
+ /** Refresh this many ms before `exp`, so a token cannot die mid-flight. */
4249
+ const TOKEN_EXPIRY_SKEW_MS = 60000;
4250
+ function classifyToken(token, nowMs = Date.now()) {
4251
+ let exp;
4252
+ try {
4253
+ const payload = JSON.parse(decodeBase64Url(token.split('.')[1]));
4254
+ exp = payload === null || payload === void 0 ? void 0 : payload.exp;
4255
+ }
4256
+ catch (_a) {
4257
+ return 'unusable';
4258
+ }
4259
+ // A token with no numeric exp used to compare against NaN and read as fresh.
4260
+ if (typeof exp !== 'number' || !Number.isFinite(exp))
4261
+ return 'unusable';
4262
+ const expiresAtMs = exp * 1000;
4263
+ if (nowMs >= expiresAtMs)
4264
+ return 'expired';
4265
+ if (nowMs >= expiresAtMs - TOKEN_EXPIRY_SKEW_MS)
4266
+ return 'expiring-soon';
4267
+ return 'fresh';
4268
+ }
4269
+ /** Short, non-secret fingerprint used only to key the in-flight map. Never logged. */
4270
+ function tokenFingerprint(token) {
4271
+ let h = 5381;
4272
+ for (let i = 0; i < token.length; i++)
4273
+ h = ((h << 5) + h + token.charCodeAt(i)) >>> 0;
4274
+ return h.toString(16);
4275
+ }
4276
+ /**
4277
+ * In-flight refresh TRANSACTIONS, keyed by the session they act on.
4278
+ *
4279
+ * The issuer-level dedupe in `refreshSession` shares only the network response;
4280
+ * each caller then commits independently. With a generation check that means the
4281
+ * first commit wins and every other caller sees storage it does not recognise —
4282
+ * so a burst of ordinary requests at expiry would all fail as "the user changed".
4283
+ * Deduping the WHOLE snapshot -> refresh -> commit makes them share one outcome.
4284
+ *
4285
+ * Keyed by generation as well as app, so a caller that arrives after a re-login
4286
+ * starts its own transaction instead of joining a stale one.
4287
+ */
4288
+ const refreshTransactions = new Map();
4289
+ function transactionKey(scope, appId, generation, refreshToken) {
4290
+ return `${scope}:${appId}:${generation !== null && generation !== void 0 ? generation : 'none'}:${tokenFingerprint(refreshToken)}`;
4291
+ }
4292
+ function isDefinitiveRejection(error) {
4293
+ var _a;
4294
+ const status = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status;
4295
+ return status === 401 || status === 403;
4296
+ }
4297
+ async function readSessionParts(isServer) {
4298
+ var _a, _b, _c, _d, _e, _f;
3770
4299
  if (isServer) {
3771
- const sessionMgr = ServerSessionManager.instance;
3772
- const session = await sessionMgr.getSession();
3773
- if (!(session === null || session === void 0 ? void 0 : session.idToken)) {
3774
- return null;
4300
+ const session = await ServerSessionManager.instance.getSession();
4301
+ return {
4302
+ idToken: (_a = session === null || session === void 0 ? void 0 : session.idToken) !== null && _a !== void 0 ? _a : null,
4303
+ refreshToken: (_b = session === null || session === void 0 ? void 0 : session.refreshToken) !== null && _b !== void 0 ? _b : null,
4304
+ issuer: undefined,
4305
+ generation: null,
4306
+ };
4307
+ }
4308
+ const mgr = getActiveSessionManager();
4309
+ const session = await mgr.readSessionWithGeneration();
4310
+ return {
4311
+ idToken: (_c = session === null || session === void 0 ? void 0 : session.idToken) !== null && _c !== void 0 ? _c : null,
4312
+ refreshToken: (_d = session === null || session === void 0 ? void 0 : session.refreshToken) !== null && _d !== void 0 ? _d : null,
4313
+ issuer: (_e = session === null || session === void 0 ? void 0 : session.issuer) !== null && _e !== void 0 ? _e : undefined,
4314
+ generation: (_f = session === null || session === void 0 ? void 0 : session.sessionGeneration) !== null && _f !== void 0 ? _f : null,
4315
+ };
4316
+ }
4317
+ async function commit(isServer, expected, refreshed) {
4318
+ var _a, _b;
4319
+ if (isServer) {
4320
+ const mgr = ServerSessionManager.instance;
4321
+ const session = await mgr.getSession();
4322
+ if (!session)
4323
+ return { outcome: 'superseded', token: null };
4324
+ const next = Object.assign(Object.assign(Object.assign({}, session), { idToken: refreshed.idToken, accessToken: refreshed.accessToken }), (refreshed.refreshToken ? { refreshToken: refreshed.refreshToken } : {}));
4325
+ mgr.setSession(next);
4326
+ return { outcome: 'applied', token: next.idToken };
4327
+ }
4328
+ const result = await getActiveSessionManager().commitRefreshedTokens(expected, refreshed.idToken, refreshed.accessToken, refreshed.refreshToken);
4329
+ // NEVER return refreshed.idToken directly. When the commit is refused or lost,
4330
+ // that token was not stored, and using it would authenticate the request as
4331
+ // whoever the refresh was for rather than whoever the session now belongs to.
4332
+ return { outcome: result.outcome, token: (_b = (_a = result.session) === null || _a === void 0 ? void 0 : _a.idToken) !== null && _b !== void 0 ? _b : null };
4333
+ }
4334
+ /**
4335
+ * Get a token that is safe to send right now, refreshing if needed.
4336
+ *
4337
+ * `null` means anonymous is fine. A throw means there is a session that cannot be
4338
+ * used; the caller must surface it rather than downgrade to anonymous.
4339
+ */
4340
+ async function getFreshAuthToken(isServer = false, options = {}) {
4341
+ const { idToken, refreshToken, issuer, generation } = await readSessionParts(isServer);
4342
+ // No session at all: anonymous is legitimate.
4343
+ if (!idToken)
4344
+ return null;
4345
+ const force = options.force === true;
4346
+ const freshness = classifyToken(idToken);
4347
+ if (!force && freshness === 'fresh')
4348
+ return idToken;
4349
+ // Still valid for a little longer, so a failed refresh can fall back to it —
4350
+ // but never when forced, since the server has already refused this exact token.
4351
+ const stillUsable = !force && freshness === 'expiring-soon';
4352
+ if (!refreshToken) {
4353
+ if (stillUsable)
4354
+ return idToken;
4355
+ // Nothing can revive this session. Drop it so isAuthenticated() stops lying
4356
+ // and the UI is told, then fail loudly instead of going anonymous.
4357
+ await clearDeadSession(isServer, generation);
4358
+ throw makeAuthExpiredError('Authentication expired and no refresh token is available');
4359
+ }
4360
+ const config = await getConfig();
4361
+ const key = transactionKey(isServer ? 'server' : 'web', config.appId, generation, refreshToken);
4362
+ const existing = refreshTransactions.get(key);
4363
+ if (existing)
4364
+ return await existing;
4365
+ const transaction = (async () => {
4366
+ var _a, _b, _c;
4367
+ try {
4368
+ const refreshed = await refreshSession(refreshToken, issuer);
4369
+ if (!(refreshed === null || refreshed === void 0 ? void 0 : refreshed.idToken) || !(refreshed === null || refreshed === void 0 ? void 0 : refreshed.accessToken)) {
4370
+ throw makeAuthExpiredError('Authentication refresh returned an incomplete session');
4371
+ }
4372
+ const { outcome, token } = await commit(isServer, { generation, idToken }, refreshed);
4373
+ if (outcome === 'superseded') {
4374
+ // A login or logout landed while we were on the network. Refuse rather
4375
+ // than authenticate this request as the principal that just left.
4376
+ throw makeAuthChangedError('The signed-in account changed while this request was in flight');
4377
+ }
4378
+ // applied / already-equal / same-generation-winner are all successes: the
4379
+ // stored session is valid for this same login either way.
4380
+ if (!token) {
4381
+ throw makeAuthExpiredError('Authentication refresh produced no usable token');
4382
+ }
4383
+ return token;
4384
+ }
4385
+ catch (error) {
4386
+ if (isAuthExpiredError(error))
4387
+ throw error;
4388
+ // Log the STATUS only: the axios error carries config.data, which holds
4389
+ // the refresh token, and must never reach a console or log sink.
4390
+ const status = (_c = (_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : error === null || error === void 0 ? void 0 : error.code) !== null && _c !== void 0 ? _c : 'unknown';
4391
+ if (isDefinitiveRejection(error)) {
4392
+ // The issuer says this refresh token is dead. Drop the session.
4393
+ await clearDeadSession(isServer, generation);
4394
+ throw makeAuthExpiredError('Authentication expired and refresh was rejected', status);
4395
+ }
4396
+ // TRANSIENT: network blip, 5xx, timeout. Keep the session — it may well
4397
+ // work in a moment. If the access token is still inside its skew window
4398
+ // it is valid right now, so use it rather than failing the request.
4399
+ if (stillUsable)
4400
+ return idToken;
4401
+ throw makeAuthExpiredError('Authentication expired and refresh failed', status);
4402
+ }
4403
+ finally {
4404
+ refreshTransactions.delete(key);
4405
+ }
4406
+ })();
4407
+ refreshTransactions.set(key, transaction);
4408
+ return await transaction;
4409
+ }
4410
+ async function clearDeadSession(isServer, generation) {
4411
+ if (isServer) {
4412
+ try {
4413
+ ServerSessionManager.instance.clearSession();
3775
4414
  }
3776
- return `Bearer ${session.idToken}`;
4415
+ catch ( /* best effort */_a) { /* best effort */ }
4416
+ return;
4417
+ }
4418
+ try {
4419
+ // Conditional: never remove a session that a concurrent login already replaced.
4420
+ await getActiveSessionManager().clearSessionIfGeneration(generation);
3777
4421
  }
3778
- const idToken = getActiveSessionManager().getIdToken();
4422
+ catch ( /* a clear failure must not mask the auth error we are about to throw */_b) { /* a clear failure must not mask the auth error we are about to throw */ }
4423
+ }
4424
+
4425
+ async function createBearerToken(isServer) {
4426
+ // Both branches go through the freshness helper now. The web branch used to read
4427
+ // localStorage synchronously and send whatever was there, so an expired token was
4428
+ // handed to the server on every request and only recovered from AFTER a 401 —
4429
+ // and until the worker started returning 401 for a rejected bearer, never.
4430
+ //
4431
+ // A throw here is deliberate and must propagate: it means a session exists but
4432
+ // cannot be used. Swallowing it and sending the request anonymously would put
4433
+ // the caller back in policy as an anonymous user, which is the original bug.
4434
+ const idToken = await getFreshAuthToken(isServer);
3779
4435
  if (!idToken) {
3780
4436
  return null;
3781
4437
  }
@@ -3859,41 +4515,18 @@ async function getIdToken(isServer) {
3859
4515
  }
3860
4516
  return getActiveSessionManager().getIdToken();
3861
4517
  }
3862
- async function getRefreshToken(isServer) {
3863
- if (isServer) {
3864
- const sessionMgr = ServerSessionManager.instance;
3865
- const session = await sessionMgr.getSession();
3866
- return session === null || session === void 0 ? void 0 : session.refreshToken;
3867
- }
3868
- return getActiveSessionManager().getRefreshToken();
3869
- }
3870
- /** The auth issuer base that minted the active client session, so a refresh POSTs
3871
- * to the right /session/refresh. Undefined on the server (keypair, no refresh). */
3872
- function getSessionIssuer(isServer) {
4518
+ /**
4519
+ * Read the active session's generation, minting one for a pre-generation session.
4520
+ *
4521
+ * The server manager is a single in-memory session per process, so it has no
4522
+ * cross-realm lineage problem and reports null.
4523
+ */
4524
+ async function getSessionGeneration(isServer = false) {
3873
4525
  var _a;
3874
4526
  if (isServer)
3875
- return undefined;
3876
- return (_a = getActiveSessionManager().getIssuer()) !== null && _a !== void 0 ? _a : undefined;
3877
- }
3878
- async function updateIdTokenAndAccessToken(idToken, accessToken, isServer = false, refreshToken) {
3879
- if (isServer) {
3880
- const sessionMgr = ServerSessionManager.instance;
3881
- // Ensure we have a session to update; if missing, let lazy creation happen on next request.
3882
- let session = null;
3883
- try {
3884
- session = await sessionMgr.getSession();
3885
- }
3886
- catch (_a) {
3887
- return;
3888
- }
3889
- if (!session) {
3890
- return;
3891
- }
3892
- sessionMgr.setSession(Object.assign(Object.assign(Object.assign({}, session), { idToken,
3893
- accessToken }), (refreshToken ? { refreshToken } : {})));
3894
- return;
3895
- }
3896
- await getActiveSessionManager().updateIdTokenAndAccessToken(idToken, accessToken, refreshToken);
4527
+ return null;
4528
+ const session = await getActiveSessionManager().readSessionWithGeneration();
4529
+ return (_a = session === null || session === void 0 ? void 0 : session.sessionGeneration) !== null && _a !== void 0 ? _a : null;
3897
4530
  }
3898
4531
 
3899
4532
  // ---------------------------------------------------------------------------
@@ -4115,57 +4748,44 @@ axiosRetry(apiClient, {
4115
4748
  }
4116
4749
  },
4117
4750
  });
4118
- const refreshInFlight = new Map();
4119
- // Short, bounded, non-secret fingerprint of a refresh token — used only to KEY the
4120
- // in-flight de-dup map (never logged, never the raw secret).
4121
- function refreshTokenFingerprint(token) {
4122
- let h = 5381;
4123
- for (let i = 0; i < token.length; i++)
4124
- h = ((h << 5) + h + token.charCodeAt(i)) >>> 0;
4125
- return h.toString(16);
4126
- }
4127
- async function refreshAuthSessionOnce(appId, isServer) {
4128
- // Capture the CURRENT session's refresh token up front and key the in-flight
4129
- // refresh by it, not just by the app. Keyed by app only, a refresh started for
4130
- // user A would be reused for a request issued after switching to user B (guest→
4131
- // wallet upgrade, logout/login), so B's 401 recovery would piggyback on A's
4132
- // refresh (#112). Per-session keying keeps de-dup for the SAME session while
4133
- // making a post-switch refresh a distinct entry. (The token WRITE is separately
4134
- // guarded per-principal in the session managers, #220/#221.)
4135
- const refreshToken = await getRefreshToken(isServer);
4136
- if (!refreshToken) {
4137
- return false;
4751
+ /**
4752
+ * Recover from a 401, bound to the session that authenticated the ORIGINAL attempt.
4753
+ *
4754
+ * The dedupe, rotation and conditional-clear all live in `getFreshAuthToken` now;
4755
+ * this only adds the principal binding a reactive retry needs.
4756
+ *
4757
+ * Why the binding matters: `createAuthHeader` is acquired inside `executeRequest`,
4758
+ * so a retry re-reads whatever session is current at retry time. If B logs in while
4759
+ * A's request is in flight, A's 401 handler used to refresh B's session and replay
4760
+ * A's exact request as B — writing data under the wrong principal. Making a rejected
4761
+ * bearer return 401 turned that from a near-impossible race into the normal recovery
4762
+ * path, so it has to be closed here.
4763
+ *
4764
+ * Returns true only when the retry may proceed under the SAME login.
4765
+ */
4766
+ async function refreshAuthSessionOnce(capturedGeneration, isServer) {
4767
+ try {
4768
+ // force: the server just rejected the stored token, so a locally-fresh-looking
4769
+ // one is not evidence of anything. Without this, a 401 caused by anything the
4770
+ // client cannot see (revoked family, app mismatch, key rotation) would be
4771
+ // "recovered" by replaying the exact same credential.
4772
+ const token = await getFreshAuthToken(isServer, { force: true });
4773
+ if (!token)
4774
+ return false;
4775
+ // Equal generation covers both "unchanged" and "refreshed descendant": a
4776
+ // refresh preserves the generation, a re-login mints a new one. Comparing
4777
+ // tokens instead would reject a perfectly good retry whenever a concurrent
4778
+ // request refreshed first, which at expiry is the common case.
4779
+ const currentGeneration = await getSessionGeneration(isServer);
4780
+ if (currentGeneration !== capturedGeneration)
4781
+ return false;
4782
+ return true;
4138
4783
  }
4139
- const key = `${isServer ? "server" : "web"}:${appId}:${refreshTokenFingerprint(refreshToken)}`;
4140
- const existing = refreshInFlight.get(key);
4141
- if (existing) {
4142
- return existing;
4784
+ catch (_a) {
4785
+ // Includes auth_changed and a definitively rejected refresh. Either way the
4786
+ // retry must not proceed; the caller surfaces the original 401.
4787
+ return false;
4143
4788
  }
4144
- const refreshPromise = (async () => {
4145
- var _a, _b;
4146
- try {
4147
- const refreshData = await refreshSession(refreshToken, getSessionIssuer(isServer));
4148
- if (!(refreshData === null || refreshData === void 0 ? void 0 : refreshData.idToken) || !(refreshData === null || refreshData === void 0 ? void 0 : refreshData.accessToken)) {
4149
- return false;
4150
- }
4151
- await updateIdTokenAndAccessToken(refreshData.idToken, refreshData.accessToken, isServer,
4152
- // Persist the ROTATED refresh token — the old one is single-use spent.
4153
- refreshData.refreshToken);
4154
- return true;
4155
- }
4156
- catch (error) {
4157
- if (!isServer && (((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) === 401 || ((_b = error === null || error === void 0 ? void 0 : error.response) === null || _b === void 0 ? void 0 : _b.status) === 403)) {
4158
- const { getActiveSessionManager } = await Promise.resolve().then(function () { return sessionManager; });
4159
- getActiveSessionManager().clearSession();
4160
- }
4161
- return false;
4162
- }
4163
- finally {
4164
- refreshInFlight.delete(key);
4165
- }
4166
- })();
4167
- refreshInFlight.set(key, refreshPromise);
4168
- return refreshPromise;
4169
4789
  }
4170
4790
  async function makeApiRequest(method, urlPath, data, _overrides) {
4171
4791
  var _a, _b, _c, _d, _e, _f, _g;
@@ -4173,6 +4793,24 @@ async function makeApiRequest(method, urlPath, data, _overrides) {
4173
4793
  // fall back to the module-global init() config.
4174
4794
  const config = (_a = _overrides === null || _overrides === void 0 ? void 0 : _overrides._config) !== null && _a !== void 0 ? _a : await getConfig();
4175
4795
  const readOnly = (_overrides === null || _overrides === void 0 ? void 0 : _overrides._readOnly) === true;
4796
+ // CREDENTIAL PROVENANCE.
4797
+ //
4798
+ // Only a MANAGED credential — one this SDK minted from the global session — may
4799
+ // trigger global-session recovery. A caller that supplies its own Authorization
4800
+ // (via `_overrides.headers` or a wallet client's `_getAuthHeaders`) owns that
4801
+ // token; refreshing or clearing the global session because THAT token got a 401
4802
+ // would sign out an unrelated user. Rare before, because a rejected bearer used
4803
+ // to come back as 403; routine now that it is a 401.
4804
+ const usesOverrideAuthHeader = !!(_overrides === null || _overrides === void 0 ? void 0 : _overrides.headers)
4805
+ && Object.keys(_overrides.headers).some((name) => name.toLowerCase() === "authorization");
4806
+ const isManagedCredential = !readOnly
4807
+ && !(_overrides === null || _overrides === void 0 ? void 0 : _overrides._getAuthHeaders)
4808
+ && !usesOverrideAuthHeader;
4809
+ // The session that authenticated the FIRST attempt. 401 recovery is bound to it
4810
+ // so a retry can never execute under a principal that replaced it mid-flight.
4811
+ const capturedGeneration = isManagedCredential
4812
+ ? await getSessionGeneration(config.isServer)
4813
+ : null;
4176
4814
  async function executeRequest() {
4177
4815
  var _a;
4178
4816
  // When _getAuthHeaders is provided (wallet client), use it as the sole auth source.
@@ -4222,30 +4860,50 @@ async function makeApiRequest(method, urlPath, data, _overrides) {
4222
4860
  try {
4223
4861
  return await executeRequest();
4224
4862
  }
4225
- catch (error) {
4863
+ catch (initialError) {
4864
+ // The error that ultimately gets normalized below. A retry REPLACES it, so a
4865
+ // genuine post-refresh policy denial surfaces as itself.
4866
+ //
4867
+ // Both retry paths used to `return await executeRequest()` from inside this
4868
+ // catch, which meant a retry that failed skipped the whole normalization
4869
+ // block further down. The default path additionally swallowed the retry error
4870
+ // and rethrew the ORIGINAL 401 — so `401 -> refresh succeeds -> retry is
4871
+ // genuinely denied 403` reached the caller as a 401, contradicting the point
4872
+ // of returning 401 for expired credentials in the first place.
4873
+ let error = initialError;
4226
4874
  if (((_b = error.response) === null || _b === void 0 ? void 0 : _b.status) === 401) {
4227
4875
  // Read-only scoped instance: it holds no session, so there is nothing
4228
4876
  // to refresh. Surface the 401 without touching the global session.
4229
4877
  if (readOnly) {
4230
4878
  throw error;
4231
4879
  }
4232
- // Wallet client path: use client-specific retry, skip global fallback entirely
4233
4880
  if (_overrides === null || _overrides === void 0 ? void 0 : _overrides._clearAuth) {
4881
+ // Wallet client path: client-specific retry, never the global session.
4234
4882
  await _overrides._clearAuth();
4235
- return await executeRequest();
4236
- // If this throws, let it propagate — do NOT clear global ServerSessionManager
4237
- }
4238
- // Default path (no wallet client): refresh global session
4239
- try {
4240
- const refreshed = await refreshAuthSessionOnce(config.appId, config.isServer);
4241
- if (!refreshed) {
4242
- throw new Error("Unable to refresh auth session");
4883
+ try {
4884
+ return await executeRequest();
4885
+ }
4886
+ catch (retryError) {
4887
+ // Normalize the RETRY's own failure. Still no global auth touched.
4888
+ error = retryError;
4243
4889
  }
4244
- return await executeRequest();
4245
4890
  }
4246
- catch (_h) {
4247
- throw error;
4891
+ else if (isManagedCredential) {
4892
+ // Default path: recover the global session, bound to the principal
4893
+ // that authenticated the first attempt.
4894
+ const recovered = await refreshAuthSessionOnce(capturedGeneration, config.isServer);
4895
+ if (recovered) {
4896
+ try {
4897
+ return await executeRequest();
4898
+ }
4899
+ catch (retryError) {
4900
+ error = retryError;
4901
+ }
4902
+ }
4903
+ // Not recovered: keep the original 401 for normalization below.
4248
4904
  }
4905
+ // Unmanaged credential (caller-supplied Authorization): its 401 is the
4906
+ // caller's to handle. Never refresh or clear the global session for it.
4249
4907
  }
4250
4908
  if (((_c = error.response) === null || _c === void 0 ? void 0 : _c.status) == 404) {
4251
4909
  return { data: null, status: 404 };
@@ -4334,7 +4992,6 @@ const MIN_RECONNECT_DELAY_JITTER_MS = 1000;
4334
4992
  const MAX_RECONNECT_DELAY_MS = 300000;
4335
4993
  const RECONNECT_DELAY_GROW_FACTOR = 1.8;
4336
4994
  const MIN_BROWSER_RECONNECT_INTERVAL_MS = 5000;
4337
- const WS_AUTH_EXPIRED_CODE = 'auth_expired';
4338
4995
  // React runs old-route effect cleanup before new-route effects. Keep an
4339
4996
  // otherwise-idle socket for one short handoff window so the new route can
4340
4997
  // cancel teardown and reuse it; genuinely idle sockets still close promptly.
@@ -4470,19 +5127,11 @@ function bearerFromAuthHeaders(headers) {
4470
5127
  }
4471
5128
  // Monotonic counter for unique-ish fallback identity keys (no Date.now in some envs).
4472
5129
  let connectionEpoch = 0;
4473
- function isTokenExpired(token) {
4474
- var _a;
4475
- try {
4476
- const payload = JSON.parse(decodeBase64Url(token.split('.')[1]));
4477
- const expirationTime = payload.exp * 1000;
4478
- const currentTime = Date.now();
4479
- return currentTime > (expirationTime - 60000); // 60 second buffer
4480
- }
4481
- catch (error) {
4482
- console.error('[WS v2] Error checking token expiration. reason:', (_a = error === null || error === void 0 ? void 0 : error.name) !== null && _a !== void 0 ? _a : 'decode_failed');
4483
- return true;
4484
- }
4485
- }
5130
+ // isTokenExpired / makeAuthExpiredError / isAuthExpiredError /
5131
+ // normalizeAuthExpiredError now live in utils/auth-token-freshness so the HTTP and
5132
+ // WebSocket paths cannot drift apart. `classifyToken` replaces the old boolean,
5133
+ // which collapsed "expiring soon" and "already expired" into one answer and read a
5134
+ // token with no numeric `exp` as fresh.
4486
5135
  function getTokenExpirationTime(token) {
4487
5136
  try {
4488
5137
  const payload = JSON.parse(decodeBase64Url(token.split('.')[1]));
@@ -4492,27 +5141,34 @@ function getTokenExpirationTime(token) {
4492
5141
  return null;
4493
5142
  }
4494
5143
  }
4495
- function makeAuthExpiredError(message, status) {
4496
- const err = new Error(`${WS_AUTH_EXPIRED_CODE}: ${message}`);
4497
- err.code = WS_AUTH_EXPIRED_CODE;
4498
- if (status !== undefined)
4499
- err.status = status;
4500
- return err;
4501
- }
4502
- function isAuthExpiredError(error) {
4503
- return !!error && typeof error === 'object' && error.code === WS_AUTH_EXPIRED_CODE;
4504
- }
4505
- function normalizeAuthExpiredError(error, fallbackMessage) {
4506
- var _a, _b;
4507
- if (isAuthExpiredError(error))
4508
- return error;
4509
- const status = (_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : error === null || error === void 0 ? void 0 : error.code;
4510
- return makeAuthExpiredError(fallbackMessage, status);
4511
- }
5144
+ /**
5145
+ * Latch a failure so a dead session does not re-hammer /session/refresh every tick.
5146
+ *
5147
+ * NOT latched when the failure came with the session being cleared or replaced.
5148
+ * The clear notifies auth-state listeners, which kicks a reconnect that resets this
5149
+ * — and if we re-latched afterwards, `getConnectionAuthToken` would keep throwing
5150
+ * even though storage is now empty, so an ordinary expiry left WebSocket
5151
+ * acquisition permanently broken instead of falling back to an anonymous socket.
5152
+ */
4512
5153
  function rememberAmbientAuthFailure(error) {
5154
+ if ((error === null || error === void 0 ? void 0 : error.code) === AUTH_CHANGED_CODE)
5155
+ return error;
5156
+ // The session is gone: the right next move is an anonymous connection, not a
5157
+ // remembered failure.
5158
+ if (!getIdTokenSync())
5159
+ return error;
4513
5160
  ambientAuthFailure = error;
4514
5161
  return error;
4515
5162
  }
5163
+ /** Best-effort synchronous peek, only for deciding whether a session still exists. */
5164
+ function getIdTokenSync() {
5165
+ try {
5166
+ return getActiveSessionManager().getIdToken();
5167
+ }
5168
+ catch (_a) {
5169
+ return null;
5170
+ }
5171
+ }
4516
5172
  function failConnectionAuth(connection, error) {
4517
5173
  connection.authFailure = error;
4518
5174
  connection.pendingAuthToken = null;
@@ -4540,7 +5196,7 @@ function scheduleTokenRefresh(connection, isServer) {
4540
5196
  // This replaces the old single setTimeout approach which was unreliable for long
4541
5197
  // delays (browsers throttle/suspend timers in background tabs).
4542
5198
  connection.tokenRefreshTimer = setInterval(async () => {
4543
- var _a, _b, _c, _d, _e, _f, _g, _h;
5199
+ var _a, _b, _c, _d, _e;
4544
5200
  try {
4545
5201
  const currentToken = await getIdToken(isServer);
4546
5202
  if (!currentToken)
@@ -4555,90 +5211,70 @@ function scheduleTokenRefresh(connection, isServer) {
4555
5211
  // verifies JWT at $connect time, so the existing connection continues
4556
5212
  // working. The fresh token is picked up by urlProvider the next time
4557
5213
  // a reconnect naturally happens (network drop, keepalive timeout, etc.).
5214
+ //
5215
+ // Routed through the shared transaction. This used to read a refresh
5216
+ // token, call refreshSession, and write the result with nothing binding
5217
+ // it to the session it started from — so a timer that fired for S1 and
5218
+ // completed after a same-wallet re-login could overwrite S2's rotated
5219
+ // token, and the next refresh would trip the issuer's reuse detection
5220
+ // and revoke the whole family.
4558
5221
  try {
4559
- const currentRefreshToken = await getRefreshToken(isServer);
4560
- if (!currentRefreshToken) {
4561
- console.warn('[WS v2] No refresh token available for proactive refresh');
4562
- return;
4563
- }
4564
- const refreshData = await refreshSession(currentRefreshToken, getSessionIssuer(isServer));
4565
- if (refreshData && refreshData.idToken && refreshData.accessToken) {
4566
- await updateIdTokenAndAccessToken(refreshData.idToken, refreshData.accessToken, isServer, refreshData.refreshToken);
4567
- console.info('[WS v2] Token refreshed successfully, stored for next connection');
4568
- }
4569
- else {
4570
- console.warn('[WS v2] Proactive token refresh returned incomplete data');
4571
- }
5222
+ await getFreshAuthToken(isServer);
5223
+ console.info('[WS v2] Token refreshed successfully, stored for next connection');
4572
5224
  }
4573
5225
  catch (refreshError) {
4574
- // If the refresh token itself is invalid (401/403), stop retrying —
4575
- // the token won't magically become valid next interval, and continuing
4576
- // to hammer /session/refresh causes 429 rate-limit storms.
4577
- if (((_a = refreshError === null || refreshError === void 0 ? void 0 : refreshError.response) === null || _a === void 0 ? void 0 : _a.status) === 401 || ((_b = refreshError === null || refreshError === void 0 ? void 0 : refreshError.response) === null || _b === void 0 ? void 0 : _b.status) === 403) {
4578
- console.warn('[WS v2] Refresh token rejected (401/403), stopping periodic refresh');
5226
+ const definitive = (refreshError === null || refreshError === void 0 ? void 0 : refreshError.code) === AUTH_EXPIRED_CODE
5227
+ || (refreshError === null || refreshError === void 0 ? void 0 : refreshError.code) === AUTH_CHANGED_CODE;
5228
+ if (definitive) {
5229
+ // The session is dead or belongs to someone else now. The shared
5230
+ // helper has already cleared storage and notified auth-state
5231
+ // listeners, so the UI stops showing a connection that is gone —
5232
+ // this branch used to just stop the timer and leave the UI
5233
+ // claiming to be connected.
5234
+ console.warn('[WS v2] Session no longer usable, stopping periodic refresh');
4579
5235
  if (connection.tokenRefreshTimer) {
4580
5236
  clearInterval(connection.tokenRefreshTimer);
4581
5237
  connection.tokenRefreshTimer = null;
4582
5238
  }
4583
5239
  return;
4584
5240
  }
4585
- // Log only the status — the raw axios error carries config.data
4586
- // (the refresh token) and must never hit the console / log sinks.
4587
- console.warn('[WS v2] Proactive token refresh failed, will retry next interval. status:', (_e = (_d = (_c = refreshError === null || refreshError === void 0 ? void 0 : refreshError.response) === null || _c === void 0 ? void 0 : _c.status) !== null && _d !== void 0 ? _d : refreshError === null || refreshError === void 0 ? void 0 : refreshError.code) !== null && _e !== void 0 ? _e : 'unknown');
5241
+ // Transient: keep the session and try again next interval.
5242
+ // Status only — the raw axios error carries config.data (the refresh
5243
+ // token) and must never hit the console / log sinks.
5244
+ console.warn('[WS v2] Proactive token refresh failed, will retry next interval. status:', (_b = (_a = refreshError === null || refreshError === void 0 ? void 0 : refreshError.status) !== null && _a !== void 0 ? _a : refreshError === null || refreshError === void 0 ? void 0 : refreshError.code) !== null && _b !== void 0 ? _b : 'unknown');
4588
5245
  }
4589
5246
  }
4590
5247
  }
4591
5248
  catch (error) {
4592
- console.error('[WS v2] Error in periodic token check. status:', (_h = (_g = (_f = error === null || error === void 0 ? void 0 : error.response) === null || _f === void 0 ? void 0 : _f.status) !== null && _g !== void 0 ? _g : error === null || error === void 0 ? void 0 : error.code) !== null && _h !== void 0 ? _h : 'unknown');
5249
+ console.error('[WS v2] Error in periodic token check. status:', (_e = (_d = (_c = error === null || error === void 0 ? void 0 : error.response) === null || _c === void 0 ? void 0 : _c.status) !== null && _d !== void 0 ? _d : error === null || error === void 0 ? void 0 : error.code) !== null && _e !== void 0 ? _e : 'unknown');
4593
5250
  }
4594
5251
  }, TOKEN_CHECK_INTERVAL);
4595
5252
  }
4596
- async function getFreshAuthToken(isServer) {
4597
- var _a, _b, _c, _d, _e;
4598
- const currentToken = await getIdToken(isServer);
4599
- if (!currentToken) {
4600
- if (ambientAuthFailure) {
4601
- throw ambientAuthFailure;
4602
- }
4603
- return null;
4604
- }
4605
- if (!isTokenExpired(currentToken)) {
4606
- ambientAuthFailure = null;
4607
- return currentToken;
4608
- }
4609
- if (ambientAuthFailure) {
5253
+ /**
5254
+ * WS-local wrapper around the shared freshness helper.
5255
+ *
5256
+ * The shared helper owns expiry classification, the deduplicated
5257
+ * snapshot -> refresh -> commit transaction, and conditional clearing. This adds
5258
+ * only the WS-specific storm guard: without it, a dead session would retry the
5259
+ * refresh on every reconnect attempt and every 5-minute tick.
5260
+ */
5261
+ async function getWsAuthToken(isServer) {
5262
+ var _a, _b;
5263
+ // A remembered failure short-circuits, but only while a session still exists.
5264
+ // See rememberAmbientAuthFailure: once storage is cleared the correct next move
5265
+ // is an anonymous connection, not a replayed error.
5266
+ if (ambientAuthFailure && getIdTokenSync()) {
4610
5267
  throw ambientAuthFailure;
4611
5268
  }
4612
- // Token is expired — attempt refresh
4613
- const refreshToken = await getRefreshToken(isServer);
4614
- if (!refreshToken) {
4615
- console.warn('[WS v2] Token expired but no refresh token available');
4616
- throw rememberAmbientAuthFailure(makeAuthExpiredError('Authentication expired and no refresh token is available'));
4617
- }
4618
5269
  try {
4619
- const refreshData = await refreshSession(refreshToken, getSessionIssuer(isServer));
4620
- if (refreshData && refreshData.idToken && refreshData.accessToken) {
4621
- await updateIdTokenAndAccessToken(refreshData.idToken, refreshData.accessToken, isServer, refreshData.refreshToken);
4622
- ambientAuthFailure = null;
4623
- return refreshData.idToken;
4624
- }
4625
- throw makeAuthExpiredError('Authentication refresh returned an incomplete session');
5270
+ const token = await getFreshAuthToken(isServer);
5271
+ ambientAuthFailure = null;
5272
+ return token;
4626
5273
  }
4627
5274
  catch (error) {
4628
- // Log only the status — the raw axios error carries config.data (the refresh
4629
- // token) and must never reach the console / log sinks.
4630
- console.error('[WS v2] Error refreshing token. status:', (_c = (_b = (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : error === null || error === void 0 ? void 0 : error.code) !== null && _c !== void 0 ? _c : 'unknown');
4631
- // If the refresh token is permanently invalid (401/403), clear the stale
4632
- // session from storage so future attempts don't keep retrying with it.
4633
- if (!isServer && (((_d = error === null || error === void 0 ? void 0 : error.response) === null || _d === void 0 ? void 0 : _d.status) === 401 || ((_e = error === null || error === void 0 ? void 0 : error.response) === null || _e === void 0 ? void 0 : _e.status) === 403)) {
4634
- try {
4635
- const { getActiveSessionManager } = await Promise.resolve().then(function () { return sessionManager; });
4636
- getActiveSessionManager().clearSession();
4637
- }
4638
- catch (clearError) {
4639
- console.warn('[WS v2] Failed to clear stale session:', clearError);
4640
- }
4641
- }
5275
+ // Status only — the raw axios error carries config.data (the refresh token)
5276
+ // and must never reach a console or log sink.
5277
+ console.error('[WS v2] Error acquiring auth token. status:', (_b = (_a = error === null || error === void 0 ? void 0 : error.status) !== null && _a !== void 0 ? _a : error === null || error === void 0 ? void 0 : error.code) !== null && _b !== void 0 ? _b : 'unknown');
4642
5278
  throw rememberAmbientAuthFailure(normalizeAuthExpiredError(error, 'Authentication expired and refresh failed'));
4643
5279
  }
4644
5280
  }
@@ -4650,7 +5286,10 @@ async function getConnectionAuthToken(isServer, authTokenProvider, anonymous) {
4650
5286
  return null;
4651
5287
  }
4652
5288
  if (!authTokenProvider) {
4653
- return getFreshAuthToken(isServer);
5289
+ // getWsAuthToken, not the shared helper directly: the wrapper carries the
5290
+ // reconnect-storm guard that keeps a dead session from re-refreshing on
5291
+ // every reconnect attempt.
5292
+ return getWsAuthToken(isServer);
4654
5293
  }
4655
5294
  try {
4656
5295
  const token = await authTokenProvider();
@@ -6010,7 +6649,7 @@ async function doReconnectWithNewAuth() {
6010
6649
  console.warn('[WS v2] Failed to clear HTTP read cache on auth change:', error);
6011
6650
  }
6012
6651
  try {
6013
- const { reconnectRealtimeStoreWithNewAuth } = await Promise.resolve().then(function () { return require('./realtime-store-BpQsQYhl.js'); });
6652
+ const { reconnectRealtimeStoreWithNewAuth } = await Promise.resolve().then(function () { return require('./realtime-store-Bz4Ob6K6.js'); });
6014
6653
  await reconnectRealtimeStoreWithNewAuth();
6015
6654
  }
6016
6655
  catch (error) {
@@ -7717,11 +8356,11 @@ async function setMany(many, options) {
7717
8356
  transactionResult = await handleEvmTransaction(curTx.evmTransaction, authProvider, options);
7718
8357
  }
7719
8358
  else if (curTx.serializedTransaction) {
7720
- const { handlePreBuiltTransaction } = await Promise.resolve().then(function () { return require('./solana-operations-BOKHbEuy.js'); });
8359
+ const { handlePreBuiltTransaction } = await Promise.resolve().then(function () { return require('./solana-operations-ngqIkN9W.js'); });
7721
8360
  transactionResult = await handlePreBuiltTransaction(curTx, authProvider, options, expectedWritePaths);
7722
8361
  }
7723
8362
  else {
7724
- const { handleSolanaTransaction } = await Promise.resolve().then(function () { return require('./solana-operations-BOKHbEuy.js'); });
8363
+ const { handleSolanaTransaction } = await Promise.resolve().then(function () { return require('./solana-operations-ngqIkN9W.js'); });
7725
8364
  transactionResult = await handleSolanaTransaction(curTx, authProvider, options, expectedWritePaths);
7726
8365
  }
7727
8366
  // Sync items after all transactions are confirmed
@@ -8820,7 +9459,7 @@ async function buildSetDocumentsTransaction(connection, idl, programProvider, pa
8820
9459
  }
8821
9460
  /** SIWS message creation is async already, so its onchain module can be lazy. */
8822
9461
  async function genSolanaMessage(address, nonce) {
8823
- const implementation = await Promise.resolve().then(function () { return require('./solana-message-DKOPPJwk.js'); });
9462
+ const implementation = await Promise.resolve().then(function () { return require('./solana-message-B0NoE4nC.js'); });
8824
9463
  return implementation.genSolanaMessage(address, nonce);
8825
9464
  }
8826
9465
  const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
@@ -9362,6 +10001,8 @@ function defineLiveModule(mod) {
9362
10001
  return mod;
9363
10002
  }
9364
10003
 
10004
+ exports.AUTH_CHANGED_CODE = AUTH_CHANGED_CODE;
10005
+ exports.AUTH_EXPIRED_CODE = AUTH_EXPIRED_CODE;
9365
10006
  exports.BOUNDED_RUNTIME_SET_DOCUMENTS_ATTESTED_SELECTOR = BOUNDED_RUNTIME_SET_DOCUMENTS_ATTESTED_SELECTOR;
9366
10007
  exports.BOUNDED_RUNTIME_SET_DOCUMENTS_SELECTOR = BOUNDED_RUNTIME_SET_DOCUMENTS_SELECTOR;
9367
10008
  exports.BoundedDeclineError = BoundedDeclineError;
@@ -9376,6 +10017,8 @@ exports.RATE_LIMIT_EVENT_NAME = RATE_LIMIT_EVENT_NAME;
9376
10017
  exports.ReactNativeSessionManager = ReactNativeSessionManager;
9377
10018
  exports.ScopedClientReadOnlyError = ScopedClientReadOnlyError;
9378
10019
  exports.ServerSessionManager = ServerSessionManager;
10020
+ exports.SessionEnvironmentError = SessionEnvironmentError;
10021
+ exports.UNSUPPORTED_ENVIRONMENT_CODE = UNSUPPORTED_ENVIRONMENT_CODE;
9379
10022
  exports.WebSessionManager = WebSessionManager;
9380
10023
  exports.aggregate = aggregate;
9381
10024
  exports.assertCanonicalBoundedRuntimeWriteCalldata = assertCanonicalBoundedRuntimeWriteCalldata;
@@ -9415,10 +10058,12 @@ exports.increment = increment;
9415
10058
  exports.init = init;
9416
10059
  exports.intent = intent;
9417
10060
  exports.invoke = invoke;
10061
+ exports.isAuthExpiredError = isAuthExpiredError;
9418
10062
  exports.isEffectResult = isEffectResult;
9419
10063
  exports.live = live;
9420
10064
  exports.normalizeEvmAddress = normalizeEvmAddress;
9421
10065
  exports.now = now;
10066
+ exports.onSessionCleared = onSessionCleared;
9422
10067
  exports.queryAggregate = queryAggregate;
9423
10068
  exports.reconnectWithNewAuth = reconnectWithNewAuth;
9424
10069
  exports.refreshSession = refreshSession;
@@ -9444,4 +10089,4 @@ exports.toLowerEvmAddress = toLowerEvmAddress;
9444
10089
  exports.toMillis = toMillis;
9445
10090
  exports.toSeconds = toSeconds;
9446
10091
  exports.withEffects = withEffects;
9447
- //# sourceMappingURL=index-B30-XpaX.js.map
10092
+ //# sourceMappingURL=index-BIdD2yUB.js.map