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 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-31 19:05:59 +03:00
parent 045f3965ec
commit 427b98e154
7 changed files with 277 additions and 64 deletions
@@ -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> = {}): 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<string>()
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)
})
})
+9 -40
View File
@@ -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)
+30
View File
@@ -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<string>
}): 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
}
+1 -1
View File
@@ -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'
+23
View File
@@ -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
}
}
+107
View File
@@ -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
}
+3 -23
View File
@@ -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.