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