add the passwordsession bridge agent

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-31 19:25:43 +03:00
parent 427b98e154
commit 9f40ba0a0b
2 changed files with 953 additions and 0 deletions
@@ -0,0 +1,578 @@
import {
PasswordSession,
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode() {
return {scope: 'com.atproto.access'}
},
}))
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import {sessionAccountToSessionData} from '../session-data'
import {type SessionAccount} from '../types'
const DID = 'did:plc:example123'
const HANDLE = 'alice.test'
const SERVICE = 'https://bsky.social'
const PDS_HOST = 'https://shimeji.us-east.host.bsky.network'
const DIDDOC_PDS_HOST = 'https://morel.us-west.host.bsky.network'
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
return {
service: SERVICE,
did: DID,
handle: HANDLE,
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,
}
}
/** A minimal valid DID document whose only service entry is a PDS. */
function makeDidDoc(pdsUrl: string) {
return {
id: DID,
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: pdsUrl,
},
],
}
}
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {'content-type': 'application/json'},
})
}
/**
* Build a mock `fetch` that returns canned XRPC responses keyed by the last
* path segment (nsid). `refreshSession` returns fresh tokens; `getSession`
* echoes the account; anything else returns an empty 200.
*/
function makeMockFetch(
overrides: Record<
string,
(url: string, init: RequestInit) => Response | Promise<Response>
> = {},
) {
return jest.fn(
/*
* PasswordSession calls fetch with a URL object (new URL(path, service));
* asFetch() below widens the mock to the full fetch signature it expects.
*/
async (input: URL | string, init: RequestInit = {}): Promise<Response> => {
const url = input instanceof URL ? input.href : input
const nsid = url.split('/xrpc/')[1]?.split('?')[0]
const handler = nsid ? overrides[nsid] : undefined
if (handler) {
return handler(url, init)
}
if (nsid === 'com.atproto.server.refreshSession') {
return json({
accessJwt: 'access-jwt-2',
refreshJwt: 'refresh-jwt-2',
handle: HANDLE,
did: DID,
email: 'alice@example.com',
/* both emailConfirmed and didDoc present -> no getSession follow-up */
emailConfirmed: true,
didDoc: makeDidDoc(DIDDOC_PDS_HOST),
active: true,
})
}
if (nsid === 'com.atproto.server.getSession') {
return json({
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
active: true,
})
}
return json({})
},
)
}
type MockFetch = ReturnType<typeof makeMockFetch>
/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */
function asFetch(mock: MockFetch): typeof fetch {
return mock as unknown as typeof fetch
}
function urlsOf(mock: MockFetch): string[] {
return mock.mock.calls.map(c => (c[0] instanceof URL ? c[0].href : c[0]))
}
/**
* Build the manager + agent pair under test.
*
* The mock fetch is installed in both places it can be reached from: as the
* inner `PasswordSession`'s fetch (the authenticated path) and, via
* `setFetch`, as the manager's own fetch (the unauthenticated bypass path,
* which would otherwise use the real network-aware fetch).
*/
function setup({
account = makeAccount(),
didDoc,
pdsUrl,
fetchMock = makeMockFetch(),
sessionOptions,
}: {
account?: SessionAccount
didDoc?: SessionData['didDoc']
pdsUrl?: string
fetchMock?: MockFetch
sessionOptions?: PasswordSessionOptions
} = {}) {
const data: SessionData = {
...sessionAccountToSessionData(account),
...(didDoc ? {didDoc} : {}),
}
const inner = new PasswordSession(data, {
fetch: asFetch(fetchMock),
...sessionOptions,
})
const manager = new PasswordSessionManager(inner, {
service: account.service,
pdsUrl,
})
manager.setFetch(asFetch(fetchMock))
const agent = new BskyAppAgent(manager)
return {inner, manager, agent, fetchMock}
}
function setupPublic(fetchMock: MockFetch = makeMockFetch()) {
const manager = new PasswordSessionManager(null, {service: SERVICE})
manager.setFetch(asFetch(fetchMock))
return {manager, agent: new BskyAppAgent(manager), fetchMock}
}
describe('PasswordSessionManager getters', () => {
it('reads live SessionData through .session', () => {
const {agent} = setup()
expect(agent.session?.did).toBe(DID)
expect(agent.session?.handle).toBe(HANDLE)
expect(agent.session?.email).toBe('alice@example.com')
expect(agent.session?.emailConfirmed).toBe(true)
expect(agent.did).toBe(DID)
expect(agent.hasSession).toBe(true)
})
it('defaults active to true when the payload omits it', () => {
const {agent} = setup({account: makeAccount({active: undefined})})
expect(agent.session?.active).toBe(true)
})
it('exposes serviceUrl from the constructor service', () => {
const {agent} = setup()
expect(agent.serviceUrl.toString()).toBe('https://bsky.social/')
})
it('derives pdsUrl/dispatchUrl from the didDoc', () => {
const {agent} = setup({didDoc: makeDidDoc(PDS_HOST)})
expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`)
expect(agent.dispatchUrl.toString()).toBe(`${PDS_HOST}/`)
})
it('falls back to the stored pdsUrl when there is no didDoc', () => {
const {agent} = setup({pdsUrl: PDS_HOST})
expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`)
expect(agent.dispatchUrl.toString()).toBe(`${PDS_HOST}/`)
})
it('prefers the didDoc PDS over the stored pdsUrl', () => {
const {agent} = setup({
didDoc: makeDidDoc(DIDDOC_PDS_HOST),
pdsUrl: PDS_HOST,
})
expect(agent.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`)
})
it('dispatchUrl falls back to serviceUrl with no PDS at all', () => {
const {agent} = setup()
expect(agent.pdsUrl).toBe(undefined)
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
})
it('ignores an unparseable stored pdsUrl', () => {
const {agent} = setup({pdsUrl: 'not a url'})
expect(agent.pdsUrl).toBe(undefined)
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
})
})
describe('PasswordSessionManager.session identity', () => {
it('is stable across consecutive reads', () => {
const {agent} = setup()
expect(agent.session).toBe(agent.session)
})
it('is a new object after a refresh rotates tokens', async () => {
const {agent} = setup()
const before = agent.session
expect(before?.accessJwt).toBe('access-jwt')
await agent.sessionManager.refreshSession()
const after = agent.session
expect(after).not.toBe(before)
expect(after?.accessJwt).toBe('access-jwt-2')
expect(after).toBe(agent.session)
})
it('rejects writes to .session', () => {
const {agent} = setup()
expect(() => {
/* the whole point of the accessor: writes must not silently drift */
agent.sessionManager.session = agent.session
}).toThrow('read-only')
})
it('rejects writes to .pdsUrl', () => {
const {agent} = setup()
expect(() => {
agent.sessionManager.pdsUrl = new URL(PDS_HOST)
}).toThrow('read-only')
})
})
describe('PasswordSessionManager.fetchHandler routing', () => {
it('dispatches to the stored PDS before a refresh, then to the didDoc PDS', async () => {
const {manager, fetchMock} = setup({pdsUrl: PDS_HOST})
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(urlsOf(fetchMock).at(-1)).toBe(
`${PDS_HOST}/xrpc/app.bsky.actor.getProfile`,
)
/* the refresh response carries a didDoc pointing at a different host */
await manager.refreshSession()
expect(manager.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`)
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(urlsOf(fetchMock).at(-1)).toBe(
`${DIDDOC_PDS_HOST}/xrpc/app.bsky.actor.getProfile`,
)
})
it('attaches the session bearer token', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'app.bsky.actor.getProfile': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {manager} = setup({fetchMock})
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(seen[0].get('authorization')).toBe('Bearer access-jwt')
})
it('bypasses the inner session when authorization is pre-set', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'com.atproto.server.describeServer': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {manager} = setup({fetchMock, pdsUrl: PDS_HOST})
/*
* PasswordSession throws TypeError on a pre-set authorization header, so
* this path must never reach it.
*/
await expect(
manager.fetchHandler('/xrpc/com.atproto.server.describeServer', {
headers: {authorization: 'Bearer caller-supplied'},
}),
).resolves.toBeDefined()
expect(seen.length).toBe(1)
/* the caller's header survives, and there is exactly one of them */
expect(seen[0].get('authorization')).toBe('Bearer caller-supplied')
expect(urlsOf(fetchMock).at(-1)).toBe(
`${PDS_HOST}/xrpc/com.atproto.server.describeServer`,
)
})
})
describe('BskyAppAgent namespace requests', () => {
it('carries proxy, labeler and bearer headers to the dispatch host', async () => {
const seen: {url: string; headers: Headers}[] = []
const fetchMock = makeMockFetch({
'app.bsky.actor.getProfile': (url, init) => {
seen.push({url, headers: new Headers(init.headers)})
return json({did: DID, handle: HANDLE})
},
})
const {agent} = setup({fetchMock, pdsUrl: PDS_HOST})
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
agent.configureLabelers(['did:plc:custom-labeler'])
/*
* The request headers (what we assert) are captured by the fetch mock
* before the agent parses the response body. Response-body lexicon
* validation can throw in the jest environment (a multiformats CID mock
* quirk unrelated to the header composition under test), so we ignore any
* parse error here.
*/
await agent.app.bsky.actor.getProfile({actor: HANDLE}).catch(() => {})
expect(seen.length).toBe(1)
expect(seen[0].url.startsWith(`${PDS_HOST}/xrpc/`)).toBe(true)
expect(seen[0].headers.get('atproto-proxy')).toBe(
'did:web:api.bsky.app#bsky_appview',
)
expect(seen[0].headers.get('atproto-accept-labelers')).toContain(
'did:plc:custom-labeler',
)
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
})
})
describe('PasswordSessionManager.refreshSession', () => {
it('returns an old-shaped XRPC envelope with fresh tokens', async () => {
const {manager, fetchMock} = setup()
const res = await manager.refreshSession()
expect(res.success).toBe(true)
expect(res.data.accessJwt).toBe('access-jwt-2')
expect(res.data.refreshJwt).toBe('refresh-jwt-2')
expect(res.data.did).toBe(DID)
expect(res.data.handle).toBe(HANDLE)
expect(
urlsOf(fetchMock).some(u =>
u.includes('com.atproto.server.refreshSession'),
),
).toBe(true)
})
it('throws when there is no live session', async () => {
const {manager} = setupPublic()
await expect(manager.refreshSession()).rejects.toThrow(
'No session to refresh',
)
})
})
describe('PasswordSessionManager.resumeSession', () => {
const staleData = {
accessJwt: 'stale-access',
refreshJwt: 'stale-refresh',
handle: 'stale.test',
did: DID,
active: true,
}
it('ignores its argument and returns fresh tokens from a refresh', async () => {
const {manager} = setup()
const res = await manager.resumeSession(staleData)
expect(res.data.accessJwt).toBe('access-jwt-2')
expect(res.data.refreshJwt).toBe('refresh-jwt-2')
expect(manager.session?.accessJwt).toBe('access-jwt-2')
})
it('is reachable through the agent and does not install the stale data', async () => {
const {agent} = setup()
await agent.resumeSession(staleData)
expect(agent.session?.accessJwt).toBe('access-jwt-2')
expect(agent.session?.handle).toBe(HANDLE)
})
})
describe('PasswordSessionManager unsupported methods', () => {
it('refuses login()', async () => {
const {agent} = setup()
await expect(
agent.login({identifier: HANDLE, password: 'hunter2'}),
).rejects.toThrow('Not supported on PasswordSessionManager')
})
it('refuses createAccount()', async () => {
const {agent} = setup()
await expect(
agent.createAccount({handle: HANDLE, email: 'a@b.c', password: 'x'}),
).rejects.toThrow('Not supported on PasswordSessionManager')
})
})
describe('PasswordSessionManager destroyed inner session', () => {
it('getters return undefined rather than throwing after logout', async () => {
const {agent, inner} = setup()
await agent.logout()
expect(inner.destroyed).toBe(true)
/* PasswordSession.did/.session throw once destroyed; the bridge must not */
expect(() => agent.did).not.toThrow()
expect(agent.did).toBe(undefined)
expect(agent.session).toBe(undefined)
expect(agent.hasSession).toBe(false)
expect(agent.pdsUrl).toBe(undefined)
})
it('logout() is idempotent', async () => {
const {agent} = setup()
await agent.logout()
await expect(agent.logout()).resolves.toBeUndefined()
})
it('fetchHandler stops attaching auth once destroyed', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'app.bsky.actor.getProfile': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {agent, manager} = setup({fetchMock})
await agent.logout()
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(seen.length).toBe(1)
expect(seen[0].get('authorization')).toBe(null)
})
})
describe('BskyAppAgent.dispose', () => {
let ctx: ReturnType<typeof setup>
beforeEach(() => {
ctx = setup({pdsUrl: PDS_HOST})
})
it('makes the session read as logged out', () => {
expect(ctx.agent.session).toBeDefined()
ctx.agent.dispose()
expect(ctx.agent.session).toBe(undefined)
expect(ctx.agent.did).toBe(undefined)
expect(ctx.agent.pdsUrl).toBe(undefined)
expect(ctx.agent.hasSession).toBe(false)
})
it('routes requests through the plain unauthenticated fetch', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'app.bsky.actor.getProfile': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {agent, manager} = setup({fetchMock, pdsUrl: PDS_HOST})
agent.dispose()
await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile')
expect(seen.length).toBe(1)
expect(seen[0].get('authorization')).toBe(null)
/* dispatch falls back to the service, since pdsUrl now reads undefined */
expect(urlsOf(fetchMock).at(-1)).toBe(
`${SERVICE}/xrpc/app.bsky.actor.getProfile`,
)
})
it('leaves refreshSession unusable', async () => {
ctx.agent.dispose()
await expect(ctx.agent.sessionManager.refreshSession()).rejects.toThrow(
'No session to refresh',
)
})
})
describe('public PasswordSessionManager (no inner session)', () => {
it('reads as logged out', () => {
const {agent} = setupPublic()
expect(agent.session).toBe(undefined)
expect(agent.did).toBe(undefined)
expect(agent.hasSession).toBe(false)
expect(agent.pdsUrl).toBe(undefined)
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
})
it('dispatches to the service unauthenticated', async () => {
const seen: Headers[] = []
const fetchMock = makeMockFetch({
'app.bsky.feed.getFeed': (_url, init) => {
seen.push(new Headers(init.headers))
return json({})
},
})
const {manager} = setupPublic(fetchMock)
await manager.fetchHandler('/xrpc/app.bsky.feed.getFeed')
expect(seen.length).toBe(1)
expect(seen[0].get('authorization')).toBe(null)
expect(urlsOf(fetchMock).at(-1)).toBe(
`${SERVICE}/xrpc/app.bsky.feed.getFeed`,
)
})
})
describe('PasswordSession lifecycle over mocked fetch', () => {
it('resume fast path: constructing does not hit the network', () => {
const fetchMock = makeMockFetch()
setup({fetchMock})
expect(fetchMock.mock.calls.length).toBe(0)
})
it('a refresh fires onUpdated with fresh tokens', async () => {
const onUpdated =
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
const {manager} = setup({sessionOptions: {onUpdated}})
await manager.refreshSession()
expect(onUpdated).toHaveBeenCalledTimes(1)
expect(manager.session?.accessJwt).toBe('access-jwt-2')
})
it('onDeleted fires when refresh returns a declared invalid-token error', async () => {
const onDeleted =
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
const onUpdated =
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
const fetchMock = makeMockFetch({
'com.atproto.server.refreshSession': () =>
json({error: 'ExpiredToken', message: 'Token expired'}, 400),
})
const {manager} = setup({fetchMock, sessionOptions: {onDeleted, onUpdated}})
await expect(manager.refreshSession()).rejects.toBeDefined()
expect(onDeleted).toHaveBeenCalledTimes(1)
expect(onUpdated).not.toHaveBeenCalled()
/* and the bridge reads as logged out afterwards */
expect(manager.session).toBe(undefined)
})
it('onUpdateFailure fires on a transient (500) refresh error, session preserved', async () => {
const onDeleted =
jest.fn<NonNullable<PasswordSessionOptions['onDeleted']>>()
const onUpdateFailure =
jest.fn<NonNullable<PasswordSessionOptions['onUpdateFailure']>>()
const fetchMock = makeMockFetch({
'com.atproto.server.refreshSession': () =>
json({error: 'InternalServerError'}, 500),
})
const {manager} = setup({
fetchMock,
sessionOptions: {onDeleted, onUpdateFailure},
})
await manager.refreshSession()
expect(onUpdateFailure).toHaveBeenCalledTimes(1)
expect(onDeleted).not.toHaveBeenCalled()
expect(manager.session?.accessJwt).toBe('access-jwt')
})
})
+375
View File
@@ -0,0 +1,375 @@
import {
AtpAgent,
type AtpAgentLoginOpts,
type AtpSessionData,
type ComAtprotoServerCreateAccount,
type ComAtprotoServerCreateSession,
type ComAtprotoServerRefreshSession,
CredentialSession,
} from '@atproto/api'
import {getPdsEndpoint, isValidDidDoc} from '@atproto/common-web'
import {
type PasswordSession,
type SessionData,
} from '@atproto/lex-password-session'
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {configureModerationForGuest} from './moderation'
import {networkAwareFetch} from './network'
const UNSUPPORTED =
'Not supported on PasswordSessionManager; use the session factories in session-core'
/**
* Convert live `PasswordSession` session data into the old `AtpSessionData`
* shape that `CredentialSession.session` consumers expect.
*
* The only real adaptation is `active`: `AtpSessionData` requires it, while the
* lexicon payload leaves it optional (absent means active, per the lexicon
* docs).
*/
function toAtpSessionData(d: SessionData): AtpSessionData {
return {
refreshJwt: d.refreshJwt,
accessJwt: d.accessJwt,
handle: d.handle,
did: d.did,
email: d.email,
emailConfirmed: d.emailConfirmed,
emailAuthFactor: d.emailAuthFactor,
active: d.active ?? true,
status: d.status,
}
}
/**
* Parse a URL without throwing.
*
* `URL.canParse` exists on Expo 54's Hermes, but a try/catch needs no feature
* detection and behaves identically, so we avoid the branch entirely.
*/
function parseUrl(input: string): URL | undefined {
try {
return new URL(input)
} catch {
return undefined
}
}
/**
* A `CredentialSession` whose auth core is a `PasswordSession`.
*
* This is the compat shim that lets the new session layer sit under the old
* `AtpAgent`: every call site that reads `agent.session`, `agent.pdsUrl`,
* `agent.dispatchUrl`, `agent.did` or calls `agent.resumeSession()` keeps
* working, while the actual tokens, refresh serialization and PDS routing live
* in the `PasswordSession` underneath.
*
* A `null` inner session means "logged out" - the public/guest agent. In that
* mode requests still go out (unauthenticated) through the inherited `fetch`.
*/
export class PasswordSessionManager extends CredentialSession {
#inner: PasswordSession | null
#storedPdsUrl: URL | undefined
#disposed = false
/*
* Identity caches for the two pull-through accessors. Each holds the
* `SessionData` it was derived from so a repeated read returns the very same
* object (see the note on identity stability below). Keying on the whole
* `SessionData` rather than the individual field works because
* `PasswordSession` replaces the object wholesale on every rotation.
*/
#sessionSource: SessionData | undefined
#sessionValue: AtpSessionData | undefined
#pdsSource: SessionData | undefined
#pdsValue: URL | undefined
constructor(
inner: PasswordSession | null,
{service, pdsUrl}: {service: string; pdsUrl?: string},
) {
/*
* `persistSession` is deliberately undefined: the inner `PasswordSession`
* owns persistence through its own hooks, and none of the inherited methods
* that would call this handler survive the overrides below.
*/
super(new URL(service), networkAwareFetch, undefined)
this.#inner = inner
this.#storedPdsUrl = pdsUrl ? parseUrl(pdsUrl) : undefined
/*
* `session` and `pdsUrl` are pull-through accessors over the inner session
* rather than mirrored values, installed here with `defineProperty` for two
* reasons.
*
* Why accessors at all: a mirror has to be written on every token rotation,
* and any missed write silently serves stale tokens. Pulling through cannot
* drift.
*
* Why `defineProperty` and not `get session()` in the class body: the
* parent declares `session` and `pdsUrl` as *properties*, and TypeScript
* rejects overriding a property with an accessor (TS2611). Installing them
* at runtime sidesteps that, and it is safe because the parent's emitted
* constructor never assigns either one - both are declaration-only in the
* 0.20.34 dist, so there is nothing to clobber and no ordering hazard.
*
* For the same reason this class must NOT redeclare `session`/`pdsUrl` as
* fields: under `useDefineForClassFields` semantics (target esnext) a field
* declaration emits an own-property definition that would overwrite these
* accessors with `undefined`.
*/
Object.defineProperty(this, 'session', {
configurable: true,
get: () => this.#readSession(),
set: () => {
throw new Error('PasswordSessionManager.session is read-only')
},
})
Object.defineProperty(this, 'pdsUrl', {
configurable: true,
get: () => this.#readPdsUrl(),
set: () => {
throw new Error('PasswordSessionManager.pdsUrl is read-only')
},
})
}
/**
* The inner session's live data, or `undefined` when there is nothing to read
* from.
*
* `PasswordSession`'s `session`/`did`/`handle` getters *throw* `Logged out`
* once the session has been destroyed. Every read in this class funnels
* through here so that failure mode can never escape into the app, which
* reads `agent.session` from render paths.
*/
#liveData(): SessionData | undefined {
if (this.#disposed || !this.#inner || this.#inner.destroyed) {
return undefined
}
return this.#inner.session
}
/**
* The `session` accessor's implementation.
*
* Identity-cached on the source `SessionData`: consecutive reads with no
* intervening token rotation return the same object, and a rotation produces
* a new one. Consumers depend on this - `useAccountEmailState` has a
* `useMemo` keyed on `agent.session`, which would recompute on every render
* if we allocated a fresh object per read.
*/
#readSession(): AtpSessionData | undefined {
const live = this.#liveData()
if (!live) {
this.#sessionSource = undefined
this.#sessionValue = undefined
return undefined
}
if (live !== this.#sessionSource) {
this.#sessionSource = live
this.#sessionValue = toAtpSessionData(live)
}
return this.#sessionValue
}
/**
* The `pdsUrl` accessor's implementation.
*
* The DID document's PDS endpoint wins when there is one. Before the first
* refresh delivers a didDoc (the non-expired resume fast path, which makes no
* network call) we fall back to the `pdsUrl` persisted on the account, so the
* very first requests still reach the right host - entryway accounts have
* `service: bsky.social` but live on a different PDS.
*
* Identity-cached on the didDoc for the same reason as `session`.
*/
#readPdsUrl(): URL | undefined {
const live = this.#liveData()
if (!live) {
this.#pdsSource = undefined
this.#pdsValue = undefined
return undefined
}
if (live !== this.#pdsSource) {
this.#pdsSource = live
const endpoint = isValidDidDoc(live.didDoc)
? getPdsEndpoint(live.didDoc)
: undefined
this.#pdsValue = endpoint ? parseUrl(endpoint) : this.#storedPdsUrl
}
return this.#pdsValue
}
/*
* `did`, `hasSession` and `dispatchUrl` are deliberately NOT overridden: the
* inherited getters read `this.session` / `this.pdsUrl`, which now resolve
* through the accessors above, so they are already live.
*/
override async fetchHandler(
url: string,
init?: RequestInit,
): Promise<Response> {
/*
* Absolutizing against `dispatchUrl` is what replaces the old
* `sessionManager.pdsUrl = ...` writes and `_updateApiEndpoint`: it routes
* to the stored PDS on the resume fast path and to the didDoc PDS from the
* first refresh onwards, since `PasswordSession` resolves an already
* absolute URL against its own base as a no-op.
*/
const target = new URL(url, this.dispatchUrl)
const inner = this.#disposed ? null : this.#inner
/*
* A caller that set its own `authorization` header bypasses the inner
* session entirely. This preserves old `CredentialSession` semantics (it
* skipped its own bearer in that case) and is mandatory here:
* `PasswordSession.fetchHandler` throws `TypeError` on a pre-set
* authorization header rather than deferring to it.
*/
if (
!inner ||
inner.destroyed ||
new Headers(init?.headers).has('authorization')
) {
return (0, this.fetch)(target, init)
}
/*
* `init ?? {}` because `PasswordSession.fetchHandler` reads `init.headers`
* unguarded, while the inherited signature makes `init` optional.
*/
return inner.fetchHandler(target.href, init ?? {})
}
override async refreshSession(): Promise<ComAtprotoServerRefreshSession.Response> {
const inner = this.#disposed ? null : this.#inner
if (!inner || inner.destroyed) {
throw new Error('No session to refresh')
}
const data = await inner.refresh()
/*
* Re-shape the lex payload into the old XRPC envelope. `headers` is empty
* because the inner session does not surface response headers, and no
* caller in this app reads them off a refresh.
*/
return {
success: true,
headers: {},
data: {
accessJwt: data.accessJwt,
refreshJwt: data.refreshJwt,
handle: data.handle,
did: data.did,
didDoc: data.didDoc,
email: data.email,
emailConfirmed: data.emailConfirmed,
emailAuthFactor: data.emailAuthFactor,
active: data.active,
status: data.status,
},
}
}
/**
* Force a refresh, ignoring the passed-in session data.
*
* The inner session already owns its tokens, so there is nothing to install;
* every call site in the app uses `resumeSession` as "refresh my session
* now". The returned envelope is the refresh one, which is structurally a
* superset of `ComAtprotoServerGetSession.Response` (the shape `AtpAgent`
* advertises), so both layers stay type-correct.
*/
override resumeSession(
_session: AtpSessionData,
): Promise<ComAtprotoServerRefreshSession.Response> {
return this.refreshSession()
}
override async logout(): Promise<void> {
const inner = this.#inner
if (!inner || inner.destroyed) {
return
}
try {
await inner.logout()
} catch {
/* matches the parent, which swallows delete-session failures */
}
}
override login(
_opts: AtpAgentLoginOpts,
): Promise<ComAtprotoServerCreateSession.Response> {
return Promise.reject(new Error(UNSUPPORTED))
}
override createAccount(
_data: ComAtprotoServerCreateAccount.InputSchema,
_opts?: ComAtprotoServerCreateAccount.CallOptions,
): Promise<ComAtprotoServerCreateAccount.Response> {
return Promise.reject(new Error(UNSUPPORTED))
}
/**
* Detach this manager from its inner session.
*
* All reads then behave as logged out and requests fall back to the
* unauthenticated `fetch` path. The inner session is left alone: it may still
* be shared, and logging out is a separate, explicit operation.
*/
dispose() {
this.#disposed = true
}
}
/*
* Declaration merging to narrow the inherited `sessionManager` (typed as
* `CredentialSession` by `AtpAgent`) to the manager `BskyAppAgent` actually
* receives. A `declare` class field would be the direct way to say this, but
* babel's TypeScript transform rejects `declare` fields in this config, and a
* `get sessionManager()` override is forbidden because the parent declares it
* as a property (TS2611). The merge is sound: the constructor passes the
* manager straight to `super`, which assigns it.
*/
// eslint-disable-next-line typescript/no-unsafe-declaration-merging
export interface BskyAppAgent {
readonly sessionManager: PasswordSessionManager
}
/**
* The app's `AtpAgent`, backed by a `PasswordSession`.
*
* Everything interesting lives in {@link PasswordSessionManager}; this exists
* so `useAgent()` consumers keep getting a real `AtpAgent` (proxy headers,
* labeler headers, the `app`/`com`/`chat` namespaces) and so the agent can be
* disposed alongside its session.
*/
export class BskyAppAgent extends AtpAgent {
constructor(manager: PasswordSessionManager) {
super(manager)
}
dispose() {
this.sessionManager.dispose()
}
}
/**
* Build the logged-out agent used for public/guest browsing.
*
* Temporary name: it exists alongside `createPublicAgent` in `./agent` until
* the provider is switched over to the bridge.
*/
export function createPublicBridgeAgent() {
configureModerationForGuest() // Side effect but only relevant for tests
const agent = new BskyAppAgent(
new PasswordSessionManager(null, {service: PUBLIC_BSKY_SERVICE}),
)
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent
}