diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 4b0617d991..61aca5d0c9 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -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}), ]) } diff --git a/src/ageAssurance/useBeginAgeAssurance.ts b/src/ageAssurance/useBeginAgeAssurance.ts index cf64eaef23..49f2c678f6 100644 --- a/src/ageAssurance/useBeginAgeAssurance.ts +++ b/src/ageAssurance/useBeginAgeAssurance.ts @@ -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 diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts index 82ec3d7d3e..00472be52f 100644 --- a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts +++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts @@ -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() }, diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts index 000256728a..d00e487474 100644 --- a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts +++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts @@ -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() }, diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts index 53a39b8dee..ffcd37622b 100644 --- a/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailUpdate.ts @@ -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}, ) }, }) diff --git a/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts index ba93b9bc97..fd55dd697a 100644 --- a/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts +++ b/src/components/dialogs/EmailDialog/data/useRequestEmailVerification.ts @@ -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}, ) }, }) diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts index 862de07e70..7293227f59 100644 --- a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts +++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts @@ -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() } diff --git a/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx index f52e1cd8ca..a496c31475 100644 --- a/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx +++ b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx @@ -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( diff --git a/src/components/hooks/useRichText.ts b/src/components/hooks/useRichText.ts index 3444a63308..5664d9b6fd 100644 --- a/src/components/hooks/useRichText.ts +++ b/src/components/hooks/useRichText.ts @@ -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(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})) diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx index 1ce1dc8863..0989b36242 100644 --- a/src/components/intents/VerifyEmailIntentDialog.tsx +++ b/src/components/intents/VerifyEmailIntentDialog.tsx @@ -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') } diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx index 1dee1cdeed..bac9ac4d4f 100644 --- a/src/features/liveNow/index.tsx +++ b/src/features/liveNow/index.tsx @@ -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, { diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts index 9a2a3e9f5a..24dd5910dd 100644 --- a/src/lib/generate-starterpack.ts +++ b/src/lib/generate-starterpack.ts @@ -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 } diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index 00976a476d..2cb97892ff 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -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) { diff --git a/src/screens/Settings/components/ChangePasswordDialog.tsx b/src/screens/Settings/components/ChangePasswordDialog.tsx index f1888aa365..f530556100 100644 --- a/src/screens/Settings/components/ChangePasswordDialog.tsx +++ b/src/screens/Settings/components/ChangePasswordDialog.tsx @@ -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)) { diff --git a/src/screens/Settings/components/DeactivateAccountDialog.tsx b/src/screens/Settings/components/DeactivateAccountDialog.tsx index 97a56ae942..6560e75192 100644 --- a/src/screens/Settings/components/DeactivateAccountDialog.tsx +++ b/src/screens/Settings/components/DeactivateAccountDialog.tsx @@ -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') }) diff --git a/src/screens/Settings/components/DeleteAccountDialog.tsx b/src/screens/Settings/components/DeleteAccountDialog.tsx index 179152d99e..0fc4d93790 100644 --- a/src/screens/Settings/components/DeleteAccountDialog.tsx +++ b/src/screens/Settings/components/DeleteAccountDialog.tsx @@ -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') diff --git a/src/screens/Settings/components/DisableEmail2FADialog.tsx b/src/screens/Settings/components/DisableEmail2FADialog.tsx index f7125f6077..a7200ce951 100644 --- a/src/screens/Settings/components/DisableEmail2FADialog.tsx +++ b/src/screens/Settings/components/DisableEmail2FADialog.tsx @@ -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'}))) } diff --git a/src/screens/Settings/components/ExportCarDialog.tsx b/src/screens/Settings/components/ExportCarDialog.tsx index f889ad68dd..b5c83ba3e5 100644 --- a/src/screens/Settings/components/ExportCarDialog.tsx +++ b/src/screens/Settings/components/ExportCarDialog.tsx @@ -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 diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx index 024cdc9ab5..a3bff2b154 100644 --- a/src/screens/SignupQueued.tsx +++ b/src/screens/SignupQueued.tsx @@ -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() diff --git a/src/state/queries/app-passwords.ts b/src/state/queries/app-passwords.ts index 3c81a86db0..c1326d0a00 100644 --- a/src/state/queries/app-passwords.ts +++ b/src/state/queries/app-passwords.ts @@ -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({ 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({ diff --git a/src/state/queries/handle.ts b/src/state/queries/handle.ts index a652402a85..29dff9a202 100644 --- a/src/state/queries/handle.ts +++ b/src/state/queries/handle.ts @@ -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) diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index 6c77edaa89..c884b70d38 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -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 diff --git a/src/state/queries/messages/actor-declaration.ts b/src/state/queries/messages/actor-declaration.ts index 380e5d83fe..c3a71c49b5 100644 --- a/src/state/queries/messages/actor-declaration.ts +++ b/src/state/queries/messages/actor-declaration.ts @@ -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 }, }) diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts index 2cf6000b98..626c8354cd 100644 --- a/src/state/queries/postgate/index.ts +++ b/src/state/queries/postgate/index.ts @@ -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, + }), ) } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index bdfcb161a3..81beb8e83a 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -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, diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts index 491e12bff8..849b8d1913 100644 --- a/src/state/queries/resolve-link.ts +++ b/src/state/queries/resolve-link.ts @@ -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} } diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts index 588d772325..03646b7b00 100644 --- a/src/state/queries/starter-packs.ts +++ b/src/state/queries/starter-packs.ts @@ -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) diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts index 9a3f8235d2..1bfebbd05f 100644 --- a/src/state/queries/threadgate/index.ts +++ b/src/state/queries/threadgate/index.ts @@ -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, + }), ) } diff --git a/src/state/queries/trending/useGetTrendsQuery.ts b/src/state/queries/trending/useGetTrendsQuery.ts index d7f269410a..5ba9b4d4b2 100644 --- a/src/state/queries/trending/useGetTrendsQuery.ts +++ b/src/state/queries/trending/useGetTrendsQuery.ts @@ -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(() => { diff --git a/src/state/session/__tests__/clients-bundle-test.ts b/src/state/session/__tests__/clients-bundle-test.ts index d817badc06..807fea1da3 100644 --- a/src/state/session/__tests__/clients-bundle-test.ts +++ b/src/state/session/__tests__/clients-bundle-test.ts @@ -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 => { - /* - * 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(() => {}) diff --git a/src/state/session/__tests__/expiry-rescue-test.ts b/src/state/session/__tests__/expiry-rescue-test.ts new file mode 100644 index 0000000000..c33eb527de --- /dev/null +++ b/src/state/session/__tests__/expiry-rescue-test.ts @@ -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 { + 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() + 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) + }) +}) diff --git a/src/state/session/__tests__/session-core-test.ts b/src/state/session/__tests__/session-core-test.ts index 61ae2f54f2..f351d6e7c2 100644 --- a/src/state/session/__tests__/session-core-test.ts +++ b/src/state/session/__tests__/session-core-test.ts @@ -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>() 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 { + return { + id: did, + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: pdsUrl, + }, + ], + } +} + function makeSessionData(overrides: Partial = {}): SessionData { return { accessJwt: 'access-jwt', @@ -117,54 +113,6 @@ function makeSessionData(overrides: Partial = {}): 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 { } } +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 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, @@ -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() - 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) - }) -}) diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 0ace358b39..359de0c1b6 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -23,20 +23,7 @@ jest.mock('#/lib/notifications/notifications', () => ({ }, })) -/* - * The reducer no longer sees real agents - the provider stores a SessionBundle - * and the reducer's opaque view only reads `.service`. These helpers replace - * the old `new AtpAgent(...)` + `agentToSessionAccountOrThrow(agent)` dance: - * - * - `makeBundle(service)` builds the opaque bundle view the reducer receives as - * `newAgent`/`agent` - just `{service: new URL(service)}`. Object identity is - * load-bearing (the reducer compares `agent === currentAgentState.agent`), so - * reuse the same bundle instance across a test's actions for one account. - * - `makeAccount(service, session)` builds the persisted `SessionAccount` via - * the real `sessionDataToSessionAccount` converter, so field order and - * derivation stay byte-identical to the old `agentToSessionAccount` output - * (the JSON.stringify fast path and the inline snapshots depend on this). - */ +// Reuse a bundle within each test: session events are scoped by bundle identity. function makeBundle(service: string) { return {service: new URL(service)} } @@ -70,8 +57,8 @@ describe('session', () => { expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -91,11 +78,11 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: aliceAccount, }, ]) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(state.accounts.length).toBe(1) expect(state.accounts[0].did).toBe('alice-did') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') @@ -119,8 +106,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -135,7 +122,7 @@ describe('session', () => { }, ]) // Should keep the account but clear out the tokens. - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(state.accounts.length).toBe(1) expect(state.accounts[0].did).toBe('alice-did') expect(state.accounts[0].accessJwt).toBe(undefined) @@ -159,8 +146,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -185,14 +172,14 @@ describe('session', () => { { // Switch to Alice. type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: aliceAccount, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].did).toBe('alice-did') - expect(state.currentAgentState.did).toBe('alice-did') - expect(state.currentAgentState.agent).toBe(aliceBundle) + expect(state.currentBundleState.did).toBe('alice-did') + expect(state.currentBundleState.bundle).toBe(aliceBundle) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -212,8 +199,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -234,7 +221,7 @@ describe('session', () => { { // Switch to Bob. type: 'switched-to-account', - newAgent: bobBundle, + newBundle: bobBundle, newAccount: bobAccount, }, ]) @@ -242,8 +229,8 @@ describe('session', () => { // Bob should float upwards. expect(state.accounts[0].did).toBe('bob-did') expect(state.accounts[1].did).toBe('alice-did') - expect(state.currentAgentState.did).toBe('bob-did') - expect(state.currentAgentState.agent).toBe(bobBundle) + expect(state.currentBundleState.did).toBe('bob-did') + expect(state.currentBundleState.bundle).toBe(bobBundle) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -278,8 +265,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://bob.com/", }, "did": "bob-did", @@ -300,7 +287,7 @@ describe('session', () => { { // Switch back to Alice. type: 'switched-to-account', - newAgent: aliceBundle2, + newBundle: aliceBundle2, newAccount: aliceAccount2, }, ]) @@ -308,8 +295,8 @@ describe('session', () => { // Alice should float upwards. expect(state.accounts[0].did).toBe('alice-did') expect(state.accounts[0].handle).toBe('alice-updated.test') - expect(state.currentAgentState.did).toBe('alice-did') - expect(state.currentAgentState.agent).toBe(aliceBundle2) + expect(state.currentBundleState.did).toBe('alice-did') + expect(state.currentBundleState.bundle).toBe(aliceBundle2) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -344,8 +331,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -366,14 +353,14 @@ describe('session', () => { { // Switch to Jay. type: 'switched-to-account', - newAgent: jayBundle, + newBundle: jayBundle, newAccount: jayAccount, }, ]) expect(state.accounts.length).toBe(3) expect(state.accounts[0].did).toBe('jay-did') - expect(state.currentAgentState.did).toBe('jay-did') - expect(state.currentAgentState.agent).toBe(jayBundle) + expect(state.currentBundleState.did).toBe('jay-did') + expect(state.currentBundleState.bundle).toBe(jayBundle) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -423,8 +410,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://jay.com/", }, "did": "jay-did", @@ -440,7 +427,7 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(3) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) // All tokens should be gone. expect(state.accounts[0].accessJwt).toBe(undefined) expect(state.accounts[0].refreshJwt).toBe(undefined) @@ -497,8 +484,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -522,14 +509,14 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: aliceAccount, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') state = run(state, [ { @@ -539,7 +526,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe(undefined) expect(state.accounts[0].refreshJwt).toBe(undefined) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -559,8 +546,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -580,14 +567,14 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle2, + newBundle: aliceBundle2, newAccount: aliceAccount2, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -607,8 +594,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -632,14 +619,14 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: aliceAccount, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') state = run(state, [ { @@ -648,12 +635,12 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(0) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -685,17 +672,17 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: aliceAccount, }, { type: 'switched-to-account', - newAgent: bobBundle, + newBundle: bobBundle, newAccount: bobAccount, }, ]) expect(state.accounts.length).toBe(2) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') state = run(state, [ { @@ -704,7 +691,7 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -724,8 +711,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://bob.com/", }, "did": "bob-did", @@ -741,7 +728,7 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(0) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) }) it('can log out of the current account', () => { @@ -758,14 +745,14 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: aliceAccount, }, ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') const bobBundle = makeBundle('https://bob.com') const bobAccount = makeAccount('https://bob.com', { @@ -778,14 +765,14 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: bobBundle, + newBundle: bobBundle, newAccount: bobAccount, }, ]) expect(state.accounts.length).toBe(2) expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') state = run(state, [ { @@ -797,7 +784,7 @@ describe('session', () => { expect(state.accounts[0].refreshJwt).toBe(undefined) expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -832,8 +819,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -850,7 +837,7 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -861,13 +848,13 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -886,7 +873,7 @@ describe('session', () => { expect(state.accounts[0].handle).toBe('alice-updated.test') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -906,8 +893,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -918,9 +905,9 @@ describe('session', () => { state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -939,7 +926,7 @@ describe('session', () => { expect(state.accounts[0].handle).toBe('alice-updated.test') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-3') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-3') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -959,8 +946,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -971,9 +958,9 @@ describe('session', () => { state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -992,7 +979,7 @@ describe('session', () => { expect(state.accounts[0].handle).toBe('alice-updated.test') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-4') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-4') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -1012,8 +999,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -1030,7 +1017,7 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1041,13 +1028,13 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1064,9 +1051,9 @@ describe('session', () => { const lastState = state state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1081,9 +1068,9 @@ describe('session', () => { state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1098,7 +1085,7 @@ describe('session', () => { expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-3') }) - it('ignores updates from a stale agent bundle', () => { + it('ignores updates from a stale bundle', () => { let state = getInitialState([]) const aliceBundle = makeBundle('https://alice.com') @@ -1108,7 +1095,7 @@ describe('session', () => { { // Switch to Alice. type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1120,7 +1107,7 @@ describe('session', () => { { // Switch to Bob. type: 'switched-to-account', - newAgent: bobBundle, + newBundle: bobBundle, newAccount: makeAccount('https://bob.com', { active: true, did: 'bob-did', @@ -1131,7 +1118,7 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(2) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') /* * An 'update' from the stale (background) Alice bundle is now dropped @@ -1142,9 +1129,9 @@ describe('session', () => { const beforeStaleUpdate = state state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1168,9 +1155,9 @@ describe('session', () => { state = run(state, [ { // Update Bob (the current bundle) - this still applies. - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'bob-did', - agent: bobBundle, + bundle: bobBundle, refreshedAccount: makeAccount('https://bob.com', { active: true, did: 'bob-did', @@ -1221,8 +1208,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://bob.com/", }, "did": "bob-did", @@ -1231,13 +1218,13 @@ describe('session', () => { } `) - // Ignore other events for the inactive agent too (network-error, expired). + // Ignore other events for the inactive bundle too (network-error, expired). const lastState = state state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: undefined, sessionEvent: 'network-error', }, @@ -1245,9 +1232,9 @@ describe('session', () => { expect(lastState === state).toBe(true) state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: undefined, sessionEvent: 'expired', }, @@ -1262,7 +1249,7 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1272,12 +1259,12 @@ describe('session', () => { }), }, ]) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') // Alice logs out: her account entry stays, but tokens are cleared and the // current bundle becomes the public (logged-out) bundle. state = run(state, [{type: 'logged-out-current-account'}]) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(state.accounts[0].did).toBe('alice-did') expect(state.accounts[0].accessJwt).toBe(undefined) expect(state.accounts[0].refreshJwt).toBe(undefined) @@ -1291,9 +1278,9 @@ describe('session', () => { const afterLogout = state state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1307,7 +1294,7 @@ describe('session', () => { expect(afterLogout === state).toBe(true) expect(state.accounts[0].accessJwt).toBe(undefined) expect(state.accounts[0].refreshJwt).toBe(undefined) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) }) it('applies an update from the current bundle', () => { @@ -1317,7 +1304,7 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1330,9 +1317,9 @@ describe('session', () => { state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1346,10 +1333,10 @@ describe('session', () => { // The current bundle's update lands and rotates the stored tokens. expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-2') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-2') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') }) - it('ignores updates from a removed agent', () => { + it('ignores updates from a removed bundle', () => { let state = getInitialState([]) const aliceBundle = makeBundle('https://alice.com') @@ -1358,7 +1345,7 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1369,7 +1356,7 @@ describe('session', () => { }, { type: 'switched-to-account', - newAgent: bobBundle, + newBundle: bobBundle, newAccount: makeAccount('https://bob.com', { active: true, did: 'bob-did', @@ -1384,13 +1371,13 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1404,7 +1391,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.accounts[0].did).toBe('bob-did') expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1') - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') }) it('ignores network errors', () => { @@ -1415,7 +1402,7 @@ describe('session', () => { { // Switch to Alice. type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1426,13 +1413,13 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(1) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: undefined, sessionEvent: 'network-error', }, @@ -1440,7 +1427,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -1460,8 +1447,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://alice.com/", }, "did": "alice-did", @@ -1478,7 +1465,7 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1490,13 +1477,13 @@ describe('session', () => { ]) expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.did).toBe('alice-did') state = run(state, [ { - type: 'received-agent-event', + type: 'received-session-event', accountDid: 'alice-did', - agent: aliceBundle, + bundle: aliceBundle, refreshedAccount: undefined, sessionEvent: 'expired', }, @@ -1504,7 +1491,7 @@ describe('session', () => { expect(state.accounts.length).toBe(1) expect(state.accounts[0].accessJwt).toBe(undefined) expect(state.accounts[0].refreshJwt).toBe(undefined) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(printState(state)).toMatchInlineSnapshot(` { "accounts": [ @@ -1524,72 +1511,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { - "service": "https://public.api.bsky.app/", - }, - "did": undefined, - }, - "needsPersist": true, - } - `) - }) - - it('resets tokens on created-failed event', () => { - let state = getInitialState([]) - - const aliceBundle = makeBundle('https://alice.com') - state = run(state, [ - { - type: 'switched-to-account', - newAgent: aliceBundle, - newAccount: makeAccount('https://alice.com', { - active: true, - did: 'alice-did', - handle: 'alice.test', - accessJwt: 'alice-access-jwt-1', - refreshJwt: 'alice-refresh-jwt-1', - }), - }, - ]) - expect(state.accounts.length).toBe(1) - expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') - expect(state.currentAgentState.did).toBe('alice-did') - - state = run(state, [ - { - type: 'received-agent-event', - accountDid: 'alice-did', - agent: aliceBundle, - refreshedAccount: undefined, - sessionEvent: 'create-failed', - }, - ]) - expect(state.accounts.length).toBe(1) - expect(state.accounts[0].accessJwt).toBe(undefined) - expect(state.accounts[0].refreshJwt).toBe(undefined) - expect(state.currentAgentState.did).toBe(undefined) - expect(printState(state)).toMatchInlineSnapshot(` - { - "accounts": [ - { - "accessJwt": undefined, - "active": true, - "did": "alice-did", - "email": undefined, - "emailAuthFactor": false, - "emailConfirmed": false, - "handle": "alice.test", - "isSelfHosted": true, - "pdsUrl": undefined, - "refreshJwt": undefined, - "service": "https://alice.com/", - "signupQueued": false, - "status": undefined, - }, - ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -1607,7 +1530,7 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1618,7 +1541,7 @@ describe('session', () => { }, { type: 'switched-to-account', - newAgent: bobBundle, + newBundle: bobBundle, newAccount: makeAccount('https://bob.com', { active: true, did: 'bob-did', @@ -1629,7 +1552,7 @@ describe('session', () => { }, ]) expect(state.accounts.length).toBe(2) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') state = run(state, [ { @@ -1659,7 +1582,7 @@ describe('session', () => { expect(state.accounts[1].accessJwt).toBe('bob-access-jwt-2') // Keep Bob logged in. // (The bundle is rebuilt from the synced tokens outside the reducer.) - expect(state.currentAgentState.did).toBe('bob-did') + expect(state.currentBundleState.did).toBe('bob-did') expect(state.needsPersist).toBe(false) expect(printState(state)).toMatchInlineSnapshot(` { @@ -1695,8 +1618,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://bob.com/", }, "did": "bob-did", @@ -1724,7 +1647,7 @@ describe('session', () => { expect(state.accounts[0].did).toBe('clarence-did') // Log out because we have no matching user. // (In practice, we'll resume this session outside the reducer.) - expect(state.currentAgentState.did).toBe(undefined) + expect(state.currentBundleState.did).toBe(undefined) expect(state.needsPersist).toBe(false) expect(printState(state)).toMatchInlineSnapshot(` { @@ -1745,8 +1668,8 @@ describe('session', () => { "status": undefined, }, ], - "currentAgentState": { - "agent": { + "currentBundleState": { + "bundle": { "service": "https://public.api.bsky.app/", }, "did": undefined, @@ -1763,7 +1686,7 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1773,7 +1696,7 @@ describe('session', () => { }), }, ]) - expect(state.currentAgentState.agent).toBe(aliceBundle) + expect(state.currentBundleState.bundle).toBe(aliceBundle) expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') // A fresh bundle rebuilt from synced tokens (no network). @@ -1788,13 +1711,13 @@ describe('session', () => { state = run(state, [ { type: 'replaced-current-bundle', - newAgent: aliceBundle2, + newBundle: aliceBundle2, newAccount: aliceAccount2, }, ]) // The bundle is swapped in place, the did is preserved. - expect(state.currentAgentState.agent).toBe(aliceBundle2) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.bundle).toBe(aliceBundle2) + expect(state.currentBundleState.did).toBe('alice-did') // The matching account entry is replaced with the synced one. expect(state.accounts.length).toBe(1) expect(state.accounts[0].did).toBe('alice-did') @@ -1811,7 +1734,7 @@ describe('session', () => { state = run(state, [ { type: 'switched-to-account', - newAgent: aliceBundle, + newBundle: aliceBundle, newAccount: makeAccount('https://alice.com', { active: true, did: 'alice-did', @@ -1824,7 +1747,7 @@ describe('session', () => { }), }, ]) - expect(state.currentAgentState.agent).toBe(aliceBundle) + expect(state.currentBundleState.bundle).toBe(aliceBundle) expect(state.accounts[0].emailConfirmed).toBe(false) expect(state.accounts[0].emailAuthFactor).toBe(false) @@ -1839,8 +1762,8 @@ describe('session', () => { expect(state.accounts[0].emailConfirmed).toBe(true) expect(state.accounts[0].emailAuthFactor).toBe(true) // The bundle is untouched - no session mutation, same reference. - expect(state.currentAgentState.agent).toBe(aliceBundle) - expect(state.currentAgentState.did).toBe('alice-did') + expect(state.currentBundleState.bundle).toBe(aliceBundle) + expect(state.currentBundleState.did).toBe('alice-did') expect(state.needsPersist).toBe(true) }) }) @@ -1856,9 +1779,9 @@ function run(initialState: State, actions: Action[]): State { function printState(state: State) { return { accounts: state.accounts, - currentAgentState: { - agent: {service: state.currentAgentState.agent.service}, - did: state.currentAgentState.did, + currentBundleState: { + bundle: {service: state.currentBundleState.bundle.service}, + did: state.currentBundleState.did, }, needsPersist: state.needsPersist, } diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index 7408ede88e..f61f559579 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -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) + }, + } } diff --git a/src/state/session/create-account.ts b/src/state/session/create-account.ts new file mode 100644 index 0000000000..ae0b9179fe --- /dev/null +++ b/src/state/session/create-account.ts @@ -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[] = [ + 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 + isProd: boolean + }, +) { + return retryPostSignupTask('set initial profile', 3, () => + client.call(upsertProfile, prev => { + const next: Partial = 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, + 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( + description: string, + retries: number, + task: () => Promise, +) { + return networkRetry(retries, task).catch(e => { + logger.info(`createSessionBundleAndCreateAccount: failed to ${description}`) + throw e + }) +} + +async function reportPostSignupFailures(tasks: Promise[]) { + const results = await Promise.allSettled(tasks) + if (results.some(result => result.status === 'rejected')) { + logger.error( + `session: createSessionBundleAndCreateAccount failed to save post-signup settings`, + ) + } +} diff --git a/src/state/session/expiry-rescue.ts b/src/state/session/expiry-rescue.ts new file mode 100644 index 0000000000..f750a1ba5c --- /dev/null +++ b/src/state/session/expiry-rescue.ts @@ -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 +}): 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 +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 13c24dd56b..2f31f1d293 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -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({ }) 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( 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) diff --git a/src/state/session/logging.ts b/src/state/session/logging.ts index 3be52525dc..64e6dfc17c 100644 --- a/src/state/session/logging.ts +++ b/src/state/session/logging.ts @@ -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 } diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index 6107c5b4be..921a3197b7 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -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 diff --git a/src/state/session/network.ts b/src/state/session/network.ts new file mode 100644 index 0000000000..84e92d564b --- /dev/null +++ b/src/state/session/network.ts @@ -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 + } +} diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index cc9fc453f5..56875c450a 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -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 } -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. } } diff --git a/src/state/session/session-core.ts b/src/state/session/session-core.ts index 1417ce1dc6..74ac56e348 100644 --- a/src/state/session/session-core.ts +++ b/src/state/session/session-core.ts @@ -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).service - if (!Array.isArray(services)) { - return null - } - const pds = (services as unknown[]).find( - (s): s is Record => { - if (typeof s !== 'object' || s === null) { - return false - } - const id = (s as Record).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 { - 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 = 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 = 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 -}): 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 -} diff --git a/src/state/session/session-data.ts b/src/state/session/session-data.ts new file mode 100644 index 0000000000..3ead91c173 --- /dev/null +++ b/src/state/session/session-data.ts @@ -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 +} diff --git a/src/state/session/types.ts b/src/state/session/types.ts index 3f57c5532a..72fa231895 100644 --- a/src/state/session/types.ts +++ b/src/state/session/types.ts @@ -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 diff --git a/src/state/session/util.ts b/src/state/session/util.ts index 32b51ea7fc..66a5786d8d 100644 --- a/src/state/session/util.ts +++ b/src/state/session/util.ts @@ -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. diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index a27ab6786d..a3495ce1f0 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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 diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 8f4af5178b..a6ed433637 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -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, diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 315f59009c..d47177f456 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -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(null)