diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts index f90841dbc8..452de11d9d 100644 --- a/src/state/session/__tests__/clients-test.ts +++ b/src/state/session/__tests__/clients-test.ts @@ -13,9 +13,16 @@ jest.mock('jwt-decode', () => ({ }, })) -import {app} from '#/lexicons' +import {CHAT_PROXY_SERVICE} from '#/lib/constants' +import {app, chat, com} from '#/lexicons' import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' -import {agentToLexClient} from '../clients' +import { + agentToAppviewClient, + agentToChatClient, + agentToPdsClient, + getUnauthenticatedThrowingClient, + NotAuthenticatedError, +} from '../clients' import {sessionAccountToSessionData} from '../session-data' import { asFetch, @@ -26,6 +33,7 @@ import { makeMockFetch, type MockFetch, SERVICE, + urlsOf, } from './mock-fetch' const PROFILE_BODY = { @@ -70,7 +78,7 @@ function initFor(mock: MockFetch, nsid: string): RequestInit | undefined { return call?.[1] } -describe('agentToLexClient', () => { +describe('agentToAppviewClient', () => { let fetchMock: MockFetch beforeEach(() => { @@ -81,9 +89,9 @@ describe('agentToLexClient', () => { const {agent: agentA} = setup(fetchMock) const {agent: agentB} = setup(fetchMock) - const clientA1 = agentToLexClient(agentA) - const clientA2 = agentToLexClient(agentA) - const clientB = agentToLexClient(agentB) + const clientA1 = agentToAppviewClient(agentA) + const clientA2 = agentToAppviewClient(agentA) + const clientB = agentToAppviewClient(agentB) expect(clientA1).toBeInstanceOf(Client) expect(clientA1).toBe(clientA2) @@ -92,20 +100,23 @@ describe('agentToLexClient', () => { it('passes through the agent did', () => { const {agent} = setup(fetchMock) - expect(agentToLexClient(agent).did).toBe(DID) + expect(agentToAppviewClient(agent).did).toBe(DID) }) it('reflects an undefined did on a logged-out agent', () => { const {agent} = setupPublic(fetchMock) - expect(agentToLexClient(agent).did).toBeUndefined() + expect(agentToAppviewClient(agent).did).toBeUndefined() }) it('routes client.call through the agent to the network', async () => { const {agent} = setup(fetchMock) - const body = await agentToLexClient(agent).call(app.bsky.actor.getProfile, { - actor: HANDLE, - }) + const body = await agentToAppviewClient(agent).call( + app.bsky.actor.getProfile, + { + actor: HANDLE, + }, + ) expect(body.handle).toBe(HANDLE) const call = fetchMock.mock.calls.find(c => { @@ -121,7 +132,7 @@ describe('agentToLexClient', () => { const {agent} = setup(fetchMock) agent.configureProxy('did:web:api.bsky.app#bsky_appview') - await agentToLexClient(agent).call(app.bsky.actor.getProfile, { + await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { actor: HANDLE, }) @@ -135,7 +146,7 @@ describe('agentToLexClient', () => { const {agent} = setup(fetchMock) agent.configureLabelersHeader(['did:plc:labeler']) - await agentToLexClient(agent).call(app.bsky.actor.getProfile, { + await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { actor: HANDLE, }) @@ -155,7 +166,7 @@ describe('agentToLexClient', () => { it('sends the session access token', async () => { const {agent} = setup(fetchMock) - await agentToLexClient(agent).call(app.bsky.actor.getProfile, { + await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { actor: HANDLE, }) @@ -167,7 +178,7 @@ describe('agentToLexClient', () => { it('falls back to unauthenticated requests once the agent is disposed', async () => { const {agent} = setup(fetchMock) - const client = agentToLexClient(agent) + const client = agentToAppviewClient(agent) agent.dispose() await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) @@ -177,3 +188,158 @@ describe('agentToLexClient', () => { expect(client.did).toBeUndefined() }) }) + +describe('agentToPdsClient', () => { + let fetchMock: MockFetch + + beforeEach(() => { + fetchMock = makeProfileFetch() + }) + + 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('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', + ) + }) + + it('emits neither the proxy nor the labeler header the agent is configured with', 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. + */ + const {agent} = setup(fetchMock) + agent.configureProxy('did:web:api.bsky.app#bsky_appview') + agent.configureLabelersHeader(['did:plc:labeler']) + + await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) + + const headers = new Headers( + initFor(fetchMock, 'com.atproto.server.getSession')?.headers, + ) + 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. + */ + const {agent} = setup(fetchMock) + + await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) + + expect(urlsOf(fetchMock)).toContain( + `${SERVICE}/xrpc/com.atproto.server.getSession`, + ) + }) +}) + +describe('agentToChatClient', () => { + let fetchMock: MockFetch + + beforeEach(() => { + fetchMock = makeProfileFetch() + }) + + 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('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) + .call(chat.bsky.convo.listConvos, {}) + .catch(() => {}) + + const headers = new Headers( + initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers, + ) + /* + * An exact match, not `toContain`: `Headers` comma-joins repeated entries + * for the same name, so a second contributor would show up here. + */ + expect(headers.get('atproto-proxy')).toBe(CHAT_PROXY_SERVICE) + 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']) + + await agentToChatClient(agent) + .call(chat.bsky.convo.listConvos, {}) + .catch(() => {}) + + const headers = new Headers( + initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers, + ) + expect(headers.get('atproto-accept-labelers')).toBeNull() + }) +}) + +describe('getUnauthenticatedThrowingClient', () => { + it('is a stable singleton with no did', () => { + const client = getUnauthenticatedThrowingClient() + + expect(client.did).toBeUndefined() + /* identity is stable so it is safe in React Query keys */ + expect(getUnauthenticatedThrowingClient()).toBe(client) + }) + + it('rejects any call with NotAuthenticatedError as the cause, with no fetch', async () => { + /* + * The throwing fetchHandler fires before any network I/O. lex-client wraps a + * fetchHandler throw in an internal error whose `cause` is the original, so + * the NotAuthenticatedError surfaces there. + */ + const fetchMock = makeProfileFetch() + const err = await getUnauthenticatedThrowingClient() + .call(com.atproto.server.getSession, {}) + .then(() => undefined) + .catch((e: unknown) => e) + + expect((err as Error).cause).toBeInstanceOf(NotAuthenticatedError) + expect(((err as Error).cause as Error).name).toBe('NotAuthenticatedError') + expect(((err as Error).cause as Error).message).toBe( + 'Not authenticated: this operation requires an active session', + ) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index ff6a597ac7..4daf5368b3 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -1,15 +1,15 @@ import {type Client} from '@atproto/lex' -import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {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, 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 + * 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 @@ -17,10 +17,12 @@ import {networkAwareFetch} from './network' * 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 lexClients = new WeakMap() +const appviewClients = new WeakMap() +const pdsClients = new WeakMap() +const chatClients = new WeakMap() /** - * The lex {@link Client} for an agent, memoized per agent. + * The appview {@link Client} for an agent, memoized per agent. * * The wrapped handler is `agent.fetchHandler`, NOT * `agent.sessionManager.fetchHandler`. The agent-level handler is where @@ -30,8 +32,8 @@ const lexClients = new WeakMap() * deliberately built with neither a `service` option nor labelers - setting * either here would emit them a second time. */ -export function agentToLexClient(agent: BskyAppAgent): Client { - const existing = lexClients.get(agent) +export function agentToAppviewClient(agent: BskyAppAgent): Client { + const existing = appviewClients.get(agent) if (existing) { return existing } @@ -41,10 +43,101 @@ export function agentToLexClient(agent: BskyAppAgent): Client { }, fetchHandler: (path, init) => agent.fetchHandler(path, init), }) - lexClients.set(agent, client) + appviewClients.set(agent, client) return client } +/** + * The account-host {@link Client} for an agent, memoized per agent. + * + * 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` option for the same reason: adding one would reintroduce the + * proxy header this client exists to avoid. + * + * 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`. + */ +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), + }) + pdsClients.set(agent, client) + return client +} + +/** + * The chat {@link Client} for an agent, memoized per agent. + * + * 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. + */ +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), + }, + {service: CHAT_PROXY_SERVICE}, + ) + chatClients.set(agent, client) + return client +} + +/** Thrown when a write/auth-only client is used with no active session. */ +export class NotAuthenticatedError extends Error { + constructor(op = 'this operation') { + super(`Not authenticated: ${op} requires an active session`) + this.name = 'NotAuthenticatedError' + } +} + +let unauthedClient: Client | undefined + +/** + * A {@link Client} that throws {@link NotAuthenticatedError} on any request, + * before any network I/O. It is the logged-out value of the write/auth-only + * hooks (`usePdsClient`/`useChatClient`) so an unauthenticated call fails + * immediately and legibly instead of silently hitting public infrastructure, + * which would answer with an opaque 4xx. + * + * A single module-level instance, so its identity is stable across renders - + * safe to use in React Query keys and as a hook return value. + */ +export function getUnauthenticatedThrowingClient(): Client { + return (unauthedClient ??= createLexClient({ + did: undefined, + fetchHandler: () => { + throw new NotAuthenticatedError() + }, + })) +} + let publicLexClient: Client | undefined /** @@ -52,9 +145,10 @@ let publicLexClient: Client | undefined * appview. * * A single module-level instance for the same identity-stability reason as - * {@link agentToLexClient}: 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. + * {@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. * * Unlike the public agent it parallels, this client sends neither * `atproto-proxy` nor `atproto-accept-labelers`. `createPublicAgent` configures @@ -63,7 +157,7 @@ let publicLexClient: Client | undefined * consumer that needs moderation labels on public reads must configure labelers * itself before issuing the request. */ -export function getPublicLexClient(): Client { +export function getPublicAppviewClient(): Client { return (publicLexClient ??= createLexClient({ service: PUBLIC_BSKY_SERVICE, fetch: networkAwareFetch,