From 03a3ec04765bbc0cbd1721f0e2cdc620b7e4213f Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 31 Aug 2026 13:12:50 -0500 Subject: [PATCH] Update versioned session plan --- plans/versioned-localstorage-sessions.md | 58 +++++++++++++++--------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/plans/versioned-localstorage-sessions.md b/plans/versioned-localstorage-sessions.md index 44f6953bde..961afc45a1 100644 --- a/plans/versioned-localstorage-sessions.md +++ b/plans/versioned-localstorage-sessions.md @@ -62,9 +62,11 @@ Tab B: (7, A) -> (8, B') Therefore: - Use the refresh JWT's `jti` for generation identity. -- Use a locally persisted monotonic version for generation ordering. +- Use a locally persisted monotonic version for sequencing, invalidations, and tombstone ordering. - Do not use JWT `iat` as the generation. +The persisted `(credentialVersion, refreshJti)` pair describes the credential state, but conditional commits do not compare both fields as a CAS key. They establish eligibility from the current status and the `jti` of the refresh token the operation actually used. If that generation is still active, the accepted mutation writes the next local version. + A persisted credential could look like: ```ts @@ -77,7 +79,7 @@ type PersistedCredential = { } ``` -`credentialVersion` should advance only when credential state changes. Metadata changes, account ordering, and current-account selection are not credential generations. +`credentialVersion` advances when an accepted mutation establishes a new credential-ordering boundary: a new refresh `jti`, login, expiration, logout, or removal. Repeating an explicit logout advances the tombstone even if the account is already logged out. Concurrent refresh responses with the same successor `jti` keep the existing version. Metadata changes, account ordering, and current-account selection do not advance it. ## 2. Make localStorage authoritative @@ -89,15 +91,17 @@ Tab B memory: (7, A) <- stale Shared localStorage: (8, B) <- authoritative ``` -Before Tab B uses its in-memory token, it must read localStorage and adopt `(8, B)`. +The app does not preflight localStorage before every authenticated request. A request already queued by a frozen tab may leave with a stale access token and trigger an automatic refresh before queued invalidations are processed. That is acceptable as long as the refresh result or expiration cannot mutate shared state without reconciliation. Every tab must synchronously read localStorage: -- before using a refresh token; -- before writing refreshed credentials; +- before explicitly resuming a persisted session; +- before committing refreshed credentials; - before treating `ExpiredToken` as a logout; and - when notified that another tab updated the session. +If a queued stale refresh succeeds, its result is conditionally committed against the generation it used. If it expires, the expiration handler reads localStorage and adopts a healthy newer generation. The original queued request may fail, but subsequent requests use the rebuilt latest session. Correctness does not depend on processing a broadcast before the queued request. + Tabs must not publish complete in-memory session snapshots as authoritative state. Metadata updates should patch metadata onto a fresh localStorage read without touching credential fields. ## 3. Commit refreshes conditionally @@ -110,15 +114,15 @@ Tab B memory: (7, A) Shared localStorage: (7, A) ``` -Tab A synchronously reads `(7, A)` from localStorage and captures it as the base generation for its refresh: +Tab A captures refresh generation `A` from the session performing the request. An explicit resume first rereads localStorage; an automatic refresh may begin from the live in-memory bundle before a queued invalidation is processed: ```text -Tab A base: (7, A) +Tab A base: generation A Tab A sends refresh token A to the PDS Tab A receives successor generation B ``` -Before committing the response, Tab A synchronously rereads localStorage. The source of truth may have changed while its network request was in flight. +Before committing the response, Tab A acquires the credential lock and synchronously rereads localStorage. The source of truth may have changed while its network request was in flight. ### Case 1: nothing else changed @@ -152,6 +156,8 @@ Tab A result: based on stale generation A Tab A action: do not overwrite localStorage ``` +Commit eligibility is generation-specific: the current persisted credential must still be `active` and its `refreshJti` must match the base refresh token's `jti`. `credentialVersion` records the accepted ordering but is not itself part of this comparison. + This makes concurrent refreshes harmless while preventing a suspended Tab A from rolling back state already advanced by Tab B. ## 4. Make expiration generation-specific @@ -193,7 +199,7 @@ Tab A receives a refresh result based on (8, B) Tab A rereads localStorage and preserves Tab B's version 9 tombstone ``` -That prevents Tab A's delayed refresh from resurrecting credentials after Tab B's explicit logout. +That prevents Tab A's delayed refresh from resurrecting credentials after Tab B's explicit logout. Each accepted explicit logout advances the tombstone, including a repeated logout of an already logged-out account. Account removal must create a versioned removal tombstone. Stale tabs preserve that tombstone rather than restoring the removed account. An explicit fresh login is the one operation allowed to resurrect it: the login advances the version and replaces the removal tombstone with active credentials. @@ -257,38 +263,48 @@ Tab A plans: write version 9 Tab B plans: write version 9 ``` -Use a per-account Web Lock to serialize credential operations across cooperating tabs: +Network refreshes do not run while holding a Web Lock. A refresh captures the generation it uses, performs the network request, and acquires the lock only to reconcile and commit its result: ```ts +const baseRefreshJti = getRefreshJti(session.refreshJwt) +const refreshed = await refreshSession() + await navigator.locks.request(`bsky-session:${did}`, async () => { const latest = readAccountFromLocalStorage(did) - // Adopt latest, refresh or mutate it, then synchronously persist the result. + // Commit only if latest is active and still has baseRefreshJti. }) ``` -If Tab A holds the lock, Tab B waits. Once Tab A writes and releases it, Tab B acquires the lock and rereads Tab A's new localStorage state before deciding what to do. +This avoids holding a cross-tab lock over network I/O. Multiple refresh requests may be in flight concurrently; server-side convergence and the generation-specific conditional commit make their results safe. -All credential-changing operations should use the same lock: +All credential-changing commits use the same per-account lock: -- refresh; +- successful refresh reconciliation; - expiration; - logout; - account removal; and - login replacing an existing account. -The complete flow is: +Because all persisted values share one localStorage blob, every write also takes a root persisted-storage lock. The root lock prevents an unrelated preference write from racing the session read-modify-write; the per-account lock expresses credential ownership and gives account operations a consistent order. + +If Tab A holds the locks, Tab B waits. Once Tab A writes and releases them, Tab B acquires them and rereads Tab A's new localStorage state before deciding what to commit. + +The complete refresh flow is: ```text -Web Lock +Capture base refresh jti + | + v +Perform network refresh without a lock + | + v +Acquire root + per-account Web Locks | v Read authoritative localStorage state | v -Operate against captured (version, jti) - | - v -Reread and conditionally commit +Conditionally commit against active base jti | v Write and verify localStorage @@ -336,6 +352,6 @@ Partial client-side mitigations can narrow the window and handle ordinary storag - Schedule persistence as the first action after receiving B, before unrelated work. - Track the persistence promise for B and await it before an explicit refresh operation reports success. - Propagate AsyncStorage failures instead of swallowing them. -- Retry a failed write with the latest in-memory session snapshot while the process is still alive. +- Retry the same enqueued root-state snapshot once before propagating the storage failure. These measures improve durability during normal execution but cannot protect the interval in which the OS terminates the process after the server commits B and before the storage write completes.