add the passwordsession bridge agent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user