From 7fee516d4c982dd3ee90622179713127c52d3cd5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 3 Aug 2026 12:42:29 +0300 Subject: [PATCH] add the lex client seam over the bridge agent Co-Authored-By: Claude Fable 5 --- src/lib/lexClient.ts | 26 +++ src/state/session/__tests__/clients-test.ts | 179 ++++++++++++++++++++ src/state/session/clients.ts | 64 +++++++ src/state/session/index.tsx | 21 +++ 4 files changed, 290 insertions(+) create mode 100644 src/lib/lexClient.ts create mode 100644 src/state/session/__tests__/clients-test.ts create mode 100644 src/state/session/clients.ts diff --git a/src/lib/lexClient.ts b/src/lib/lexClient.ts new file mode 100644 index 0000000000..40b0640665 --- /dev/null +++ b/src/lib/lexClient.ts @@ -0,0 +1,26 @@ +import { + type Agent, + type AgentOptions, + Client, + type ClientOptions, +} from '@atproto/lex' + +/** + * App-standard factory for lex {@link Client}s. Use this instead of `new + * Client(...)` so every client shares the same lenient response processing. + * + * lex-client defaults to strict Lex processing, which rejects responses + * containing the LEGACY blob reference format (objects with `cid` and + * `mimeType` properties instead of `$type: 'blob'`). Older records on the + * network still carry these, and rejecting them would drop records the app + * currently renders. Lenient mode also relaxes datetime format checks (e.g. + * missing timezones) and blob MIME/size constraints. `Client.configure` only + * accepts `appLabelers` globally, so the option is defaulted here, per + * constructed client. + */ +export function createLexClient( + agent: Agent | AgentOptions, + options?: ClientOptions, +): Client { + return new Client(agent, {strictResponseProcessing: false, ...options}) +} diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts new file mode 100644 index 0000000000..f90841dbc8 --- /dev/null +++ b/src/state/session/__tests__/clients-test.ts @@ -0,0 +1,179 @@ +import {Client} from '@atproto/lex' +import {PasswordSession} 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 {app} from '#/lexicons' +import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' +import {agentToLexClient} from '../clients' +import {sessionAccountToSessionData} from '../session-data' +import { + asFetch, + DID, + HANDLE, + json, + makeAccount, + makeMockFetch, + type MockFetch, + SERVICE, +} from './mock-fetch' + +const PROFILE_BODY = { + did: DID, + handle: HANDLE, +} + +/** A mock fetch that answers `getProfile` and records every request. */ +function makeProfileFetch(): MockFetch { + return makeMockFetch({ + 'app.bsky.actor.getProfile': () => json(PROFILE_BODY), + }) +} + +/** An authenticated agent whose whole network path is the mock fetch. */ +function setup(fetchMock: MockFetch = makeProfileFetch()) { + 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} +} + +/** The `init` a mock fetch was called with for a given nsid. */ +function initFor(mock: MockFetch, nsid: string): RequestInit | undefined { + const call = mock.mock.calls.find(c => { + const url = c[0] instanceof URL ? c[0].href : String(c[0]) + return url.includes(`/xrpc/${nsid}`) + }) + return call?.[1] +} + +describe('agentToLexClient', () => { + let fetchMock: MockFetch + + beforeEach(() => { + fetchMock = makeProfileFetch() + }) + + it('memoizes one client per agent', () => { + const {agent: agentA} = setup(fetchMock) + const {agent: agentB} = setup(fetchMock) + + const clientA1 = agentToLexClient(agentA) + const clientA2 = agentToLexClient(agentA) + const clientB = agentToLexClient(agentB) + + expect(clientA1).toBeInstanceOf(Client) + expect(clientA1).toBe(clientA2) + expect(clientA1).not.toBe(clientB) + }) + + it('passes through the agent did', () => { + const {agent} = setup(fetchMock) + expect(agentToLexClient(agent).did).toBe(DID) + }) + + it('reflects an undefined did on a logged-out agent', () => { + const {agent} = setupPublic(fetchMock) + expect(agentToLexClient(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, + }) + + 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}`) + }) + + it('emits the agent proxy header', async () => { + const {agent} = setup(fetchMock) + agent.configureProxy('did:web:api.bsky.app#bsky_appview') + + await agentToLexClient(agent).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', + ) + }) + + it('emits the agent labeler header exactly once', async () => { + const {agent} = setup(fetchMock) + agent.configureLabelersHeader(['did:plc:labeler']) + + await agentToLexClient(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. + */ + const entries = labelers! + .split(',') + .filter(l => l.includes('did:plc:labeler')) + expect(entries).toHaveLength(1) + }) + + it('sends the session access token', async () => { + const {agent} = setup(fetchMock) + + await agentToLexClient(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 = agentToLexClient(agent) + agent.dispose() + + 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() + }) +}) diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts new file mode 100644 index 0000000000..58c082893e --- /dev/null +++ b/src/state/session/clients.ts @@ -0,0 +1,64 @@ +import {type Client} from '@atproto/lex' + +import {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 + * 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 lexClients = new WeakMap() + +/** + * The lex {@link Client} for an agent, memoized per agent. + * + * 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. + */ +export function agentToLexClient(agent: BskyAppAgent): Client { + const existing = lexClients.get(agent) + if (existing) { + return existing + } + const client = createLexClient({ + get did() { + return agent.did + }, + fetchHandler: (path, init) => agent.fetchHandler(path, init), + }) + lexClients.set(agent, client) + return client +} + +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 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. + */ +export function getPublicLexClient(): Client { + return (publicLexClient ??= createLexClient({ + service: PUBLIC_BSKY_SERVICE, + fetch: networkAwareFetch, + })) +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 1a22a0e215..618804ee7b 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -10,6 +10,7 @@ import { useSyncExternalStore, } from 'react' import {type AtpAgent} from '@atproto/api' +import {type Client} from '@atproto/lex' import {type SessionData} from '@atproto/lex-password-session' import * as persisted from '#/state/persisted' @@ -18,6 +19,7 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' import {IS_WEB} from '#/env' import {emitSessionDropped} from '../events' +import {agentToLexClient, getPublicLexClient} from './clients' import {createSessionBundleAndCreateAccount} from './create-account' import {pickExpiryRescueCandidate} from './expiry-rescue' import {type Action, getInitialState, reducer, type State} from './reducer' @@ -707,3 +709,22 @@ export function useAgent(): AtpAgent { } return bundle.agent } + +/** + * The lex client for the active session, or for the public agent when logged + * out. + */ +export function useLexClient(): Client { + const bundle = useContext(BundleContext) + if (!bundle) { + throw Error('useLexClient() must be below .') + } + return agentToLexClient(bundle.agent) +} + +/** + * The unauthenticated lex client for public reads. + */ +export function usePublicLexClient(): Client { + return getPublicLexClient() +}