clean up session clients and lifecycle

This commit is contained in:
Samuel Newman
2026-07-31 16:07:52 +03:00
parent 436369e9d4
commit 37822a523a
48 changed files with 1210 additions and 1573 deletions
+11 -12
View File
@@ -678,21 +678,20 @@ export function useDeviceSignalsQuery() {
)
}
/**
* 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({client}: {client: Client}) {
/** Prefetch age-assurance data from both the appview and account host. */
export function prefetchAgeAssuranceServerData({
appviewClient,
accountClient,
}: {
appviewClient: Client
accountClient: Client
}) {
return Promise.allSettled([
// config fetch initiated at the top of the App.platform.tsx files, awaited here
configPrefetchPromise,
prefetchServerState({appviewClient: client}),
prefetchOtherRequiredData({accountClient: client}),
prefetchDeviceSignals({appviewClient: client}),
prefetchServerState({appviewClient}),
prefetchOtherRequiredData({accountClient}),
prefetchDeviceSignals({appviewClient}),
])
}
+4 -9
View File
@@ -39,15 +39,10 @@ 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`,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
const {token} = await pdsClient.call(com.atproto.server.getServiceAuth, {
aud: BLUESKY_PROXY_DID,
lxm: `app.bsky.ageassurance.begin`,
})
/*
* A non-refreshing throwaway client scoped to the service-auth token: it
@@ -17,15 +17,10 @@ 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(),
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await pdsClient.call(com.atproto.server.confirmEmail, {
email: currentAccount.email.trim(),
token: token.trim(),
})
// will update session state at root of app
await refreshSession()
},
@@ -19,16 +19,11 @@ 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,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await pdsClient.call(com.atproto.server.updateEmail, {
email: currentAccount.email,
emailAuthFactor: enabled,
token,
})
// will update session state at root of app
await refreshSession()
},
@@ -11,8 +11,6 @@ export function useRequestEmailUpdate() {
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},
)
},
})
@@ -11,8 +11,6 @@ export function useRequestEmailVerification() {
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,15 +11,10 @@ async function updateEmailAndRefreshSession(
email: string,
token?: string,
) {
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 pdsClient.call(com.atproto.server.updateEmail, {
email: email.trim(),
token,
})
await refreshSession()
}
@@ -98,15 +98,10 @@ 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,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await pdsClient.call(com.atproto.repo.applyWrites, {
repo: currentAccount.did as AtIdentifierString,
writes: c,
})
}
await until(
+4 -5
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react'
import {RichText as RichTextAPI} from '@bsky.app/sdk/richtext'
import {useLexClient} from '#/state/session'
import {useAppviewClient} from '#/state/session'
export function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = useState(text)
@@ -9,11 +9,10 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
const [resolvedRT, setResolvedRT] = useState<RichTextAPI | null>(null)
/*
* Facet/mention resolution is an appview job - it resolves handles via
* `com.atproto.identity.resolveHandle` through the appview. `useLexClient`
* falls back to the public client when logged out, so mentions still resolve
* on logged-out surfaces (StarterPackLandingScreen, web ProfileHoverCard).
* `com.atproto.identity.resolveHandle` through the appview. The public
* fallback keeps mentions working on logged-out surfaces.
*/
const client = useLexClient()
const client = useAppviewClient()
if (text !== prevText) {
setPrevText(text)
setRawRT(new RichTextAPI({text}))
@@ -53,12 +53,7 @@ function Inner({}: {control: DialogControlProps}) {
const onPressResendEmail = async () => {
setSending(true)
await pdsClient.call(
com.atproto.server.requestEmailConfirmation,
undefined,
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await pdsClient.call(com.atproto.server.requestEmailConfirmation, undefined)
setSending(false)
setStatus('resent')
}
+11 -17
View File
@@ -277,26 +277,20 @@ export function useUpsertLiveStatusMutation(
const collection = 'app.bsky.actor.status'
const existing = await pdsClient
// 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,
{
.call(com.atproto.repo.getRecord, {
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},
)
})
.catch(_e => undefined)
await pdsClient.call(com.atproto.repo.putRecord, {
repo,
collection,
rkey: 'self',
record,
swapRecord: existing?.cid || null,
})
}
await retry(upsert, {
+4 -11
View File
@@ -36,17 +36,10 @@ 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}),
),
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await client.call(com.atproto.repo.applyWrites, {
repo: client.assertDid,
writes: profiles.map(p => createListItem({did: p.did, listUri: list.uri})),
})
return list
}
+1 -6
View File
@@ -72,12 +72,7 @@ export function Deactivated() {
const handleActivate = useCallback(async () => {
try {
setPending(true)
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 pdsClient.call(com.atproto.server.activateAccount, undefined)
await queryClient.resetQueries()
await refreshSession()
} catch (e: any) {
@@ -86,14 +86,9 @@ function Inner() {
setError('')
setIsProcessing(true)
try {
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},
)
await pdsClient.call(com.atproto.server.requestPasswordReset, {
email: currentAccount.email,
})
setStage(Stages.ChangePassword)
} catch (e: any) {
if (isNetworkError(e)) {
@@ -135,15 +130,10 @@ function Inner() {
setError('')
setIsProcessing(true)
try {
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},
)
await pdsClient.call(com.atproto.server.resetPassword, {
token: formattedCode,
password: newPassword,
})
setStage(Stages.Done)
} catch (e: any) {
if (isNetworkError(e)) {
@@ -44,12 +44,7 @@ function DeactivateAccountDialogInner({
const handleDeactivate = useCallback(async () => {
try {
setPending(true)
await pdsClient.call(
com.atproto.server.deactivateAccount,
{},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await pdsClient.call(com.atproto.server.deactivateAccount, {})
control.close(() => {
logoutCurrentAccount('Deactivated')
})
@@ -96,12 +96,7 @@ function DeleteAccountDialogInner({
}
try {
setEmailState(EmailState.PENDING)
await pdsClient.call(
com.atproto.server.requestAccountDelete,
undefined,
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await pdsClient.call(com.atproto.server.requestAccountDelete, undefined)
setError('')
setEmailSentCount(prevCount => prevCount + 1)
setStep(Step.VERIFY_CODE)
@@ -127,16 +122,11 @@ 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,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await pdsClient.call(com.atproto.server.deleteAccount, {
did: currentAccount.did as DidString,
password,
token,
})
control.close(() => {
toast.show(_(msg`Your account has been deleted, see ya! ✌️`))
resetToTab('HomeTab')
@@ -44,12 +44,7 @@ export function DisableEmail2FADialog({
setError('')
setIsProcessing(true)
try {
await pdsClient.call(
com.atproto.server.requestEmailUpdate,
undefined,
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await pdsClient.call(com.atproto.server.requestEmailUpdate, undefined)
setStage(Stages.ConfirmCode)
} catch (e) {
setError(cleanError(String(e)))
@@ -63,16 +58,11 @@ export function DisableEmail2FADialog({
setIsProcessing(true)
try {
if (currentAccount?.email) {
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 pdsClient.call(com.atproto.server.updateEmail, {
email: currentAccount.email,
token: confirmationCode.trim(),
emailAuthFactor: false,
})
await refreshSession()
Toast.show(_(msg({message: 'Email 2FA disabled', context: 'toast'})))
}
@@ -35,12 +35,7 @@ export function ExportCarDialog({
try {
setLoading('repo')
const did = currentAccount.did as DidString
const data = await pdsClient.call(
com.atproto.sync.getRepo,
{did},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
const data = await pdsClient.call(com.atproto.sync.getRepo, {did})
/*
* getRepo returns raw bytes; the lex client does not surface the response
* content-type, and this endpoint always returns CAR data, so the constant
+1 -6
View File
@@ -39,12 +39,7 @@ export function SignupQueued() {
const checkStatus = useCallback(async () => {
setProcessing(true)
try {
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},
)
const res = await pdsClient.call(com.atproto.temp.checkSignupQueue, {})
if (res.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
const refreshed = await refreshSession()
+8 -23
View File
@@ -13,12 +13,7 @@ export function useAppPasswordsQuery() {
staleTime: STALE.MINUTES.FIVE,
queryKey: RQKEY(),
queryFn: async () => {
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},
)
const data = await pdsClient.call(com.atproto.server.listAppPasswords, {})
return data.passwords
},
})
@@ -33,15 +28,10 @@ export function useAppPasswordCreateMutation() {
{name: string; privileged: boolean}
>({
mutationFn: async ({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},
)
return await pdsClient.call(com.atproto.server.createAppPassword, {
name,
privileged,
})
},
onSuccess() {
queryClient.invalidateQueries({
@@ -56,14 +46,9 @@ export function useAppPasswordDeleteMutation() {
const pdsClient = usePdsClient()
return useMutation<void, Error, {name: string}>({
mutationFn: async ({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},
)
await pdsClient.call(com.atproto.server.revokeAppPassword, {
name,
})
},
onSuccess() {
queryClient.invalidateQueries({
+3 -8
View File
@@ -45,14 +45,9 @@ export function useUpdateHandleMutation(opts?: {
return useMutation({
mutationFn: async ({handle}: {handle: string}) => {
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},
)
await pdsClient.call(com.atproto.identity.updateHandle, {
handle: handle as HandleString,
})
},
onSuccess(_data, variables) {
opts?.onSuccess?.(variables.handle)
+10 -20
View File
@@ -149,17 +149,12 @@ 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,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
const res = await pdsClient.call(com.atproto.repo.putRecord, {
repo: currentAccount.did,
collection: 'app.bsky.graph.list',
rkey,
record,
})
// wait for the appview to update
await whenAppViewReady(appviewClient, res.uri, v => {
@@ -232,15 +227,10 @@ 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,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
await pdsClient.call(com.atproto.repo.applyWrites, {
repo: currentAccount.did as AtIdentifierString,
writes: writesChunk,
})
}
// wait for the appview to update. once the list is deleted, getList
+14 -24
View File
@@ -39,21 +39,16 @@ 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 => {
@@ -109,16 +104,11 @@ 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',
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
const result = await pdsClient.call(com.atproto.repo.deleteRecord, {
repo: currentAccount.did as DidString,
collection: 'chat.bsky.actor.declaration',
rkey: 'self',
})
return result
},
})
+11 -21
View File
@@ -49,16 +49,11 @@ export async function getPostgateRecord({
return true
},
() =>
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},
),
pdsClient.call(com.atproto.repo.getRecord, {
repo: urip.host,
collection: POSTGATE_COLLECTION,
rkey: urip.rkey,
}),
)
if (data.value && bsky.matches(app.bsky.feed.postgate, data.value)) {
@@ -92,17 +87,12 @@ 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,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
),
pdsClient.call(com.atproto.repo.putRecord, {
repo: pdsClient.assertDid,
collection: POSTGATE_COLLECTION,
rkey: postUrip.rkey,
record: postgate,
}),
)
}
+1 -6
View File
@@ -145,12 +145,7 @@ export function useClearPreferencesMutation() {
return useMutation({
mutationFn: async () => {
// 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},
)
await client.call(app.bsky.actor.putPreferences, {preferences: []})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
+2 -2
View File
@@ -7,7 +7,7 @@ import {
resolveLink,
} from '#/lib/api/resolve'
import {STALE} from '#/state/queries/index'
import {useChatClient, useLexClient} from '#/state/session'
import {useAppviewClient, useChatClient} from '#/state/session'
import {type Gif} from '#/features/gifPicker/types'
export const RQKEY_LINK_ROOT = 'resolve-link'
@@ -30,7 +30,7 @@ export function resolveLinkQueryOptions(clients: ResolveClients, url: string) {
* chat client serves group join-link previews.
*/
export function useResolveClients(): ResolveClients {
const appview = useLexClient()
const appview = useAppviewClient()
const chat = useChatClient()
return {appview, chat}
}
+43 -58
View File
@@ -194,23 +194,18 @@ 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,
}),
),
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
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,
}),
),
})
}
}
@@ -220,52 +215,42 @@ 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(),
},
}),
),
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
)
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(),
},
}),
),
})
}
}
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)
+11 -21
View File
@@ -112,16 +112,11 @@ export async function getThreadgateRecord({
return true
},
() =>
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},
),
pdsClient.call(com.atproto.repo.getRecord, {
repo: urip.host,
collection: 'app.bsky.feed.threadgate',
rkey: urip.rkey,
}),
)
if (data.value && bsky.matches(app.bsky.feed.threadgate, data.value)) {
@@ -160,17 +155,12 @@ 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,
},
// service: null strips the appview proxy header - this must hit the account host (PDS)
{service: null},
),
pdsClient.call(com.atproto.repo.putRecord, {
repo: pdsClient.assertDid,
collection: 'app.bsky.feed.threadgate',
rkey: postUrip.rkey,
record,
}),
)
}
@@ -10,7 +10,7 @@ import {logger} from '#/logger'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useLexClient} from '#/state/session'
import {useAppviewClient} from '#/state/session'
import {app} from '#/lexicons'
export const DEFAULT_LIMIT = 5
@@ -33,7 +33,7 @@ export const createGetTrendsQueryKey = (limit?: number) =>
limit === undefined ? ['trends'] : ['trends', {limit}]
export function useGetTrendsQuery(props: QueryProps = {}) {
const client = useLexClient()
const client = useAppviewClient()
const {data: preferences} = usePreferencesQuery()
const limit = props.limit ?? DEFAULT_LIMIT
const mutedWords = useMemo(() => {
@@ -3,44 +3,29 @@ import {PasswordSession} from '@atproto/lex-password-session'
import {api} from '@bsky.app/sdk'
import {describe, expect, it, jest} from '@jest/globals'
/*
* clients.ts imports session-core (for networkAwareFetch), which pulls the
* factory dependency graph. Mock the heavy leaves so this test does not load
* the native module chain (same approach as session-core-test.ts).
*/
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
jest.mock('#/state/birthdate')
jest.mock('#/ageAssurance/data')
jest.mock('#/ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
}))
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
restrictChatSettings: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode() {
return {scope: 'com.atproto.access'}
},
}))
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {app, chat, com} from '#/lexicons'
import {
buildBskyClient,
buildAppviewClient,
buildChatClient,
getPublicLexClient,
getUnauthenticatedClient,
buildPdsClient,
getPublicAppviewClient,
getUnauthenticatedThrowingClient,
NotAuthenticatedError,
routeSessionToPds,
} from '../clients'
import {sessionAccountToSessionData} from '../session-core'
import {sessionAccountToSessionData} from '../session-data'
import {type SessionAccount} from '../types'
const DID = 'did:plc:example123'
const HANDLE = 'alice.test'
const SERVICE = 'https://bsky.social'
const PDS_URL = 'https://pds.example.com'
const APPVIEW_PROXY = 'did:web:api.bsky.app#bsky_appview'
const CHAT_PROXY = 'did:web:api.bsky.chat#bsky_chat'
const CUSTOM_LABELER = 'did:plc:custom-labeler'
@@ -84,11 +69,7 @@ function makeCapturingFetch() {
input: URL | string | Request,
init: RequestInit = {},
): Promise<Response> => {
/*
* The lex Client calls fetch as (url, {headers}); the old AtpAgent
* (XrpcClient) calls it with a single Request object carrying the
* headers. Read headers from whichever the caller used.
*/
/* Read headers from either valid fetch input shape. */
const url = isRequest(input)
? input.url
: input instanceof URL
@@ -122,11 +103,11 @@ function makeSession(
})
}
describe('buildBskyClient', () => {
describe('buildAppviewClient', () => {
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 = buildBskyClient(session, [CUSTOM_LABELER])
const client = buildAppviewClient(session, [CUSTOM_LABELER])
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
@@ -138,7 +119,7 @@ describe('buildBskyClient', () => {
* The moderation DID is NOT a per-instance labeler: it flows only through
* the global Client.appLabelers (unset in this test), where lex-client
* merges it into the header with `;redact` on every request. See the
* labeler-header regression guard below for the merged composition.
* labeler header composition suite below.
*/
expect(labelers).not.toContain(api.moderation.did)
})
@@ -146,7 +127,7 @@ describe('buildBskyClient', () => {
it('routes through the session fetchHandler with the bearer token', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildBskyClient(session, [])
const client = buildAppviewClient(session, [])
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
@@ -170,7 +151,7 @@ describe('buildBskyClient', () => {
*/
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildBskyClient(session, [CUSTOM_LABELER])
const client = buildAppviewClient(session, [CUSTOM_LABELER])
await client.getRecord('app.bsky.feed.post', 'self').catch(() => {})
@@ -182,7 +163,7 @@ describe('buildBskyClient', () => {
it('inherits the appview proxy on a raw appview query call', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildBskyClient(session, [])
const client = buildAppviewClient(session, [])
await client.call(app.bsky.feed.getTimeline.main, {}).catch(() => {})
@@ -198,7 +179,7 @@ describe('buildBskyClient', () => {
*/
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildBskyClient(session, [])
const client = buildAppviewClient(session, [])
await client
.call(com.atproto.server.getSession.main, {}, {service: null})
@@ -209,6 +190,33 @@ describe('buildBskyClient', () => {
})
})
describe('buildPdsClient', () => {
it('targets the account host by default with the session bearer token', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildPdsClient(session)
await client.call(com.atproto.server.getSession.main, {}).catch(() => {})
expect(seen).toHaveLength(1)
expect(seen[0].url).toContain('bsky.social')
expect(seen[0].headers.get('atproto-proxy')).toBeNull()
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
})
it('targets an explicitly routed PDS using the same session auth', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildPdsClient(routeSessionToPds(session, PDS_URL))
await client.call(com.atproto.server.getSession.main, {}).catch(() => {})
expect(seen).toHaveLength(1)
expect(seen[0].url).toContain('pds.example.com')
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
})
})
describe('buildChatClient', () => {
it('sets the chat atproto-proxy header on a request', async () => {
const {seen, fetchMock} = makeCapturingFetch()
@@ -233,12 +241,12 @@ describe('buildChatClient', () => {
})
})
describe('getUnauthenticatedClient', () => {
describe('getUnauthenticatedThrowingClient', () => {
it('is a stable singleton with no did', () => {
const client = getUnauthenticatedClient()
const client = getUnauthenticatedThrowingClient()
expect(client.did).toBeUndefined()
/* identity is stable so it is safe in React Query keys */
expect(getUnauthenticatedClient()).toBe(client)
expect(getUnauthenticatedThrowingClient()).toBe(client)
})
it('rejects on a call, with NotAuthenticatedError as the root cause', async () => {
@@ -247,7 +255,7 @@ describe('getUnauthenticatedClient', () => {
* a fetchHandler throw in an XrpcInternalError whose `.cause` is the
* original error, so the NotAuthenticatedError surfaces as the cause.
*/
const client = getUnauthenticatedClient()
const client = getUnauthenticatedThrowingClient()
const err = await client
.call(chat.bsky.convo.listConvos.main)
@@ -259,7 +267,7 @@ describe('getUnauthenticatedClient', () => {
})
it('surfaces a NotAuthenticatedError with a stable name and message', async () => {
const client = getUnauthenticatedClient()
const client = getUnauthenticatedThrowingClient()
const err = await client
.call(chat.bsky.convo.listConvos.main)
@@ -275,17 +283,17 @@ describe('getUnauthenticatedClient', () => {
})
})
describe('getPublicLexClient', () => {
describe('getPublicAppviewClient', () => {
it('is an unauthenticated singleton (no session did)', () => {
const client = getPublicLexClient()
const client = getPublicAppviewClient()
expect(client.did).toBeUndefined()
/* process-wide singleton: identity is stable across calls */
expect(getPublicLexClient()).toBe(client)
expect(getPublicAppviewClient()).toBe(client)
})
it('routes to public.api.bsky.app with no proxy or auth header', async () => {
/*
* getPublicLexClient builds `new Client({service: PUBLIC_BSKY_SERVICE,
* getPublicAppviewClient builds `new Client({service: PUBLIC_BSKY_SERVICE,
* fetch: networkAwareFetch})`. networkAwareFetch captures the global fetch
* at import time, which is hard to intercept here, so we reconstruct the
* same Client shape with an observable fetch to assert the routing +
@@ -308,20 +316,12 @@ describe('getPublicLexClient', () => {
})
})
/*
* Regression guard: the emitted `atproto-accept-labelers` header from a
* fully-configured appview client must carry the exact byte-shape the old
* AtpAgent produced - global appLabelers carry the `;redact` suffix, per
* -instance labelers are plain. The old AtpAgent reference implementation is
* gone with the bridge, so we assert the composition invariant directly (design
* section 6).
*/
describe('labeler-header regression guard', () => {
describe('labeler header composition', () => {
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; buildBskyClient no longer lists it as a per-instance labeler.
* suffix; buildAppviewClient does not list 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.
@@ -330,7 +330,7 @@ describe('labeler-header regression guard', () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildBskyClient(session, [CUSTOM_LABELER])
const client = buildAppviewClient(session, [CUSTOM_LABELER])
await client
.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
.catch(() => {})
@@ -0,0 +1,104 @@
import {describe, expect, it} from '@jest/globals'
import {
MAX_EXPIRY_RESCUE_GENERATIONS,
pickExpiryRescueCandidate,
} from '../expiry-rescue'
import {type SessionAccount} from '../types'
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
return {
service: 'https://bsky.social',
did: 'did:plc:example123',
handle: 'alice.test',
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt',
accessJwt: 'access-jwt',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: undefined,
isSelfHosted: false,
...overrides,
}
}
describe('pickExpiryRescueCandidate', () => {
it('picks a candidate whose refreshJwt differs from the dying one', () => {
const fresh = makeAccount({refreshJwt: 'refresh-jwt-2'})
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [fresh],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(fresh)
})
it('rejects a candidate carrying the dying refreshJwt (equally dead)', () => {
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [makeAccount({refreshJwt: 'refresh-jwt-1'})],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(undefined)
})
it('rejects a candidate with no refreshJwt', () => {
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [makeAccount({refreshJwt: undefined}), undefined],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(undefined)
})
it('rejects a candidate already recorded as failed (loop guard)', () => {
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [makeAccount({refreshJwt: 'refresh-jwt-2'})],
failedRefreshJwts: new Set(['refresh-jwt-2']),
})
expect(picked).toBe(undefined)
})
it('tries candidates in order, preferring the first qualifying one', () => {
const persistedCandidate = makeAccount({
refreshJwt: 'refresh-jwt-persisted',
})
const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'})
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [persistedCandidate, reducerCandidate],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(persistedCandidate)
})
it('skips an unusable first candidate and falls back to a later one', () => {
const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'})
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [
makeAccount({refreshJwt: 'refresh-jwt-1'}),
reducerCandidate,
],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(reducerCandidate)
})
it('gives up once the failed-generation set hits the hard cap', () => {
const failed = new Set<string>()
for (let i = 0; i < MAX_EXPIRY_RESCUE_GENERATIONS; i++) {
failed.add(`refresh-jwt-failed-${i}`)
}
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-dying',
candidates: [makeAccount({refreshJwt: 'refresh-jwt-brand-new'})],
failedRefreshJwts: failed,
})
expect(picked).toBe(undefined)
})
})
+97 -203
View File
@@ -12,32 +12,16 @@ jest.mock('#/state/events', () => ({
emitNetworkLost: jest.fn(),
}))
/*
* session-core now imports the factory dependency graph (birthdate,
* restrictChatSettings, ageAssurance, moderation). Mock the heavy leaves so the
* pure-converter tests here stay lightweight and do not pull in the native
* bottom-sheet module chain (same approach as session-test.ts).
*/
jest.mock('#/state/birthdate')
/*
* `prefetchAgeAssuranceServerData` is a genuine prep await in each factory
* (moderation config is synchronous now, so the AA prefetch is where the
* fix-1 tests inject a mid-prep token rotation). The default is a no-op;
* (moderation config is synchronous, so the AA prefetch is where the factory
* tests inject a mid-prep token rotation). The default is a no-op;
* individual tests install behavior via `mockImplementationOnce`.
*/
const mockPrefetchAgeAssuranceServerData = jest.fn<() => void | Promise<void>>()
jest.mock('#/ageAssurance/data', () => ({
prefetchAgeAssuranceServerData: () => mockPrefetchAgeAssuranceServerData(),
setBirthdateForDid: () => {},
setCreatedAtForDid: () => {},
}))
jest.mock('#/ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
}))
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
restrictChatSettings: () => Promise.resolve(),
}))
/*
* The factory tail awaits `features.refresh(...)`; stub the analytics module so
* the factory does not pull GrowthBook (and its native deps) into this
@@ -51,11 +35,10 @@ jest.mock('#/analytics', () => ({
* `configureModerationForAccount` is now fully synchronous (the labeler cache
* is a local MMKV read), so it is no longer a prep await - but it still runs
* inside each factory with the freshly built bundle, before the awaited prep
* steps. The fix-1 tests use this mock to CAPTURE the bundle, then inject a
* REAL `session.refresh()` into the awaited AA prefetch (see the
* `#/ageAssurance/data` mock above), so a token rotation happens DURING prep
* (before arm()) - exactly the 401 auto-refresh scenario the re-snapshot fix
* guards against. The default is a no-op so other tests are unaffected.
* steps. The factory tests capture the bundle with this mock, then inject a
* real `session.refresh()` into the awaited AA prefetch so a token rotation
* happens during prep, before arm(). The default is a no-op so other tests are
* unaffected.
* (jest requires out-of-scope factory references to be `mock`-prefixed.)
*/
const mockConfigureModerationForAccount =
@@ -85,16 +68,13 @@ jest.mock('jwt-decode', () => ({
import {
type AtpSessionEvent,
createSessionBundleFromStoredAccount,
disposeBundle,
extractPdsUrl,
makeSessionHooks,
MAX_EXPIRY_RESCUE_GENERATIONS,
pickExpiryRescueCandidate,
registerBundleKillSwitch,
sessionAccountToSessionData,
type SessionBundle,
sessionDataToSessionAccount,
synthDidDoc,
} from '../session-core'
const DID = 'did:plc:example123'
@@ -102,6 +82,22 @@ const HANDLE = 'alice.test'
const SERVICE = 'https://bsky.social'
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
function synthDidDoc(
did: string,
pdsUrl: string,
): NonNullable<SessionData['didDoc']> {
return {
id: did,
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: pdsUrl,
},
],
}
}
function makeSessionData(overrides: Partial<SessionData> = {}): SessionData {
return {
accessJwt: 'access-jwt',
@@ -117,54 +113,6 @@ function makeSessionData(overrides: Partial<SessionData> = {}): SessionData {
}
}
describe('extractPdsUrl', () => {
it('extracts the PDS endpoint from a didDoc', () => {
expect(extractPdsUrl(synthDidDoc(DID, PDS_URL))).toBe(PDS_URL)
})
it('matches full service ids ending in #atproto_pds', () => {
const didDoc = {
id: DID,
service: [
{
id: `${DID}#atproto_pds`,
type: 'AtprotoPersonalDataServer',
serviceEndpoint: PDS_URL,
},
],
}
expect(extractPdsUrl(didDoc)).toBe(PDS_URL)
})
it('returns null for missing/invalid input', () => {
expect(extractPdsUrl(undefined)).toBe(null)
expect(extractPdsUrl(null)).toBe(null)
expect(extractPdsUrl({})).toBe(null)
expect(extractPdsUrl({service: 'not-an-array'})).toBe(null)
expect(
extractPdsUrl({
service: [{id: '#other_service', serviceEndpoint: PDS_URL}],
}),
).toBe(null)
expect(
extractPdsUrl({service: [{id: '#atproto_pds', serviceEndpoint: 42}]}),
).toBe(null)
expect(
extractPdsUrl({
service: [{id: '#atproto_pds', serviceEndpoint: 'not a url'}],
}),
).toBe(null)
})
})
describe('synthDidDoc', () => {
it('produces a didDoc that extractPdsUrl round-trips', () => {
const doc = synthDidDoc(DID, PDS_URL)
expect(extractPdsUrl(doc)).toBe(PDS_URL)
expect(doc.id).toBe(DID)
})
})
describe('sessionDataToSessionAccount', () => {
it('returns undefined for a missing session', () => {
expect(sessionDataToSessionAccount(undefined, 'https://bsky.social')).toBe(
@@ -197,7 +145,7 @@ describe('sessionDataToSessionAccount', () => {
})
})
it('normalizes service with a trailing slash like agent.serviceUrl.toString()', () => {
it('serializes service as a normalized URL', () => {
const account = sessionDataToSessionAccount(
makeSessionData(),
'https://bsky.social',
@@ -205,15 +153,11 @@ describe('sessionDataToSessionAccount', () => {
expect(account.service).toBe('https://bsky.social/')
})
it('derives pdsUrl from the didDoc, normalized as a URL string', () => {
it('serializes the didDoc PDS endpoint as a normalized URL', () => {
const account = sessionDataToSessionAccount(
makeSessionData({didDoc: synthDidDoc(DID, PDS_URL)}),
'https://bsky.social',
)!
/*
* The old code read agent.pdsUrl?.toString() - a URL - so the persisted
* value carries a trailing slash.
*/
expect(account.pdsUrl).toBe(`${PDS_URL}/`)
})
@@ -225,6 +169,15 @@ describe('sessionDataToSessionAccount', () => {
expect(account.pdsUrl).toBe(undefined)
})
it('retains the stored PDS when a valid didDoc has no PDS service', () => {
const account = sessionDataToSessionAccount(
makeSessionData({didDoc: {id: DID}}),
'https://bsky.social',
PDS_URL,
)!
expect(account.pdsUrl).toBe(`${PDS_URL}/`)
})
it('derives isSelfHosted from the service URL', () => {
const hosted = sessionDataToSessionAccount(
makeSessionData(),
@@ -267,11 +220,10 @@ describe('sessionDataToSessionAccount', () => {
expect(account.emailAuthFactor).toBe(false)
})
it('preserves the exact field order of the old agentToSessionAccount literal', () => {
it('preserves the exact SessionAccount field order', () => {
/*
* Byte-stability guard: the reducer's JSON.stringify fast path and the
* session test snapshots depend on this exact key order. This is the
* object literal order of the old agentToSessionAccount in agent.ts.
* session test snapshots depend on this exact persisted key order.
*/
const account = sessionDataToSessionAccount(
makeSessionData({didDoc: synthDidDoc(DID, PDS_URL)}),
@@ -332,15 +284,14 @@ describe('sessionAccountToSessionData', () => {
it('omits didDoc when the account has no stored pdsUrl', () => {
const data = sessionAccountToSessionData(baseAccount)
expect('didDoc' in data).toBe(false)
expect(extractPdsUrl(data.didDoc)).toBe(null)
})
it('synthesizes a didDoc from a stored pdsUrl so PDS routing works pre-refresh', () => {
it('does not synthesize protocol data from a stored pdsUrl', () => {
const data = sessionAccountToSessionData({
...baseAccount,
pdsUrl: `${PDS_URL}/`,
})
expect(extractPdsUrl(data.didDoc)).toBe(`${PDS_URL}/`)
expect('didDoc' in data).toBe(false)
})
it('round-trips account -> SessionData -> account preserving all fields', () => {
@@ -350,7 +301,11 @@ describe('sessionAccountToSessionData', () => {
}
for (const account of [baseAccount, withPds]) {
const data = sessionAccountToSessionData(account)
const roundTripped = sessionDataToSessionAccount(data, account.service)!
const roundTripped = sessionDataToSessionAccount(
data,
account.service,
account.pdsUrl,
)!
expect(roundTripped).toEqual(account)
expect(JSON.stringify(roundTripped)).toBe(JSON.stringify(account))
}
@@ -380,6 +335,7 @@ describe('sessionAccountToSessionData', () => {
const roundTripped = sessionDataToSessionAccount(
sessionAccountToSessionData(selfHosted),
selfHosted.service,
selfHosted.pdsUrl,
)!
expect(roundTripped).toEqual(selfHosted)
})
@@ -404,6 +360,41 @@ function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
}
}
describe('createSessionBundleFromStoredAccount', () => {
it('builds distinct appview and PDS clients over one session', () => {
const result = createSessionBundleFromStoredAccount(
makeAccount(),
jest.fn(),
)!
expect(result.bundle.appviewClient).not.toBe(result.bundle.pdsClient)
expect(result.bundle.appviewClient.service).toBe(
'did:web:api.bsky.app#bsky_appview',
)
expect(result.bundle.pdsClient.service).toBeNull()
disposeBundle(result.bundle)
})
it('disposes a bundle rejected by the activation guard', async () => {
const onSessionChange = jest.fn()
let rejectedBundle: SessionBundle | undefined
const result = createSessionBundleFromStoredAccount(
makeAccount(),
onSessionChange,
bundle => {
rejectedBundle = bundle
return false
},
)
expect(result).toBeUndefined()
await expect(
rejectedBundle!.session.fetchHandler('/xrpc/test', {}),
).rejects.toThrow('session disposed')
expect(onSessionChange).not.toHaveBeenCalled()
})
})
/**
* Build a mock `fetch` that returns canned XRPC responses keyed by the last
* path segment (nsid). `refreshSession` returns fresh tokens; `getSession`
@@ -457,12 +448,6 @@ function asFetch(mock: ReturnType<typeof makeMockFetch>): typeof fetch {
return mock as unknown as typeof fetch
}
/*
* Ported from the now-deleted bridge-agent-test: the arm-latch + event mapping
* is the durable session-hook semantics that survives the bridge removal. The
* hook now hands the whole bundle to onSessionChange (not a bridge agent), so
* getBundle returns a stand-in bundle whose identity is what matters.
*/
describe('makeSessionHooks arm-latch + event mapping', () => {
/*
* The hooks read neither `this` (the PasswordSession) nor their data
@@ -549,8 +534,8 @@ describe('makeSessionHooks arm-latch + event mapping', () => {
/*
* The exact derivation from the provider's onSessionChange (index.tsx). Pinned
* here because the payload threading (session-core) and this mapping together
* are the fix: read tokens from the delivered payload on 'update', and force
* here because payload threading and this mapping together read tokens from
* the delivered payload on 'update' and force
* undefined on the drop paths so the reducer logs the user out.
*/
function deriveRefreshedAccount(
@@ -563,7 +548,7 @@ function deriveRefreshedAccount(
}
/*
* Pins the pre-commit ordering bug fix. `PasswordSession` fires onUpdated with
* `PasswordSession` fires onUpdated with
* the fresh session BEFORE committing it internally, so the live getter is
* still stale at hook time. Driven through the real library (not a hand-rolled
* fixture) so the ordering is authentic.
@@ -581,12 +566,12 @@ describe('session-hook payload threading (pre-commit ordering)', () => {
event: AtpSessionEvent,
sessionData?: SessionData,
) => {
/* what the OLD code did: snapshot the live (mutable) getter */
/* Capture the live getter to demonstrate its pre-commit state. */
liveGetterAtHookTime = sessionDataToSessionAccount(
session.session,
session.session.service,
)
/* what the fix does: derive from the delivered payload */
/* Derive fresh data from the delivered payload. */
refreshedAccountAtHookTime = deriveRefreshedAccount(event, sessionData)
},
)
@@ -603,9 +588,9 @@ describe('session-hook payload threading (pre-commit ordering)', () => {
await session.refresh()
/* pre-commit ordering: at hook time the live getter still held OLD tokens */
/* at hook time the live getter still held the previous tokens */
expect(liveGetterAtHookTime?.accessJwt).toBe('access-jwt')
/* the fix reads the fresh tokens from the payload the hook delivered */
/* the payload already contains the fresh tokens */
expect(refreshedAccountAtHookTime?.accessJwt).toBe('access-jwt-2')
expect(refreshedAccountAtHookTime?.refreshJwt).toBe('refresh-jwt-2')
/* and the session does eventually commit those same tokens */
@@ -661,7 +646,7 @@ describe('session-hook payload threading (pre-commit ordering)', () => {
})
/*
* Pins the disposal kill-switch (fix 3). `PasswordSession` exposes no local
* `PasswordSession` exposes no local
* destroy, so disposeBundle neutralizes the session by tripping the flag inside
* the injected fetch - after disposal every request (direct or auto-refresh,
* which shares this same captured fetch) throws before touching the network.
@@ -707,10 +692,8 @@ describe('disposeBundle kill-switch', () => {
})
/*
* Ported from bridge-agent-test: PasswordSession lifecycle over a mocked fetch.
* This exercises the auth core directly (the bridge that used to wrap it is
* gone), covering the resume fast path plus the onUpdated/onDeleted/
* onUpdateFailure hook firing that makeSessionHooks maps into reducer events.
* PasswordSession lifecycle over a mocked fetch, covering the resume fast path
* and the hooks that makeSessionHooks maps into reducer events.
*/
describe('PasswordSession lifecycle over mocked fetch', () => {
it('resume fast path: constructing does not hit the network', () => {
@@ -784,8 +767,7 @@ describe('PasswordSession lifecycle over mocked fetch', () => {
})
/*
* refreshSession coverage (design decision (b) / Test plan). The
* `useSessionApi().refreshSession()` callback is a thin wrapper over
* `useSessionApi().refreshSession()` is a thin wrapper over
* `PasswordSession.refresh()`: on success the armed hooks dispatch exactly one
* 'update' event and the returned snapshot reflects the refreshed data;
* rejections propagate. We exercise the auth-core mechanics that the callback
@@ -849,7 +831,7 @@ describe('refreshSession semantics', () => {
})
/*
* Fix 1: the resume/login factories must snapshot the RETURNED account AFTER
* The resume/login factories must snapshot the returned account after
* the prep awaits, not before. A 401 during prep triggers PasswordSession's
* internal auto-refresh (rotating BOTH tokens and firing an onUpdated the
* disarmed latch drops); an early snapshot would persist the stale refreshJwt,
@@ -860,12 +842,11 @@ describe('refreshSession semantics', () => {
* `prefetchAgeAssuranceServerData` (a genuine prep await in each factory) run
* a real `session.refresh()`. The factory itself is re-required inside
* `jest.isolateModulesAsync` AFTER overriding `globalThis.fetch`, because
* session-core captures `globalThis.fetch` into `networkAwareFetch` at module
* load - and that captured fetch is what PasswordSession's auto-refresh routes
* through.
* the network leaf captures `globalThis.fetch` at module load - and that
* captured fetch is what PasswordSession's auto-refresh routes through.
*/
describe('factory account snapshot is taken AFTER prep (fix 1)', () => {
/** Load a fresh session-core whose networkAwareFetch captures `fetch`. */
describe('factory account snapshot after preparation', () => {
/** Load a fresh factory graph whose network leaf captures `fetch`. */
async function withFreshFactory(
fetch: typeof globalThis.fetch,
run: (core: typeof import('../session-core')) => Promise<void>,
@@ -924,9 +905,8 @@ describe('factory account snapshot is taken AFTER prep (fix 1)', () => {
it('resume: returned account falls back to the stored account when the fast path yields no live token change', async () => {
/*
* Control: no mid-prep refresh. The re-snapshot still reflects the (still
* valid) stored tokens, confirming the moved snapshot did not regress the
* happy path.
* With no mid-prep refresh, the snapshot still reflects the valid stored
* tokens.
*/
mockConfigureModerationForAccount.mockReturnValueOnce(undefined)
const fetchMock = makeMockFetch()
@@ -941,89 +921,3 @@ describe('factory account snapshot is taken AFTER prep (fix 1)', () => {
})
})
})
/*
* Fix 1: the pure decision behind the cross-tab expiry rescue. Given the dying
* session's refreshJwt and a preference-ordered list of "latest known"
* candidates, it picks the first candidate that is a usable, strictly-newer,
* not-already-failed generation - or undefined (fall through to logout).
*/
describe('pickExpiryRescueCandidate', () => {
it('picks a candidate whose refreshJwt differs from the dying one', () => {
const fresh = makeAccount({refreshJwt: 'refresh-jwt-2'})
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [fresh],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(fresh)
})
it('rejects a candidate carrying the dying refreshJwt (equally dead)', () => {
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [makeAccount({refreshJwt: 'refresh-jwt-1'})],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(undefined)
})
it('rejects a candidate with no refreshJwt', () => {
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [makeAccount({refreshJwt: undefined}), undefined],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(undefined)
})
it('rejects a candidate already recorded as failed (loop guard)', () => {
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [makeAccount({refreshJwt: 'refresh-jwt-2'})],
failedRefreshJwts: new Set(['refresh-jwt-2']),
})
expect(picked).toBe(undefined)
})
it('tries candidates in order, preferring the first qualifying one', () => {
const persistedCandidate = makeAccount({
refreshJwt: 'refresh-jwt-persisted',
})
const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'})
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
candidates: [persistedCandidate, reducerCandidate],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(persistedCandidate)
})
it('skips an unusable first candidate and falls back to a later one', () => {
const reducerCandidate = makeAccount({refreshJwt: 'refresh-jwt-reducer'})
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-1',
/* first candidate is the dying token; second is genuinely newer */
candidates: [
makeAccount({refreshJwt: 'refresh-jwt-1'}),
reducerCandidate,
],
failedRefreshJwts: new Set(),
})
expect(picked).toBe(reducerCandidate)
})
it('gives up once the failed-generation set hits the hard cap', () => {
const failed = new Set<string>()
for (let i = 0; i < MAX_EXPIRY_RESCUE_GENERATIONS; i++) {
failed.add(`refresh-jwt-failed-${i}`)
}
const picked = pickExpiryRescueCandidate({
dyingRefreshJwt: 'refresh-jwt-dying',
/* a genuinely newer candidate exists, but the budget is exhausted */
candidates: [makeAccount({refreshJwt: 'refresh-jwt-brand-new'})],
failedRefreshJwts: failed,
})
expect(picked).toBe(undefined)
})
})
File diff suppressed because it is too large Load Diff
+30 -39
View File
@@ -1,4 +1,4 @@
import {type Client} from '@atproto/lex'
import {type Agent, type Client} from '@atproto/lex'
import {type PasswordSession} from '@atproto/lex-password-session'
import {
@@ -7,7 +7,7 @@ import {
PUBLIC_BSKY_SERVICE,
} from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import {networkAwareFetch} from './session-core'
import {networkAwareFetch} from './network'
/**
* Lazily-constructed unauthenticated client pointed at the public appview. It
@@ -15,10 +15,10 @@ import {networkAwareFetch} from './session-core'
*/
let publicClient: Client | undefined
export function getPublicLexClient(): Client {
export function getPublicAppviewClient(): Client {
/*
* Pass networkAwareFetch so the unauthenticated public path feeds the same
* reachability signal as the session-backed clients (see session-core).
* reachability signal as the session-backed clients.
*/
publicClient ??= createLexClient({
service: PUBLIC_BSKY_SERVICE,
@@ -37,8 +37,8 @@ export function getPublicLexClient(): Client {
* env-configurable `CHAT_PROXY_DID` (via `EXPO_PUBLIC_CHAT_PROXY_DID`) rather
* than the hard-coded SDK constant, so it can be retargeted per environment.
*/
export function buildChatClient(session: PasswordSession): Client {
return createLexClient(session, {service: CHAT_PROXY_SERVICE})
export function buildChatClient(agent: Agent): Client {
return createLexClient(agent, {service: CHAT_PROXY_SERVICE})
}
/** Thrown when a write/auth-only client is used with no active session. */
@@ -61,7 +61,7 @@ export class NotAuthenticatedError extends Error {
*/
let unauthedClient: Client | undefined
export function getUnauthenticatedClient(): Client {
export function getUnauthenticatedThrowingClient(): Client {
unauthedClient ??= createLexClient({
did: undefined,
fetchHandler: () => {
@@ -72,29 +72,8 @@ export function getUnauthenticatedClient(): Client {
}
/**
* 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).
* Build the signed-in appview client. Raw calls inherit the configured appview
* proxy; record helpers still target the account host by default.
*
* The Bluesky moderation labeler (`api.moderation.did`) is deliberately NOT
* listed in `labelerDids` - it must flow only through the global
@@ -104,20 +83,32 @@ export function getUnauthenticatedClient(): Client {
* 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 buildBskyClient(
session: PasswordSession,
export function buildAppviewClient(
agent: Agent,
labelerDids: string[],
): Client {
return createLexClient(session, {
return createLexClient(agent, {
service: BLUESKY_PROXY_HEADER.get(),
labelers: labelerDids as `did:${string}:${string}`[],
})
}
/**
* Unauthenticated lex {@link Client} for public appview reads. A process-wide
* singleton, so its identity is stable across renders.
*/
export function usePublicLexClient(): Client {
return getPublicLexClient()
/** Build the signed-in account-host client with no service proxy. */
export function buildPdsClient(agent: Agent): Client {
return createLexClient(agent)
}
/** Route client requests to a PDS while retaining the session's auth lifecycle. */
export function routeSessionToPds(
session: PasswordSession,
pdsUrl: string,
): Agent {
return {
did: session.did,
fetchHandler(path, init) {
const url = new URL(path, pdsUrl).href
// PasswordSession preserves absolute inputs while applying auth/refresh.
return session.fetchHandler(url, init)
},
}
}
+217
View File
@@ -0,0 +1,217 @@
import {TID} from '@atproto/common-web'
import {type Client} from '@atproto/lex'
import {PasswordSession} from '@atproto/lex-password-session'
import {toDatetimeString} from '@atproto/syntax'
import {
overwriteSavedFeeds,
setPersonalDetails,
upsertProfile,
} from '@bsky.app/sdk'
import {networkRetry} from '#/lib/async/retry'
import {
DISCOVER_SAVED_FEED,
IS_PROD_SERVICE,
TIMELINE_SAVED_FEED,
} from '#/lib/constants'
import {logger} from '#/logger'
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
prefetchAgeAssuranceServerData,
setBirthdateForDid,
setCreatedAtForDid,
} from '#/ageAssurance/data'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {features} from '#/analytics'
import {type app} from '#/lexicons'
import {configureModerationForAccount} from './moderation'
import {
buildBundle,
makeSessionHooks,
type OnSessionChange,
registerBundleKillSwitch,
type SessionBundle,
sessionDataToSessionAccountOrThrow,
} from './session-core'
import {type SessionAccount} from './types'
/** Create an account, prepare its session, and start post-signup writes. */
export async function createSessionBundleAndCreateAccount(
{
service,
email,
password,
handle,
birthDate,
inviteCode,
verificationPhone,
verificationCode,
}: {
service: string
email: string
password: string
handle: string
birthDate: Date
inviteCode?: string
verificationPhone?: string
verificationCode?: string
},
onSessionChange: OnSessionChange,
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
let bundle!: SessionBundle
let accountDid = ''
const hooks = makeSessionHooks(
onSessionChange,
() => bundle,
() => accountDid,
)
const session = await PasswordSession.createAccount(
{
email,
password,
/* the lexicon types handle as `${string}.${string}`; user input is a plain string */
handle: handle as `${string}.${string}`,
inviteCode,
verificationPhone,
verificationCode,
},
{...hooks, service},
)
bundle = buildBundle(session)
registerBundleKillSwitch(bundle, hooks.kill)
// Seed the hook and the deferred writes with refresh-stable account fields.
const earlyAccount = sessionDataToSessionAccountOrThrow(session)
accountDid = earlyAccount.did
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
configureModerationForAccount(bundle, earlyAccount)
const createdAt = toDatetimeString(new Date())
const birthdate = birthDate.toISOString()
/*
* Since we have a race with account creation, profile creation, and AA
* state, set these values locally to ensure sync reads. Values are written
* to the server in the next step, so on subsequent reloads, the server will
* be the source of truth.
*/
setCreatedAtForDid({did: earlyAccount.did, createdAt})
setBirthdateForDid({did: earlyAccount.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(earlyAccount.did)
// Start the prefetch after seeding its synchronous birthdate inputs.
const aa = prefetchAgeAssuranceServerData({
appviewClient: bundle.appviewClient,
accountClient: bundle.pdsClient,
})
const isProd = Boolean(IS_PROD_SERVICE(service))
const postSignupTasks: Promise<unknown>[] = [
savePersonalDetails(bundle.pdsClient, birthDate),
initializeProfile(bundle.pdsClient, {handle, createdAt, isProd}),
]
if (isProd) {
postSignupTasks.push(
initializeSavedFeeds(bundle.pdsClient),
restrictChatAfterAgeAssurance(aa, bundle.pdsClient, earlyAccount.did),
)
}
// Post-signup writes are not required to enter onboarding.
void reportPostSignupFailures(postSignupTasks)
try {
// snooze first prompt after signup, defer to next prompt
snoozeEmailConfirmationPrompt()
} catch (e) {
logger.error(e instanceof Error ? e : String(e), {
message: `session: failed snoozeEmailConfirmationPrompt`,
})
}
await Promise.all([gates, aa])
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = sessionDataToSessionAccountOrThrow(session)
hooks.arm()
return {account, bundle}
}
function savePersonalDetails(client: Client, birthDate: Date) {
return retryPostSignupTask('set birthDate', 3, () =>
client.call(setPersonalDetails, {birthDate}),
)
}
function initializeProfile(
client: Client,
{
handle,
createdAt,
isProd,
}: {
handle: string
createdAt: ReturnType<typeof toDatetimeString>
isProd: boolean
},
) {
return retryPostSignupTask('set initial profile', 3, () =>
client.call(upsertProfile, prev => {
const next: Partial<app.bsky.actor.profile.Main> = prev || {}
if (isProd) {
next.displayName = handle
next.createdAt = createdAt
} else {
next.createdAt = prev?.createdAt || toDatetimeString(new Date())
}
return next
}),
)
}
function initializeSavedFeeds(client: Client) {
return retryPostSignupTask('set initial feeds', 1, () =>
client.call(overwriteSavedFeeds, [
{...DISCOVER_SAVED_FEED, id: TID.nextStr()},
{...TIMELINE_SAVED_FEED, id: TID.nextStr()},
]),
)
}
function restrictChatAfterAgeAssurance(
ageAssurance: Promise<unknown>,
client: Client,
did: string,
) {
return ageAssurance.then(() => {
const {flags} = unsafeGetAndComputeAgeAssurance({did})
if (flags?.chatDisabled || flags?.groupChatDisabled) {
void restrictChatSettings({
client,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
}
})
}
function retryPostSignupTask<T>(
description: string,
retries: number,
task: () => Promise<T>,
) {
return networkRetry(retries, task).catch(e => {
logger.info(`createSessionBundleAndCreateAccount: failed to ${description}`)
throw e
})
}
async function reportPostSignupFailures(tasks: Promise<unknown>[]) {
const results = await Promise.allSettled(tasks)
if (results.some(result => result.status === 'rejected')) {
logger.error(
`session: createSessionBundleAndCreateAccount failed to save post-signup settings`,
)
}
}
+30
View File
@@ -0,0 +1,30 @@
import {type SessionAccount} from './types'
/** Maximum failed token generations considered during one expiry rescue. */
export const MAX_EXPIRY_RESCUE_GENERATIONS = 5
/** Pick the first unfailed token generation newer than the one that expired. */
export function pickExpiryRescueCandidate({
dyingRefreshJwt,
candidates,
failedRefreshJwts,
}: {
dyingRefreshJwt: string
candidates: (SessionAccount | undefined)[]
failedRefreshJwts: ReadonlySet<string>
}): SessionAccount | undefined {
if (failedRefreshJwts.size >= MAX_EXPIRY_RESCUE_GENERATIONS) {
return undefined
}
for (const candidate of candidates) {
const refreshJwt = candidate?.refreshJwt
if (
refreshJwt &&
refreshJwt !== dyingRefreshJwt &&
!failedRefreshJwts.has(refreshJwt)
) {
return candidate
}
}
return undefined
}
+87 -176
View File
@@ -9,7 +9,7 @@ import {
useSyncExternalStore,
} from 'react'
import {type Client} from '@atproto/lex'
import {PasswordSession, type SessionData} from '@atproto/lex-password-session'
import {type SessionData} from '@atproto/lex-password-session'
import * as persisted from '#/state/persisted'
import {useCloseAllActiveElements} from '#/state/util'
@@ -18,21 +18,20 @@ import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
import {IS_WEB} from '#/env'
import {com} from '#/lexicons'
import {emitSessionDropped} from '../events'
import {getPublicLexClient, getUnauthenticatedClient} from './clients'
import {configureModerationForAccount} from './moderation'
import {
getPublicAppviewClient,
getUnauthenticatedThrowingClient,
} from './clients'
import {createSessionBundleAndCreateAccount} from './create-account'
import {pickExpiryRescueCandidate} from './expiry-rescue'
import {type Action, getInitialState, reducer, type State} from './reducer'
import {
type AtpSessionEvent,
buildBundle,
createSessionBundleAndCreateAccount,
createSessionBundleAndLogin,
createSessionBundleAndResume,
createSessionBundleFromStoredAccount,
disposeBundle,
makeSessionHooks,
pickExpiryRescueCandidate,
type PublicSessionBundle,
registerBundleKillSwitch,
sessionAccountToSessionData,
type SessionBundle,
sessionDataToSessionAccount,
} from './session-core'
@@ -58,12 +57,7 @@ const StateContext = createContext<SessionStateContext>({
})
StateContext.displayName = 'SessionStateContext'
/**
* Holds the full {@link SessionBundle} (or the logged-out
* {@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.
*/
/** Active account bundle, or the public bundle when logged out. */
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
null,
)
@@ -112,7 +106,7 @@ class SessionStore {
const persistedData = {
accounts: nextState.accounts,
currentAccount: nextState.accounts.find(
a => a.did === nextState.currentAgentState.did,
a => a.did === nextState.currentBundleState.did,
),
}
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
@@ -174,14 +168,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
*/
if (sessionEvent === 'expired') {
const current = store.getState()
const currentAgent = current.currentAgentState.agent as unknown as
const currentBundle = current.currentBundleState.bundle as unknown as
| SessionBundle
| PublicSessionBundle
const dyingRefreshJwt = sessionData?.refreshJwt
// Stale bundle events are handled by the reducer's identity guard.
if (
currentAgent === bundle &&
current.currentAgentState.did === accountDid &&
currentBundle === bundle &&
current.currentBundleState.did === accountDid &&
dyingRefreshJwt
) {
let failedSet = failedExpiryTokensRef.current.get(accountDid)
@@ -204,47 +198,33 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
})
if (candidate) {
let newBundle!: SessionBundle
const hooks = makeSessionHooks(
const rebuilt = createSessionBundleFromStoredAccount(
candidate,
onSessionChangeRef.current!,
() => newBundle,
() => candidate.did,
)
const newSession = new PasswordSession(
sessionAccountToSessionData(candidate),
hooks,
)
newBundle = buildBundle(newSession)
registerBundleKillSwitch(newBundle, hooks.kill)
configureModerationForAccount(newBundle, candidate)
const newAccount = newBundle.session.destroyed
? candidate
: (sessionDataToSessionAccount(
newBundle.session.session,
newBundle.session.session.service,
) ?? candidate)
hooks.arm()
store.dispatch({
type: 'replaced-current-bundle',
newAgent: newBundle,
newAccount,
})
return
if (rebuilt) {
store.dispatch({
type: 'replaced-current-bundle',
newBundle: rebuilt.bundle,
newAccount: rebuilt.account,
})
return
}
}
}
}
// Only the current bundle may report that its session was dropped.
if (
(sessionEvent === 'expired' || sessionEvent === 'create-failed') &&
store.getState().currentAgentState.agent === bundle
sessionEvent === 'expired' &&
store.getState().currentBundleState.bundle === bundle
) {
emitSessionDropped()
}
// Bundle identity prevents stale sessions from changing the active account.
store.dispatch({
type: 'received-agent-event',
agent: bundle,
type: 'received-session-event',
bundle,
refreshedAccount,
accountDid,
sessionEvent,
@@ -269,7 +249,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
store.dispatch({
type: 'switched-to-account',
newAgent: bundle,
newBundle: bundle,
newAccount: account,
})
ax.metric('account:create:success', metrics, {
@@ -294,7 +274,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
store.dispatch({
type: 'switched-to-account',
newAgent: bundle,
newBundle: bundle,
newAccount: account,
})
ax.metric(
@@ -323,17 +303,17 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{
session: utils.accountToSessionMetadata(
prevState.accounts.find(
a => a.did === prevState.currentAgentState.did,
a => a.did === prevState.currentBundleState.did,
),
),
},
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
if (prevState.currentAgentState.did) {
if (prevState.currentBundleState.did) {
clearAgeAssuranceServerDataForDid({
did: prevState.currentAgentState.did,
did: prevState.currentBundleState.did,
})
void clearPersistedQueryStorage(prevState.currentAgentState.did)
void clearPersistedQueryStorage(prevState.currentBundleState.did)
}
// reset onboarding flow on logout
onboardingDispatch({type: 'skip'})
@@ -357,7 +337,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
{
session: utils.accountToSessionMetadata(
prevState.accounts.find(
a => a.did === prevState.currentAgentState.did,
a => a.did === prevState.currentBundleState.did,
),
),
},
@@ -404,7 +384,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
store.dispatch({
type: 'switched-to-account',
newAgent: bundle,
newBundle: bundle,
newAccount: account,
})
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
@@ -419,14 +399,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const partialRefreshSession = useCallback<
SessionApiContext['partialRefreshSession']
>(async () => {
const bundle = state.currentAgentState.agent as unknown as SessionBundle
const bundle = state.currentBundleState.bundle as unknown as SessionBundle
const signal = cancelPendingTask()
/* getSession targets the PDS; only the persisted account fields are patched. */
const data = await bundle.bskyClient.call(
com.atproto.server.getSession,
{},
{service: null},
)
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
if (signal.aborted) return
store.dispatch({
type: 'partial-refresh-session',
@@ -441,7 +417,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const refreshSession = useCallback<
SessionApiContext['refreshSession']
>(async () => {
const bundle = store.getState().currentAgentState.agent as unknown as
const bundle = store.getState().currentBundleState.bundle as unknown as
| SessionBundle
| PublicSessionBundle
if (!bundle.session) return undefined // logged out: nothing to refresh
@@ -491,12 +467,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
: undefined
if (
syncedDid === undefined &&
state.currentAgentState.did !== undefined
state.currentBundleState.did !== undefined
) {
cancelPendingTask()
}
if (syncedAccount && syncedAccount.refreshJwt) {
if (syncedAccount.did !== state.currentAgentState.did) {
if (syncedAccount.did !== state.currentBundleState.did) {
// The leader refreshes before broadcasting, so followers receive fresh tokens.
void resumeSession(syncedAccount)
} else {
@@ -504,7 +480,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
* PasswordSession cannot be patched in place. Rebuild from the tokens
* the leader already refreshed, then dispose the previous bundle.
*/
const prevBundle = state.currentAgentState.agent as unknown as
const prevBundle = state.currentBundleState.bundle as unknown as
| SessionBundle
| PublicSessionBundle
// Avoid replacing the live bundle for an unrelated account update.
@@ -519,56 +495,39 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) {
return
}
let newBundle!: SessionBundle
const hooks = makeSessionHooks(
const rebuilt = createSessionBundleFromStoredAccount(
syncedAccount,
onSessionChange,
() => newBundle,
() => syncedAccount.did,
newBundle => {
const current = store.getState()
const latestAccount = current.accounts.find(
account => account.did === syncedAccount.did,
)
const isCurrent =
current.currentBundleState.bundle === prevBundle &&
latestAccount?.accessJwt === syncedAccount.accessJwt &&
latestAccount?.refreshJwt === syncedAccount.refreshJwt
if (isCurrent) {
addSessionDebugLog({
type: 'bundle:patch',
bundle: newBundle,
prevSession:
prevBundle.session && !prevBundle.session.destroyed
? prevBundle.session.session
: undefined,
nextSession: newBundle.session.session,
})
}
return isCurrent
},
)
const newSession = new PasswordSession(
sessionAccountToSessionData(syncedAccount),
hooks,
)
newBundle = buildBundle(newSession)
registerBundleKillSwitch(newBundle, hooks.kill)
// Apply cached labelers before the new session is armed and installed.
configureModerationForAccount(newBundle, syncedAccount)
/*
* If this path becomes asynchronous, do not let a stale rebuild
* replace a newer bundle or token generation.
*/
const current = store.getState()
const latestAccount = current.accounts.find(
account => account.did === syncedAccount.did,
)
if (
current.currentAgentState.agent !== prevBundle ||
latestAccount?.accessJwt !== syncedAccount.accessJwt ||
latestAccount?.refreshJwt !== syncedAccount.refreshJwt
) {
// This bundle was never installed, so the normal disposal effect cannot run.
disposeBundle(newBundle)
if (!rebuilt) {
return
}
addSessionDebugLog({
type: 'agent:patch',
agent: newBundle,
prevSession:
prevBundle.session && !prevBundle.session.destroyed
? prevBundle.session.session
: undefined,
nextSession: newBundle.session.session,
})
const newAccount = newBundle.session.destroyed
? syncedAccount
: (sessionDataToSessionAccount(
newBundle.session.session,
newBundle.session.session.service,
) ?? syncedAccount)
hooks.arm()
const {bundle: newBundle, account: newAccount} = rebuilt
store.dispatch({
type: 'replaced-current-bundle',
newAgent: newBundle,
newBundle,
newAccount,
})
}
@@ -580,9 +539,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({
accounts: state.accounts,
currentAccount: state.accounts.find(
a => a.did === state.currentAgentState.did,
a => a.did === state.currentBundleState.did,
),
hasSession: !!state.currentAgentState.did,
hasSession: !!state.currentBundleState.did,
}),
[state],
)
@@ -610,7 +569,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
],
)
const bundle = state.currentAgentState.agent as unknown as
const bundle = state.currentBundleState.bundle as unknown as
| SessionBundle
| PublicSessionBundle
@@ -624,9 +583,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const prevBundle = currentBundleRef.current
currentBundleRef.current = bundle
addSessionDebugLog({
type: 'agent:switch',
prevAgent: prevBundle,
nextAgent: bundle,
type: 'bundle:switch',
prevBundle,
nextBundle: bundle,
})
// Replaced bundles must never consume another refresh token.
disposeBundle(prevBundle)
@@ -690,91 +649,43 @@ export function useRequireAuth() {
}
/**
* 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?.bskyClient ?? getPublicLexClient()
}
/**
* Alias of {@link useLexClient}: the authenticated merged Bluesky client for the
* active account, falling back to the public read client when logged out.
* Client for appview reads. Falls back to the public client when logged out.
*/
export function useAppviewClient(): Client {
const bundle = useContext(BundleContext)
return bundle?.bskyClient ?? getPublicLexClient()
return bundle?.appviewClient ?? getPublicAppviewClient()
}
/**
* 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`. 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.
* Client for account-host requests. It shares the active auth session but has
* no service proxy, so raw calls and record helpers both target the PDS.
* Logged out, calls throw `NotAuthenticatedError` before network I/O. Use
* {@link useMaybePdsClient} when the caller must branch on authentication.
*/
export function usePdsClient(): Client {
const bundle = useContext(BundleContext)
return bundle?.session ? bundle.bskyClient : getUnauthenticatedClient()
return bundle?.pdsClient ?? getUnauthenticatedThrowingClient()
}
/**
* The chat lex {@link Client} for the active account. `chat.bsky.*` calls go
* here - proxied to `did:web:api.bsky.chat#bsky_chat`.
*
* Logged out, returns a stable client ({@link getUnauthenticatedClient}) that
* throws `NotAuthenticatedError` before any network I/O. Chat is meaningless
* logged out, so this must NOT fall back to the public appview. To branch on
* auth state, use {@link useMaybeChatClient} instead.
* Client for `chat.bsky.*` calls. Logged-out calls throw
* `NotAuthenticatedError`; use {@link useMaybeChatClient} to branch on auth.
*/
export function useChatClient(): Client {
const bundle = useContext(BundleContext)
return bundle?.chatClient ?? getUnauthenticatedClient()
return bundle?.chatClient ?? getUnauthenticatedThrowingClient()
}
/**
* 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
* {@link usePdsClient} for the common case; do NOT reach for this hook merely to
* dodge the throwing client's `NotAuthenticatedError`.
* Account-host client for the active session, or `null` when logged out.
*/
export function useMaybePdsClient(): Client | null {
const bundle = useContext(BundleContext)
return bundle?.session ? bundle.bskyClient : null
return bundle?.session ? bundle.pdsClient : null
}
/**
* The chat lex {@link Client} for the active account, 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. Prefer {@link useChatClient} for the common case; do NOT reach for
* this hook merely to dodge the throwing client's `NotAuthenticatedError`.
* Chat client for the active session, or `null` when logged out.
*/
export function useMaybeChatClient(): Client | null {
const bundle = useContext(BundleContext)
+6 -7
View File
@@ -1,7 +1,6 @@
import {type Schema} from '../persisted'
import {type Action, type State} from './reducer'
import {type AtpSessionEvent} from './session-core'
import {type SessionAccount} from './types'
import {type AtpSessionEvent, type SessionAccount} from './types'
type Reducer = (state: State, action: Action) => State
@@ -45,9 +44,9 @@ type Log =
data: Schema['session']
}
| {
type: 'agent:switch'
prevAgent: object
nextAgent: object
type: 'bundle:switch'
prevBundle: object
nextBundle: object
}
| {
/*
@@ -55,8 +54,8 @@ type Log =
* (the reducer never reads its internals); the session snapshots are
* plain objects captured for debugging.
*/
type: 'agent:patch'
agent: object
type: 'bundle:patch'
bundle: object
prevSession: object | undefined
nextSession: object | undefined
}
+6 -5
View File
@@ -5,9 +5,10 @@ import {IS_TEST_USER} from '#/lib/constants'
import {com} from '#/lexicons'
import {account as accountStorage} from '#/storage'
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
import {type SessionBundle} from './session-core'
import {type SessionAccount} from './types'
type ModerationSession = {appviewClient: Client}
/**
* Set the global app labelers on the lex `Client` static so every client emits
* the same `;redact` moderation authorities.
@@ -56,7 +57,7 @@ export function configureModerationForGuest() {
/** Configure global authorities and cached account subscriptions. */
export function configureModerationForAccount(
bundle: SessionBundle,
bundle: ModerationSession,
account: SessionAccount,
) {
switchToBskyAppLabeler()
@@ -67,7 +68,7 @@ export function configureModerationForAccount(
const labelerDids = readLabelers(account.did)
if (labelerDids) {
applyLabelersToClient(bundle.bskyClient, labelerDids)
applyLabelersToClient(bundle.appviewClient, labelerDids)
} else {
// The preferences query populates the cache after the initial requests.
}
@@ -80,9 +81,9 @@ function switchToBskyAppLabeler() {
}
/** Resolve and install the test environment's moderation authority. */
async function trySwitchToTestAppLabeler(bundle: SessionBundle) {
async function trySwitchToTestAppLabeler(bundle: ModerationSession) {
const did = (
await bundle.bskyClient
await bundle.appviewClient
.call(com.atproto.identity.resolveHandle, {handle: 'mod-authority.test'})
.catch(_ => undefined)
)?.did
+23
View File
@@ -0,0 +1,23 @@
import {emitNetworkConfirmed, emitNetworkLost} from '#/state/events'
/*
* Captured once at module load so the wrapper below is immune to later
* monkey-patching of globalThis.fetch.
*/
const realFetch = globalThis.fetch
/**
* Fetch wrapper that reports network reachability to the app-wide event bus.
* Any resolved response (including HTTP errors) confirms the network is up; a
* thrown error (DNS failure, timeout, offline) reports it as lost.
*/
export const networkAwareFetch: typeof fetch = async (...args) => {
try {
const res = await realFetch(...args)
emitNetworkConfirmed()
return res
} catch (e) {
emitNetworkLost()
throw e
}
}
+38 -38
View File
@@ -10,34 +10,34 @@ type OpaqueSessionBundle = {
readonly service: URL
}
type AgentState = {
readonly agent: OpaqueSessionBundle
type BundleState = {
readonly bundle: OpaqueSessionBundle
readonly did: string | undefined
}
export type State = {
readonly accounts: SessionAccount[]
readonly currentAgentState: AgentState
readonly currentBundleState: BundleState
needsPersist: boolean // Cleared after persistence is scheduled.
}
export type Action =
| {
type: 'received-agent-event'
agent: OpaqueSessionBundle
type: 'received-session-event'
bundle: OpaqueSessionBundle
accountDid: string
refreshedAccount: SessionAccount | undefined
sessionEvent: AtpSessionEvent
}
| {
type: 'switched-to-account'
newAgent: OpaqueSessionBundle
newBundle: OpaqueSessionBundle
newAccount: SessionAccount
}
| {
// Replace an immutable session from synced or rescued tokens without rebroadcasting.
type: 'replaced-current-bundle'
newAgent: OpaqueSessionBundle
newBundle: OpaqueSessionBundle
newAccount: SessionAccount
}
| {
@@ -61,9 +61,9 @@ export type Action =
patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
}
function createPublicAgentState(): AgentState {
function createPublicBundleState(): BundleState {
return {
agent: createPublicSessionBundle(),
bundle: createPublicSessionBundle(),
did: undefined,
}
}
@@ -71,16 +71,16 @@ function createPublicAgentState(): AgentState {
export function getInitialState(persistedAccounts: SessionAccount[]): State {
return {
accounts: persistedAccounts,
currentAgentState: createPublicAgentState(),
currentBundleState: createPublicBundleState(),
needsPersist: false,
}
}
let reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'received-agent-event': {
const {agent, accountDid, refreshedAccount, sessionEvent} = action
if (agent !== state.currentAgentState.agent) {
case 'received-session-event': {
const {bundle, accountDid, refreshedAccount, sessionEvent} = action
if (bundle !== state.currentBundleState.bundle) {
/*
* Stale bundles must neither log out the current account nor restore
* tokens after logout or an account switch.
@@ -115,33 +115,33 @@ let reducer = (state: State, action: Action): State => {
return a
}
}),
currentAgentState: refreshedAccount
? state.currentAgentState
: createPublicAgentState(), // Log out if expired.
currentBundleState: refreshedAccount
? state.currentBundleState
: createPublicBundleState(), // Log out if expired.
needsPersist: true,
}
}
case 'switched-to-account': {
const {newAccount, newAgent} = action
const {newAccount, newBundle} = action
return {
accounts: [
newAccount,
...state.accounts.filter(a => a.did !== newAccount.did),
],
currentAgentState: {
currentBundleState: {
did: newAccount.did,
agent: newAgent,
bundle: newBundle,
},
needsPersist: true,
}
}
case 'replaced-current-bundle': {
const {newAgent, newAccount} = action
const {newBundle, newAccount} = action
return {
...state,
currentAgentState: {
did: state.currentAgentState.did,
agent: newAgent,
currentBundleState: {
did: state.currentBundleState.did,
bundle: newBundle,
},
accounts: state.accounts.map(a =>
a.did === newAccount.did ? newAccount : a,
@@ -155,7 +155,7 @@ let reducer = (state: State, action: Action): State => {
const account = state.accounts.find(a => a.did === accountDid)
if (account) {
createTemporaryClientsAndResume([account])
.then(agents => unregisterPushToken(agents))
.then(clients => unregisterPushToken(clients))
.then(() =>
logger.debug('Push token unregistered', {did: accountDid}),
)
@@ -169,20 +169,20 @@ let reducer = (state: State, action: Action): State => {
return {
accounts: state.accounts.filter(a => a.did !== accountDid),
currentAgentState:
state.currentAgentState.did === accountDid
? createPublicAgentState() // Log out if removing the current one.
: state.currentAgentState,
currentBundleState:
state.currentBundleState.did === accountDid
? createPublicBundleState() // Log out if removing the current one.
: state.currentBundleState,
needsPersist: true,
}
}
case 'logged-out-current-account': {
const {currentAgentState} = state
const accountDid = currentAgentState.did
const {currentBundleState} = state
const accountDid = currentBundleState.did
const account = state.accounts.find(a => a.did === accountDid)
if (account && accountDid) {
createTemporaryClientsAndResume([account])
.then(agents => unregisterPushToken(agents))
.then(clients => unregisterPushToken(clients))
.then(() =>
logger.debug('Push token unregistered', {did: accountDid}),
)
@@ -204,13 +204,13 @@ let reducer = (state: State, action: Action): State => {
}
: a,
),
currentAgentState: createPublicAgentState(),
currentBundleState: createPublicBundleState(),
needsPersist: true,
}
}
case 'logged-out-every-account': {
createTemporaryClientsAndResume(state.accounts)
.then(agents => unregisterPushToken(agents))
.then(clients => unregisterPushToken(clients))
.then(() => logger.debug('Push token unregistered'))
.catch(err => {
logger.error('Failed to unregister push token', {
@@ -225,7 +225,7 @@ let reducer = (state: State, action: Action): State => {
refreshJwt: undefined,
accessJwt: undefined,
})),
currentAgentState: createPublicAgentState(),
currentBundleState: createPublicBundleState(),
needsPersist: true,
}
}
@@ -233,10 +233,10 @@ let reducer = (state: State, action: Action): State => {
const {syncedAccounts, syncedCurrentDid} = action
return {
accounts: syncedAccounts,
currentAgentState:
syncedCurrentDid === state.currentAgentState.did
? state.currentAgentState
: createPublicAgentState(), // Log out if different user.
currentBundleState:
syncedCurrentDid === state.currentBundleState.did
? state.currentBundleState
: createPublicBundleState(), // Log out if different user.
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
}
}
+88 -445
View File
@@ -1,245 +1,42 @@
import {TID} from '@atproto/common-web'
import {type Client} from '@atproto/lex'
import {
PasswordSession,
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {toDatetimeString} from '@atproto/syntax'
import {
overwriteSavedFeeds,
setPersonalDetails,
upsertProfile,
} from '@bsky.app/sdk'
import {jwtDecode} from 'jwt-decode'
import {networkRetry} from '#/lib/async/retry'
import {
BSKY_SERVICE,
DISCOVER_SAVED_FEED,
IS_PROD_SERVICE,
PUBLIC_BSKY_SERVICE,
TIMELINE_SAVED_FEED,
} from '#/lib/constants'
import {hasProp} from '#/lib/type-guards'
import {logger} from '#/logger'
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {emitNetworkConfirmed, emitNetworkLost} from '#/state/events'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
prefetchAgeAssuranceServerData,
setBirthdateForDid,
setCreatedAtForDid,
} from '#/ageAssurance/data'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data'
import {features} from '#/analytics'
import {type app} from '#/lexicons'
import {
buildBskyClient,
buildAppviewClient,
buildChatClient,
getPublicLexClient,
getUnauthenticatedClient,
buildPdsClient,
getPublicAppviewClient,
getUnauthenticatedThrowingClient,
routeSessionToPds,
} from './clients'
import {addSessionErrorLog} from './logging'
import {
configureModerationForAccount,
configureModerationForGuest,
} from './moderation'
import {type SessionAccount} from './types'
import {isSessionExpired} from './util'
import {networkAwareFetch} from './network'
import {
isSessionExpired,
sessionAccountToSessionData,
sessionDataToSessionAccount,
} from './session-data'
import {type AtpSessionEvent, type SessionAccount} from './types'
/**
* The session-change events the reducer/logging/tests speak. In production only
* `'update'`/`'expired'`/`'network-error'` are ever emitted from
* {@link makeSessionHooks}; `'create'`/`'create-failed'` exist only for the
* reducer and the session tests.
*/
export type AtpSessionEvent =
| 'create'
| 'create-failed'
| 'update'
| 'expired'
| 'network-error'
/**
* Whether an access token was issued for a queued (waitlisted) signup rather
* than a full session.
*/
export function isSignupQueued(accessJwt: string | undefined) {
if (accessJwt) {
const sessData = jwtDecode(accessJwt)
return (
hasProp(sessData, 'scope') &&
sessData.scope === 'com.atproto.signupQueued'
)
}
return false
}
/*
* Captured once at module load so the wrapper below is immune to later
* monkey-patching of globalThis.fetch.
*/
const realFetch = globalThis.fetch
/**
* Fetch wrapper that reports network reachability to the app-wide event bus.
* Any resolved response (including HTTP errors) confirms the network is up; a
* thrown error (DNS failure, timeout, offline) reports it as lost.
*
* Passed as `PasswordSessionOptions.fetch` and as the `fetch` option of
* unauthenticated lex `Client`s, so every network path in the session stack
* feeds the same reachability signal.
*/
export const networkAwareFetch: typeof fetch = async (...args) => {
try {
const res = await realFetch(...args)
emitNetworkConfirmed()
return res
} catch (e) {
emitNetworkLost()
throw e
}
}
/**
* Extract the PDS endpoint URL from a DID document, if present and valid.
*
* Must stay behaviorally identical to `@atproto/lex-password-session`'s private
* `extractPdsUrl` (non-exported, so we reimplement it): `PasswordSession.
* fetchHandler` derives its request origin as `extractPdsUrl(didDoc) ?? service`,
* and we reuse this derivation to persist `pdsUrl` on the account snapshot.
*/
export function extractPdsUrl(didDoc: unknown): string | null {
if (typeof didDoc !== 'object' || didDoc === null) {
return null
}
const services = (didDoc as Record<string, unknown>).service
if (!Array.isArray(services)) {
return null
}
const pds = (services as unknown[]).find(
(s): s is Record<string, unknown> => {
if (typeof s !== 'object' || s === null) {
return false
}
const id = (s as Record<string, unknown>).id
return typeof id === 'string' && id.endsWith('#atproto_pds')
},
)
const ep = pds?.serviceEndpoint
return typeof ep === 'string' && canParseUrl(ep) ? ep : null
}
/*
* URL.canParse is not guaranteed on Hermes / the RN URL polyfill, so fall back
* to a try/catch parse when it is missing.
*/
function canParseUrl(input: string): boolean {
if (typeof URL.canParse === 'function') {
return URL.canParse(input)
}
try {
new URL(input)
return true
} catch {
return false
}
}
/**
* Build a minimal synthetic DID document whose only service entry is the given
* PDS endpoint.
*
* The persisted `SessionAccount` stores `pdsUrl` but `SessionData` routes
* requests via `extractPdsUrl(didDoc) ?? service`. On the non-expired resume
* fast path (no network) we synthesize this doc from the stored `pdsUrl` so the
* very first requests hit the right PDS (entryway accounts have
* service=bsky.social but a different PDS host). After the first refresh,
* `PasswordSession` refetches `getSession` and replaces it with the real doc.
*/
export function synthDidDoc(
did: string,
pdsUrl: string,
): NonNullable<SessionData['didDoc']> {
return {
id: did,
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: pdsUrl,
},
],
}
}
/**
* Convert live `PasswordSession` session data into the persisted
* `SessionAccount` snapshot.
*
* The object literal's field ORDER is load-bearing: the reducer's
* `JSON.stringify` fast path and the session test snapshots depend on
* byte-stable serialization. `service` and `pdsUrl` are normalized through
* `new URL().toString()` for a stable trailing slash.
*
* `pdsUrl` intentionally does NOT fall back to `service`: hosted accounts (no
* didDoc PDS entry) keep `pdsUrl: undefined`.
*/
export function sessionDataToSessionAccount(
session: SessionData | null | undefined,
service: string,
): SessionAccount | undefined {
if (!session) {
return undefined
}
const normalizedService = new URL(service).toString()
const pdsUrl = extractPdsUrl(session.didDoc)
return {
service: normalizedService,
did: session.did,
handle: session.handle,
email: session.email,
emailConfirmed: session.emailConfirmed || false,
emailAuthFactor: session.emailAuthFactor || false,
refreshJwt: session.refreshJwt,
accessJwt: session.accessJwt,
signupQueued: isSignupQueued(session.accessJwt),
active: session.active,
status: session.status,
pdsUrl: pdsUrl ? new URL(pdsUrl).toString() : undefined,
isSelfHosted: !normalizedService.startsWith(BSKY_SERVICE),
}
}
/**
* Convert a persisted `SessionAccount` back into `SessionData` for
* constructing/resuming a `PasswordSession`.
*
* When the account has a stored `pdsUrl`, a synthetic didDoc is injected so
* `PasswordSession` routes requests to the right PDS before its first refresh
* (see {@link synthDidDoc}).
*/
export function sessionAccountToSessionData(
account: SessionAccount,
): SessionData {
return {
accessJwt: account.accessJwt ?? '',
active: account.active ?? true,
did: account.did as SessionData['did'],
...(account.pdsUrl
? {didDoc: synthDidDoc(account.did, account.pdsUrl)}
: {}),
email: account.email,
emailAuthFactor: account.emailAuthFactor,
emailConfirmed: account.emailConfirmed,
handle: account.handle as SessionData['handle'],
refreshJwt: account.refreshJwt ?? '',
status: account.status,
service: account.service,
}
}
export {networkAwareFetch} from './network'
export {
isSignupQueued,
sessionAccountToSessionData,
sessionDataToSessionAccount,
} from './session-data'
export type {AtpSessionEvent} from './types'
function deriveServiceUrl(session: PasswordSession | null): URL {
return new URL(
@@ -252,8 +49,8 @@ function deriveServiceUrl(session: PasswordSession | null): URL {
/** Clients backed by one `PasswordSession`, the bundle's sole auth core. */
export type SessionBundle = {
session: PasswordSession
/** Authed appview client whose record helpers target the account's PDS. */
bskyClient: Client
appviewClient: Client
pdsClient: Client
chatClient: Client
readonly service: URL
}
@@ -273,11 +70,18 @@ export function registerBundleKillSwitch(
bundleKillSwitches.set(bundle, kill)
}
export function buildBundle(session: PasswordSession): SessionBundle {
export function buildBundle(
session: PasswordSession,
storedPdsUrl?: string,
): SessionBundle {
const agent = storedPdsUrl
? routeSessionToPds(session, storedPdsUrl)
: session
return {
session,
bskyClient: buildBskyClient(session, []),
chatClient: buildChatClient(session),
appviewClient: buildAppviewClient(agent, []),
pdsClient: buildPdsClient(agent),
chatClient: buildChatClient(agent),
get service() {
return deriveServiceUrl(session)
},
@@ -288,7 +92,7 @@ export function buildBundle(session: PasswordSession): SessionBundle {
* PasswordSession delivers `sessionData` before updating its live getter. The
* provider uses that payload for rotated tokens and expiry rescue.
*/
type OnSessionChange = (
export type OnSessionChange = (
bundle: SessionBundle,
did: string,
event: AtpSessionEvent,
@@ -313,8 +117,7 @@ export function makeSessionHooks(
}
const did = getDid()
onSessionChange(getBundle(), did, event, sessionData)
// Log the error-ish events ('expired'/'network-error').
if (event !== 'create' && event !== 'update') {
if (event !== 'update') {
addSessionErrorLog(did, event)
}
}
@@ -349,19 +152,21 @@ export function makeSessionHooks(
/** Clients exposed while logged out. */
export type PublicSessionBundle = {
session: null
bskyClient: Client
appviewClient: Client
pdsClient: Client
chatClient: Client
readonly service: URL
}
/** Build the logged-out bundle and configure guest moderation. */
/** Build the logged-out bundle and install its moderation authorities. */
export function createPublicSessionBundle(): PublicSessionBundle {
configureModerationForGuest() // Side effect but only relevant for tests
const publicClient = getPublicLexClient()
configureModerationForGuest()
const publicClient = getPublicAppviewClient()
return {
session: null,
bskyClient: publicClient,
chatClient: getUnauthenticatedClient(),
appviewClient: publicClient,
pdsClient: getUnauthenticatedThrowingClient(),
chatClient: getUnauthenticatedThrowingClient(),
service: new URL(PUBLIC_BSKY_SERVICE),
}
}
@@ -395,20 +200,29 @@ export async function createSessionBundleAndResume(
session = new PasswordSession(sessionData, hooks)
}
bundle = buildBundle(session)
bundle = buildBundle(session, storedAccount.pdsUrl)
registerBundleKillSwitch(bundle, hooks.kill)
// The returned account is captured again after asynchronous preparation.
const earlyAccount =
sessionDataToSessionAccount(session.session, session.session.service) ??
storedAccount
sessionDataToSessionAccount(
session.session,
session.session.service,
storedAccount.pdsUrl,
) ?? storedAccount
configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({client: bundle.bskyClient})
const aa = prefetchAgeAssuranceServerData({
appviewClient: bundle.appviewClient,
accountClient: bundle.pdsClient,
})
await Promise.all([gates, aa])
// Preparation may auto-refresh the session while hooks are still disarmed.
const account =
sessionDataToSessionAccount(session.session, session.session.service) ??
storedAccount
sessionDataToSessionAccount(
session.session,
session.session.service,
storedAccount.pdsUrl,
) ?? storedAccount
hooks.arm()
return {account, bundle}
}
@@ -455,7 +269,10 @@ export async function createSessionBundleAndLogin(
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({client: bundle.bskyClient})
const aa = prefetchAgeAssuranceServerData({
appviewClient: bundle.appviewClient,
accountClient: bundle.pdsClient,
})
await Promise.all([gates, aa])
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = sessionDataToSessionAccountOrThrow(session)
@@ -464,192 +281,47 @@ export async function createSessionBundleAndLogin(
}
/**
* Create an account and build a {@link SessionBundle}. Writes created-at and
* birthdate locally for sync reads, then fires the deferred server-write block
* (personal details, profile, saved feeds, and AA-gated chat restrictions) as
* SDK actions against the account (PDS) client.
* Rebuild a bundle synchronously from stored tokens. The optional guard runs
* after construction but before hooks are armed; rejected bundles are disposed.
*/
export async function createSessionBundleAndCreateAccount(
{
service,
email,
password,
handle,
birthDate,
inviteCode,
verificationPhone,
verificationCode,
}: {
service: string
email: string
password: string
handle: string
birthDate: Date
inviteCode?: string
verificationPhone?: string
verificationCode?: string
},
export function createSessionBundleFromStoredAccount(
storedAccount: SessionAccount,
onSessionChange: OnSessionChange,
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
shouldActivate: (
bundle: SessionBundle,
account: SessionAccount,
) => boolean = () => true,
): {account: SessionAccount; bundle: SessionBundle} | undefined {
let bundle!: SessionBundle
let accountDid = ''
const hooks = makeSessionHooks(
onSessionChange,
() => bundle,
() => accountDid,
() => storedAccount.did,
)
const session = await PasswordSession.createAccount(
{
email,
password,
/* the lexicon types handle as `${string}.${string}`; user input is a plain string */
handle: handle as `${string}.${string}`,
inviteCode,
verificationPhone,
verificationCode,
},
{...hooks, service},
const session = new PasswordSession(
sessionAccountToSessionData(storedAccount),
hooks,
)
bundle = buildBundle(session)
bundle = buildBundle(session, storedAccount.pdsUrl)
registerBundleKillSwitch(bundle, hooks.kill)
// Seed the hook and the deferred writes with refresh-stable account fields.
const earlyAccount = sessionDataToSessionAccountOrThrow(session)
accountDid = earlyAccount.did
configureModerationForAccount(bundle, storedAccount)
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
configureModerationForAccount(bundle, earlyAccount)
const createdAt = toDatetimeString(new Date())
const birthdate = birthDate.toISOString()
/*
* Since we have a race with account creation, profile creation, and AA
* state, set these values locally to ensure sync reads. Values are written
* to the server in the next step, so on subsequent reloads, the server will
* be the source of truth.
*/
setCreatedAtForDid({did: earlyAccount.did, createdAt})
setBirthdateForDid({did: earlyAccount.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(earlyAccount.did)
// do this last
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.bskyClient.call(setPersonalDetails, {
birthDate,
})
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set birthDate`,
)
throw e
}),
networkRetry(3, () => {
return bundle.bskyClient.call(upsertProfile, prev => {
const next: Partial<app.bsky.actor.profile.Main> = prev || {}
next.displayName = handle
next.createdAt = createdAt
return next
})
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set initial profile`,
)
throw e
}),
networkRetry(1, () => {
return bundle.bskyClient.call(overwriteSavedFeeds, [
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
},
{
...TIMELINE_SAVED_FEED,
id: TID.nextStr(),
},
])
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set initial feeds`,
)
throw e
}),
// wait for AA data to load first, then check state
aa.then(() => {
const {flags} = unsafeGetAndComputeAgeAssurance({did: earlyAccount.did})
if (flags?.chatDisabled || flags?.groupChatDisabled) {
void restrictChatSettings({
client: bundle.bskyClient,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
}
}),
]).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createSessionBundleAndCreateAccount failed to save personal details and feeds`,
)
}
})
} else {
void Promise.allSettled([
networkRetry(3, () => {
return bundle.bskyClient.call(setPersonalDetails, {
birthDate,
})
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set birthDate`,
)
throw e
}),
networkRetry(3, () => {
return bundle.bskyClient.call(upsertProfile, prev => {
const next: Partial<app.bsky.actor.profile.Main> = prev || {}
next.createdAt = prev?.createdAt || toDatetimeString(new Date())
return next
})
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set initial profile`,
)
throw e
}),
]).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createSessionBundleAndCreateAccount failed to save personal details and feeds`,
)
}
})
const account = session.destroyed
? storedAccount
: (sessionDataToSessionAccount(
session.session,
session.session.service,
storedAccount.pdsUrl,
) ?? storedAccount)
if (!shouldActivate(bundle, account)) {
disposeBundle(bundle)
return undefined
}
try {
// snooze first prompt after signup, defer to next prompt
snoozeEmailConfirmationPrompt()
} catch (e) {
logger.error(e instanceof Error ? e : String(e), {
message: `session: failed snoozeEmailConfirmationPrompt`,
})
}
await Promise.all([gates, aa])
// Preparation may auto-refresh the session while hooks are still disarmed.
const account = sessionDataToSessionAccountOrThrow(session)
hooks.arm()
return {account, bundle}
}
function sessionDataToSessionAccountOrThrow(
export function sessionDataToSessionAccountOrThrow(
session: PasswordSession,
): SessionAccount {
const account = sessionDataToSessionAccount(
@@ -674,32 +346,3 @@ export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
}
bundleKillSwitches.get(bundle)?.()
}
/** Maximum failed token generations considered during one expiry rescue. */
export const MAX_EXPIRY_RESCUE_GENERATIONS = 5
/** Pick the first unfailed token generation newer than the one that expired. */
export function pickExpiryRescueCandidate({
dyingRefreshJwt,
candidates,
failedRefreshJwts,
}: {
dyingRefreshJwt: string
candidates: (SessionAccount | undefined)[]
failedRefreshJwts: ReadonlySet<string>
}): SessionAccount | undefined {
if (failedRefreshJwts.size >= MAX_EXPIRY_RESCUE_GENERATIONS) {
return undefined
}
for (const candidate of candidates) {
const refreshJwt = candidate?.refreshJwt
if (
refreshJwt &&
refreshJwt !== dyingRefreshJwt &&
!failedRefreshJwts.has(refreshJwt)
) {
return candidate
}
}
return undefined
}
+85
View File
@@ -0,0 +1,85 @@
import {getPdsEndpoint, isValidDidDoc} from '@atproto/common-web'
import {type SessionData} from '@atproto/lex-password-session'
import {jwtDecode} from 'jwt-decode'
import {BSKY_SERVICE} from '#/lib/constants'
import {isJwtExpired} from '#/lib/jwt'
import {hasProp} from '#/lib/type-guards'
import {type SessionAccount} from './types'
/** Whether an access token was issued for a queued (waitlisted) signup. */
export function isSignupQueued(accessJwt: string | undefined) {
if (accessJwt) {
const sessData = jwtDecode(accessJwt)
return (
hasProp(sessData, 'scope') &&
sessData.scope === 'com.atproto.signupQueued'
)
}
return false
}
/**
* Convert live `PasswordSession` session data into the persisted
* `SessionAccount` snapshot.
*
* The object literal's field order is load-bearing: the reducer's
* `JSON.stringify` fast path and the session test snapshots depend on
* byte-stable serialization. `service` and `pdsUrl` are normalized through
* `new URL().toString()` for a stable trailing slash.
*
* `pdsUrl` comes from the DID document or a pre-refresh stored value. It does
* not fall back to the login service.
*/
export function sessionDataToSessionAccount(
session: SessionData | null | undefined,
service: string,
storedPdsUrl?: string,
): SessionAccount | undefined {
if (!session) {
return undefined
}
const normalizedService = new URL(service).toString()
const didDocPdsUrl =
session.didDoc && isValidDidDoc(session.didDoc)
? getPdsEndpoint(session.didDoc)
: undefined
const pdsUrl = didDocPdsUrl ?? storedPdsUrl
return {
service: normalizedService,
did: session.did,
handle: session.handle,
email: session.email,
emailConfirmed: session.emailConfirmed || false,
emailAuthFactor: session.emailAuthFactor || false,
refreshJwt: session.refreshJwt,
accessJwt: session.accessJwt,
signupQueued: isSignupQueued(session.accessJwt),
active: session.active,
status: session.status,
pdsUrl: pdsUrl ? new URL(pdsUrl).toString() : undefined,
isSelfHosted: !normalizedService.startsWith(BSKY_SERVICE),
}
}
/** Convert a persisted account into data suitable for `PasswordSession`. */
export function sessionAccountToSessionData(
account: SessionAccount,
): SessionData {
return {
accessJwt: account.accessJwt ?? '',
active: account.active ?? true,
did: account.did as SessionData['did'],
email: account.email,
emailAuthFactor: account.emailAuthFactor,
emailConfirmed: account.emailConfirmed,
handle: account.handle as SessionData['handle'],
refreshJwt: account.refreshJwt ?? '',
status: account.status,
service: account.service,
}
}
export function isSessionExpired(account: SessionAccount) {
return account.accessJwt ? isJwtExpired(account.accessJwt) : true
}
+3
View File
@@ -3,6 +3,9 @@ import {type Metrics} from '#/analytics/metrics'
export type SessionAccount = PersistedAccount
/** Session-change events understood by the reducer and logging hooks. */
export type AtpSessionEvent = 'update' | 'expired' | 'network-error'
export type SessionStateContext = {
accounts: SessionAccount[]
currentAccount: SessionAccount | undefined
+3 -11
View File
@@ -1,27 +1,19 @@
import {PasswordSession} from '@atproto/lex-password-session'
import {isJwtExpired} from '#/lib/jwt'
import {createLexClient} from '#/lib/lexClient'
import {type TemporaryPushClient} from '#/lib/notifications/notifications'
import * as persisted from '#/state/persisted'
import {networkAwareFetch, sessionAccountToSessionData} from './session-core'
import {networkAwareFetch} from './network'
import {sessionAccountToSessionData} from './session-data'
import {type SessionAccount} from './types'
export {isSignupQueued} from './session-core'
export {isSessionExpired, isSignupQueued} from './session-data'
export function readLastActiveAccount() {
const {currentAccount, accounts} = persisted.get('session')
return accounts.find(a => a.did === currentAccount?.did)
}
export function isSessionExpired(account: SessionAccount) {
if (account.accessJwt) {
return isJwtExpired(account.accessJwt)
} else {
return true
}
}
/**
* Resume hook-free, single-use sessions for push-token revocation. They must
* never persist or race the active session.
+2 -2
View File
@@ -95,7 +95,7 @@ import {
resolveLinkQueryOptions,
useResolveClients,
} from '#/state/queries/resolve-link'
import {useLexClient, usePdsClient, useSession} from '#/state/session'
import {useAppviewClient, usePdsClient, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
@@ -268,7 +268,7 @@ export const ComposePost = ({
const t = useTheme()
const ax = useAnalytics()
const pdsClient = usePdsClient()
const appviewClient = useLexClient()
const appviewClient = useAppviewClient()
const resolveClients = useResolveClients()
const queryClient = useQueryClient()
const currentDid = currentAccount!.did
+2 -2
View File
@@ -12,7 +12,7 @@ import {mimeToExt} from '#/lib/media/video/util'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {type ComposerImage} from '#/state/gallery'
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
import {getPublicLexClient} from '#/state/session/clients'
import {getPublicAppviewClient} from '#/state/session/clients'
import {
type ComposerState,
type EmbedDraft,
@@ -140,7 +140,7 @@ async function postDraftToServerPost(
// Add quote record embed
if (post.embed.quote) {
const publicClient = getPublicLexClient()
const publicClient = getPublicAppviewClient()
const resolved = await resolveLink(
{appview: publicClient, chat: publicClient},
post.embed.quote.uri,
+2 -2
View File
@@ -29,7 +29,7 @@ import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {useProfileQuery} from '#/state/queries/profile'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useLexClient, useSession} from '#/state/session'
import {useAppviewClient, useSession} from '#/state/session'
import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
import {ProfileLists} from '#/view/com/lists/ProfileLists'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
@@ -610,7 +610,7 @@ function ProfileScreenLoaded({
}
function useRichText(text: string): [RichTextAPI, boolean] {
const client = useLexClient()
const client = useAppviewClient()
const [prevText, setPrevText] = useState(text)
const [rawRT, setRawRT] = useState(() => new RichTextAPI({text}))
const [resolvedRT, setResolvedRT] = useState<RichTextAPI | null>(null)