/** * The one protocol every session-storage mutation goes through. * * WHY THIS EXISTS * * `localStorage` is shared by every tab on an origin but offers no atomic * compare-and-set spanning a read and a write, and each tab is a separate JS * realm, so an in-memory guard cannot serialize them. The old code relied on * doing its read → check → write inside one synchronous turn, which is only * atomic *within* a realm. * * That is not a theoretical gap. `REFRESH_REUSE_GRACE_MS` on the issuer is 60s, * so two tabs presenting the same refresh token inside that window BOTH succeed * and BOTH receive the same successor refresh token — while each response mints * its own distinct idToken. Concurrent cross-tab commits are a designed-for * occurrence, not an edge case. * * Two failures follow from an unlocked read-then-write, and both are closed here: * * 1. Lost update / resurrection. Tab A reads, tab B logs in (or logs out), tab A * writes its stale blob back. B's login is silently replaced, or a logged-out * session comes back to life. * 2. Spurious "the user changed". Tab B's commit finds tab A's blob, which * matches neither B's snapshot nor B's own result because the idTokens * differ — even though it is the same user and the same login. * * THE CRITICAL SECTION IS THE COMMIT, NOT THE NETWORK CALL * * The lock covers read → compare → write only. The refresh POST happens outside * it: holding a cross-tab lock across a 30s network call would serialize every * tab behind the slowest one. This is safe because the snapshot captured before * the network call is used only for COMPARISON. What actually gets written is * decided against storage re-read inside the lock, so a login or logout that lands * during the network call is always observed. * * NO BEST-EFFORT FALLBACK * * A `localStorage` lease was considered and rejected: its own acquire sequence is * a non-atomic read-then-write across realms, and a backgrounded tab can be frozen * past any lease timeout and then resume and write. Both leave exactly the windows * this module exists to close, so a browser without Web Locks fails closed instead. * Absence of `navigator.locks` does NOT imply a single realm — such a browser still * has tabs. Single realm is determined by the absence of `localStorage`. */ export declare const UNSUPPORTED_ENVIRONMENT_CODE = "unsupported_environment"; /** * Thrown when authenticated session work is attempted in a browser that shares * `localStorage` across tabs but cannot serialize access to it. Reads and * anonymous requests are unaffected; only session mutation is refused. */ export declare class SessionEnvironmentError extends Error { readonly code = "unsupported_environment"; constructor(message: string); } /** * Run `fn` with exclusive access to session storage across every tab on this origin. * * Single-realm runtimes execute directly. Browsers serialize through Web Locks. * A browser without Web Locks throws rather than running unserialized. */ export declare function withSessionLock(fn: () => Promise | T): Promise; /** Throws if this environment cannot safely mutate a session. Cheap pre-flight. */ export declare function assertSessionMutationSupported(): void; /** * Opaque per-login identifier stamped into the stored session. * * Minted on every `storeSession` (login, guest upgrade, wallet switch) and * PRESERVED across refresh commits. That is the whole point: it is what makes * "S1 refreshed into S1'" provable and distinguishable from "someone logged in * again", which token equality and address equality both get wrong — token * equality rejects a valid post-refresh retry, and address equality accepts a * same-wallet re-login as if it were the same session. * * The issuer has a server-side `familyId` that would serve, but `/session/refresh` * does not return it, so this is client-side. */ export declare function mintSessionGeneration(): string; /** * What happened when a refreshed token was committed. * * `applied` - storage still held the snapshot; the new tokens landed. * `already-equal` - storage already held exactly this result (another caller * in this or another tab committed the same refresh). An * idempotent SUCCESS, not a conflict. * `same-generation-winner`- storage holds a different but valid session of the SAME * login. Someone else's refresh won the race; theirs is the * truth. Also a success — the caller uses the stored one. * `superseded` - the generation changed, or the session is gone entirely. * The only outcome that means "the principal changed". */ export type CommitOutcome = "applied" | "already-equal" | "same-generation-winner" | "superseded"; export interface CommitResult { outcome: CommitOutcome; /** The session the caller must use. Null only when `outcome` is `superseded`. */ session: S | null; } /** Minimal shape the commit logic needs; both managers' blobs satisfy it. */ export interface SessionLike { idToken?: string; accessToken?: string; refreshToken?: string; sessionGeneration?: string; } /** * The session as the caller saw it BEFORE its network refresh. * * `generation` proves which login it belongs to. `idToken` distinguishes "storage * is untouched, so my write is the right one" from "someone else's refresh of this * same login already landed" — without it, an ordinary successful refresh would be * indistinguishable from losing a race. Omit `idToken` when the caller has no * snapshot to compare (the generation check alone still applies). */ export interface ExpectedSession { generation: string | null; idToken?: string; } /** * Decide a commit against what storage ACTUALLY holds right now. * * Must be called inside `withSessionLock`, with `current` read inside that same * critical section — comparing against a value read before the network call is * exactly the race this exists to prevent. */ export declare function decideCommit(current: S | null, expected: ExpectedSession, next: S): CommitOutcome;