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