From ffba34c54813b11ca7623883ff5b0042c3d3e64a Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sun, 19 Jul 2026 13:19:26 +0300 Subject: [PATCH] add createLexClient factory with strictResponseProcessing: false lex-client's strict mode rejects the legacy blob reference format ({cid, mimeType}) still present in older records; the old stack tolerated it. Route all client construction through the factory. Co-Authored-By: Claude Fable 5 --- src/ageAssurance/data.tsx | 7 +++--- src/ageAssurance/useBeginAgeAssurance.ts | 4 ++-- src/lib/lexClient.ts | 27 ++++++++++++++++++++++++ src/lib/media/video/util.ts | 9 +++----- src/screens/Login/ForgotPasswordForm.tsx | 4 ++-- src/screens/Login/SetNewPasswordForm.tsx | 4 ++-- src/state/queries/handle-availability.ts | 6 +++--- src/state/queries/join-links.ts | 5 +++-- src/state/queries/pds-detection.ts | 4 ++-- src/state/queries/service.ts | 4 ++-- src/state/session/util.ts | 16 ++++++-------- 11 files changed, 55 insertions(+), 35 deletions(-) create mode 100644 src/lib/lexClient.ts diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index ff202807ec..27e29ce3e7 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -1,6 +1,6 @@ import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' import * as AgeRange from 'expo-age-range' -import {Client} from '@atproto/lex' +import {type Client} from '@atproto/lex' import {getPreferences} from '@bsky.app/sdk' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' @@ -9,6 +9,7 @@ import debounce from 'lodash.debounce' import {networkRetry} from '#/lib/async/retry' import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {createLexClient} from '#/lib/lexClient' import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {getAge} from '#/lib/strings/time' import { @@ -99,9 +100,7 @@ export function setBirthdateForDid({ export const configQueryKey = ['config'] export async function getConfig() { if (debug.enabled) return debug.resolve(debug.config) - const client = new Client({ - service: PUBLIC_BSKY_SERVICE, - }) + const client = createLexClient({service: PUBLIC_BSKY_SERVICE}) return await client.call(app.bsky.ageassurance.getConfig) } export function getConfigFromCache(): diff --git a/src/ageAssurance/useBeginAgeAssurance.ts b/src/ageAssurance/useBeginAgeAssurance.ts index 3dddb60dc0..49f2c678f6 100644 --- a/src/ageAssurance/useBeginAgeAssurance.ts +++ b/src/ageAssurance/useBeginAgeAssurance.ts @@ -1,5 +1,4 @@ import {Platform} from 'react-native' -import {Client} from '@atproto/lex' import {useMutation} from '@tanstack/react-query' import {wait} from '#/lib/async/wait' @@ -9,6 +8,7 @@ import { PUBLIC_APPVIEW_DID, } from '#/lib/constants' import {isNetworkError} from '#/lib/hooks/useCleanError' +import {createLexClient} from '#/lib/lexClient' import {usePdsClient} from '#/state/session' import {usePatchAgeAssuranceServerState} from '#/ageAssurance' import {logger} from '#/ageAssurance/logger' @@ -50,7 +50,7 @@ export function useBeginAgeAssurance() { * appview with the token as a static Authorization header (a raw client, * unlike a session, is allowed to preset that header). */ - const scopedClient = new Client({ + const scopedClient = createLexClient({ service: APPVIEW, headers: {authorization: `Bearer ${token}`}, }) diff --git a/src/lib/lexClient.ts b/src/lib/lexClient.ts new file mode 100644 index 0000000000..97c7cdc5a9 --- /dev/null +++ b/src/lib/lexClient.ts @@ -0,0 +1,27 @@ +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 the old @atproto/api stack tolerated them, so + * strict mode would be a behavior regression. Lenient mode also relaxes + * datetime format checks (e.g. missing timezones) and blob MIME/size + * constraints, again matching the old stack's tolerance. `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/media/video/util.ts b/src/lib/media/video/util.ts index 8147926d28..c0bbc682b1 100644 --- a/src/lib/media/video/util.ts +++ b/src/lib/media/video/util.ts @@ -1,6 +1,5 @@ -import {Client} from '@atproto/lex' - import {type SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' +import {createLexClient} from '#/lib/lexClient' export const createVideoEndpointUrl = ( route: string, @@ -25,7 +24,7 @@ export const createVideoEndpointUrl = ( * `#/ageAssurance/useBeginAgeAssurance`. */ export function createVideoServiceClient(token: string) { - return new Client({ + return createLexClient({ service: VIDEO_SERVICE, headers: {authorization: `Bearer ${token}`}, }) @@ -37,9 +36,7 @@ export function createVideoServiceClient(token: string) { * `AtpAgent` at `VIDEO_SERVICE`. */ export function createTokenlessVideoServiceClient() { - return new Client({ - service: VIDEO_SERVICE, - }) + return createLexClient({service: VIDEO_SERVICE}) } export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx index 835d86438d..21c831310c 100644 --- a/src/screens/Login/ForgotPasswordForm.tsx +++ b/src/screens/Login/ForgotPasswordForm.tsx @@ -1,9 +1,9 @@ import {useCallback, useState} from 'react' import {Keyboard, View} from 'react-native' -import {Client} from '@atproto/lex' import {Trans, useLingui} from '@lingui/react/macro' import * as EmailValidator from 'email-validator' +import {createLexClient} from '#/lib/lexClient' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {atoms as a, useTheme, web} from '#/alf' @@ -55,7 +55,7 @@ export const ForgotPasswordForm = ({ setIsProcessing(true) try { - const client = new Client({service: serviceUrl}) + const client = createLexClient({service: serviceUrl}) await client.call(com.atproto.server.requestPasswordReset, {email}) onEmailSent() } catch (err) { diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx index 3493799c88..49bc0d8594 100644 --- a/src/screens/Login/SetNewPasswordForm.tsx +++ b/src/screens/Login/SetNewPasswordForm.tsx @@ -1,8 +1,8 @@ import {useState} from 'react' import {View} from 'react-native' -import {Client} from '@atproto/lex' import {Trans, useLingui} from '@lingui/react/macro' +import {createLexClient} from '#/lib/lexClient' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {checkAndFormatResetCode} from '#/lib/strings/password' import {logger} from '#/logger' @@ -62,7 +62,7 @@ export const SetNewPasswordForm = ({ setIsProcessing(true) try { - const client = new Client({service: serviceUrl}) + const client = createLexClient({service: serviceUrl}) await client.call(com.atproto.server.resetPassword, { token: formattedCode, password, diff --git a/src/state/queries/handle-availability.ts b/src/state/queries/handle-availability.ts index 5f48df57a9..1788a19091 100644 --- a/src/state/queries/handle-availability.ts +++ b/src/state/queries/handle-availability.ts @@ -1,4 +1,3 @@ -import {Client} from '@atproto/lex' import {type DatetimeString, type HandleString} from '@atproto/syntax' import {useQuery} from '@tanstack/react-query' @@ -8,6 +7,7 @@ import { PUBLIC_BSKY_SERVICE, } from '#/lib/constants' import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' +import {createLexClient} from '#/lib/lexClient' import {createFullHandle} from '#/lib/strings/handles' import {useAnalytics} from '#/analytics' import {com} from '#/lexicons' @@ -81,7 +81,7 @@ export async function checkHandleAvailability( ) { if (serviceDid === BSKY_SERVICE_DID) { // entryway has a special API for handle availability - const client = new Client({service: BSKY_SERVICE}) + const client = createLexClient({service: BSKY_SERVICE}) const data = await client.call(com.atproto.temp.checkHandleAvailability, { handle: handle as HandleString, birthDate: birthDate as DatetimeString | undefined, @@ -114,7 +114,7 @@ export async function checkHandleAvailability( } } else { // 3rd party PDSes won't have this API so just try and resolve the handle - const client = new Client({service: PUBLIC_BSKY_SERVICE}) + const client = createLexClient({service: PUBLIC_BSKY_SERVICE}) try { const res = await client.call(com.atproto.identity.resolveHandle, { handle: handle as HandleString, diff --git a/src/state/queries/join-links.ts b/src/state/queries/join-links.ts index a56d367d95..afaf5e2972 100644 --- a/src/state/queries/join-links.ts +++ b/src/state/queries/join-links.ts @@ -1,9 +1,10 @@ import {useCallback} from 'react' -import {type $Typed, Client} from '@atproto/lex' +import {type $Typed, type Client} from '@atproto/lex' import {toDatetimeString} from '@atproto/syntax' import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import {CHAT_SERVICE} from '#/lib/constants' +import {createLexClient} from '#/lib/lexClient' import {logger} from '#/logger' import {STALE} from '#/state/queries/index' import {createQueryKey, type StructuredQueryKey} from '#/state/queries/util' @@ -19,7 +20,7 @@ import * as bsky from '#/types/bsky' */ let publicChatClient: Client | undefined function getPublicChatClient(): Client { - publicChatClient ??= new Client({service: CHAT_SERVICE}) + publicChatClient ??= createLexClient({service: CHAT_SERVICE}) return publicChatClient } diff --git a/src/state/queries/pds-detection.ts b/src/state/queries/pds-detection.ts index 512ef9d385..d76910155f 100644 --- a/src/state/queries/pds-detection.ts +++ b/src/state/queries/pds-detection.ts @@ -1,11 +1,11 @@ import {useState} from 'react' import {type DidDocument, getPdsEndpoint} from '@atproto/common-web' -import {Client} from '@atproto/lex' import {type HandleString} from '@atproto/syntax' import {useQuery, useQueryClient} from '@tanstack/react-query' import {DEFAULT_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' +import {createLexClient} from '#/lib/lexClient' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {STALE} from '#/state/queries' @@ -153,7 +153,7 @@ export async function resolvePdsForIdentifier( * Unauthenticated throwaway client pointed at the public appview - * resolveHandle is a public read. */ - const client = new Client({service: PUBLIC_BSKY_SERVICE}) + const client = createLexClient({service: PUBLIC_BSKY_SERVICE}) try { let did: string if (norm.startsWith('did:')) { diff --git a/src/state/queries/service.ts b/src/state/queries/service.ts index 45ccc42e8d..cb50428adb 100644 --- a/src/state/queries/service.ts +++ b/src/state/queries/service.ts @@ -1,6 +1,6 @@ -import {Client} from '@atproto/lex' import {useQuery} from '@tanstack/react-query' +import {createLexClient} from '#/lib/lexClient' import {com} from '#/lexicons' const RQKEY_ROOT = 'service' @@ -14,7 +14,7 @@ export function useServiceQuery(serviceUrl: string) { * Unauthenticated throwaway client pointed at the candidate service - * describeServer is a public endpoint on the target PDS/entryway. */ - const client = new Client({service: serviceUrl}) + const client = createLexClient({service: serviceUrl}) return await client.call(com.atproto.server.describeServer) }, enabled: isValidUrl(serviceUrl), diff --git a/src/state/session/util.ts b/src/state/session/util.ts index 02e21aee88..325a33f756 100644 --- a/src/state/session/util.ts +++ b/src/state/session/util.ts @@ -1,14 +1,14 @@ -import {Client} from '@atproto/lex' import {PasswordSession} from '@atproto/lex-password-session' import {isJwtExpired} from '#/lib/jwt' +import {createLexClient} from '#/lib/lexClient' import {type TemporaryPushClient} from '#/lib/notifications/notifications' import * as persisted from '#/state/persisted' import {networkAwareFetch, sessionAccountToSessionData} from './session-core' import {type SessionAccount} from './types' /* - * Canonical implementation moved to session-core.ts so that module stays + * Canonical implementation lives in session-core.ts so that module stays * dependency-light (this file transitively pulls in a large chunk of the app). * Re-exported here for existing consumers. */ @@ -28,18 +28,14 @@ export function isSessionExpired(account: SessionAccount) { } /** - * Creates and resumes a throwaway session for every stored account. - * Intended to send push token revocations just before logout. + * Creates and resumes a throwaway session for every stored account. Intended to + * send push token revocations just before logout. * * Each returned {@link TemporaryPushClient} wraps a temporary `PasswordSession` - * resumed over the network to obtain a valid access token. These sessions are + * resumed over the network for a valid access token. These sessions are * deliberately hook-free (no `onUpdated`/`onDeleted`): they must NEVER persist * or race the active session. They are used once for the unregister call and * discarded (reclaimed by GC), so we never call `logout()` on them. - * - * Each session is wrapped in a plain account-shaped `Client` (no proxy header) - * paired with the account's service origin and handle, matching the contract - * {@link unregisterPushToken} consumes. */ export async function createTemporaryClientsAndResume( accounts: SessionAccount[], @@ -51,7 +47,7 @@ export async function createTemporaryClientsAndResume( {fetch: networkAwareFetch}, ) return { - client: new Client(session), + client: createLexClient(session), service: session.session.service, handle: session.session.handle, } satisfies TemporaryPushClient