clean up session clients and lifecycle
This commit is contained in:
+11
-12
@@ -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}),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user