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'
|
} from '#/ageAssurance/data'
|
||||||
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
|
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
|
||||||
import {features} from '#/analytics'
|
import {features} from '#/analytics'
|
||||||
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
|
||||||
import {addSessionErrorLog} from './logging'
|
import {addSessionErrorLog} from './logging'
|
||||||
import {
|
import {
|
||||||
configureModerationForAccount,
|
configureModerationForAccount,
|
||||||
configureModerationForGuest,
|
configureModerationForGuest,
|
||||||
} from './moderation'
|
} from './moderation'
|
||||||
|
import {networkAwareFetch} from './network'
|
||||||
|
import {
|
||||||
|
isSessionExpired,
|
||||||
|
isSignupQueued,
|
||||||
|
sessionAccountToSession,
|
||||||
|
} from './session-data'
|
||||||
import {type SessionAccount} from './types'
|
import {type SessionAccount} from './types'
|
||||||
import {isSessionExpired, isSignupQueued} from './util'
|
|
||||||
|
export {sessionAccountToSession} from './session-data'
|
||||||
|
|
||||||
export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
|
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 {
|
export class Agent extends BaseAgent {
|
||||||
constructor(
|
constructor(
|
||||||
proxyHeader: ProxyHeaderValue | null,
|
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.
|
// 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
|
// 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.
|
// feels safer to just let those run as-is and set the header afterward.
|
||||||
let realFetch = globalThis.fetch
|
|
||||||
class BskyAppAgent extends AtpAgent {
|
class BskyAppAgent extends AtpAgent {
|
||||||
persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
|
persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
|
||||||
undefined
|
undefined
|
||||||
@@ -357,23 +342,7 @@ class BskyAppAgent extends AtpAgent {
|
|||||||
constructor({service}: {service: string}) {
|
constructor({service}: {service: string}) {
|
||||||
super({
|
super({
|
||||||
service,
|
service,
|
||||||
async fetch(...args) {
|
fetch: networkAwareFetch,
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
persistSession: (event: AtpSessionEvent) => {
|
persistSession: (event: AtpSessionEvent) => {
|
||||||
if (this.persistSessionHandler) {
|
if (this.persistSessionHandler) {
|
||||||
this.persistSessionHandler(event)
|
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,
|
sessionAccountToSession,
|
||||||
} from './agent'
|
} from './agent'
|
||||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||||
export {isSignupQueued} from './util'
|
export {isSignupQueued} from './session-data'
|
||||||
import {addSessionDebugLog} from './logging'
|
import {addSessionDebugLog} from './logging'
|
||||||
export type {SessionAccount} from '#/state/session/types'
|
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 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 * as persisted from '#/state/persisted'
|
||||||
import {sessionAccountToSession} from './agent'
|
import {sessionAccountToSession} from './session-data'
|
||||||
import {type SessionAccount} from './types'
|
import {type SessionAccount} from './types'
|
||||||
|
|
||||||
|
export {isSessionExpired, isSignupQueued} from './session-data'
|
||||||
|
|
||||||
export function readLastActiveAccount() {
|
export function readLastActiveAccount() {
|
||||||
const {currentAccount, accounts} = persisted.get('session')
|
const {currentAccount, accounts} = persisted.get('session')
|
||||||
return accounts.find(a => a.did === currentAccount?.did)
|
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.
|
* Creates and attempted to resumeSession for every stored session.
|
||||||
* Intended to be used to send push token revokations just before logout.
|
* Intended to be used to send push token revokations just before logout.
|
||||||
|
|||||||
Reference in New Issue
Block a user