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