hold the three clients in the bundle and delete the bridge agent

The bundle becomes `{session, appviewClient, pdsClient, chatClient, service}`
and the hooks read those fields directly, so `useAgent` and the three
`agentTo*Client` memo maps go with the agent. The WeakMaps existed only because
clients were derived from a long-lived agent; a bundle that HOLDS its clients
gets identity stability for free.

The clients are now the sole producers of their own headers, which is what lets
`bridge-agent.ts` (442 lines) and `agent.ts` go:

- the appview client passes BLUESKY_PROXY_HEADER as its `service`, so it emits
  atproto-proxy itself rather than inheriting it from a configureProxy call
  sequenced after the PDS-targeting setup;
- labeler subscriptions go on the appview instance via `setLabelers`, and the
  global `;redact` authorities come from the `Client` static alone. Both halves
  of the double-emit hazard the previous slice documented are closed by there
  being one producer instead of two, so the appview client no longer suppresses
  `appLabelers` - only the PDS and chat clients do, because those services take
  no moderation authorities at all;
- `configureGlobalAppLabelers` drops its `AtpAgent.configure` half.

PDS routing for the pre-didDoc window is `routeSessionToPds`, a 10-line shim
replacing the manager's dispatchUrl/extractPdsUrl/identity-cache apparatus.
`PasswordSession` resolves each request against `extractPdsUrl(didDoc) ??
service`, so an entryway account with no didDoc yet - the synchronous resume
fast path, i.e. the common cold start - would send every request to the
entryway. The shim absolutizes against the stored url first, which survives
`new URL(path, base)` untouched. It pins that url for the bundle's lifetime
where the manager would have preferred a later didDoc endpoint; that only
differs if the account's PDS moved, and the next cold start pins the new one.
Four cases in clients-test cover this, including the two the old suite could
not express (pinned-vs-didDoc precedence, and didDoc routing with nothing
pinned).

`createPublicSessionBundle` now runs `configureModerationForGuest` itself. That
is a behavior fix, not a refactor: it populates the `Client.appLabelers` static
the public appview client reads, and with no agent left to stamp the header a
logged-out read would otherwise carry no moderation authorities.

finishPreparation, the kill-switch disposal, the redacted logging, refreshSession
and the expiry-rescue path are unchanged - only how the bundle is constructed
moved. Disposal is now just the hook kill: the clients hold no state, and every
request they make goes through the session's injected fetch.

