From 427b98e154a42ecadc3229d9f56798d8e73379b6 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Jul 2026 19:05:59 +0300 Subject: [PATCH] extract session-data, network, and expiry-rescue modules Session-account conversion, the network-aware fetch wrapper, and the expiry-rescue candidate picker were tangled into agent.ts and util.ts. Pulling them into agent-agnostic modules lets the upcoming session layer share them. Co-Authored-By: Claude Fable 5 --- .../session/__tests__/expiry-rescue-test.ts | 104 +++++++++++++++++ src/state/session/agent.ts | 49 ++------ src/state/session/expiry-rescue.ts | 30 +++++ src/state/session/index.tsx | 2 +- src/state/session/network.ts | 23 ++++ src/state/session/session-data.ts | 107 ++++++++++++++++++ src/state/session/util.ts | 26 +---- 7 files changed, 277 insertions(+), 64 deletions(-) create mode 100644 src/state/session/__tests__/expiry-rescue-test.ts create mode 100644 src/state/session/expiry-rescue.ts create mode 100644 src/state/session/network.ts create mode 100644 src/state/session/session-data.ts diff --git a/src/state/session/__tests__/expiry-rescue-test.ts b/src/state/session/__tests__/expiry-rescue-test.ts new file mode 100644 index 0000000000..c33eb527de --- /dev/null +++ b/src/state/session/__tests__/expiry-rescue-test.ts @@ -0,0 +1,104 @@ +import {describe, expect, it} from '@jest/globals' + +import { + MAX_EXPIRY_RESCUE_GENERATIONS, + pickExpiryRescueCandidate, +} from '../expiry-rescue' +import {type SessionAccount} from '../types' + +function makeAccount(overrides: Partial = {}): SessionAccount { + return { + service: 'https://bsky.social', + did: 'did:plc:example123', + handle: 'alice.test', + email: 'alice@example.com', + emailConfirmed: true, + emailAuthFactor: false, + refreshJwt: 'refresh-jwt', + accessJwt: 'access-jwt', + signupQueued: false, + active: true, + status: undefined, + pdsUrl: undefined, + isSelfHosted: false, + ...overrides, + } +} + +describe('pickExpiryRescueCandidate', () => { + it('picks a candidate whose refreshJwt differs from the dying one', () => { + const fresh = makeAccount({refreshJwt: 'refresh-jwt-2'}) + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [fresh], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(fresh) + }) + + it('rejects a candidate carrying the dying refreshJwt (equally dead)', () => { + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [makeAccount({refreshJwt: 'refresh-jwt-1'})], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(undefined) + }) + + it('rejects a candidate with no refreshJwt', () => { + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [makeAccount({refreshJwt: undefined}), undefined], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(undefined) + }) + + it('rejects a candidate already recorded as failed (loop guard)', () => { + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [makeAccount({refreshJwt: 'refresh-jwt-2'})], + failedRefreshJwts: new Set(['refresh-jwt-2']), + }) + expect(picked).toBe(undefined) + }) + + it('tries candidates in order, preferring the first qualifying one', () => { + const persistedCandidate = makeAccount({ + refreshJwt: 'refresh-jwt-persisted', + }) + const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'}) + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [persistedCandidate, reducerCandidate], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(persistedCandidate) + }) + + it('skips an unusable first candidate and falls back to a later one', () => { + const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'}) + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-1', + candidates: [ + makeAccount({refreshJwt: 'refresh-jwt-1'}), + reducerCandidate, + ], + failedRefreshJwts: new Set(), + }) + expect(picked).toBe(reducerCandidate) + }) + + it('gives up once the failed-generation set hits the hard cap', () => { + const failed = new Set() + for (let i = 0; i < MAX_EXPIRY_RESCUE_GENERATIONS; i++) { + failed.add(`refresh-jwt-failed-${i}`) + } + const picked = pickExpiryRescueCandidate({ + dyingRefreshJwt: 'refresh-jwt-dying', + candidates: [makeAccount({refreshJwt: 'refresh-jwt-brand-new'})], + failedRefreshJwts: failed, + }) + expect(picked).toBe(undefined) + }) +}) diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 0d92199bdd..2c1ad89ab0 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -30,14 +30,20 @@ import { } from '#/ageAssurance/data' import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' import {features} from '#/analytics' -import {emitNetworkConfirmed, emitNetworkLost} from '../events' import {addSessionErrorLog} from './logging' import { configureModerationForAccount, configureModerationForGuest, } from './moderation' +import {networkAwareFetch} from './network' +import { + isSessionExpired, + isSignupQueued, + sessionAccountToSession, +} from './session-data' import {type SessionAccount} from './types' -import {isSessionExpired, isSignupQueued} from './util' + +export {sessionAccountToSession} from './session-data' export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}` @@ -313,26 +319,6 @@ export function agentToSessionAccount( } } -export function sessionAccountToSession( - account: SessionAccount, -): AtpSessionData { - return { - // Sorted in the same property order as when returned by BskyAgent (alphabetical). - accessJwt: account.accessJwt ?? '', - did: account.did, - email: account.email, - emailAuthFactor: account.emailAuthFactor, - emailConfirmed: account.emailConfirmed, - handle: account.handle, - refreshJwt: account.refreshJwt ?? '', - /** - * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188 - */ - active: account.active ?? true, - status: account.status, - } -} - export class Agent extends BaseAgent { constructor( proxyHeader: ProxyHeaderValue | null, @@ -349,7 +335,6 @@ export class Agent extends BaseAgent { // WARN: In the factories above, we _manually set a proxy header_ for the agent after we do whatever it is we are supposed to do. // Ideally, we wouldn't be doing this. However, since there is so much logic that requires making calls to the PDS right now, it // feels safer to just let those run as-is and set the header afterward. -let realFetch = globalThis.fetch class BskyAppAgent extends AtpAgent { persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = undefined @@ -357,23 +342,7 @@ class BskyAppAgent extends AtpAgent { constructor({service}: {service: string}) { super({ service, - async fetch(...args) { - let success = false - try { - const result = await realFetch(...args) - success = true - return result - } catch (e) { - success = false - throw e - } finally { - if (success) { - emitNetworkConfirmed() - } else { - emitNetworkLost() - } - } - }, + fetch: networkAwareFetch, persistSession: (event: AtpSessionEvent) => { if (this.persistSessionHandler) { this.persistSessionHandler(event) diff --git a/src/state/session/expiry-rescue.ts b/src/state/session/expiry-rescue.ts new file mode 100644 index 0000000000..f750a1ba5c --- /dev/null +++ b/src/state/session/expiry-rescue.ts @@ -0,0 +1,30 @@ +import {type SessionAccount} from './types' + +/** Maximum failed token generations considered during one expiry rescue. */ +export const MAX_EXPIRY_RESCUE_GENERATIONS = 5 + +/** Pick the first unfailed token generation newer than the one that expired. */ +export function pickExpiryRescueCandidate({ + dyingRefreshJwt, + candidates, + failedRefreshJwts, +}: { + dyingRefreshJwt: string + candidates: (SessionAccount | undefined)[] + failedRefreshJwts: ReadonlySet +}): SessionAccount | undefined { + if (failedRefreshJwts.size >= MAX_EXPIRY_RESCUE_GENERATIONS) { + return undefined + } + for (const candidate of candidates) { + const refreshJwt = candidate?.refreshJwt + if ( + refreshJwt && + refreshJwt !== dyingRefreshJwt && + !failedRefreshJwts.has(refreshJwt) + ) { + return candidate + } + } + return undefined +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index fd31261a9d..c1720ddb23 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -25,7 +25,7 @@ import { sessionAccountToSession, } from './agent' import {type Action, getInitialState, reducer, type State} from './reducer' -export {isSignupQueued} from './util' +export {isSignupQueued} from './session-data' import {addSessionDebugLog} from './logging' export type {SessionAccount} from '#/state/session/types' diff --git a/src/state/session/network.ts b/src/state/session/network.ts new file mode 100644 index 0000000000..84e92d564b --- /dev/null +++ b/src/state/session/network.ts @@ -0,0 +1,23 @@ +import {emitNetworkConfirmed, emitNetworkLost} from '#/state/events' + +/* + * Captured once at module load so the wrapper below is immune to later + * monkey-patching of globalThis.fetch. + */ +const realFetch = globalThis.fetch + +/** + * Fetch wrapper that reports network reachability to the app-wide event bus. + * Any resolved response (including HTTP errors) confirms the network is up; a + * thrown error (DNS failure, timeout, offline) reports it as lost. + */ +export const networkAwareFetch: typeof fetch = async (...args) => { + try { + const res = await realFetch(...args) + emitNetworkConfirmed() + return res + } catch (e) { + emitNetworkLost() + throw e + } +} diff --git a/src/state/session/session-data.ts b/src/state/session/session-data.ts new file mode 100644 index 0000000000..2fab1cbd99 --- /dev/null +++ b/src/state/session/session-data.ts @@ -0,0 +1,107 @@ +import {type AtpSessionData} from '@atproto/api' +import {getPdsEndpoint, isValidDidDoc} from '@atproto/common-web' +import {type SessionData} from '@atproto/lex-password-session' +import {jwtDecode} from 'jwt-decode' + +import {BSKY_SERVICE} from '#/lib/constants' +import {isJwtExpired} from '#/lib/jwt' +import {hasProp} from '#/lib/type-guards' +import {type SessionAccount} from './types' + +/** Whether an access token was issued for a queued (waitlisted) signup. */ +export function isSignupQueued(accessJwt: string | undefined) { + if (accessJwt) { + const sessData = jwtDecode(accessJwt) + return ( + hasProp(sessData, 'scope') && + sessData.scope === 'com.atproto.signupQueued' + ) + } + return false +} + +/** + * Convert live `PasswordSession` session data into the persisted + * `SessionAccount` snapshot. + * + * The object literal's field order is load-bearing: the reducer's + * `JSON.stringify` fast path and the session test snapshots depend on + * byte-stable serialization. `service` and `pdsUrl` are normalized through + * `new URL().toString()` for a stable trailing slash. + * + * `pdsUrl` comes from the DID document or a pre-refresh stored value. It does + * not fall back to the login service. + */ +export function sessionDataToSessionAccount( + session: SessionData | null | undefined, + service: string, + storedPdsUrl?: string, +): SessionAccount | undefined { + if (!session) { + return undefined + } + const normalizedService = new URL(service).toString() + const didDocPdsUrl = + session.didDoc && isValidDidDoc(session.didDoc) + ? getPdsEndpoint(session.didDoc) + : undefined + const pdsUrl = didDocPdsUrl ?? storedPdsUrl + return { + service: normalizedService, + did: session.did, + handle: session.handle, + email: session.email, + emailConfirmed: session.emailConfirmed || false, + emailAuthFactor: session.emailAuthFactor || false, + refreshJwt: session.refreshJwt, + accessJwt: session.accessJwt, + signupQueued: isSignupQueued(session.accessJwt), + active: session.active, + status: session.status, + pdsUrl: pdsUrl ? new URL(pdsUrl).toString() : undefined, + isSelfHosted: !normalizedService.startsWith(BSKY_SERVICE), + } +} + +/** Convert a persisted account into data suitable for `PasswordSession`. */ +export function sessionAccountToSessionData( + account: SessionAccount, +): SessionData { + return { + accessJwt: account.accessJwt ?? '', + active: account.active ?? true, + did: account.did as SessionData['did'], + email: account.email, + emailAuthFactor: account.emailAuthFactor, + emailConfirmed: account.emailConfirmed, + handle: account.handle as SessionData['handle'], + refreshJwt: account.refreshJwt ?? '', + status: account.status, + service: account.service, + } +} + +/** Convert a persisted account into data suitable for `AtpAgent`. */ +export function sessionAccountToSession( + account: SessionAccount, +): AtpSessionData { + return { + // Sorted in the same property order as when returned by BskyAgent (alphabetical). + accessJwt: account.accessJwt ?? '', + did: account.did, + email: account.email, + emailAuthFactor: account.emailAuthFactor, + emailConfirmed: account.emailConfirmed, + handle: account.handle, + refreshJwt: account.refreshJwt ?? '', + /** + * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188 + */ + active: account.active ?? true, + status: account.status, + } +} + +export function isSessionExpired(account: SessionAccount) { + return account.accessJwt ? isJwtExpired(account.accessJwt) : true +} diff --git a/src/state/session/util.ts b/src/state/session/util.ts index ea6d817f36..af1c2846c7 100644 --- a/src/state/session/util.ts +++ b/src/state/session/util.ts @@ -1,36 +1,16 @@ import AtpAgent from '@atproto/api' -import {jwtDecode} from 'jwt-decode' -import {isJwtExpired} from '#/lib/jwt' -import {hasProp} from '#/lib/type-guards' import * as persisted from '#/state/persisted' -import {sessionAccountToSession} from './agent' +import {sessionAccountToSession} from './session-data' import {type SessionAccount} from './types' +export {isSessionExpired, isSignupQueued} from './session-data' + export function readLastActiveAccount() { const {currentAccount, accounts} = persisted.get('session') return accounts.find(a => a.did === currentAccount?.did) } -export function isSignupQueued(accessJwt: string | undefined) { - if (accessJwt) { - const sessData = jwtDecode(accessJwt) - return ( - hasProp(sessData, 'scope') && - sessData.scope === 'com.atproto.signupQueued' - ) - } - return false -} - -export function isSessionExpired(account: SessionAccount) { - if (account.accessJwt) { - return isJwtExpired(account.accessJwt) - } else { - return true - } -} - /** * Creates and attempted to resumeSession for every stored session. * Intended to be used to send push token revokations just before logout.