diff --git a/src/lib/__tests__/xrpc-error.test.ts b/src/lib/__tests__/xrpc-error.test.ts new file mode 100644 index 0000000000..5c09fb4f08 --- /dev/null +++ b/src/lib/__tests__/xrpc-error.test.ts @@ -0,0 +1,91 @@ +import { + getMain, + type Procedure, + type Query, + XrpcInternalError, + XrpcResponseError, +} from '@atproto/lex' +import {describe, expect, it} from '@jest/globals' + +import {app, com} from '#/lexicons' +import {matchXrpcError} from '../xrpc-error' + +const createAccount = com.atproto.server.createAccount +const getTrends = app.bsky.unspecced.getTrends + +/** + * An `XrpcResponseError` as a lex `Client` would construct it: the method + * schema it was thrown for, plus the server's error response and its parsed + * payload. + */ +function responseError(method: Procedure | Query, error: string, status = 400) { + return new XrpcResponseError( + method, + new Response(JSON.stringify({error}), { + status, + headers: {'content-type': 'application/json'}, + }), + {encoding: 'application/json', body: {error}}, + ) +} + +describe('matchXrpcError', () => { + it('returns a code declared by the method', () => { + const e = responseError(getMain(createAccount), 'InvalidHandle') + expect(matchXrpcError(e, createAccount)).toBe('InvalidHandle') + }) + + it('accepts the .main schema as well as the namespace', () => { + const e = responseError(getMain(createAccount), 'InvalidInviteCode') + expect(matchXrpcError(e, createAccount.main)).toBe('InvalidInviteCode') + }) + + it('returns undefined for a code the method does not declare', () => { + const e = responseError(getMain(createAccount), 'RateLimitExceeded') + expect(matchXrpcError(e, createAccount)).toBeUndefined() + }) + + it('returns undefined for a method that declares no errors at all', () => { + const e = responseError(getMain(getTrends), 'InvalidHandle') + expect(matchXrpcError(e, getTrends)).toBeUndefined() + }) + + it('does not match a declared code thrown for a different method', () => { + /* + * `InvalidHandle` is declared by createAccount but this error came from a + * getTrends call, so scoping must reject it. + */ + const e = responseError(getMain(getTrends), 'InvalidHandle') + expect(matchXrpcError(e, createAccount)).toBeUndefined() + }) + + it('returns undefined for a lex error carrying no server code', () => { + const e = new XrpcInternalError(getMain(createAccount), 'boom') + expect(matchXrpcError(e, createAccount)).toBeUndefined() + }) + + it('returns undefined for non-lex errors and non-errors', () => { + expect(matchXrpcError(new Error('InvalidHandle'), createAccount)).toBe( + undefined, + ) + expect(matchXrpcError('InvalidHandle', createAccount)).toBeUndefined() + expect(matchXrpcError(undefined, createAccount)).toBeUndefined() + }) + + it('narrows the result to the declared-errors union', () => { + const e = responseError(getMain(createAccount), 'InvalidHandle') + const code = matchXrpcError(e, createAccount) + + /* + * A misspelled or undeclared code is not comparable to the narrowed union, + * which is what makes a typo'd `switch` case a compile error (TS2678 / + * TS2367) rather than a branch that never runs. + */ + // @ts-expect-error 'InvalidHandel' is not a declared createAccount error + expect(code === 'InvalidHandel').toBe(false) + // @ts-expect-error 'RateLimitExceeded' is not declared by createAccount + expect(code === 'RateLimitExceeded').toBe(false) + + expect(code === 'UnsupportedDomain').toBe(false) + }) +}) 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/lib/xrpc-error.ts b/src/lib/xrpc-error.ts new file mode 100644 index 0000000000..25f9a1277e --- /dev/null +++ b/src/lib/xrpc-error.ts @@ -0,0 +1,59 @@ +import { + getMain, + type InferMethodError, + type Main, + type Procedure, + type Query, + XrpcResponseError, +} from '@atproto/lex' + +/** + * Same nsid means `e` was thrown for this method schema, so `e` can be + * treated as an `XrpcResponseError` - which is what lets the SDK's + * `matchesSchemaErrors()` narrow `e.error` to M's declared errors. + */ +function isThrownFor( + e: XrpcResponseError, + schema: M, +): e is XrpcResponseError { + return e.method.nsid === schema.nsid +} + +/** + * The lexicon error code carried by `e`, narrowed to the errors DECLARED by + * `method`, or `undefined` when `e` is not such an error. + * + * `XrpcResponseError.error` is the open `LexErrorCode` union, so comparing it + * as a plain string lets a typo silently never match. Narrowing the return type + * to `InferMethodError` makes a `switch` over the result reject an + * undeclared or misspelled `case` at compile time: + * + * ```ts + * switch (matchXrpcError(e, com.atproto.server.createAccount)) { + * case 'InvalidHandle': + * ... + * } + * ``` + * + * Matching is scoped to `method`: `XrpcError` records the method schema it was + * thrown for, so a declared code arriving from a DIFFERENT call does not match. + * Undeclared codes, non-XRPC errors, and the internal/fetch lex errors (which + * carry no server error code) all return `undefined`. + * + * `method` accepts the same value passed to `client.call` - either the + * generated method namespace (`com.atproto.server.createAccount`) or its + * `.main` schema - via lex's `Main`. + */ +export function matchXrpcError( + e: unknown, + method: Main, +): InferMethodError | undefined { + if (!(e instanceof XrpcResponseError)) { + return undefined + } + const schema = getMain(method) + if (isThrownFor(e, schema) && e.matchesSchemaErrors()) { + return e.error + } + return undefined +} diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts index 8acbd47908..9f0ce29bcf 100644 --- a/src/screens/Signup/state.ts +++ b/src/screens/Signup/state.ts @@ -1,7 +1,6 @@ import {createContext, useCallback, useContext} from 'react' import {LayoutAnimation} from 'react-native' import {type ComAtprotoServerDescribeServer} from '@atproto/api' -import {XrpcResponseError} from '@atproto/lex' import {useLingui} from '@lingui/react/macro' import * as EmailValidator from 'email-validator' @@ -9,9 +8,11 @@ import {DEFAULT_SERVICE} from '#/lib/constants' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {getAge} from '#/lib/strings/time' +import {matchXrpcError} from '#/lib/xrpc-error' import {useSessionApi} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {type AnalyticsContextType, useAnalytics} from '#/analytics' +import {com} from '#/lexicons' export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema @@ -258,34 +259,13 @@ export const useSignupContext = () => useContext(SignupContext) * failure is unexpected and should be reported to Sentry. */ function classifyExpectedSignupError(e: unknown): string | undefined { - /* - * TODO: `XrpcResponseError.error` is the open `LexErrorCode` union, so these - * codes are compared as plain strings and a typo silently never matches. - * Once the generated lexicons land, replace this (and every multi-code error - * site) with a shared helper that narrows against the method schema: - * - * function matchXrpcError( - * e: unknown, - * method: Main, - * ): InferMethodError | undefined - * - * switch (matchXrpcError(e, com.atproto.server.createAccount)) { - * case 'InvalidHandle': ... - * } - * - * The return type is the method's declared-errors union, so a typo'd case is - * a compile error, and undeclared codes fall through to `undefined`. The - * helper should also match `e.method.nsid` so a declared code from a - * different call cannot match, mirroring the old per-method error classes. - */ - if (e instanceof XrpcResponseError) { - switch (e.error) { - case 'InvalidHandle': - case 'HandleNotAvailable': - case 'InvalidPassword': - case 'UnsupportedDomain': - return e.error - } + const code = matchXrpcError(e, com.atproto.server.createAccount) + switch (code) { + case 'InvalidHandle': + case 'HandleNotAvailable': + case 'InvalidPassword': + case 'UnsupportedDomain': + return code } /* the server sends no typed error for this case */ if (String(e).includes('Email already taken')) return 'EmailTaken' @@ -376,7 +356,10 @@ export function useSubmitSignup() { } catch (err) { const e = err as Error let errMsg = e.toString() - if (e instanceof XrpcResponseError && e.error === 'InvalidInviteCode') { + if ( + matchXrpcError(e, com.atproto.server.createAccount) === + 'InvalidInviteCode' + ) { dispatch({ type: 'setError', value: l`Invite code not accepted. Check that you input it correctly and try again.`, diff --git a/src/state/queries/trending/useGetTrendsQuery.ts b/src/state/queries/trending/useGetTrendsQuery.ts index 757e868d1f..e7d6806b3e 100644 --- a/src/state/queries/trending/useGetTrendsQuery.ts +++ b/src/state/queries/trending/useGetTrendsQuery.ts @@ -1,5 +1,5 @@ import {useCallback, useMemo} from 'react' -import {type AppBskyUnspeccedGetTrends, hasMutedWord} from '@atproto/api' +import {hasMutedWord} from '@atproto/api' import {useQuery} from '@tanstack/react-query' import { @@ -10,7 +10,8 @@ import {logger} from '#/logger' import {getContentLanguages} from '#/state/preferences/languages' import {STALE} from '#/state/queries' import {usePreferencesQuery} from '#/state/queries/preferences' -import {useAgent} from '#/state/session' +import {useLexClient} from '#/state/session' +import {app} from '#/lexicons' export const DEFAULT_LIMIT = 5 @@ -32,7 +33,7 @@ export const createGetTrendsQueryKey = (limit?: number) => limit === undefined ? ['trends'] : ['trends', {limit}] export function useGetTrendsQuery(props: QueryProps = {}) { - const agent = useAgent() + const client = useLexClient() const {data: preferences} = usePreferencesQuery() const limit = props.limit ?? DEFAULT_LIMIT const mutedWords = useMemo(() => { @@ -46,7 +47,8 @@ export function useGetTrendsQuery(props: QueryProps = {}) { queryKey: createGetTrendsQueryKey(limit), queryFn: async () => { const contentLangs = getContentLanguages().join(',') - const {data} = await agent.app.bsky.unspecced.getTrends( + const data = await client.call( + app.bsky.unspecced.getTrends, { limit, }, @@ -63,7 +65,7 @@ export function useGetTrendsQuery(props: QueryProps = {}) { return data }, select: useCallback( - (data: AppBskyUnspeccedGetTrends.OutputSchema) => { + (data: app.bsky.unspecced.getTrends.$OutputBody) => { return { recId: data.recIdStr, trends: dedupe( 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..ff6a597ac7 --- /dev/null +++ b/src/state/session/clients.ts @@ -0,0 +1,71 @@ +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. + * + * Unlike the public agent it parallels, this client sends neither + * `atproto-proxy` nor `atproto-accept-labelers`. `createPublicAgent` configures + * the app labeler and the proxy header, so a logged-out appview *agent* read + * does carry labelers while the same read through this client does not. A + * consumer that needs moderation labels on public reads must configure labelers + * itself before issuing the request. + */ +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() +}