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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user