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 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-19 13:19:26 +03:00
parent 5541a48552
commit ffba34c548
11 changed files with 55 additions and 35 deletions
+3 -4
View File
@@ -1,6 +1,6 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import * as AgeRange from 'expo-age-range' 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 {getPreferences} from '@bsky.app/sdk'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
@@ -9,6 +9,7 @@ import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import { import {
@@ -99,9 +100,7 @@ export function setBirthdateForDid({
export const configQueryKey = ['config'] export const configQueryKey = ['config']
export async function getConfig() { export async function getConfig() {
if (debug.enabled) return debug.resolve(debug.config) if (debug.enabled) return debug.resolve(debug.config)
const client = new Client({ const client = createLexClient({service: PUBLIC_BSKY_SERVICE})
service: PUBLIC_BSKY_SERVICE,
})
return await client.call(app.bsky.ageassurance.getConfig) return await client.call(app.bsky.ageassurance.getConfig)
} }
export function getConfigFromCache(): export function getConfigFromCache():
+2 -2
View File
@@ -1,5 +1,4 @@
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {Client} from '@atproto/lex'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait' import {wait} from '#/lib/async/wait'
@@ -9,6 +8,7 @@ import {
PUBLIC_APPVIEW_DID, PUBLIC_APPVIEW_DID,
} from '#/lib/constants' } from '#/lib/constants'
import {isNetworkError} from '#/lib/hooks/useCleanError' import {isNetworkError} from '#/lib/hooks/useCleanError'
import {createLexClient} from '#/lib/lexClient'
import {usePdsClient} from '#/state/session' import {usePdsClient} from '#/state/session'
import {usePatchAgeAssuranceServerState} from '#/ageAssurance' import {usePatchAgeAssuranceServerState} from '#/ageAssurance'
import {logger} from '#/ageAssurance/logger' import {logger} from '#/ageAssurance/logger'
@@ -50,7 +50,7 @@ export function useBeginAgeAssurance() {
* appview with the token as a static Authorization header (a raw client, * appview with the token as a static Authorization header (a raw client,
* unlike a session, is allowed to preset that header). * unlike a session, is allowed to preset that header).
*/ */
const scopedClient = new Client({ const scopedClient = createLexClient({
service: APPVIEW, service: APPVIEW,
headers: {authorization: `Bearer ${token}`}, headers: {authorization: `Bearer ${token}`},
}) })
+27
View File
@@ -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})
}
+3 -6
View File
@@ -1,6 +1,5 @@
import {Client} from '@atproto/lex'
import {type SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' import {type SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
export const createVideoEndpointUrl = ( export const createVideoEndpointUrl = (
route: string, route: string,
@@ -25,7 +24,7 @@ export const createVideoEndpointUrl = (
* `#/ageAssurance/useBeginAgeAssurance`. * `#/ageAssurance/useBeginAgeAssurance`.
*/ */
export function createVideoServiceClient(token: string) { export function createVideoServiceClient(token: string) {
return new Client({ return createLexClient({
service: VIDEO_SERVICE, service: VIDEO_SERVICE,
headers: {authorization: `Bearer ${token}`}, headers: {authorization: `Bearer ${token}`},
}) })
@@ -37,9 +36,7 @@ export function createVideoServiceClient(token: string) {
* `AtpAgent` at `VIDEO_SERVICE`. * `AtpAgent` at `VIDEO_SERVICE`.
*/ */
export function createTokenlessVideoServiceClient() { export function createTokenlessVideoServiceClient() {
return new Client({ return createLexClient({service: VIDEO_SERVICE})
service: VIDEO_SERVICE,
})
} }
export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
+2 -2
View File
@@ -1,9 +1,9 @@
import {useCallback, useState} from 'react' import {useCallback, useState} from 'react'
import {Keyboard, View} from 'react-native' import {Keyboard, View} from 'react-native'
import {Client} from '@atproto/lex'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import {createLexClient} from '#/lib/lexClient'
import {cleanError, isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
@@ -55,7 +55,7 @@ export const ForgotPasswordForm = ({
setIsProcessing(true) setIsProcessing(true)
try { try {
const client = new Client({service: serviceUrl}) const client = createLexClient({service: serviceUrl})
await client.call(com.atproto.server.requestPasswordReset, {email}) await client.call(com.atproto.server.requestPasswordReset, {email})
onEmailSent() onEmailSent()
} catch (err) { } catch (err) {
+2 -2
View File
@@ -1,8 +1,8 @@
import {useState} from 'react' import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Client} from '@atproto/lex'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {createLexClient} from '#/lib/lexClient'
import {cleanError, isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {checkAndFormatResetCode} from '#/lib/strings/password' import {checkAndFormatResetCode} from '#/lib/strings/password'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -62,7 +62,7 @@ export const SetNewPasswordForm = ({
setIsProcessing(true) setIsProcessing(true)
try { try {
const client = new Client({service: serviceUrl}) const client = createLexClient({service: serviceUrl})
await client.call(com.atproto.server.resetPassword, { await client.call(com.atproto.server.resetPassword, {
token: formattedCode, token: formattedCode,
password, password,
+3 -3
View File
@@ -1,4 +1,3 @@
import {Client} from '@atproto/lex'
import {type DatetimeString, type HandleString} from '@atproto/syntax' import {type DatetimeString, type HandleString} from '@atproto/syntax'
import {useQuery} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
@@ -8,6 +7,7 @@ import {
PUBLIC_BSKY_SERVICE, PUBLIC_BSKY_SERVICE,
} from '#/lib/constants' } from '#/lib/constants'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {createLexClient} from '#/lib/lexClient'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle} from '#/lib/strings/handles'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {com} from '#/lexicons' import {com} from '#/lexicons'
@@ -81,7 +81,7 @@ export async function checkHandleAvailability(
) { ) {
if (serviceDid === BSKY_SERVICE_DID) { if (serviceDid === BSKY_SERVICE_DID) {
// entryway has a special API for handle availability // 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, { const data = await client.call(com.atproto.temp.checkHandleAvailability, {
handle: handle as HandleString, handle: handle as HandleString,
birthDate: birthDate as DatetimeString | undefined, birthDate: birthDate as DatetimeString | undefined,
@@ -114,7 +114,7 @@ export async function checkHandleAvailability(
} }
} else { } else {
// 3rd party PDSes won't have this API so just try and resolve the handle // 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 { try {
const res = await client.call(com.atproto.identity.resolveHandle, { const res = await client.call(com.atproto.identity.resolveHandle, {
handle: handle as HandleString, handle: handle as HandleString,
+3 -2
View File
@@ -1,9 +1,10 @@
import {useCallback} from 'react' 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 {toDatetimeString} from '@atproto/syntax'
import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {CHAT_SERVICE} from '#/lib/constants' import {CHAT_SERVICE} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries/index' import {STALE} from '#/state/queries/index'
import {createQueryKey, type StructuredQueryKey} from '#/state/queries/util' import {createQueryKey, type StructuredQueryKey} from '#/state/queries/util'
@@ -19,7 +20,7 @@ import * as bsky from '#/types/bsky'
*/ */
let publicChatClient: Client | undefined let publicChatClient: Client | undefined
function getPublicChatClient(): Client { function getPublicChatClient(): Client {
publicChatClient ??= new Client({service: CHAT_SERVICE}) publicChatClient ??= createLexClient({service: CHAT_SERVICE})
return publicChatClient return publicChatClient
} }
+2 -2
View File
@@ -1,11 +1,11 @@
import {useState} from 'react' import {useState} from 'react'
import {type DidDocument, getPdsEndpoint} from '@atproto/common-web' import {type DidDocument, getPdsEndpoint} from '@atproto/common-web'
import {Client} from '@atproto/lex'
import {type HandleString} from '@atproto/syntax' import {type HandleString} from '@atproto/syntax'
import {useQuery, useQueryClient} from '@tanstack/react-query' import {useQuery, useQueryClient} from '@tanstack/react-query'
import {DEFAULT_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {DEFAULT_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue' import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {createLexClient} from '#/lib/lexClient'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
@@ -153,7 +153,7 @@ export async function resolvePdsForIdentifier(
* Unauthenticated throwaway client pointed at the public appview - * Unauthenticated throwaway client pointed at the public appview -
* resolveHandle is a public read. * resolveHandle is a public read.
*/ */
const client = new Client({service: PUBLIC_BSKY_SERVICE}) const client = createLexClient({service: PUBLIC_BSKY_SERVICE})
try { try {
let did: string let did: string
if (norm.startsWith('did:')) { if (norm.startsWith('did:')) {
+2 -2
View File
@@ -1,6 +1,6 @@
import {Client} from '@atproto/lex'
import {useQuery} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import {createLexClient} from '#/lib/lexClient'
import {com} from '#/lexicons' import {com} from '#/lexicons'
const RQKEY_ROOT = 'service' const RQKEY_ROOT = 'service'
@@ -14,7 +14,7 @@ export function useServiceQuery(serviceUrl: string) {
* Unauthenticated throwaway client pointed at the candidate service - * Unauthenticated throwaway client pointed at the candidate service -
* describeServer is a public endpoint on the target PDS/entryway. * 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) return await client.call(com.atproto.server.describeServer)
}, },
enabled: isValidUrl(serviceUrl), enabled: isValidUrl(serviceUrl),
+6 -10
View File
@@ -1,14 +1,14 @@
import {Client} from '@atproto/lex'
import {PasswordSession} from '@atproto/lex-password-session' import {PasswordSession} from '@atproto/lex-password-session'
import {isJwtExpired} from '#/lib/jwt' import {isJwtExpired} from '#/lib/jwt'
import {createLexClient} from '#/lib/lexClient'
import {type TemporaryPushClient} from '#/lib/notifications/notifications' import {type TemporaryPushClient} from '#/lib/notifications/notifications'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {networkAwareFetch, sessionAccountToSessionData} from './session-core' import {networkAwareFetch, sessionAccountToSessionData} from './session-core'
import {type SessionAccount} from './types' 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). * dependency-light (this file transitively pulls in a large chunk of the app).
* Re-exported here for existing consumers. * 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. * Creates and resumes a throwaway session for every stored account. Intended to
* Intended to send push token revocations just before logout. * send push token revocations just before logout.
* *
* Each returned {@link TemporaryPushClient} wraps a temporary `PasswordSession` * 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 * deliberately hook-free (no `onUpdated`/`onDeleted`): they must NEVER persist
* or race the active session. They are used once for the unregister call and * 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. * 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( export async function createTemporaryClientsAndResume(
accounts: SessionAccount[], accounts: SessionAccount[],
@@ -51,7 +47,7 @@ export async function createTemporaryClientsAndResume(
{fetch: networkAwareFetch}, {fetch: networkAwareFetch},
) )
return { return {
client: new Client(session), client: createLexClient(session),
service: session.session.service, service: session.session.service,
handle: session.session.handle, handle: session.session.handle,
} satisfies TemporaryPushClient } satisfies TemporaryPushClient