fix session refresh persistence, 2fa login, bundle disposal, cross-tab labelers

Review fixes (PR #11182 round 1):
- PasswordSession fires onUpdated/onDeleted BEFORE committing its
  session data; thread the hook payload through to the provider instead
  of reading the live getter. Fixes rotated tokens never persisting
  (eventual forced logout) and expiry not logging out.
- getErrorName now gates on LexError, so LexAuthFactorError (a sibling
  of XrpcError) surfaces AuthFactorTokenRequired and email-2fa users
  get the code input.
- disposeBundle was a no-op; add a kill-switch closure around the
  session's injected fetch (covers the internal auto-refresh path) so a
  replaced session can't consume rotated refresh tokens. kill() also
  disarms the hooks so stale bundles can't dispatch into the reducer.
- cross-tab same-did rebuild now reapplies subscribed labelers to the
  fresh appview client (was built with an empty per-instance set).
- isAppLabeler reads Client.appLabelers instead of the hard-coded prod
  did, restoring test-env and regional-authority classification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-17 15:17:40 +03:00
parent 564044f6ac
commit 4c2771eca3
5 changed files with 300 additions and 36 deletions
+31 -7
View File
@@ -9,7 +9,7 @@ import {
useSyncExternalStore,
} from 'react'
import {type Client} from '@atproto/lex-client'
import {PasswordSession} from '@atproto/lex-password-session'
import {PasswordSession, type SessionData} from '@atproto/lex-password-session'
import * as persisted from '#/state/persisted'
import {useCloseAllActiveElements} from '#/state/util'
@@ -19,6 +19,7 @@ import {IS_WEB} from '#/env'
import {com} from '#/lexicons'
import {emitSessionDropped} from '../events'
import {getPublicLexClient, getUnauthenticatedClient} from './clients'
import {configureModerationForAccount} from './moderation'
import {type Action, getInitialState, reducer, type State} from './reducer'
import {
type AtpSessionEvent,
@@ -29,6 +30,7 @@ import {
disposeBundle,
makeSessionHooks,
type PublicSessionBundle,
registerBundleKillSwitch,
sessionAccountToSessionData,
type SessionBundle,
sessionDataToSessionAccount,
@@ -131,14 +133,23 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
bundle: SessionBundle,
accountDid: string,
sessionEvent: AtpSessionEvent,
sessionData?: SessionData,
) => {
// Snapshot the (mutable) live session data right away.
/*
* Build the refreshed account from the payload the hook delivers, NOT the
* live session getter. `PasswordSession` fires onUpdated/onDeleted BEFORE
* it commits its internal `#sessionData` (see password-session.js), so at
* hook time `bundle.session.session` still holds the OLD tokens (and, on
* the expiry path, `destroyed` is still false). Reading the live getter
* here would (a) persist stale tokens on 'update' -> eventual forced
* logout once the real refresh token expires, and (b) keep the user
* signed in on 'expired'. On 'update' the payload carries the new session;
* on 'expired'/'create-failed' we force it undefined so the reducer clears
* tokens and logs out (it treats undefined as "session gone").
*/
const refreshedAccount =
bundle.session && !bundle.session.destroyed
? sessionDataToSessionAccount(
bundle.session.session,
bundle.session.session.service,
)
sessionEvent === 'update' && sessionData
? sessionDataToSessionAccount(sessionData, sessionData.service)
: undefined
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
emitSessionDropped()
@@ -420,7 +431,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
hooks,
)
newBundle = buildBundle(newSession)
registerBundleKillSwitch(newBundle, hooks.kill)
hooks.arm()
/*
* Reapply this account's subscribed labelers to the freshly built
* appview client. buildBundle starts with an empty per-instance
* labeler set, and unlike login/resume/createAccount this rebuild path
* never ran configureModerationForAccount - so without this, subscribed
* labelers would silently drop from appview requests in follower tabs
* until the next full resume. readLabelers is a local storage read (no
* network), preserving the branch's no-network intent; setLabelers
* mutates the client's Set in place so labels reappear when it
* resolves.
*/
void configureModerationForAccount(newBundle, syncedAccount)
addSessionDebugLog({
type: 'agent:patch',
agent: newBundle,