From c5822306598c04818866b3aaf768925dc4836a66 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sun, 19 Jul 2026 15:34:02 +0300 Subject: [PATCH] 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 --- src/ageAssurance/data.tsx | 23 ++-- src/ageAssurance/useBeginAgeAssurance.ts | 13 ++- .../EmailDialog/data/useConfirmEmail.ts | 13 ++- .../EmailDialog/data/useManageEmail2FA.ts | 15 ++- .../EmailDialog/data/useRequestEmailUpdate.ts | 7 +- .../data/useRequestEmailVerification.ts | 7 +- .../EmailDialog/data/useUpdateEmail.ts | 13 ++- .../lists/CreateListFromStarterPackDialog.tsx | 13 ++- .../intents/VerifyEmailIntentDialog.tsx | 7 +- src/features/liveNow/index.tsx | 26 +++-- src/lib/api/index.ts | 15 ++- src/lib/generate-starterpack.ts | 15 ++- src/lib/media/video/upload.shared.ts | 15 ++- src/screens/Deactivated.tsx | 7 +- src/screens/Onboarding/util.ts | 13 ++- .../components/ChangePasswordDialog.tsx | 24 +++-- .../components/DeactivateAccountDialog.tsx | 7 +- .../components/DeleteAccountDialog.tsx | 22 ++-- .../components/DisableEmail2FADialog.tsx | 22 ++-- src/screens/SignupQueued.tsx | 7 +- src/state/queries/app-passwords.ts | 31 ++++-- src/state/queries/handle.ts | 11 +- src/state/queries/list.ts | 30 ++++-- .../queries/messages/actor-declaration.ts | 38 ++++--- src/state/queries/postgate/index.ts | 32 ++++-- src/state/queries/preferences/index.ts | 7 +- src/state/queries/starter-packs.ts | 101 ++++++++++-------- src/state/queries/threadgate/index.ts | 32 ++++-- .../session/__tests__/clients-bundle-test.ts | 67 ++++++++---- src/state/session/clients.ts | 52 +++++---- src/state/session/index.tsx | 74 ++++++++----- src/state/session/moderation.ts | 23 ++-- src/state/session/session-core.ts | 76 ++++++------- 33 files changed, 558 insertions(+), 300 deletions(-) diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 27e29ce3e7..4b0617d991 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -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}), ]) } diff --git a/src/ageAssurance/useBeginAgeAssurance.ts b/src/ageAssurance/useBeginAgeAssurance.ts index 49f2c678f6..cf64eaef23 100644 --- a/src/ageAssurance/useBeginAgeAssurance.ts +++ b/src/ageAssurance/useBeginAgeAssurance.ts @@ -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 diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts index 00472be52f..82ec3d7d3e 100644 --- a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts +++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts @@ -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() }, diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts index d00e487474..000256728a 100644 --- a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts +++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts @@ -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() }, diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts index a98f0c1097..53a39b8dee 100644 --- a/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts @@ -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}, + ) }, }) } diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts index 894cd54a3b..ba93b9bc97 100644 --- a/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts @@ -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}, + ) }, }) } diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts index 7293227f59..862de07e70 100644 --- a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts +++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts @@ -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() } diff --git a/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx index a496c31475..f52e1cd8ca 100644 --- a/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx +++ b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx @@ -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( diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx index 2a7746d012..1ce1dc8863 100644 --- a/src/components/intents/VerifyEmailIntentDialog.tsx +++ b/src/components/intents/VerifyEmailIntentDialog.tsx @@ -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') } diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx index 79b393a8d0..1dee1cdeed 100644 --- a/src/features/liveNow/index.tsx +++ b/src/features/liveNow/index.tsx @@ -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, { diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 522202d34e..e484aeb54b 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -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`, { diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts index 24dd5910dd..9a2a3e9f5a 100644 --- a/src/lib/generate-starterpack.ts +++ b/src/lib/generate-starterpack.ts @@ -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 } diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts index 0e7542a6cb..8193822236 100644 --- a/src/lib/media/video/upload.shared.ts +++ b/src/lib/media/video/upload.shared.ts @@ -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 } diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index c965e9fca1..00976a476d 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -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) { diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts index e026a99894..df9248ace0 100644 --- a/src/screens/Onboarding/util.ts +++ b/src/screens/Onboarding/util.ts @@ -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) diff --git a/src/screens/Settings/components/ChangePasswordDialog.tsx b/src/screens/Settings/components/ChangePasswordDialog.tsx index f530556100..f1888aa365 100644 --- a/src/screens/Settings/components/ChangePasswordDialog.tsx +++ b/src/screens/Settings/components/ChangePasswordDialog.tsx @@ -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)) { diff --git a/src/screens/Settings/components/DeactivateAccountDialog.tsx b/src/screens/Settings/components/DeactivateAccountDialog.tsx index 6560e75192..97a56ae942 100644 --- a/src/screens/Settings/components/DeactivateAccountDialog.tsx +++ b/src/screens/Settings/components/DeactivateAccountDialog.tsx @@ -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') }) diff --git a/src/screens/Settings/components/DeleteAccountDialog.tsx b/src/screens/Settings/components/DeleteAccountDialog.tsx index 3583dc0a1e..179152d99e 100644 --- a/src/screens/Settings/components/DeleteAccountDialog.tsx +++ b/src/screens/Settings/components/DeleteAccountDialog.tsx @@ -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') diff --git a/src/screens/Settings/components/DisableEmail2FADialog.tsx b/src/screens/Settings/components/DisableEmail2FADialog.tsx index f723b8cea2..f7125f6077 100644 --- a/src/screens/Settings/components/DisableEmail2FADialog.tsx +++ b/src/screens/Settings/components/DisableEmail2FADialog.tsx @@ -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'}))) } diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx index d65cd92ecc..024cdc9ab5 100644 --- a/src/screens/SignupQueued.tsx +++ b/src/screens/SignupQueued.tsx @@ -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() diff --git a/src/state/queries/app-passwords.ts b/src/state/queries/app-passwords.ts index c1326d0a00..3c81a86db0 100644 --- a/src/state/queries/app-passwords.ts +++ b/src/state/queries/app-passwords.ts @@ -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({ 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({ diff --git a/src/state/queries/handle.ts b/src/state/queries/handle.ts index 29dff9a202..a652402a85 100644 --- a/src/state/queries/handle.ts +++ b/src/state/queries/handle.ts @@ -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) diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index c884b70d38..6c77edaa89 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -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 diff --git a/src/state/queries/messages/actor-declaration.ts b/src/state/queries/messages/actor-declaration.ts index c3a71c49b5..380e5d83fe 100644 --- a/src/state/queries/messages/actor-declaration.ts +++ b/src/state/queries/messages/actor-declaration.ts @@ -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 }, }) diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts index 626c8354cd..2cf6000b98 100644 --- a/src/state/queries/postgate/index.ts +++ b/src/state/queries/postgate/index.ts @@ -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}, + ), ) } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 81beb8e83a..bdfcb161a3 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -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, diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts index 03646b7b00..588d772325 100644 --- a/src/state/queries/starter-packs.ts +++ b/src/state/queries/starter-packs.ts @@ -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) diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts index 1bfebbd05f..9a3f8235d2 100644 --- a/src/state/queries/threadgate/index.ts +++ b/src/state/queries/threadgate/index.ts @@ -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}, + ), ) } diff --git a/src/state/session/__tests__/clients-bundle-test.ts b/src/state/session/__tests__/clients-bundle-test.ts index 1f3265ca47..d817badc06 100644 --- a/src/state/session/__tests__/clients-bundle-test.ts +++ b/src/state/session/__tests__/clients-bundle-test.ts @@ -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(() => {}) diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index 2abde9d94e..7408ede88e 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -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 { diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 9e4f4d5da6..fbbe79c628 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -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( 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 } /** diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index a5d83b7735..b089bbc2a4 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -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 diff --git a/src/state/session/session-core.ts b/src/state/session/session-core.ts index a74e7c06ad..89c4385a31 100644 --- a/src/state/session/session-core.ts +++ b/src/state/session/session-core.ts @@ -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 = 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 = prev || {} next.createdAt = prev?.createdAt || toDatetimeString(new Date()) return next