collapse session bundle to a single bsky client

lex-client 0.3.0 record helpers auto-target the account host per call,
so the separate header-less account client is unnecessary: one client
with the appview proxy service covers queries and writes. Raw
com.atproto server/identity/repo calls that must hit the PDS pass
service: null explicitly. Hook names unchanged; usePdsClient now
marks "must target the account host" call sites and returns a
throwing client when logged out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-19 15:34:02 +03:00
parent a44b5458b4
commit c582230659
33 changed files with 558 additions and 300 deletions
+10 -13
View File
@@ -679,23 +679,20 @@ export function useDeviceSignalsQuery() {
}
/**
* Helper to prefetch all age assurance data from the server. Reads that hit
* the appview (`getState`, device signals) take the appview client; the
* preferences/actor-declaration read hits the PDS via the account client.
* Helper to prefetch all age assurance data from the server. Takes the single
* merged Bluesky client, which serves both roles: the appview reads (`getState`,
* device signals) inherit the appview proxy, while `getPreferences` (an SDK
* action that passes `service: null` internally) and `fetchActorDeclarationRecord`
* (a typed record `get`, `service: null` by default) auto-target the account
* host.
*/
export function prefetchAgeAssuranceServerData({
appviewClient,
accountClient,
}: {
appviewClient: Client
accountClient: Client
}) {
export function prefetchAgeAssuranceServerData({client}: {client: Client}) {
return Promise.allSettled([
// config fetch initiated at the top of the App.platform.tsx files, awaited here
configPrefetchPromise,
prefetchServerState({appviewClient}),
prefetchOtherRequiredData({accountClient}),
prefetchDeviceSignals({appviewClient}),
prefetchServerState({appviewClient: client}),
prefetchOtherRequiredData({accountClient: client}),
prefetchDeviceSignals({appviewClient: client}),
])
}
+9 -4
View File
@@ -39,10 +39,15 @@ export function useBeginAgeAssurance() {
throw new Error(`Geolocation not available, cannot init age assurance.`)
}
const {token} = await pdsClient.call(com.atproto.server.getServiceAuth, {
aud: BLUESKY_PROXY_DID,
lxm: `app.bsky.ageassurance.begin`,
})
const {token} = await pdsClient.call(
com.atproto.server.getServiceAuth,
{
aud: BLUESKY_PROXY_DID,
lxm: `app.bsky.ageassurance.begin`,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
/*
* A non-refreshing throwaway client scoped to the service-auth token: it
@@ -17,10 +17,15 @@ export function useConfirmEmail({
throw new Error('No email found for the current account')
}
await pdsClient.call(com.atproto.server.confirmEmail, {
email: currentAccount.email.trim(),
token: token.trim(),
})
await pdsClient.call(
com.atproto.server.confirmEmail,
{
email: currentAccount.email.trim(),
token: token.trim(),
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
// will update session state at root of app
await refreshSession()
},
@@ -19,11 +19,16 @@ export function useManageEmail2FA() {
throw new Error('No email found for the current account')
}
await pdsClient.call(com.atproto.server.updateEmail, {
email: currentAccount.email,
emailAuthFactor: enabled,
token,
})
await pdsClient.call(
com.atproto.server.updateEmail,
{
email: currentAccount.email,
emailAuthFactor: enabled,
token,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
// will update session state at root of app
await refreshSession()
},
@@ -8,7 +8,12 @@ export function useRequestEmailUpdate() {
return useMutation({
mutationFn: async () => {
return await pdsClient.call(com.atproto.server.requestEmailUpdate)
return await pdsClient.call(
com.atproto.server.requestEmailUpdate,
undefined,
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
},
})
}
@@ -8,7 +8,12 @@ export function useRequestEmailVerification() {
return useMutation({
mutationFn: async () => {
await pdsClient.call(com.atproto.server.requestEmailConfirmation)
await pdsClient.call(
com.atproto.server.requestEmailConfirmation,
undefined,
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
},
})
}
@@ -11,10 +11,15 @@ async function updateEmailAndRefreshSession(
email: string,
token?: string,
) {
await pdsClient.call(com.atproto.server.updateEmail, {
email: email.trim(),
token,
})
await pdsClient.call(
com.atproto.server.updateEmail,
{
email: email.trim(),
token,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await refreshSession()
}
@@ -98,10 +98,15 @@ export function CreateListFromStarterPackDialog({
const chunks = chunk(listitemWrites, 50)
for (const c of chunks) {
await pdsClient.call(com.atproto.repo.applyWrites, {
repo: currentAccount.did as AtIdentifierString,
writes: c,
})
await pdsClient.call(
com.atproto.repo.applyWrites,
{
repo: currentAccount.did as AtIdentifierString,
writes: c,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
}
await until(
@@ -53,7 +53,12 @@ function Inner({}: {control: DialogControlProps}) {
const onPressResendEmail = async () => {
setSending(true)
await pdsClient.call(com.atproto.server.requestEmailConfirmation)
await pdsClient.call(
com.atproto.server.requestEmailConfirmation,
undefined,
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
setSending(false)
setStatus('resent')
}
+18 -8
View File
@@ -277,16 +277,26 @@ export function useUpsertLiveStatusMutation(
const collection = 'app.bsky.actor.status'
const existing = await pdsClient
.call(com.atproto.repo.getRecord, {repo, collection, rkey: 'self'})
// service: null strips the appview proxy header - this must hit the account host (PDS)
.call(
com.atproto.repo.getRecord,
{repo, collection, rkey: 'self'},
{service: null},
)
.catch(_e => undefined)
await pdsClient.call(com.atproto.repo.putRecord, {
repo,
collection,
rkey: 'self',
record,
swapRecord: existing?.cid || null,
})
await pdsClient.call(
com.atproto.repo.putRecord,
{
repo,
collection,
rkey: 'self',
record,
swapRecord: existing?.cid || null,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
}
await retry(upsert, {
+10 -5
View File
@@ -177,11 +177,16 @@ export async function post(
}
try {
await pdsClient.call(com.atproto.repo.applyWrites, {
repo: did,
writes: writes,
validate: true,
})
await pdsClient.call(
com.atproto.repo.applyWrites,
{
repo: did,
writes: writes,
validate: true,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
} catch (err) {
const e = err as Error
logger.error(`Failed to create post`, {
+11 -4
View File
@@ -36,10 +36,17 @@ export const createStarterPackList = async ({
purpose: 'app.bsky.graph.defs#referencelist',
})
if (!list) throw new Error('List creation failed')
await client.call(com.atproto.repo.applyWrites, {
repo: client.assertDid,
writes: profiles.map(p => createListItem({did: p.did, listUri: list.uri})),
})
await client.call(
com.atproto.repo.applyWrites,
{
repo: client.assertDid,
writes: profiles.map(p =>
createListItem({did: p.did, listUri: list.uri}),
),
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
return list
}
+10 -5
View File
@@ -40,11 +40,16 @@ export async function getServiceAuthToken({
}
resolvedAud = pdsAud
}
const {token} = await client.call(com.atproto.server.getServiceAuth, {
aud: resolvedAud,
lxm,
exp,
})
const {token} = await client.call(
com.atproto.server.getServiceAuth,
{
aud: resolvedAud,
lxm,
exp,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
return token
}
+6 -1
View File
@@ -72,7 +72,12 @@ export function Deactivated() {
const handleActivate = useCallback(async () => {
try {
setPending(true)
await pdsClient.call(com.atproto.server.activateAccount)
await pdsClient.call(
com.atproto.server.activateAccount,
undefined,
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await queryClient.resetQueries()
await refreshSession()
} catch (e: any) {
+9 -4
View File
@@ -37,10 +37,15 @@ export async function bulkWriteFollows(
const chunks = chunk(followWrites, 50)
for (const chunk of chunks) {
await pdsClient.call(com.atproto.repo.applyWrites, {
repo: did,
writes: chunk,
})
await pdsClient.call(
com.atproto.repo.applyWrites,
{
repo: did,
writes: chunk,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
}
await whenFollowsIndexed(appviewClient, did, res => !!res.follows.length)
@@ -86,9 +86,14 @@ function Inner() {
setError('')
setIsProcessing(true)
try {
await pdsClient.call(com.atproto.server.requestPasswordReset, {
email: currentAccount.email,
})
await pdsClient.call(
com.atproto.server.requestPasswordReset,
{
email: currentAccount.email,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
setStage(Stages.ChangePassword)
} catch (e: any) {
if (isNetworkError(e)) {
@@ -130,10 +135,15 @@ function Inner() {
setError('')
setIsProcessing(true)
try {
await pdsClient.call(com.atproto.server.resetPassword, {
token: formattedCode,
password: newPassword,
})
await pdsClient.call(
com.atproto.server.resetPassword,
{
token: formattedCode,
password: newPassword,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
setStage(Stages.Done)
} catch (e: any) {
if (isNetworkError(e)) {
@@ -44,7 +44,12 @@ function DeactivateAccountDialogInner({
const handleDeactivate = useCallback(async () => {
try {
setPending(true)
await pdsClient.call(com.atproto.server.deactivateAccount, {})
await pdsClient.call(
com.atproto.server.deactivateAccount,
{},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
control.close(() => {
logoutCurrentAccount('Deactivated')
})
@@ -96,7 +96,12 @@ function DeleteAccountDialogInner({
}
try {
setEmailState(EmailState.PENDING)
await pdsClient.call(com.atproto.server.requestAccountDelete)
await pdsClient.call(
com.atproto.server.requestAccountDelete,
undefined,
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
setError('')
setEmailSentCount(prevCount => prevCount + 1)
setStep(Step.VERIFY_CODE)
@@ -122,11 +127,16 @@ function DeleteAccountDialogInner({
// Inform chat service of intent to delete account. The chat client is
// proxied to the chat service; a failure throws.
await chatClient.call(chat.bsky.actor.deleteAccount)
await pdsClient.call(com.atproto.server.deleteAccount, {
did: currentAccount.did as DidString,
password,
token,
})
await pdsClient.call(
com.atproto.server.deleteAccount,
{
did: currentAccount.did as DidString,
password,
token,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
control.close(() => {
toast.show(_(msg`Your account has been deleted, see ya! ✌️`))
resetToTab('HomeTab')
@@ -44,7 +44,12 @@ export function DisableEmail2FADialog({
setError('')
setIsProcessing(true)
try {
await pdsClient.call(com.atproto.server.requestEmailUpdate)
await pdsClient.call(
com.atproto.server.requestEmailUpdate,
undefined,
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
setStage(Stages.ConfirmCode)
} catch (e) {
setError(cleanError(String(e)))
@@ -58,11 +63,16 @@ export function DisableEmail2FADialog({
setIsProcessing(true)
try {
if (currentAccount?.email) {
await pdsClient.call(com.atproto.server.updateEmail, {
email: currentAccount.email,
token: confirmationCode.trim(),
emailAuthFactor: false,
})
await pdsClient.call(
com.atproto.server.updateEmail,
{
email: currentAccount.email,
token: confirmationCode.trim(),
emailAuthFactor: false,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await refreshSession()
Toast.show(_(msg({message: 'Email 2FA disabled', context: 'toast'})))
}
+6 -1
View File
@@ -39,7 +39,12 @@ export function SignupQueued() {
const checkStatus = useCallback(async () => {
setProcessing(true)
try {
const res = await pdsClient.call(com.atproto.temp.checkSignupQueue)
const res = await pdsClient.call(
com.atproto.temp.checkSignupQueue,
{},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
if (res.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
const refreshed = await refreshSession()
+23 -8
View File
@@ -13,7 +13,12 @@ export function useAppPasswordsQuery() {
staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY(),
queryFn: async () => {
const data = await pdsClient.call(com.atproto.server.listAppPasswords, {})
const data = await pdsClient.call(
com.atproto.server.listAppPasswords,
{},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
return data.passwords
},
})
@@ -28,10 +33,15 @@ export function useAppPasswordCreateMutation() {
{name: string; privileged: boolean}
>({
mutationFn: async ({name, privileged}) => {
return await pdsClient.call(com.atproto.server.createAppPassword, {
name,
privileged,
})
return await pdsClient.call(
com.atproto.server.createAppPassword,
{
name,
privileged,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
},
onSuccess() {
queryClient.invalidateQueries({
@@ -46,9 +56,14 @@ export function useAppPasswordDeleteMutation() {
const pdsClient = usePdsClient()
return useMutation<void, Error, {name: string}>({
mutationFn: async ({name}) => {
await pdsClient.call(com.atproto.server.revokeAppPassword, {
name,
})
await pdsClient.call(
com.atproto.server.revokeAppPassword,
{
name,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
},
onSuccess() {
queryClient.invalidateQueries({
+8 -3
View File
@@ -45,9 +45,14 @@ export function useUpdateHandleMutation(opts?: {
return useMutation({
mutationFn: async ({handle}: {handle: string}) => {
await pdsClient.call(com.atproto.identity.updateHandle, {
handle: handle as HandleString,
})
await pdsClient.call(
com.atproto.identity.updateHandle,
{
handle: handle as HandleString,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
},
onSuccess(_data, variables) {
opts?.onSuccess?.(variables.handle)
+20 -10
View File
@@ -149,12 +149,17 @@ export function useListMetadataMutation() {
} else if (avatar === null) {
record.avatar = undefined
}
const res = await pdsClient.call(com.atproto.repo.putRecord, {
repo: currentAccount.did,
collection: 'app.bsky.graph.list',
rkey,
record,
})
const res = await pdsClient.call(
com.atproto.repo.putRecord,
{
repo: currentAccount.did,
collection: 'app.bsky.graph.list',
rkey,
record,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
// wait for the appview to update
await whenAppViewReady(appviewClient, res.uri, v => {
@@ -227,10 +232,15 @@ export function useListDeleteMutation() {
// apply in chunks
for (const writesChunk of chunk(writes, 10)) {
await pdsClient.call(com.atproto.repo.applyWrites, {
repo: currentAccount.did as AtIdentifierString,
writes: writesChunk,
})
await pdsClient.call(
com.atproto.repo.applyWrites,
{
repo: currentAccount.did as AtIdentifierString,
writes: writesChunk,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
}
// wait for the appview to update. once the list is deleted, getList
+24 -14
View File
@@ -39,16 +39,21 @@ export function useUpdateActorDeclaration({
update.allowGroupInvites ??
current?.associated?.chat?.allowGroupInvites,
})
const result = await pdsClient.call(com.atproto.repo.putRecord, {
repo: currentAccount.did as DidString,
collection: 'chat.bsky.actor.declaration',
rkey: 'self',
record: {
$type: 'chat.bsky.actor.declaration',
allowIncoming,
allowGroupInvites,
const result = await pdsClient.call(
com.atproto.repo.putRecord,
{
repo: currentAccount.did as DidString,
collection: 'chat.bsky.actor.declaration',
rkey: 'self',
record: {
$type: 'chat.bsky.actor.declaration',
allowIncoming,
allowGroupInvites,
},
},
})
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
return result
},
onMutate: update => {
@@ -104,11 +109,16 @@ export function useDeleteActorDeclaration() {
return useMutation({
mutationFn: async () => {
if (!currentAccount) throw new Error('Not signed in')
const result = await pdsClient.call(com.atproto.repo.deleteRecord, {
repo: currentAccount.did as DidString,
collection: 'chat.bsky.actor.declaration',
rkey: 'self',
})
const result = await pdsClient.call(
com.atproto.repo.deleteRecord,
{
repo: currentAccount.did as DidString,
collection: 'chat.bsky.actor.declaration',
rkey: 'self',
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
return result
},
})
+21 -11
View File
@@ -49,11 +49,16 @@ export async function getPostgateRecord({
return true
},
() =>
pdsClient.call(com.atproto.repo.getRecord, {
repo: urip.host,
collection: POSTGATE_COLLECTION,
rkey: urip.rkey,
}),
pdsClient.call(
com.atproto.repo.getRecord,
{
repo: urip.host,
collection: POSTGATE_COLLECTION,
rkey: urip.rkey,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
),
)
if (data.value && bsky.matches(app.bsky.feed.postgate, data.value)) {
@@ -87,12 +92,17 @@ export async function writePostgateRecord({
const postUrip = new AtUri(postUri)
await networkRetry(2, () =>
pdsClient.call(com.atproto.repo.putRecord, {
repo: pdsClient.assertDid,
collection: POSTGATE_COLLECTION,
rkey: postUrip.rkey,
record: postgate,
}),
pdsClient.call(
com.atproto.repo.putRecord,
{
repo: pdsClient.assertDid,
collection: POSTGATE_COLLECTION,
rkey: postUrip.rkey,
record: postgate,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
),
)
}
+6 -1
View File
@@ -145,7 +145,12 @@ export function useClearPreferencesMutation() {
return useMutation({
mutationFn: async () => {
await client.call(app.bsky.actor.putPreferences, {preferences: []})
// service: null strips the appview proxy header - putPreferences is served by the account host (PDS)
await client.call(
app.bsky.actor.putPreferences,
{preferences: []},
{service: null},
)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
+58 -43
View File
@@ -194,18 +194,23 @@ export function useEditStarterPackMutation({
if (removedItems.length !== 0) {
const chunks = chunk(removedItems, 50)
for (const chunk of chunks) {
await pdsClient.call(com.atproto.repo.applyWrites, {
repo: pdsClient.assertDid,
writes: chunk.map(
(
i,
): com.atproto.repo.applyWrites.$InputBody['writes'][number] => ({
$type: 'com.atproto.repo.applyWrites#delete',
collection: 'app.bsky.graph.listitem',
rkey: new AtUri(i.uri).rkey,
}),
),
})
await pdsClient.call(
com.atproto.repo.applyWrites,
{
repo: pdsClient.assertDid,
writes: chunk.map(
(
i,
): com.atproto.repo.applyWrites.$InputBody['writes'][number] => ({
$type: 'com.atproto.repo.applyWrites#delete',
collection: 'app.bsky.graph.listitem',
rkey: new AtUri(i.uri).rkey,
}),
),
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
}
}
@@ -215,42 +220,52 @@ export function useEditStarterPackMutation({
if (addedProfiles.length > 0) {
const chunks = chunk(addedProfiles, 50)
for (const chunk of chunks) {
await pdsClient.call(com.atproto.repo.applyWrites, {
repo: pdsClient.assertDid,
writes: chunk.map(
(
p,
): com.atproto.repo.applyWrites.$InputBody['writes'][number] => ({
$type: 'com.atproto.repo.applyWrites#create',
collection: 'app.bsky.graph.listitem',
value: {
$type: 'app.bsky.graph.listitem',
subject: p.did,
list: currentStarterPack.list?.uri,
createdAt: new Date().toISOString(),
},
}),
),
})
await pdsClient.call(
com.atproto.repo.applyWrites,
{
repo: pdsClient.assertDid,
writes: chunk.map(
(
p,
): com.atproto.repo.applyWrites.$InputBody['writes'][number] => ({
$type: 'com.atproto.repo.applyWrites#create',
collection: 'app.bsky.graph.listitem',
value: {
$type: 'app.bsky.graph.listitem',
subject: p.did,
list: currentStarterPack.list?.uri,
createdAt: new Date().toISOString(),
},
}),
),
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
}
}
const rkey = parseStarterPackUri(currentStarterPack.uri)!.rkey
await pdsClient.call(com.atproto.repo.putRecord, {
repo: pdsClient.assertDid,
collection: 'app.bsky.graph.starterpack',
rkey,
record: {
$type: 'app.bsky.graph.starterpack',
name,
description,
descriptionFacets,
list: currentStarterPack.list?.uri,
feeds,
createdAt: currentStarterPack.record.createdAt,
updatedAt: new Date().toISOString(),
await pdsClient.call(
com.atproto.repo.putRecord,
{
repo: pdsClient.assertDid,
collection: 'app.bsky.graph.starterpack',
rkey,
record: {
$type: 'app.bsky.graph.starterpack',
name,
description,
descriptionFacets,
list: currentStarterPack.list?.uri,
feeds,
createdAt: currentStarterPack.record.createdAt,
updatedAt: new Date().toISOString(),
},
},
})
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
},
onSuccess: async (_, {currentStarterPack}) => {
const parsed = parseStarterPackUri(currentStarterPack.uri)
+21 -11
View File
@@ -112,11 +112,16 @@ export async function getThreadgateRecord({
return true
},
() =>
pdsClient.call(com.atproto.repo.getRecord, {
repo: urip.host,
collection: 'app.bsky.feed.threadgate',
rkey: urip.rkey,
}),
pdsClient.call(
com.atproto.repo.getRecord,
{
repo: urip.host,
collection: 'app.bsky.feed.threadgate',
rkey: urip.rkey,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
),
)
if (data.value && bsky.matches(app.bsky.feed.threadgate, data.value)) {
@@ -155,12 +160,17 @@ export async function writeThreadgateRecord({
})
await networkRetry(2, () =>
pdsClient.call(com.atproto.repo.putRecord, {
repo: pdsClient.assertDid,
collection: 'app.bsky.feed.threadgate',
rkey: postUrip.rkey,
record,
}),
pdsClient.call(
com.atproto.repo.putRecord,
{
repo: pdsClient.assertDid,
collection: 'app.bsky.feed.threadgate',
rkey: postUrip.rkey,
record,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
),
)
}
@@ -27,10 +27,9 @@ jest.mock('jwt-decode', () => ({
}))
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {app, chat} from '#/lexicons'
import {app, chat, com} from '#/lexicons'
import {
buildAccountClient,
buildAppviewClient,
buildBskyClient,
buildChatClient,
getPublicLexClient,
getUnauthenticatedClient,
@@ -123,11 +122,11 @@ function makeSession(
})
}
describe('buildAppviewClient', () => {
describe('buildBskyClient', () => {
it('sets the appview atproto-proxy header and includes only the per-instance labelers', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAppviewClient(session, [CUSTOM_LABELER])
const client = buildBskyClient(session, [CUSTOM_LABELER])
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
@@ -147,36 +146,66 @@ describe('buildAppviewClient', () => {
it('routes through the session fetchHandler with the bearer token', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAppviewClient(session, [])
const client = buildBskyClient(session, [])
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
})
})
describe('buildAccountClient', () => {
it('has no atproto-proxy header (requests hit the PDS directly)', async () => {
it('strips the appview proxy AND accept-labelers headers on a record helper (auto-targets the account host)', async () => {
/*
* Even though the instance is configured with the appview service and a
* per-instance labeler, lex-client 0.3.0's record helpers default per-call
* `service = null` / `labelers = null`, deleting both headers so the call
* hits the user's PDS. We exercise this via `getRecord`: it shares the
* identical `service = null` / `labelers = null` default with the write
* helpers (createRecord/putRecord/...), but does not lex-encode a record
* body, so it dodges a lex-data CID-encoding incompatibility that makes
* createRecord throw before any fetch in this jest environment. The stub
* response fails getRecord output validation; `.catch` swallows that
* (headers are recorded pre-parse). The request targets bsky.social (the
* PDS host), NOT the appview.
*/
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAccountClient(session)
const client = buildBskyClient(session, [CUSTOM_LABELER])
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
await client.getRecord('app.bsky.feed.post', 'self').catch(() => {})
expect(seen.length).toBe(1)
expect(seen[0].headers.get('atproto-proxy')).toBeNull()
expect(seen[0].headers.get('atproto-accept-labelers')).toBeNull()
})
it('routes through the session fetchHandler with the bearer token', async () => {
it('inherits the appview proxy on a raw appview query call', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAccountClient(session)
const client = buildBskyClient(session, [])
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
await client.call(app.bsky.feed.getTimeline.main, {}).catch(() => {})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
expect(seen.length).toBe(1)
expect(seen[0].headers.get('atproto-proxy')).toBe(APPVIEW_PROXY)
})
it('strips the appview proxy on a raw call passed {service: null}', async () => {
/*
* A raw call inherits the instance appview proxy unless the call site opts
* out with `{service: null}` - the escape hatch record helpers apply
* automatically. getSession must hit the PDS, so its call passes it.
*/
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildBskyClient(session, [])
await client
.call(com.atproto.server.getSession.main, {}, {service: null})
.catch(() => {})
expect(seen.length).toBe(1)
expect(seen[0].headers.get('atproto-proxy')).toBeNull()
})
})
@@ -288,11 +317,11 @@ describe('getPublicLexClient', () => {
* section 6).
*/
describe('labeler-header regression guard', () => {
it('appview client emits the global Bluesky labeler redacted and the per-instance labeler plain', async () => {
it('bsky client emits the global Bluesky labeler redacted and the per-instance labeler plain', async () => {
/*
* The moderation DID flows only through the global Client.appLabelers,
* which lex-client merges into the header per request with the `;redact`
* suffix; buildAppviewClient no longer lists it as a per-instance labeler.
* suffix; buildBskyClient no longer lists it as a per-instance labeler.
* Configure the global appLabelers to the Bluesky moderation DID (matching
* switchToBskyAppLabeler in moderation.ts) so the composition matches
* production.
@@ -301,7 +330,7 @@ describe('labeler-header regression guard', () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAppviewClient(session, [CUSTOM_LABELER])
const client = buildBskyClient(session, [CUSTOM_LABELER])
await client
.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
.catch(() => {})
+29 -23
View File
@@ -27,19 +27,6 @@ export function getPublicLexClient(): Client {
return publicClient
}
/**
* Build the account (PDS) client over a {@link PasswordSession}. Writes and
* record mutations go here - no `atproto-proxy` header, so requests hit the
* user's PDS directly (the session's `fetchHandler` resolves the PDS origin per
* request from the didDoc, falling back to `service`).
*
* We intentionally do NOT pass `fetch` here: a client built over a session uses
* that session's own `fetch` (networkAwareFetch, set at construction).
*/
export function buildAccountClient(session: PasswordSession): Client {
return createLexClient(session)
}
/**
* Build the chat client over a {@link PasswordSession}.
*
@@ -85,20 +72,39 @@ export function getUnauthenticatedClient(): Client {
}
/**
* Build the authed appview client over a {@link PasswordSession}. Requests are
* proxied to the Bluesky appview and carry the per-instance labelers.
* Build the single authed Bluesky client over a {@link PasswordSession}. This
* is the merged account-plus-appview client: one instance serves both reads
* (proxied to the Bluesky appview) and writes (routed to the user's PDS),
* because lex-client 0.3.0's record helpers pick the target per call.
*
* The instance is configured with `service = BLUESKY_PROXY_HEADER.get()`, so by
* default every request carries the `atproto-proxy` header and is proxied to
* the Bluesky appview, along with the per-instance labelers. The getter exists
* so the e2e `TestCtrls` hack can retarget the appview via
* `BLUESKY_PROXY_HEADER.set()` before sign-in (the client is built at sign-in,
* so it picks up the override).
*
* Two request shapes take DIFFERENT targets off this one instance:
* - Record helpers (`createRecord`/`putRecord`/...) and the typed record sugar
* (`create`/`put`/`get`/`delete`/`list`) default per-call `service = null`
* in lex-client 0.3.0, which DELETES the `atproto-proxy` header regardless of
* the instance default. So writes auto-target the account host and hit the
* user's PDS through the session's `fetchHandler` (which resolves the PDS
* origin per request from the didDoc, falling back to `service`).
* - Raw `client.call(lexicon, ...)` inherits the appview proxy from the
* instance `service`, UNLESS the call site passes `{service: null}` to strip
* it (needed for `com.atproto.server`/`identity`/`sync`/`temp` calls that
* must hit the PDS directly).
*
* The Bluesky moderation labeler (`api.moderation.did`) is deliberately NOT
* listed here - it must flow only through the global `Client.appLabelers` (see
* moderation.ts) so it carries the `;redact` suffix; adding it here would
* produce a duplicate, non-redact header entry.
* listed in `labelerDids` - it must flow only through the global
* `Client.appLabelers` (see moderation.ts) so it carries the `;redact` suffix;
* adding it here would produce a duplicate, non-redact header entry.
*
* The proxy `service` is read from `BLUESKY_PROXY_HEADER.get()`, whose default
* equals `api.app.service`. The getter exists so the e2e `TestCtrls` hack can
* retarget the appview via `BLUESKY_PROXY_HEADER.set()` before sign-in (the
* client is built at sign-in, so it picks up the override).
* We intentionally do NOT pass `fetch` here: a client built over a session uses
* that session's own `fetch` (networkAwareFetch, set at construction).
*/
export function buildAppviewClient(
export function buildBskyClient(
session: PasswordSession,
labelerDids: string[],
): Client {
+48 -26
View File
@@ -60,8 +60,9 @@ StateContext.displayName = 'SessionStateContext'
/**
* Holds the full {@link SessionBundle} (or the logged-out
* {@link PublicSessionBundle}) for the active account. The three-client hooks
* (`useLexClient`/`useAppviewClient`/`usePdsClient`) read from here.
* {@link PublicSessionBundle}) for the active account. The authed client hooks
* (`useLexClient`/`useAppviewClient`/`usePdsClient`), which all return the one
* merged `bskyClient` when signed in, read from here.
*/
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
null,
@@ -526,12 +527,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const bundle = state.currentAgentState.agent as unknown as SessionBundle
const signal = cancelPendingTask()
/*
* Fetch through the account (PDS) client and dispatch the patch. We do NOT
* mutate the session object (PasswordSession's data is immutable to us); the
* reducer patches only the `accounts` entry, and the email-state hook reads
* from the account rather than the session.
* Fetch through the merged Bluesky client and dispatch the patch. getSession
* must hit the user's PDS, not the appview proxy, so this raw call passes
* `{service: null}` to strip the instance's appview `atproto-proxy` header.
* We do NOT mutate the session object (PasswordSession's data is immutable to
* us); the reducer patches only the `accounts` entry, and the email-state
* hook reads from the account rather than the session.
*/
const data = await bundle.accountClient.call(com.atproto.server.getSession)
const data = await bundle.bskyClient.call(
com.atproto.server.getSession,
{},
{service: null},
)
if (signal.aborted) return
store.dispatch({
type: 'partial-refresh-session',
@@ -669,7 +676,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
registerBundleKillSwitch(newBundle, hooks.kill)
/*
* Reapply this account's subscribed labelers to the freshly built
* appview client: buildBundle starts with an empty per-instance
* merged Bluesky client: buildBundle starts with an empty per-instance
* labeler set, and this rebuild path never runs
* configureModerationForAccount on its own. It is fully synchronous
* (the labeler cache is a local MMKV read), so the whole prep + arm +
@@ -857,39 +864,53 @@ export function useRequireAuth() {
}
/**
* Authenticated lex {@link Client} for appview reads. Backed by the active
* bundle's appview client (proxied to the Bluesky appview, with labelers); its
* identity is stable per-bundle. Falls back to the public client when there is
* no bundle (logged out, or used outside the provider) so callers can treat it
* as always-present.
* Authenticated lex {@link Client} for the active account. Backed by the active
* bundle's single merged Bluesky client (proxied to the Bluesky appview, with
* labelers); its identity is stable per-bundle. Falls back to the public client
* when there is no bundle (logged out, or used outside the provider) so callers
* can treat it as always-present.
*
* All three authed client hooks (this, {@link useAppviewClient},
* {@link usePdsClient}) now return the SAME merged client for a signed-in
* account. They differ ONLY in their logged-out fallback: this hook and
* `useAppviewClient` fall back to the public read client, while `usePdsClient`
* falls back to the throwing unauthenticated client.
*/
export function useLexClient(): Client {
const bundle = useContext(BundleContext)
return bundle?.appviewClient ?? getPublicLexClient()
return bundle?.bskyClient ?? getPublicLexClient()
}
/**
* Alias of {@link useLexClient}: the authenticated appview client for the
* active account.
* Alias of {@link useLexClient}: the authenticated merged Bluesky client for the
* active account, falling back to the public read client when logged out.
*/
export function useAppviewClient(): Client {
const bundle = useContext(BundleContext)
return bundle?.appviewClient ?? getPublicLexClient()
return bundle?.bskyClient ?? getPublicLexClient()
}
/**
* The account (PDS) lex {@link Client} for the active account. Writes and record
* mutations go here - requests hit the user's PDS directly (no appview proxy).
* The authenticated merged Bluesky client for the active account, the SAME
* instance returned by {@link useLexClient}/{@link useAppviewClient}. The name
* is historical (there is no longer a separate PDS client): use this hook at
* call sites whose requests must target the ACCOUNT HOST. Record helpers on the
* client auto-target it (lex-client 0.3.0 defaults `service = null` per call);
* raw `com.atproto.server`/`identity`/`sync`/`temp` calls must pass
* `{service: null}` to strip the appview proxy.
*
* Logged out, returns a stable client ({@link getUnauthenticatedClient}) that
* throws `NotAuthenticatedError` before any network I/O, so an unauthenticated
* write fails loudly rather than silently hitting `public.api.bsky.app`.
* Components may safely hold this client while logged out; only calling it
* throws. To branch on auth state, use {@link useMaybePdsClient} instead.
* write fails loudly rather than silently hitting `public.api.bsky.app`. This is
* the ONLY logged-out write protection - the public bundle now carries the
* public read client in `bskyClient`, so this hook gates on `bundle.session`
* (not on a distinct bundle field) to decide whether to throw. Components may
* safely hold this client while logged out; only calling it throws. To branch
* on auth state, use {@link useMaybePdsClient} instead.
*/
export function usePdsClient(): Client {
const bundle = useContext(BundleContext)
return bundle?.accountClient ?? getUnauthenticatedClient()
return bundle?.session ? bundle.bskyClient : getUnauthenticatedClient()
}
/**
@@ -907,8 +928,9 @@ export function useChatClient(): Client {
}
/**
* The account (PDS) lex {@link Client} for the active account, or `null` when
* there is no active session (logged out, or used outside the provider).
* The authenticated merged Bluesky client for the active account (the same
* instance {@link usePdsClient} returns when signed in), or `null` when there is
* no active session (logged out, or used outside the provider).
*
* The escape hatch for the rare component that genuinely renders a logged-out
* branch and must decide whether a write path is available. Prefer
@@ -917,7 +939,7 @@ export function useChatClient(): Client {
*/
export function useMaybePdsClient(): Client | null {
const bundle = useContext(BundleContext)
return bundle?.session ? bundle.accountClient : null
return bundle?.session ? bundle.bskyClient : null
}
/**
+15 -8
View File
@@ -46,9 +46,14 @@ export function readLabelers(did: string): string[] | undefined {
}
/**
* Apply an account's subscribed labeler DIDs to a live appview client. The lex
* `Client` rebuilds the header per request, so this takes effect on the next
* request without a client rebuild.
* Apply an account's subscribed labeler DIDs to a live client. The lex `Client`
* rebuilds the header per request, so this takes effect on the next request
* without a client rebuild.
*
* These labelers ride the `atproto-accept-labelers` header, which lex-client
* 0.3.0 emits only on raw/query calls: record helpers default `labelers = null`
* per call, stripping the header. That is fine here - labelers only matter for
* the read/query calls moderation cares about.
*
* We filter out the Bluesky moderation labeler: it is already asserted globally
* via `Client.appLabelers` (with `;redact`), and a user "subscribing" to it
@@ -76,7 +81,8 @@ export function configureModerationForGuest() {
* per-account labelers already applied, in the same tick.
*
* Takes the whole {@link SessionBundle} so it can apply per-account labelers to
* the authed appview client (`bundle.appviewClient`, backing `useLexClient()`).
* the single merged Bluesky client (`bundle.bskyClient`, backing
* `useLexClient()`).
*/
export function configureModerationForAccount(
bundle: SessionBundle,
@@ -97,7 +103,7 @@ export function configureModerationForAccount(
// The code below is actually relevant to production (and isn't global).
const labelerDids = readLabelers(account.did)
if (labelerDids) {
applyLabelersToClient(bundle.appviewClient, labelerDids)
applyLabelersToClient(bundle.bskyClient, labelerDids)
} else {
/*
* No cached labelers yet (first session on this device), so the initial
@@ -115,12 +121,13 @@ function switchToBskyAppLabeler() {
/**
* In the test environment, swap the global app labeler for the test-env
* moderation authority, resolving its handle via the bundle's authed appview
* client.
* moderation authority, resolving its handle via the bundle's merged Bluesky
* client. This is a raw call, so it inherits the appview proxy - which is
* correct, as `resolveHandle` is served by the appview.
*/
async function trySwitchToTestAppLabeler(bundle: SessionBundle) {
const did = (
await bundle.appviewClient
await bundle.bskyClient
.call(com.atproto.identity.resolveHandle, {handle: 'mod-authority.test'})
.catch(_ => undefined)
)?.did
+38 -38
View File
@@ -36,8 +36,7 @@ import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {features} from '#/analytics'
import {type app} from '#/lexicons'
import {
buildAccountClient,
buildAppviewClient,
buildBskyClient,
buildChatClient,
getPublicLexClient,
getUnauthenticatedClient,
@@ -266,10 +265,12 @@ function deriveServiceUrl(session: PasswordSession | null): URL {
export type SessionBundle = {
/** The single auth core. Never exposed to the reducer. */
session: PasswordSession
/** Account (writes/records) client - talks to the user's PDS. */
accountClient: Client
/** Authed appview client (proxied, with labelers). */
appviewClient: Client
/**
* The single authed Bluesky client (merged account + appview). Proxied to
* the Bluesky appview and carrying this account's labelers; record helpers
* on it auto-target the user's PDS. See {@link buildBskyClient}.
*/
bskyClient: Client
/** Chat client (proxied to `did:web:api.bsky.chat#bsky_chat`). */
chatClient: Client
/**
@@ -306,18 +307,17 @@ export function registerBundleKillSwitch(
}
/**
* Assemble a {@link SessionBundle} from a live session: the account, appview,
* and chat clients, all read-through views over the one session.
* Assemble a {@link SessionBundle} from a live session: the merged Bluesky
* client and the chat client, both read-through views over the one session.
*/
export function buildBundle(session: PasswordSession): SessionBundle {
return {
session,
accountClient: buildAccountClient(session),
/*
* Starts with an empty per-account labeler set; configureModerationForAccount
* applies this account's labelers afterwards.
*/
appviewClient: buildAppviewClient(session, []),
bskyClient: buildBskyClient(session, []),
chatClient: buildChatClient(session),
/* A getter keeps `.service` live with the session's state (destroyed -> public). */
get service() {
@@ -415,13 +415,19 @@ export function makeSessionHooks(
}
/**
* The public (logged-out) bundle. Its appview client points at the public
* appview; the write/chat clients are the throwing unauthenticated client.
* The public (logged-out) bundle. Its `bskyClient` is the public appview client
* (reads work logged out); the chat client is the throwing unauthenticated
* client.
*/
export type PublicSessionBundle = {
session: null
accountClient: Client
appviewClient: Client
/**
* The public appview client (reads work logged out). Logged-out WRITE
* protection is NOT a property of this client - it lives in the
* `usePdsClient` hook's fallback (which returns the throwing unauthenticated
* client when there is no session; see index.tsx).
*/
bskyClient: Client
/**
* The throwing unauthenticated client (NOT the public client): chat is
* meaningless logged out, and `useChatClient()` must fail loudly rather than
@@ -442,13 +448,16 @@ export function createPublicSessionBundle(): PublicSessionBundle {
return {
session: null,
/*
* The account (PDS) and chat clients throw on use when logged out, so an
* unauthenticated write or chat call fails loudly instead of silently
* targeting the public appview. Reads keep the public client (appviewClient),
* which reads public data without auth.
* The public client reads public data without auth. Logged-out write
* protection is enforced by the usePdsClient hook (which falls back to the
* throwing unauthenticated client when there is no session), NOT here - see
* index.tsx.
*/
bskyClient: publicClient,
/*
* The chat client throws on use when logged out, so an unauthenticated chat
* call fails loudly instead of silently targeting the public appview.
*/
accountClient: getUnauthenticatedClient(),
appviewClient: publicClient,
chatClient: getUnauthenticatedClient(),
service: new URL(PUBLIC_BSKY_SERVICE),
}
@@ -498,10 +507,7 @@ export async function createSessionBundleAndResume(
storedAccount
configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({
appviewClient: bundle.appviewClient,
accountClient: bundle.accountClient,
})
const aa = prefetchAgeAssuranceServerData({client: bundle.bskyClient})
await Promise.all([gates, aa])
/*
* Re-snapshot AFTER prep, right before arm(). A 401 during a prep request
@@ -559,10 +565,7 @@ export async function createSessionBundleAndLogin(
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({
appviewClient: bundle.appviewClient,
accountClient: bundle.accountClient,
})
const aa = prefetchAgeAssuranceServerData({client: bundle.bskyClient})
await Promise.all([gates, aa])
/*
* Re-snapshot AFTER prep, right before arm(): a 401 during a prep request
@@ -650,17 +653,14 @@ export async function createSessionBundleAndCreateAccount(
setBirthdateForDid({did: earlyAccount.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(earlyAccount.did)
// do this last
const aa = prefetchAgeAssuranceServerData({
appviewClient: bundle.appviewClient,
accountClient: bundle.accountClient,
})
const aa = prefetchAgeAssuranceServerData({client: bundle.bskyClient})
// Not awaited so that we can still get into onboarding.
// This is OK because we won't let you toggle adult stuff until you set the date.
if (IS_PROD_SERVICE(service)) {
void Promise.allSettled([
networkRetry(3, () => {
return bundle.accountClient.call(setPersonalDetails, {
return bundle.bskyClient.call(setPersonalDetails, {
birthDate,
})
}).catch(e => {
@@ -670,7 +670,7 @@ export async function createSessionBundleAndCreateAccount(
throw e
}),
networkRetry(3, () => {
return bundle.accountClient.call(upsertProfile, prev => {
return bundle.bskyClient.call(upsertProfile, prev => {
const next: Partial<app.bsky.actor.profile.Main> = prev || {}
next.displayName = handle
next.createdAt = createdAt
@@ -683,7 +683,7 @@ export async function createSessionBundleAndCreateAccount(
throw e
}),
networkRetry(1, () => {
return bundle.accountClient.call(overwriteSavedFeeds, [
return bundle.bskyClient.call(overwriteSavedFeeds, [
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
@@ -704,7 +704,7 @@ export async function createSessionBundleAndCreateAccount(
const {flags} = unsafeGetAndComputeAgeAssurance({did: earlyAccount.did})
if (flags?.chatDisabled || flags?.groupChatDisabled) {
void restrictChatSettings({
client: bundle.accountClient,
client: bundle.bskyClient,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
@@ -721,7 +721,7 @@ export async function createSessionBundleAndCreateAccount(
} else {
void Promise.allSettled([
networkRetry(3, () => {
return bundle.accountClient.call(setPersonalDetails, {
return bundle.bskyClient.call(setPersonalDetails, {
birthDate,
})
}).catch(e => {
@@ -731,7 +731,7 @@ export async function createSessionBundleAndCreateAccount(
throw e
}),
networkRetry(3, () => {
return bundle.accountClient.call(upsertProfile, prev => {
return bundle.bskyClient.call(upsertProfile, prev => {
const next: Partial<app.bsky.actor.profile.Main> = prev || {}
next.createdAt = prev?.createdAt || toDatetimeString(new Date())
return next