diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1eecb92ee3..a490d92719 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 449458c075..33f7d2f8d1 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -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 diff --git a/src/state/session/__tests__/bridge-agent-test.ts b/src/state/session/__tests__/bridge-agent-test.ts deleted file mode 100644 index cbd606d12a..0000000000 --- a/src/state/session/__tests__/bridge-agent-test.ts +++ /dev/null @@ -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 - - 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>() - 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>() - const onUpdated = - jest.fn>() - 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>() - const onUpdateFailure = - jest.fn>() - 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', - ) - }) -}) diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts index 019281f784..50cf70fede 100644 --- a/src/state/session/__tests__/clients-test.ts +++ b/src/state/session/__tests__/clients-test.ts @@ -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/` path; the - * session manager absolutizes it against dispatchUrl. + * lex hands its fetchHandler an origin-less `/xrpc/` 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`, + ]) }) }) diff --git a/src/state/session/__tests__/provider-clients-test.tsx b/src/state/session/__tests__/provider-clients-test.tsx index 58ea9ad6fe..d10af7a12b 100644 --- a/src/state/session/__tests__/provider-clients-test.tsx +++ b/src/state/session/__tests__/provider-clients-test.tsx @@ -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 () => { diff --git a/src/state/session/__tests__/provider-refresh-session-test.tsx b/src/state/session/__tests__/provider-refresh-session-test.tsx index 6b1e10bdec..2b7bd1c4cb 100644 --- a/src/state/session/__tests__/provider-refresh-session-test.tsx +++ b/src/state/session/__tests__/provider-refresh-session-test.tsx @@ -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), } } diff --git a/src/state/session/__tests__/session-core-test.ts b/src/state/session/__tests__/session-core-test.ts index 52484dcf72..3503acd08a 100644 --- a/src/state/session/__tests__/session-core-test.ts +++ b/src/state/session/__tests__/session-core-test.ts @@ -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', + ) }) }) diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts index 3f3566c5c6..76d1039610 100644 --- a/src/state/session/additional-moderation-authorities.ts +++ b/src/state/session/additional-moderation-authorities.ts @@ -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([...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}`[]}) } diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts deleted file mode 100644 index 7246454ed2..0000000000 --- a/src/state/session/agent.ts +++ /dev/null @@ -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 - ) { - super(...options) - if (proxyHeader) { - this.configureProxy(proxyHeader) - } - } -} diff --git a/src/state/session/bridge-agent.ts b/src/state/session/bridge-agent.ts deleted file mode 100644 index 0c692a6c17..0000000000 --- a/src/state/session/bridge-agent.ts +++ /dev/null @@ -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)[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 { - /* - * 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 { - 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 { - return this.refreshSession() - } - - override async logout(): Promise { - 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 { - return Promise.reject(new Error(UNSUPPORTED)) - } - - override createAccount( - _data: ComAtprotoServerCreateAccount.InputSchema, - _opts?: ComAtprotoServerCreateAccount.CallOptions, - ): Promise { - 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 -} diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index fab530aba2..026b2d8bd8 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -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() -const pdsClients = new WeakMap() -const chatClients = new WeakMap() - /** - * 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: ` 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/[?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: ` 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({ diff --git a/src/state/session/create-account.ts b/src/state/session/create-account.ts index a14f50cff5..618ab1aa7a 100644 --- a/src/state/session/create-account.ts +++ b/src/state/session/create-account.ts @@ -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, diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 730cdd9808..d3045932d3 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -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 .') - } - 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 .') } - return agentToAppviewClient(bundle.agent) + return bundle.appviewClient } /** @@ -790,9 +769,7 @@ export function usePdsClient(): Client { if (!bundle) { throw Error('usePdsClient() must be below .') } - 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 .') } - 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 } /** diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index b5b3a01af8..691aad4c81 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -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]) diff --git a/src/state/session/session-core.ts b/src/state/session/session-core.ts index 7a9d1a22cc..6269cc7f29 100644 --- a/src/state/session/session-core.ts +++ b/src/state/session/session-core.ts @@ -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 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( 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