bridge-agent-test.ts is deleted with its subject. clients-test.ts is rewritten
against session-built clients, keeping every header canary (exactly-once on the
appview, none on pds/chat, exact proxy values, the throwing client's cause
chain). The three provider suites and session-core-test are adapted in place -
their constructions change, their assertions do not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-04 03:42:36 +03:00
parent 3f7b12b2fe
commit 64b0039c67
15 changed files with 455 additions and 1541 deletions
+1 -2
View File
@@ -2,7 +2,6 @@ import {type Insets, Platform} from 'react-native'
import {type AppBskyActorDefs, BSKY_LABELER_DID} from '@atproto/api'
import {type Service} from '@atproto/lex'
import {type ProxyHeaderValue} from '#/state/session/agent'
import {BLUESKY_PROXY_DID, CHAT_PROXY_DID, IS_DEV} from '#/env'
export const LOCAL_DEV_SERVICE =
@@ -241,7 +240,7 @@ export const DEV_ENV_APPVIEW_DID = `did:plc:dw4kbjf5mn7nhenabiqpkyh3` // always
export const BLUESKY_PROXY_HEADER = {
value: `${BLUESKY_PROXY_DID}#bsky_appview`,
get() {
return this.value as ProxyHeaderValue
return this.value as Service
},
set(value: string) {
this.value = value
+7 -7
View File
@@ -39,7 +39,7 @@ import {
type UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types'
import {createQueryKey} from '#/state/queries/util'
import {useAgent, usePdsClient} from '#/state/session'
import {useAppviewClient, usePdsClient} from '#/state/session'
import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation'
import {useAgeAssurance} from '#/ageAssurance'
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
@@ -58,7 +58,7 @@ export const preferencesQueryKey = createQueryKey(
export function usePreferencesQuery() {
const client = usePdsClient()
const agent = useAgent()
const appviewClient = useAppviewClient()
const aa = useAgeAssurance()
const query = useQuery({
@@ -86,12 +86,12 @@ export function usePreferencesQuery() {
* from a labeler would not affect server-attached labels until the
* session bundle was rebuilt.
*
* `applyLabelersToClient` writes to the agent, which is what stamps the
* header on the requests the wrapping appview client issues, and it
* drops the Bluesky moderation DID so the globally redacted authority is
* not also listed unredacted.
* The subscriptions go on the appview client, which is what stamps
* `atproto-accept-labelers` on its own requests. The Bluesky moderation
* DID is dropped so the globally redacted authority is not also listed
* unredacted.
*/
applyLabelersToClient(agent, labelerDids)
applyLabelersToClient(appviewClient, labelerDids)
/*
* `BskyPreferences` is now the sdk's own type, so the assembled
@@ -1,573 +0,0 @@
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'
import {
asFetch,
DID,
DIDDOC_PDS_HOST,
HANDLE,
json,
makeAccount,
makeDidDoc,
makeMockFetch,
type MockFetch,
PDS_HOST,
SERVICE,
urlsOf,
} from './mock-fetch'
/**
* 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/')
})
it('matches the inner session on didDocs a strict validator would reject', () => {
/*
* No `id` on the document and a non-canonical service `type`: enough for
* isValidDidDoc/getPdsEndpoint to bail, but PasswordSession still routes
* here, so the bridge must agree or dispatchUrl lies about where requests
* go (and service-auth aud gets minted for the wrong host).
*/
const {agent} = setup({
didDoc: {
service: [
{
id: '#atproto_pds',
type: 'SomethingElse',
serviceEndpoint: DIDDOC_PDS_HOST,
},
],
},
pdsUrl: PDS_HOST,
})
expect(agent.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`)
expect(agent.dispatchUrl.toString()).toBe(`${DIDDOC_PDS_HOST}/`)
})
it('falls back to the stored pdsUrl when the didDoc has no PDS service', () => {
const {agent} = setup({
didDoc: {
id: DID,
service: [
{
id: '#bsky_notif',
type: 'BskyNotificationService',
serviceEndpoint: DIDDOC_PDS_HOST,
},
],
},
pdsUrl: PDS_HOST,
})
expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`)
})
it('falls back to the stored pdsUrl when the PDS endpoint does not parse', () => {
const {agent} = setup({
didDoc: {
id: DID,
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: 'not a url',
},
],
},
pdsUrl: PDS_HOST,
})
expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`)
})
})
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},
})
/*
* PasswordSession.refresh() resolves with the unchanged data here; the
* bridge restores the old CredentialSession contract by rejecting.
*/
await expect(manager.refreshSession()).rejects.toThrow(
'Failed to refresh session',
)
expect(onUpdateFailure).toHaveBeenCalledTimes(1)
expect(onDeleted).not.toHaveBeenCalled()
expect(manager.session?.accessJwt).toBe('access-jwt')
})
it('rejects on a network error rather than reporting a no-op success', async () => {
const fetchMock = makeMockFetch({
'com.atproto.server.refreshSession': () => {
throw new TypeError('Network request failed')
},
})
const {manager} = setup({fetchMock})
await expect(manager.refreshSession()).rejects.toThrow(
'Failed to refresh session',
)
/* the session survives, exactly as the old transient-failure path did */
expect(manager.session?.accessJwt).toBe('access-jwt')
})
it('resumeSession rejects on a transient failure too', async () => {
const fetchMock = makeMockFetch({
'com.atproto.server.refreshSession': () =>
json({error: 'InternalServerError'}, 500),
})
const {agent} = setup({fetchMock})
await expect(agent.resumeSession(agent.session!)).rejects.toThrow(
'Failed to refresh session',
)
})
})
+185 -186
View File
@@ -13,26 +13,29 @@ jest.mock('jwt-decode', () => ({
},
}))
import {CHAT_PROXY_SERVICE} from '#/lib/constants'
import {BLUESKY_PROXY_HEADER, CHAT_PROXY_SERVICE} from '#/lib/constants'
import {app, chat, com} from '#/lexicons'
import {configureGlobalAppLabelers} from '../additional-moderation-authorities'
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import {
agentToAppviewClient,
agentToChatClient,
agentToPdsClient,
buildAppviewClient,
buildChatClient,
buildPdsClient,
getUnauthenticatedThrowingClient,
NotAuthenticatedError,
routeSessionToPds,
} from '../clients'
import {sessionAccountToSessionData} from '../session-data'
import {
asFetch,
DID,
DIDDOC_PDS_HOST,
HANDLE,
json,
makeAccount,
makeDidDoc,
makeMockFetch,
type MockFetch,
PDS_HOST,
SERVICE,
urlsOf,
} from './mock-fetch'
@@ -49,25 +52,16 @@ function makeProfileFetch(): MockFetch {
})
}
/** An authenticated agent whose whole network path is the mock fetch. */
function setup(fetchMock: MockFetch = makeProfileFetch()) {
/** A live `PasswordSession` whose whole network path is the mock fetch. */
function makeSession(fetchMock: MockFetch, didDocPdsUrl?: string) {
const account = makeAccount()
const inner = new PasswordSession(sessionAccountToSessionData(account), {
fetch: asFetch(fetchMock),
})
const manager = new PasswordSessionManager(inner, {
service: account.service,
})
manager.setFetch(asFetch(fetchMock))
const agent = new BskyAppAgent(manager)
return {agent, fetchMock}
}
/** A logged-out agent whose whole network path is the mock fetch. */
function setupPublic(fetchMock: MockFetch = makeProfileFetch()) {
const manager = new PasswordSessionManager(null, {service: SERVICE})
manager.setFetch(asFetch(fetchMock))
return {agent: new BskyAppAgent(manager), fetchMock}
return new PasswordSession(
{
...sessionAccountToSessionData(account),
...(didDocPdsUrl ? {didDoc: makeDidDoc(didDocPdsUrl)} : {}),
},
{fetch: asFetch(fetchMock)},
)
}
/** The `init` a mock fetch was called with for a given nsid. */
@@ -79,84 +73,56 @@ function initFor(mock: MockFetch, nsid: string): RequestInit | undefined {
return call?.[1]
}
describe('agentToAppviewClient', () => {
/** The headers a mock fetch was called with for a given nsid. */
function headersFor(mock: MockFetch, nsid: string): Headers {
return new Headers(initFor(mock, nsid)?.headers)
}
describe('buildAppviewClient', () => {
let fetchMock: MockFetch
beforeEach(() => {
fetchMock = makeProfileFetch()
configureGlobalAppLabelers([])
})
it('memoizes one client per agent', () => {
const {agent: agentA} = setup(fetchMock)
const {agent: agentB} = setup(fetchMock)
const clientA1 = agentToAppviewClient(agentA)
const clientA2 = agentToAppviewClient(agentA)
const clientB = agentToAppviewClient(agentB)
expect(clientA1).toBeInstanceOf(Client)
expect(clientA1).toBe(clientA2)
expect(clientA1).not.toBe(clientB)
it('passes through the session did', () => {
const client = buildAppviewClient(makeSession(fetchMock))
expect(client).toBeInstanceOf(Client)
expect(client.did).toBe(DID)
})
it('passes through the agent did', () => {
const {agent} = setup(fetchMock)
expect(agentToAppviewClient(agent).did).toBe(DID)
})
it('routes client.call through the session to the network', async () => {
const client = buildAppviewClient(makeSession(fetchMock))
it('reflects an undefined did on a logged-out agent', () => {
const {agent} = setupPublic(fetchMock)
expect(agentToAppviewClient(agent).did).toBeUndefined()
})
it('routes client.call through the agent to the network', async () => {
const {agent} = setup(fetchMock)
const body = await agentToAppviewClient(agent).call(
app.bsky.actor.getProfile,
{
actor: HANDLE,
},
)
const body = await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
expect(body.handle).toBe(HANDLE)
const call = fetchMock.mock.calls.find(c => {
const url = c[0] instanceof URL ? c[0].href : String(c[0])
return url.includes('/xrpc/app.bsky.actor.getProfile')
})
expect(call).toBeDefined()
const url = call![0] instanceof URL ? call![0].href : String(call![0])
expect(url).toContain(`actor=${HANDLE}`)
expect(urlsOf(fetchMock).join()).toContain(`actor=${HANDLE}`)
})
it('emits the agent proxy header', async () => {
const {agent} = setup(fetchMock)
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
it('emits the appview proxy header', async () => {
const client = buildAppviewClient(makeSession(fetchMock))
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
expect(new Headers(init?.headers).get('atproto-proxy')).toBe(
'did:web:api.bsky.app#bsky_appview',
expect(
headersFor(fetchMock, 'app.bsky.actor.getProfile').get('atproto-proxy'),
).toBe(BLUESKY_PROXY_HEADER.get())
})
it('emits an account subscription exactly once', async () => {
const client = buildAppviewClient(makeSession(fetchMock))
client.setLabelers(['did:plc:labeler'])
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
const labelers = headersFor(fetchMock, 'app.bsky.actor.getProfile').get(
'atproto-accept-labelers',
)
})
it('emits the agent labeler header exactly once', async () => {
const {agent} = setup(fetchMock)
agent.configureLabelersHeader(['did:plc:labeler'])
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
const labelers = new Headers(init?.headers).get('atproto-accept-labelers')
expect(labelers).toContain('did:plc:labeler')
/*
* The client contributes no labelers of its own, so the agent's single
* entry must not be duplicated.
* The client is the only producer of this header now, so a duplicate would
* mean lex itself emitted the same DID twice.
*/
const entries = labelers!
.split(',')
@@ -164,25 +130,21 @@ describe('agentToAppviewClient', () => {
expect(entries).toHaveLength(1)
})
it('does not duplicate a global app labeler set on both statics', async () => {
it('emits a global app labeler once, redacted', async () => {
/*
* `configureGlobalAppLabelers` populates the agent AND the lex `Client`
* static, because clients built without a wrapped agent read only the
* latter. On this path both producers are in play for the same request, and
* neither dedupes against the other - the agent joins its list with the
* existing header string while lex collects into a `Set` keyed on the
* `;redact`-suffixed value. The appview client suppresses its `appLabelers`
* so exactly one producer contributes.
* The global static is the ONLY producer of the redacted authorities - no
* agent stamps them any more - and lex suffixes them with `;redact`. An
* account subscription that also listed the same DID would produce a second,
* non-redacting entry, which is what `applyLabelersToClient` filters against.
*/
configureGlobalAppLabelers(['did:plc:global-labeler'])
const {agent} = setup(fetchMock)
const client = buildAppviewClient(makeSession(fetchMock))
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
const labelers = new Headers(init?.headers).get('atproto-accept-labelers')
const labelers = headersFor(fetchMock, 'app.bsky.actor.getProfile').get(
'atproto-accept-labelers',
)
const entries = labelers!
.split(',')
.map(l => l.trim())
@@ -191,100 +153,69 @@ describe('agentToAppviewClient', () => {
})
it('sends the session access token', async () => {
const {agent} = setup(fetchMock)
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, {
actor: HANDLE,
})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
expect(new Headers(init?.headers).get('authorization')).toBe(
'Bearer access-jwt',
)
})
it('falls back to unauthenticated requests once the agent is disposed', async () => {
const {agent} = setup(fetchMock)
const client = agentToAppviewClient(agent)
agent.dispose()
const client = buildAppviewClient(makeSession(fetchMock))
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile')
expect(new Headers(init?.headers).has('authorization')).toBe(false)
expect(client.did).toBeUndefined()
expect(
headersFor(fetchMock, 'app.bsky.actor.getProfile').get('authorization'),
).toBe('Bearer access-jwt')
})
})
describe('agentToPdsClient', () => {
describe('buildPdsClient', () => {
let fetchMock: MockFetch
beforeEach(() => {
fetchMock = makeProfileFetch()
configureGlobalAppLabelers([])
})
it('memoizes one client per agent', () => {
const {agent: agentA} = setup(fetchMock)
const {agent: agentB} = setup(fetchMock)
const clientA1 = agentToPdsClient(agentA)
const clientA2 = agentToPdsClient(agentA)
expect(clientA1).toBeInstanceOf(Client)
expect(clientA1).toBe(clientA2)
expect(clientA1).not.toBe(agentToPdsClient(agentB))
})
it('is a distinct client from the appview client for the same agent', () => {
const {agent} = setup(fetchMock)
expect(agentToPdsClient(agent)).not.toBe(agentToAppviewClient(agent))
})
it('passes through the agent did', () => {
const {agent} = setup(fetchMock)
expect(agentToPdsClient(agent).did).toBe(DID)
it('is a distinct client from the appview client over the same session', () => {
const session = makeSession(fetchMock)
expect(buildPdsClient(session)).not.toBe(buildAppviewClient(session))
})
it('sends the session access token', async () => {
const {agent} = setup(fetchMock)
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
const init = initFor(fetchMock, 'com.atproto.server.getSession')
expect(new Headers(init?.headers).get('authorization')).toBe(
'Bearer access-jwt',
await buildPdsClient(makeSession(fetchMock)).call(
com.atproto.server.getSession,
{},
)
expect(
headersFor(fetchMock, 'com.atproto.server.getSession').get(
'authorization',
),
).toBe('Bearer access-jwt')
})
it('emits neither the proxy nor the labeler header the agent is configured with', async () => {
it('emits neither the proxy nor any labeler header', async () => {
/*
* The load-bearing difference from the appview client: this client wraps the
* session manager, below the agent layer that sets both headers, so a
* request reaches the account's PDS instead of being proxied onward.
* The load-bearing difference from the appview client: a PDS request must
* reach the account host itself rather than being proxied onward, and it is
* not an appview read, so it carries no moderation authorities either.
*/
const {agent} = setup(fetchMock)
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
agent.configureLabelersHeader(['did:plc:labeler'])
/* nor the global authorities: a PDS call is not an appview read */
configureGlobalAppLabelers(['did:plc:global-labeler'])
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
const headers = new Headers(
initFor(fetchMock, 'com.atproto.server.getSession')?.headers,
await buildPdsClient(makeSession(fetchMock)).call(
com.atproto.server.getSession,
{},
)
const headers = headersFor(fetchMock, 'com.atproto.server.getSession')
expect(headers.get('atproto-proxy')).toBeNull()
expect(headers.get('atproto-accept-labelers')).toBeNull()
})
it('resolves the relative xrpc path against the account host', async () => {
/*
* lex-client hands its fetchHandler an origin-less `/xrpc/<nsid>` path; the
* session manager absolutizes it against dispatchUrl.
* lex hands its fetchHandler an origin-less `/xrpc/<nsid>` path; the session
* absolutizes it against its didDoc endpoint or, absent one, its service.
*/
const {agent} = setup(fetchMock)
await agentToPdsClient(agent).call(com.atproto.server.getSession, {})
await buildPdsClient(makeSession(fetchMock)).call(
com.atproto.server.getSession,
{},
)
expect(urlsOf(fetchMock)).toContain(
`${SERVICE}/xrpc/com.atproto.server.getSession`,
@@ -292,34 +223,26 @@ describe('agentToPdsClient', () => {
})
})
describe('agentToChatClient', () => {
describe('buildChatClient', () => {
let fetchMock: MockFetch
beforeEach(() => {
fetchMock = makeProfileFetch()
configureGlobalAppLabelers([])
})
it('memoizes one client per agent, distinct from the pds client', () => {
const {agent} = setup(fetchMock)
const client = agentToChatClient(agent)
expect(client).toBeInstanceOf(Client)
expect(client).toBe(agentToChatClient(agent))
expect(client).not.toBe(agentToPdsClient(agent))
it('is a distinct client from the pds client over the same session', () => {
const session = makeSession(fetchMock)
expect(buildChatClient(session)).not.toBe(buildPdsClient(session))
})
it('emits the chat proxy header exactly once, with the session token', async () => {
const {agent} = setup(fetchMock)
/* the stub body fails listConvos output validation; headers are recorded pre-parse */
await agentToChatClient(agent)
await buildChatClient(makeSession(fetchMock))
.call(chat.bsky.convo.listConvos, {})
.catch(() => {})
const headers = new Headers(
initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers,
)
const headers = headersFor(fetchMock, 'chat.bsky.convo.listConvos')
/*
* An exact match, not `toContain`: `Headers` comma-joins repeated entries
* for the same name, so a second contributor would show up here.
@@ -328,20 +251,96 @@ describe('agentToChatClient', () => {
expect(headers.get('authorization')).toBe('Bearer access-jwt')
})
it('does not emit the agent labeler header', async () => {
const {agent} = setup(fetchMock)
agent.configureLabelersHeader(['did:plc:labeler'])
/* nor the global authorities: a chat call is not an appview read */
it('emits no labeler header', async () => {
/* the global authorities do not apply: a chat call is not an appview read */
configureGlobalAppLabelers(['did:plc:global-labeler'])
await agentToChatClient(agent)
await buildChatClient(makeSession(fetchMock))
.call(chat.bsky.convo.listConvos, {})
.catch(() => {})
const headers = new Headers(
initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers,
)
expect(headers.get('atproto-accept-labelers')).toBeNull()
expect(
headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
'atproto-accept-labelers',
),
).toBeNull()
})
})
describe('routeSessionToPds', () => {
let fetchMock: MockFetch
beforeEach(() => {
fetchMock = makeProfileFetch()
})
it('sends a request to the pinned host rather than the login service', async () => {
/*
* The entryway case, and the reason this shim exists: an account whose
* service is `bsky.social` but whose PDS is elsewhere, with no didDoc yet
* (the synchronous resume fast path, i.e. the common cold start). Without
* the shim the session would resolve against its service and every request
* of that cold start would go to the entryway.
*/
const session = makeSession(fetchMock)
const client = buildPdsClient(routeSessionToPds(session, PDS_HOST))
await client.call(com.atproto.server.getSession, {})
expect(urlsOf(fetchMock)).toEqual([
`${PDS_HOST}/xrpc/com.atproto.server.getSession`,
])
})
it('keeps the session auth lifecycle on the pinned host', async () => {
const session = makeSession(fetchMock)
const client = buildPdsClient(routeSessionToPds(session, PDS_HOST))
await client.call(com.atproto.server.getSession, {})
expect(
headersFor(fetchMock, 'com.atproto.server.getSession').get(
'authorization',
),
).toBe('Bearer access-jwt')
})
it('passes through the session did', () => {
const session = makeSession(fetchMock)
expect(routeSessionToPds(session, PDS_HOST).did).toBe(DID)
})
it('pins the stored host even when the session carries a different didDoc endpoint', async () => {
/*
* The narrowing this shim accepts versus the session manager it replaces:
* the manager preferred a didDoc endpoint once one arrived, whereas an
* absolute URL handed to `session.fetchHandler` survives `new URL(path,
* base)` untouched, so the stored host wins for the bundle's lifetime. That
* only matters if the account's PDS moved, and the next cold start pins the
* newly persisted endpoint.
*/
const session = makeSession(fetchMock, DIDDOC_PDS_HOST)
const client = buildPdsClient(routeSessionToPds(session, PDS_HOST))
await client.call(com.atproto.server.getSession, {})
expect(urlsOf(fetchMock)).toEqual([
`${PDS_HOST}/xrpc/com.atproto.server.getSession`,
])
})
it('lets the session route by didDoc when nothing is pinned', async () => {
/*
* The counterpart: a bundle built with no stored `pdsUrl` goes straight over
* the session, which resolves against its own didDoc endpoint.
*/
const client = buildPdsClient(makeSession(fetchMock, DIDDOC_PDS_HOST))
await client.call(com.atproto.server.getSession, {})
expect(urlsOf(fetchMock)).toEqual([
`${DIDDOC_PDS_HOST}/xrpc/com.atproto.server.getSession`,
])
})
})
@@ -72,11 +72,10 @@ import {
useSessionApi,
} from '#/state/session'
import {type SessionApiContext} from '#/state/session/types'
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import {
agentToAppviewClient,
agentToChatClient,
agentToPdsClient,
buildAppviewClient,
buildChatClient,
buildPdsClient,
getUnauthenticatedThrowingClient,
} from '../clients'
import {type SessionBundle} from '../session-core'
@@ -92,22 +91,19 @@ type Clients = {
}
/**
* Build a bundle whose agent is a real `BskyAppAgent` over a real
* `PasswordSession`, since the client builders derive from the agent. Only the
* fields the provider reads are populated.
* Build a bundle over a real `PasswordSession`, with the three clients the
* provider serves. Only the fields the provider reads are populated.
*/
function makeBundle(account: SessionAccount): SessionBundle {
const fetchMock = makeMockFetch()
const session = new PasswordSession(sessionAccountToSessionData(account), {
fetch: asFetch(fetchMock),
})
const manager = new PasswordSessionManager(session, {
service: account.service,
})
manager.setFetch(asFetch(fetchMock))
return {
session,
agent: new BskyAppAgent(manager),
appviewClient: buildAppviewClient(session),
pdsClient: buildPdsClient(session),
chatClient: buildChatClient(session),
service: new URL(account.service),
}
}
@@ -140,10 +136,10 @@ beforeEach(() => {
})
describe('client hooks while logged out', () => {
it('serves the public agent for appview reads', () => {
it('serves the public client for appview reads', () => {
const {clients} = renderClients()
expect(clients().appview).toBeDefined()
/* the logged-out bundle's agent IS the public agent, so no separate branch */
/* the logged-out bundle holds the public appview client itself */
expect(clients().appview.did).toBeUndefined()
})
@@ -162,7 +158,7 @@ describe('client hooks while logged out', () => {
})
describe('client hooks with a session', () => {
it('derives every surface from the session bundle agent', async () => {
it('serves every surface straight off the session bundle', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
const {api, clients} = renderClients()
@@ -172,9 +168,9 @@ describe('client hooks with a session', () => {
await api.login({} as never, 'LoginForm')
})
expect(clients().appview).toBe(agentToAppviewClient(bundle.agent))
expect(clients().pds).toBe(agentToPdsClient(bundle.agent))
expect(clients().chat).toBe(agentToChatClient(bundle.agent))
expect(clients().appview).toBe(bundle.appviewClient)
expect(clients().pds).toBe(bundle.pdsClient)
expect(clients().chat).toBe(bundle.chatClient)
})
it('serves the same clients from the maybe variants', async () => {
@@ -63,7 +63,7 @@ jest.mock('../create-account', () => ({
import {Provider, useSession, useSessionApi} from '#/state/session'
import {type SessionApiContext} from '#/state/session/types'
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import {buildAppviewClient, buildChatClient, buildPdsClient} from '../clients'
import {type SessionBundle} from '../session-core'
import {sessionAccountToSessionData} from '../session-data'
import {
@@ -87,13 +87,11 @@ function makeBundle(
const session = new PasswordSession(sessionAccountToSessionData(account), {
fetch: asFetch(fetchMock),
})
const manager = new PasswordSessionManager(session, {
service: account.service,
})
manager.setFetch(asFetch(fetchMock))
return {
session,
agent: new BskyAppAgent(manager),
appviewClient: buildAppviewClient(session),
pdsClient: buildPdsClient(session),
chatClient: buildChatClient(session),
service: new URL(account.service),
}
}
@@ -59,17 +59,17 @@ jest.mock('#/analytics', () => ({
/*
* `configureModerationForAccount` is synchronous (the labeler cache is a local
* MMKV read), so it is not a prep await - but it still runs inside each factory
* with the freshly built bridge agent, before the awaited prep steps. The
* factory tests capture the agent with this mock, then inject a real refresh
* into the awaited AA prefetch so a token rotation happens during prep, before
* arm(). The default is a no-op so other tests are unaffected.
* with the freshly built bundle, before the awaited prep steps. The factory
* tests capture the bundle with this mock, then inject a real refresh into the
* awaited AA prefetch so a token rotation happens during prep, before arm().
* The default is a no-op so other tests are unaffected.
* (jest requires out-of-scope factory references to be `mock`-prefixed.)
*/
const mockConfigureModerationForAccount =
jest.fn<(agent: unknown, account: unknown) => void>()
jest.fn<(bundle: unknown, account: unknown) => void>()
jest.mock('../moderation', () => ({
configureModerationForAccount: (agent: unknown, account: unknown) =>
mockConfigureModerationForAccount(agent, account),
configureModerationForAccount: (bundle: unknown, account: unknown) =>
mockConfigureModerationForAccount(bundle, account),
configureModerationForGuest: () => {},
}))
@@ -90,7 +90,6 @@ jest.mock('jwt-decode', () => ({
},
}))
import {type BskyAppAgent} from '../bridge-agent'
import {
type AtpSessionEvent,
buildBundle,
@@ -365,22 +364,23 @@ describe('sessionAccountToSessionData', () => {
})
describe('createSessionBundleFromStoredAccount', () => {
it('builds a bridge agent over one session', () => {
it('builds three clients over one session', async () => {
const result = createSessionBundleFromStoredAccount(
makeAccount(),
jest.fn(),
)!
/* the agent reads its identity straight through the shared session */
expect(result.bundle.agent.session?.accessJwt).toBe('access-jwt')
expect(result.bundle.agent.did).toBe(DID)
expect(result.bundle.agent.sessionManager.session).toBe(
result.bundle.agent.session,
)
/* every client reads its identity straight through the shared session */
expect(result.bundle.session.session.accessJwt).toBe('access-jwt')
expect(result.bundle.appviewClient.did).toBe(DID)
expect(result.bundle.pdsClient.did).toBe(DID)
expect(result.bundle.chatClient.did).toBe(DID)
expect(result.bundle.service.toString()).toBe(`${SERVICE}/`)
disposeBundle(result.bundle)
/* disposal detaches the agent from the session */
expect(result.bundle.agent.session).toBe(undefined)
/* disposal disables the transport every client shares */
await expect(
result.bundle.session.fetchHandler('/xrpc/test', {}),
).rejects.toThrow('session disposed')
})
it('disposes a bundle rejected by the activation guard', async () => {
@@ -836,15 +836,14 @@ describe('factory account snapshot after preparation', () => {
* captured from the (synchronous) moderation call, and the rotation is
* injected into the awaited AA prefetch.
*/
let capturedAgent: BskyAppAgent | undefined
let capturedBundle: SessionBundle | undefined
mockConfigureModerationForAccount.mockImplementationOnce(
(bundle: unknown) => {
capturedAgent = (bundle as {agent: BskyAppAgent}).agent
capturedBundle = bundle as SessionBundle
},
)
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
/* routes through the bridge into the shared PasswordSession's refresh */
await capturedAgent!.sessionManager.refreshSession()
await capturedBundle!.session.refresh()
})
const fetchMock = makeMockFetch()
@@ -902,14 +901,14 @@ describe('a session destroyed or rejected during preparation', () => {
* way to drive a session to `destroyed` from outside, and it takes the same
* `deleteSession` -> onDeleted -> destroyed path the real 401 rescue does.
*/
let capturedAgent: BskyAppAgent | undefined
let capturedBundle: SessionBundle | undefined
mockConfigureModerationForAccount.mockImplementationOnce(
(bundle: unknown) => {
capturedAgent = (bundle as {agent: BskyAppAgent}).agent
capturedBundle = bundle as SessionBundle
},
)
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
await capturedAgent!.logout()
await capturedBundle!.session.logout()
})
const fetchMock = makeMockFetch()
@@ -927,15 +926,15 @@ describe('a session destroyed or rejected during preparation', () => {
).rejects.toThrow('Session was revoked while it was being prepared')
/* the bundle the caller never received reads as logged out */
expect(capturedAgent!.session).toBe(undefined)
expect(capturedBundle!.session.destroyed).toBe(true)
})
})
it('resume: a prep rejection propagates and disposes the bundle', async () => {
let capturedAgent: BskyAppAgent | undefined
let capturedBundle: SessionBundle | undefined
mockConfigureModerationForAccount.mockImplementationOnce(
(bundle: unknown) => {
capturedAgent = (bundle as {agent: BskyAppAgent}).agent
capturedBundle = bundle as SessionBundle
},
)
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(() =>
@@ -952,7 +951,9 @@ describe('a session destroyed or rejected during preparation', () => {
).rejects.toThrow('prefetch blew up')
/* the still-live session was disposed rather than left refreshing */
expect(capturedAgent!.session).toBe(undefined)
await expect(
capturedBundle!.session.fetchHandler('/xrpc/test', {}),
).rejects.toThrow('session disposed')
})
})
@@ -964,7 +965,12 @@ describe('a session destroyed or rejected during preparation', () => {
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{...hooks, fetch: asFetch(makeMockFetch())},
/*
* The hooks' own `fetch` is what the kill switch disables, so it must not
* be overridden here. Nothing in this test reaches the network: the
* post-disposal `fetchHandler` call throws before dispatching.
*/
hooks,
)
const bundle = buildBundle(session)
registerBundleKillSwitch(bundle, hooks.kill)
@@ -975,7 +981,9 @@ describe('a session destroyed or rejected during preparation', () => {
).rejects.toThrow('nope')
expect(snapshot).not.toHaveBeenCalled()
expect(bundle.agent.session).toBe(undefined)
await expect(bundle.session.fetchHandler('/xrpc/test', {})).rejects.toThrow(
'session disposed',
)
})
})
@@ -1,4 +1,3 @@
import {AtpAgent} from '@atproto/api'
import {Client} from '@atproto/lex'
import {device} from '#/storage'
@@ -83,27 +82,27 @@ export function configureAdditionalModerationAuthorities() {
additionalLabelers = []
}
/*
* Merge with whatever is already on the static rather than replacing it, so
* `switchToBskyAppLabeler`'s entry survives.
*/
const appLabelers = Array.from(
new Set([...AtpAgent.appLabelers, ...additionalLabelers]),
new Set<string>([...Client.appLabelers, ...additionalLabelers]),
)
configureGlobalAppLabelers(appLabelers)
}
/**
* Set the global app labelers on BOTH statics, so the agent-backed request path
* and any client built without a wrapped agent emit the same `;redact`
* authorities.
* Set the global app labelers on the lex `Client` static, which every client
* reads, so a request carries the same `;redact` authorities whether or not
* there is a session behind it.
*
* Keeping the two in lockstep is what makes the duplicate-header hazard
* avoidable: the agent joins its own list with whatever the caller already put
* on the request, and lex appends the `Client` static on top of that, so a DID
* present in both would appear twice. The agent-wrapping clients therefore
* suppress their `appLabelers` (see `clients.ts`), which leaves exactly one
* producer per request while both statics stay populated for the paths that
* read only one of them.
* It is a single global producer by design. The PDS and chat clients opt out
* with `appLabelers: null` (see `clients.ts`) because those services take no
* moderation authorities, leaving exactly one producer on an appview request and
* none elsewhere.
*/
export function configureGlobalAppLabelers(dids: string[]) {
AtpAgent.configure({appLabelers: dids})
Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
}
-25
View File
@@ -1,25 +0,0 @@
import {
Agent as BaseAgent,
type AtprotoServiceType,
type Did,
} from '@atproto/api'
export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
/**
* A bare `Agent` that applies a service-proxy header on construction.
*
* Used for the unauthenticated, service-specific calls that cannot go through
* the session agent (PDS detection, password reset, handle availability).
*/
export class Agent extends BaseAgent {
constructor(
proxyHeader: ProxyHeaderValue | null,
...options: ConstructorParameters<typeof BaseAgent>
) {
super(...options)
if (proxyHeader) {
this.configureProxy(proxyHeader)
}
}
}
-442
View File
@@ -1,442 +0,0 @@
import {
AtpAgent,
type AtpAgentLoginOpts,
type AtpSessionData,
type ComAtprotoServerCreateAccount,
type ComAtprotoServerCreateSession,
type ComAtprotoServerRefreshSession,
CredentialSession,
} from '@atproto/api'
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 `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.
*/
function parseUrl(input: string): URL | undefined {
try {
return new URL(input)
} catch {
return undefined
}
}
/**
* Read a property off an unknown value the way JS optional chaining would,
* without narrowing assumptions about the shape of a `LexMap`.
*/
function prop(value: unknown, key: string): unknown {
return typeof value === 'object' && value !== null
? (value as Record<string, unknown>)[key]
: undefined
}
/**
* The PDS endpoint declared by a DID document, or `undefined`.
*
* This deliberately mirrors `extractPdsUrl` in `@atproto/lex-password-session`,
* which is the predicate `PasswordSession` uses to route its own requests: the
* first service entry whose `id` ends with `#atproto_pds`, taking its
* `serviceEndpoint` if it parses as a URL. It is looser than the
* `isValidDidDoc` + `getPdsEndpoint` pair from `@atproto/common-web` (no doc
* schema validation, no `type` check), and that is the point - a stricter
* predicate here would let `dispatchUrl` disagree with the host requests
* actually go to, which in turn mints service-auth tokens (video upload) for
* the wrong audience.
*/
function extractPdsUrl(didDoc: SessionData['didDoc']): URL | undefined {
const services = prop(didDoc, 'service')
if (!Array.isArray(services)) {
return undefined
}
/*
* `find`, not a scan: the inner session stops at the first `#atproto_pds`
* entry and gives up if its endpoint does not parse, rather than falling
* through to a later entry.
*/
const pds = services.find(service => {
const id = prop(service, 'id')
return typeof id === 'string' && id.endsWith('#atproto_pds')
})
const endpoint = prop(pds, 'serviceEndpoint')
return typeof endpoint === 'string' ? parseUrl(endpoint) : undefined
}
/**
* A `CredentialSession` whose auth core is a `PasswordSession`.
*
* This is the compat shim that lets a `PasswordSession` sit under `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 as long as
* `CredentialSession`'s emitted constructor does not assign either one
* (both are declaration-only), 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. `CredentialSession` declares `session` as a plain field, so
* consumers are entitled to treat it as a value whose identity changes only
* when the session does; this class is read from render paths, and returning
* a freshly allocated object on every read would break that expectation for
* any memo, dependency array or reference comparison built on top of it.
*/
#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, derived with
* {@link extractPdsUrl} so this agrees exactly with the inner session's own
* routing. 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
this.#pdsValue = extractPdsUrl(live.didDoc) ?? this.#storedPdsUrl
}
return this.#pdsValue
}
/*
* `did`, `hasSession` and `dispatchUrl` are deliberately NOT overridden: the
* inherited getters read `this.session` / `this.pdsUrl`, which resolve
* through the accessors above, so they are already live.
*/
override async fetchHandler(
url: string,
init?: RequestInit,
): Promise<Response> {
/*
* Absolutizing against `dispatchUrl` 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 is mandatory: `PasswordSession.fetchHandler`
* throws `TypeError` on a pre-set authorization header rather than
* deferring to it.
*
* Bypassing also means these requests get no refresh-on-401 retry, since
* that lives in `PasswordSession.fetchHandler`. That is intentional: the
* caller supplied its own credential (a service-auth token, say), so
* rotating the session's tokens would not make the request any more likely
* to succeed on a retry.
*/
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 ?? {})
}
/**
* Refresh the session, rejecting if nothing was refreshed.
*
* This restores the contract of the `CredentialSession.refreshSession` this
* class replaces, which rejected on any refresh failure.
* `PasswordSession.refresh()` does not: on a transient failure (a 500, a
* network error) it reports through `onUpdateFailure` and then *resolves*
* with the unchanged session data, reserving rejection for the cases where
* the session is definitively gone. Callers here read resolution as "tokens
* rotated" - `SignupQueued` refreshes and then re-checks the token scope, and
* the various verification dialogs refresh and then close - so a resolved
* no-op would silently loop or report success.
*
* The signal is the identity of the returned `SessionData`, not a field
* comparison: `PasswordSession` builds a brand new object on every successful
* rotation and returns the existing one untouched on a transient failure, so
* identity separates the two exactly. Comparing against the data captured
* immediately before the call also gets concurrent refreshes right - if
* another caller's refresh rotated the tokens while ours was queued behind it
* (`PasswordSession` serializes refreshes), the data we get back still
* differs from what we captured, which is a success for our caller.
*/
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 before = this.#liveData()
const data = await inner.refresh()
if (data === before) {
throw new Error('Failed to refresh session')
}
/*
* Re-shape the lex payload into the `@atproto/api` XRPC response 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.
*
* It inherits {@link PasswordSessionManager.refreshSession}'s contract, so it
* rejects rather than resolving when no tokens were rotated.
*/
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. */
export function createPublicAgent() {
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
}
+89 -122
View File
@@ -1,132 +1,102 @@
import {type Client} from '@atproto/lex'
import {type Agent, type Client} from '@atproto/lex'
import {type PasswordSession} from '@atproto/lex-password-session'
import {CHAT_PROXY_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {
BLUESKY_PROXY_HEADER,
CHAT_PROXY_SERVICE,
PUBLIC_BSKY_SERVICE,
} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import {type BskyAppAgent} from './bridge-agent'
import {networkAwareFetch} from './network'
/*
* One client per agent, per surface, so that repeated reads for the same agent
* return the same instance. Client identity is observable: a lex `Client` is
* passed to React Query `queryFn`s and read from render paths, so a freshly
* allocated client on every read would break any dependency array or reference
* comparison built on top of it.
*
* Keying on the agent also ties client lifetime to agent lifetime. A disposed
* agent's `fetchHandler` falls back to unauthenticated fetch, and session
* rotation builds a new agent rather than mutating the old one, so a client
* derived from a stale agent becomes unreachable exactly when its agent does.
*/
const appviewClients = new WeakMap<BskyAppAgent, Client>()
const pdsClients = new WeakMap<BskyAppAgent, Client>()
const chatClients = new WeakMap<BskyAppAgent, Client>()
/**
* The appview {@link Client} for an agent, memoized per agent.
* Build the signed-in appview {@link Client}.
*
* The wrapped handler is `agent.fetchHandler`, NOT
* `agent.sessionManager.fetchHandler`. The agent-level handler is where
* `atproto-proxy` and `atproto-accept-labelers` are set before the request is
* passed down to the session manager, which only adds authorization and PDS
* routing. Because the agent already emits both headers, the client is
* deliberately built with neither a `service` option nor labelers - setting
* either here would emit them a second time.
* {@link BLUESKY_PROXY_HEADER} is passed as the client's `service`, so lex sets
* `atproto-proxy: <that value>` on every request and raw calls are proxied to
* the appview. Record helpers force `service: null`, so they still target the
* account host.
*
* `appLabelers: null` suppresses the class-wide `Client.appLabelers` for this
* instance specifically. The static is populated (see
* `configureGlobalAppLabelers`) so that clients built without a wrapped agent
* carry the global authorities, but the agent already stamped those same DIDs
* onto the request, and lex would append its own copy on top: the agent joins
* its list with the existing header value while lex collects into a `Set` keyed
* on the suffixed string, so neither dedupes against the other and every global
* authority would appear twice.
* The class-wide `Client.appLabelers` static is deliberately NOT suppressed
* here: this client is the only producer of `atproto-accept-labelers` on an
* appview request now that no agent sits underneath it. The account's own
* subscriptions arrive separately, through `applyLabelersToClient` on the
* instance, and that function filters out the Bluesky moderation DID so the
* globally redacted authority is not also listed unredacted.
*
* No `fetch` option: a client built over a session uses that session's own
* fetch, which is `networkAwareFetch` wrapped in the disposal kill switch.
*/
export function agentToAppviewClient(agent: BskyAppAgent): Client {
const existing = appviewClients.get(agent)
if (existing) {
return existing
}
const client = createLexClient(
{
get did() {
return agent.did
},
fetchHandler: (path, init) => agent.fetchHandler(path, init),
},
{appLabelers: null},
)
appviewClients.set(agent, client)
return client
export function buildAppviewClient(agent: Agent): Client {
return createLexClient(agent, {service: BLUESKY_PROXY_HEADER.get()})
}
/**
* The account-host {@link Client} for an agent, memoized per agent.
* Build the signed-in account-host {@link Client}.
*
* This wraps `agent.sessionManager.fetchHandler`, one layer below
* {@link agentToAppviewClient}. That layer does authorization and refresh-on-401
* and resolves the request against `dispatchUrl` (the account's PDS), but it
* does NOT set `atproto-proxy` or `atproto-accept-labelers`, so requests reach
* the PDS itself rather than being proxied onward. That is the right transport
* for `com.atproto.*` repo/server/identity calls.
* No `service`, so no proxy header: `com.atproto.*` repo, server and identity
* calls reach the account's own PDS rather than being proxied onward.
*
* No `service` option for the same reason: adding one would reintroduce the
* proxy header this client exists to avoid. `appLabelers: null` is the same
* kind of suppression: a PDS request is not an appview read, so it must carry no
* moderation authorities at all - without this it would start emitting the
* global `Client.appLabelers`.
*
* The handler is wrapped in a closure rather than passed by reference because
* `PasswordSessionManager.fetchHandler` reads `this`. Relative paths are
* intentional: lex-client hands its handler an origin-less
* `/xrpc/<nsid>[?query]` path, which the session manager absolutizes against
* `dispatchUrl`.
* `appLabelers: null` suppresses the class-wide static for this instance. A PDS
* request is not an appview read, so it must carry no moderation authorities at
* all; without the suppression it would start emitting the global list.
*/
export function agentToPdsClient(agent: BskyAppAgent): Client {
const existing = pdsClients.get(agent)
if (existing) {
return existing
}
const client = createLexClient(
{
get did() {
return agent.did
},
fetchHandler: (path, init) =>
agent.sessionManager.fetchHandler(path, init),
},
{appLabelers: null},
)
pdsClients.set(agent, client)
return client
export function buildPdsClient(agent: Agent): Client {
return createLexClient(agent, {appLabelers: null})
}
/**
* The chat {@link Client} for an agent, memoized per agent.
* Build the signed-in chat {@link Client}.
*
* Same session-manager transport as {@link agentToPdsClient} - authorization
* and PDS routing, no agent-level proxy or labeler headers - but constructed
* with {@link CHAT_PROXY_SERVICE} as its `service`, so lex-client emits
* `atproto-proxy: <CHAT_PROXY_SERVICE>` on every request and `chat.bsky.*`
* calls are proxied to the chat service. `appLabelers: null` for the same
* reason as the PDS client: the chat service takes no moderation authorities.
* {@link CHAT_PROXY_SERVICE} (`${CHAT_PROXY_DID}#bsky_chat`, default
* `did:web:api.bsky.chat#bsky_chat`) is the client's `service`, so `chat.bsky.*`
* calls are proxied to the chat service. The DID is read from the
* env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can
* be retargeted per environment.
*
* `appLabelers: null` for the same reason as the PDS client: the chat service
* takes no moderation authorities.
*/
export function agentToChatClient(agent: BskyAppAgent): Client {
const existing = chatClients.get(agent)
if (existing) {
return existing
}
const client = createLexClient(
{
get did() {
return agent.did
},
fetchHandler: (path, init) =>
agent.sessionManager.fetchHandler(path, init),
export function buildChatClient(agent: Agent): Client {
return createLexClient(agent, {
appLabelers: null,
service: CHAT_PROXY_SERVICE,
})
}
/**
* Wrap a session so requests resolve against a known PDS while auth and refresh
* stay with the session.
*
* This exists for the pre-didDoc window. `PasswordSession` resolves each request
* against `extractPdsUrl(didDoc) ?? service`, so before a refresh has delivered
* a didDoc it falls back to the login service - which for an entryway account
* (`service: bsky.social`, PDS elsewhere) is the wrong host. The synchronous
* resume fast path makes no network request at all, so that window covers every
* request of a cold start until something triggers a refresh.
*
* Absolutizing here is enough because `PasswordSession.fetchHandler` builds its
* URL with `new URL(path, base)`, which ignores the base for an already-absolute
* input. So an absolute URL passes through untouched, and the session's own
* didDoc routing still wins for any client built directly over it.
*
* The tradeoff is that this pins the STORED url for the bundle's lifetime, where
* the session would prefer a didDoc endpoint that arrived later. That is
* acceptable because the two only disagree if the account's PDS moved, and the
* next cold start persists (and therefore pins) the new endpoint.
*/
export function routeSessionToPds(
session: PasswordSession,
pdsUrl: string,
): Agent {
return {
get did() {
return session.did
},
{appLabelers: null, service: CHAT_PROXY_SERVICE},
)
chatClients.set(agent, client)
return client
fetchHandler(path, init) {
return session.fetchHandler(new URL(path, pdsUrl).href, init)
},
}
}
/** Thrown when a write/auth-only client is used with no active session. */
@@ -164,20 +134,17 @@ let publicLexClient: Client | undefined
* The unauthenticated {@link Client} for public reads, pointed at the public
* appview.
*
* A single module-level instance for the same identity-stability reason as
* {@link agentToAppviewClient}: there is no session to scope it to, so it lives
* for the lifetime of the process. Requests go through
* {@link networkAwareFetch} so public reads feed the app's reachability signal
* like authenticated ones do.
* A single module-level instance: there is no session to scope it to, so it
* lives for the lifetime of the process, and its identity is therefore stable
* enough for a React Query key. Requests go through {@link networkAwareFetch} so
* public reads feed the app's reachability signal like authenticated ones do.
*
* Unlike the agent-wrapping clients, this one does NOT suppress
* `Client.appLabelers`: there is no agent underneath to stamp the header, so the
* class-wide static is the only producer and a logged-out read carries the same
* `;redact` moderation authorities an authenticated one does.
*
* That makes `configureModerationForGuest()` load-bearing rather than
* test-only - it is what populates the static before this client's first
* request. `createPublicSessionBundle` runs it while building the bundle.
* Like the session appview client, it carries the class-wide
* `Client.appLabelers`, so a logged-out read gets the same `;redact` moderation
* authorities an authenticated one does. That makes `configureModerationForGuest`
* load-bearing rather than test-only - it is what populates the static before
* this client's first request, and `createPublicSessionBundle` runs it while
* building the bundle.
*/
export function getPublicAppviewClient(): Client {
return (publicLexClient ??= createLexClient({
+2 -6
View File
@@ -10,7 +10,6 @@ import {
import {networkRetry} from '#/lib/async/retry'
import {
BLUESKY_PROXY_HEADER,
DISCOVER_SAVED_FEED,
IS_PROD_SERVICE,
TIMELINE_SAVED_FEED,
@@ -27,7 +26,6 @@ import {
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {features} from '#/analytics'
import {type app} from '#/lexicons'
import {agentToAppviewClient, agentToPdsClient} from './clients'
import {configureModerationForAccount} from './moderation'
import {
buildBundle,
@@ -106,10 +104,10 @@ export async function createSessionBundleAndCreateAccount(
setBirthdateForDid({did: earlyAccount.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(earlyAccount.did)
// Post-signup writes all target the account's own repo and actor store.
const pdsClient = agentToPdsClient(bundle.agent)
const pdsClient = bundle.pdsClient
// Start the prefetch after seeding its synchronous birthdate inputs.
const aa = prefetchAgeAssuranceServerData({
appviewClient: agentToAppviewClient(bundle.agent),
appviewClient: bundle.appviewClient,
accountClient: pdsClient,
})
@@ -136,8 +134,6 @@ export async function createSessionBundleAndCreateAccount(
})
}
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation(
bundle,
+18 -43
View File
@@ -9,7 +9,6 @@ import {
useState,
useSyncExternalStore,
} from 'react'
import {type AtpAgent} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type SessionData} from '@atproto/lex-password-session'
@@ -18,14 +17,9 @@ import {useCloseAllActiveElements} from '#/state/util'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
import {IS_WEB} from '#/env'
import {com} from '#/lexicons'
import {emitSessionDropped} from '../events'
import {
agentToAppviewClient,
agentToChatClient,
agentToPdsClient,
getPublicAppviewClient,
getUnauthenticatedThrowingClient,
} from './clients'
import {getPublicAppviewClient} from './clients'
import {createSessionBundleAndCreateAccount} from './create-account'
import {pickExpiryRescueCandidate} from './expiry-rescue'
import {type Action, getInitialState, reducer, type State} from './reducer'
@@ -451,13 +445,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
/*
* Read the live bundle rather than the one captured by this render: a
* dispatch that lands before the next render would otherwise leave this
* holding a disposed bundle, whose agent dispatches unauthenticated.
* holding a disposed bundle, whose clients dispatch through a disabled
* fetch.
*/
const bundle = store.getState().currentBundleState
.bundle as unknown as SessionBundle
const signal = cancelPendingTask()
/* getSession targets the PDS; only the persisted account fields are patched. */
const {data} = await bundle.agent.com.atproto.server.getSession()
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
if (signal.aborted) return
store.dispatch({
type: 'partial-refresh-session',
@@ -478,9 +473,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
* Rotate the session's tokens and hand back the resulting account snapshot.
*
* Rejects when the rotation was a no-op, restoring the contract the
* `agent.resumeSession(agent.session!)` call sites were written against (the
* bridge agent's `refreshSession` override does the same, for the same
* reason). `PasswordSession.refresh()` resolves with the
* `agent.resumeSession(agent.session!)` call sites were written against.
* `PasswordSession.refresh()` resolves with the
* unchanged `SessionData` on a transient failure - a 500 or a network error
* reported through `onUpdateFailure` - and reserves rejection for a
* definitively dead session. Callers here all read resolution as "tokens
@@ -672,15 +666,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// @ts-expect-error window type is not declared, debug only
// eslint-disable-next-line react-hooks/immutability
if (__DEV__ && IS_WEB) window.agent = bundle.agent
if (__DEV__ && IS_WEB) window.bundle = bundle
const currentBundleRef = useRef(bundle)
/*
* Disposal is deferred to this post-commit effect deliberately: components may
* still render against the outgoing bundle during the commit that swaps it, so
* tearing its agent down inline would pull the agent out from under them. The
* reducer's bundle-identity guard drops any events the not-yet-disposed session
* emits in that window.
* disabling its session inline would pull the transport out from under them.
* The reducer's bundle-identity guard drops any events the not-yet-disposed
* session emits in that window.
*/
useEffect(() => {
if (currentBundleRef.current !== bundle) {
@@ -753,30 +747,15 @@ export function useRequireAuth() {
}
/**
* The active session's agent, or the public agent when logged out.
*/
export function useAgent(): AtpAgent {
const bundle = useContext(BundleContext)
if (!bundle) {
throw Error('useAgent() must be below <SessionProvider>.')
}
return bundle.agent
}
/**
* Client for appview reads.
*
* When logged out the bundle's agent is the public agent built by
* `createPublicAgent`, which is configured with the appview proxy and dispatches
* unauthenticated, so the logged-out fallback is the agent itself - there is no
* separate public branch here.
* Client for appview reads. Logged out, this is the bundle's public client,
* which dispatches unauthenticated against the public appview.
*/
export function useAppviewClient(): Client {
const bundle = useContext(BundleContext)
if (!bundle) {
throw Error('useAppviewClient() must be below <SessionProvider>.')
}
return agentToAppviewClient(bundle.agent)
return bundle.appviewClient
}
/**
@@ -790,9 +769,7 @@ export function usePdsClient(): Client {
if (!bundle) {
throw Error('usePdsClient() must be below <SessionProvider>.')
}
return bundle.session
? agentToPdsClient(bundle.agent)
: getUnauthenticatedThrowingClient()
return bundle.pdsClient
}
/**
@@ -804,9 +781,7 @@ export function useChatClient(): Client {
if (!bundle) {
throw Error('useChatClient() must be below <SessionProvider>.')
}
return bundle.session
? agentToChatClient(bundle.agent)
: getUnauthenticatedThrowingClient()
return bundle.chatClient
}
/**
@@ -814,7 +789,7 @@ export function useChatClient(): Client {
*/
export function useMaybePdsClient(): Client | null {
const bundle = useContext(BundleContext)
return bundle?.session ? agentToPdsClient(bundle.agent) : null
return bundle?.session ? bundle.pdsClient : null
}
/**
@@ -822,7 +797,7 @@ export function useMaybePdsClient(): Client | null {
*/
export function useMaybeChatClient(): Client | null {
const bundle = useContext(BundleContext)
return bundle?.session ? agentToChatClient(bundle.agent) : null
return bundle?.session ? bundle.chatClient : null
}
/**
+26 -20
View File
@@ -1,7 +1,9 @@
import {type AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
import {BSKY_LABELER_DID} from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type DidString} from '@atproto/syntax'
import {IS_TEST_USER} from '#/lib/constants'
import {com} from '#/lexicons'
import {account as accountStorage} from '#/storage'
import {
configureAdditionalModerationAuthorities,
@@ -9,6 +11,9 @@ import {
} from './additional-moderation-authorities'
import {type SessionAccount} from './types'
/** The moderation surface of a session bundle. */
type ModerationSession = {appviewClient: Client}
/**
* Cache an account's subscribed labeler DIDs. Called on every preferences
* fetch, so the cache is eventually consistent with the server.
@@ -31,25 +36,24 @@ export function readLabelers(did: string): string[] | undefined {
}
/**
* Apply an account's labeler subscriptions without duplicating the globally
* redacted Bluesky moderation authority.
* Apply an account's labeler subscriptions to the appview client, without
* duplicating the globally redacted Bluesky moderation authority.
*
* The Bluesky DID is filtered out because it already flows through the global
* `appLabelers`, which lex and the agent both emit with a `;redact` suffix.
* Listing it per-subscription would add a second, non-redacting entry for the
* same authority.
* `Client.appLabelers`, which lex emits with a `;redact` suffix. Listing it
* per-instance would add a second, non-redacting entry for the same authority:
* lex collects the two lists into a `Set` keyed on the suffixed string, so
* neither dedupes against the other.
*
* Writes to the agent rather than the client: the agent-level fetch handler is
* what stamps `atproto-accept-labelers` on the requests the wrapping clients
* issue, so setting them here reaches every appview read. The bundle rework
* moves this to `appviewClient.setLabelers` once the agent is gone.
* Only the appview client takes subscriptions - the PDS and chat clients suppress
* labelers entirely (see clients.ts).
*/
export function applyLabelersToClient(
agent: AtpAgent,
client: Client,
subscribedDids: string[],
) {
agent.configureLabelersHeader(
subscribedDids.filter(did => did !== BSKY_LABELER_DID),
client.setLabelers(
subscribedDids.filter(did => did !== BSKY_LABELER_DID) as DidString[],
)
}
@@ -66,7 +70,7 @@ export function configureModerationForGuest() {
* in the same tick, before any request goes out.
*/
export function configureModerationForAccount(
bundle: {agent: AtpAgent; appviewClient?: Client},
bundle: ModerationSession,
account: SessionAccount,
) {
// This global mutation is *only* OK because this code is only relevant for testing.
@@ -74,13 +78,13 @@ export function configureModerationForAccount(
switchToBskyAppLabeler()
if (IS_TEST_USER(account.handle)) {
// Test accounts may briefly use the production authority while this resolves.
void trySwitchToTestAppLabeler(bundle.agent)
void trySwitchToTestAppLabeler(bundle.appviewClient)
}
// The code below is actually relevant to production (and isn't global).
const labelerDids = readLabelers(account.did)
if (labelerDids) {
applyLabelersToClient(bundle.agent, labelerDids)
applyLabelersToClient(bundle.appviewClient, labelerDids)
} else {
// If there are no headers in the storage, we'll not send them on the initial requests.
// If we wanted to fix this, we could block on the preferences query here.
@@ -94,12 +98,14 @@ function switchToBskyAppLabeler() {
}
/** Resolve and install the test environment's moderation authority. */
async function trySwitchToTestAppLabeler(agent: AtpAgent) {
async function trySwitchToTestAppLabeler(client: Client) {
const did = (
await agent
.resolveHandle({handle: 'mod-authority.test'})
await client
.call(com.atproto.identity.resolveHandle, {
handle: 'mod-authority.test',
})
.catch(_ => undefined)
)?.data.did
)?.did
if (did) {
console.warn('USING TEST ENV MODERATION')
configureGlobalAppLabelers([did])
+59 -48
View File
@@ -1,21 +1,27 @@
import {type Client} from '@atproto/lex'
import {
PasswordSession,
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {logger} from '#/logger'
import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data'
import {features} from '#/analytics'
import {
BskyAppAgent,
createPublicAgent,
PasswordSessionManager,
} from './bridge-agent'
import {agentToAppviewClient, agentToPdsClient} from './clients'
buildAppviewClient,
buildChatClient,
buildPdsClient,
getPublicAppviewClient,
getUnauthenticatedThrowingClient,
routeSessionToPds,
} from './clients'
import {addSessionErrorLog} from './logging'
import {configureModerationForAccount} from './moderation'
import {
configureModerationForAccount,
configureModerationForGuest,
} from './moderation'
import {networkAwareFetch} from './network'
import {
isSessionExpired,
@@ -46,10 +52,12 @@ function deriveServiceUrl(session: PasswordSession | null): URL {
)
}
/** An `AtpAgent` bridged over one `PasswordSession`, the bundle's sole auth core. */
/** The three clients over one `PasswordSession`, the bundle's sole auth core. */
export type SessionBundle = {
session: PasswordSession
agent: BskyAppAgent
appviewClient: Client
pdsClient: Client
chatClient: Client
readonly service: URL
}
@@ -63,38 +71,38 @@ const bundleKillSwitches = new WeakMap<SessionBundle, () => void>()
/**
* Register the lifecycle closure used by {@link disposeBundle}.
*
* Disposing also detaches the bridge agent from its session, so a stale
* bundle's `agent.session` / `agent.pdsUrl` read as `undefined` rather than
* serving tokens the app has stopped tracking.
* Killing the hooks is the whole of disposal now: the clients hold no state of
* their own, and every request they make goes through the session's injected
* fetch, which the kill switch disables.
*/
export function registerBundleKillSwitch(
bundle: SessionBundle,
kill: () => void,
) {
bundleKillSwitches.set(bundle, () => {
kill()
bundle.agent.dispose()
})
bundleKillSwitches.set(bundle, kill)
}
/**
* Wrap a session in the bridge agent.
* Build the three clients over a session.
*
* `storedPdsUrl` seeds {@link PasswordSessionManager}'s PDS routing so requests
* made before the first refresh delivers a didDoc still reach the right host.
* Once a didDoc arrives the manager prefers its endpoint.
* `storedPdsUrl` pins PDS routing for requests made before a refresh has
* delivered a didDoc - see {@link routeSessionToPds}, which explains why the
* session's own routing is not sufficient in that window. With no stored url
* there is nothing better to pin to, so the clients go straight over the
* session and it resolves them against its own service.
*/
export function buildBundle(
session: PasswordSession,
storedPdsUrl?: string,
): SessionBundle {
const manager = new PasswordSessionManager(session, {
service: deriveServiceUrl(session).toString(),
pdsUrl: storedPdsUrl,
})
const agent = storedPdsUrl
? routeSessionToPds(session, storedPdsUrl)
: session
return {
session,
agent: new BskyAppAgent(manager),
appviewClient: buildAppviewClient(agent),
pdsClient: buildPdsClient(agent),
chatClient: buildChatClient(agent),
get service() {
return deriveServiceUrl(session)
},
@@ -182,23 +190,36 @@ export function makeSessionHooks({
})
}
/** The agent exposed while logged out. */
/** The clients exposed while logged out. */
export type PublicSessionBundle = {
session: null
agent: BskyAppAgent
appviewClient: Client
pdsClient: Client
chatClient: Client
readonly service: URL
}
/**
* Build the logged-out bundle. `createPublicAgent` installs the guest
* moderation authorities as part of building the agent, which is what populates
* the global `Client.appLabelers` that {@link getPublicAppviewClient} relies on
* for its labeler header.
* Build the logged-out bundle.
*
* `configureModerationForGuest` is what populates the global
* `Client.appLabelers` that {@link getPublicAppviewClient} reads for its labeler
* header, so it must run before the public client's first request. There is no
* agent stamping that header any more, which makes this call load-bearing rather
* than test-only: without it a logged-out read would carry no moderation
* authorities at all.
*
* The write surfaces get the throwing client rather than a public one, so an
* unauthenticated write fails legibly instead of 4xx-ing against public
* infrastructure.
*/
export function createPublicSessionBundle(): PublicSessionBundle {
configureModerationForGuest()
return {
session: null,
agent: createPublicAgent(),
appviewClient: getPublicAppviewClient(),
pdsClient: getUnauthenticatedThrowingClient(),
chatClient: getUnauthenticatedThrowingClient(),
service: new URL(PUBLIC_BSKY_SERVICE),
}
}
@@ -224,9 +245,8 @@ export function createPublicSessionBundle(): PublicSessionBundle {
* Both failure modes dispose: the bundle is fully built by this point, and a
* still-live session left behind would keep its refresh and dispatch paths
* alive with nothing tracking it. (Disposal is a no-op for the destroyed case,
* where the session already refuses to refresh and the bridge agent already
* reads as logged out - but the two paths are indistinguishable to the caller,
* so both go through it.)
* where the session already refuses to refresh - but the two paths are
* indistinguishable to the caller, so both go through it.)
*/
export async function finishPreparation<T>(
bundle: SessionBundle,
@@ -294,16 +314,10 @@ export async function createSessionBundleAndResume(
configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({
appviewClient: agentToAppviewClient(bundle.agent),
accountClient: agentToPdsClient(bundle.agent),
appviewClient: bundle.appviewClient,
accountClient: bundle.pdsClient,
})
/*
* The proxy header is applied after the PDS-targeting setup above, so those
* calls run without it.
*/
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation(
bundle,
@@ -362,12 +376,10 @@ export async function createSessionBundleAndLogin(
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({
appviewClient: agentToAppviewClient(bundle.agent),
accountClient: agentToPdsClient(bundle.agent),
appviewClient: bundle.appviewClient,
accountClient: bundle.pdsClient,
})
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation(
bundle,
@@ -403,7 +415,6 @@ export function createSessionBundleFromStoredAccount(
bundle = buildBundle(session, storedAccount.pdsUrl)
registerBundleKillSwitch(bundle, hooks.kill)
configureModerationForAccount(bundle, storedAccount)
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
const account = session.destroyed
? storedAccount