From 54da80dfb0ef19ea5b26aa834669683843d55a16 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 13 Aug 2026 22:26:22 +0300 Subject: [PATCH] [SDK] Delete the bridge agent and rework the session bundle onto lex clients (#11385) Co-authored-by: Claude Fable 5 --- src/ageAssurance/data.tsx | 16 +- src/lib/api/feed/custom.ts | 12 +- src/lib/api/index.ts | 64 +- src/lib/api/resolve.ts | 88 +-- src/lib/constants.ts | 10 +- src/lib/moderation.ts | 8 +- .../Messages/components/MessagesList.tsx | 14 +- .../components/ChangeHandleDialog.tsx | 6 +- src/state/messages/convo/agent.ts | 123 ++-- src/state/messages/convo/index.tsx | 16 +- src/state/messages/convo/types.ts | 9 +- src/state/messages/events/agent.ts | 37 +- src/state/messages/events/index.tsx | 16 +- src/state/messages/events/types.ts | 9 +- src/state/queries/handle.ts | 30 +- src/state/queries/post-feed.ts | 6 +- src/state/queries/post.ts | 96 ++- src/state/queries/preferences/index.ts | 14 +- src/state/queries/preferences/moderation.ts | 4 +- src/state/queries/resolve-link.ts | 38 +- .../session/__tests__/bridge-agent-test.ts | 573 ------------------ src/state/session/__tests__/clients-test.ts | 371 ++++++------ .../__tests__/provider-clients-test.tsx | 32 +- .../provider-refresh-session-test.tsx | 10 +- .../session/__tests__/session-core-test.ts | 68 ++- .../additional-moderation-authorities.ts | 25 +- src/state/session/agent.ts | 25 - src/state/session/bridge-agent.ts | 442 -------------- src/state/session/clients.ts | 211 +++---- src/state/session/create-account.ts | 8 +- src/state/session/index.tsx | 61 +- src/state/session/moderation.ts | 46 +- src/state/session/session-core.ts | 107 ++-- src/state/session/session-data.ts | 22 - src/view/com/composer/Composer.tsx | 11 +- src/view/com/composer/drafts/state/api.ts | 16 +- src/view/com/composer/drafts/state/queries.ts | 9 +- src/view/com/testing/TestCtrls.e2e.tsx | 10 +- 38 files changed, 812 insertions(+), 1851 deletions(-) delete mode 100644 src/state/session/__tests__/bridge-agent-test.ts delete mode 100644 src/state/session/agent.ts delete mode 100644 src/state/session/bridge-agent.ts diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 2beac738c2..2540c4b1f9 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -4,7 +4,6 @@ import { type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceGetConfig, type AppBskyAgeassuranceGetState, - AtpAgent, type ChatBskyActorDeclaration, } from '@atproto/api' import {type Client} from '@atproto/lex' @@ -15,7 +14,6 @@ import {persistQueryClient} from '@tanstack/react-query-persist-client' import debounce from 'lodash.debounce' import {networkRetry} from '#/lib/async/retry' -import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {getAge} from '#/lib/strings/time' import { @@ -24,6 +22,7 @@ import { } from '#/state/birthdate' import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration' import {useAppviewClient, usePdsClient, useSession} from '#/state/session' +import {getPublicAppviewClient} from '#/state/session/clients' import {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const' import * as debug from '#/ageAssurance/debug' import {logger} from '#/ageAssurance/logger' @@ -98,11 +97,14 @@ export function setBirthdateForDid({ export const configQueryKey = ['config'] export async function getConfig() { if (debug.enabled) return debug.resolve(debug.config) - const agent = new AtpAgent({ - service: PUBLIC_BSKY_SERVICE, - }) - const res = await agent.app.bsky.ageassurance.getConfig() - return res.data + /* + * An unauthenticated read against the public appview: the config is fetched + * before there is any session (and while logged out), so it goes through the + * process-wide public client rather than a bundle one. + */ + return (await getPublicAppviewClient().call( + app.bsky.ageassurance.getConfig, + )) as AppBskyAgeassuranceGetConfig.OutputSchema } export function getConfigFromCache(): | AppBskyAgeassuranceGetConfig.OutputSchema diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 3b1ec3bec3..6d960813d3 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -1,5 +1,5 @@ -import {type AppBskyFeedDefs, AtpAgent, jsonStringToLex} from '@atproto/api' -import {type Client, type XrpcRequestParams} from '@atproto/lex' +import {type AppBskyFeedDefs, jsonStringToLex} from '@atproto/api' +import {Client, type XrpcRequestParams} from '@atproto/lex' import { getAppLanguageAsContentLanguage, @@ -115,12 +115,12 @@ async function loggedOutFetch({ }): Promise { let contentLangs = getAppLanguageAsContentLanguage() - /** - * Copied from our root `Agent` class - * @see https://github.com/bluesky-social/atproto/blob/60df3fc652b00cdff71dd9235d98a7a4bb828f05/packages/api/src/agent.ts#L120 + /* + * This request is hand-rolled rather than issued through a client, so it has + * to reproduce the header lex would have emitted from the global static. */ const labelersHeader = { - 'atproto-accept-labelers': AtpAgent.appLabelers + 'atproto-accept-labelers': Client.appLabelers .map(l => `${l};redact`) .join(', '), } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index f13c227857..e03a40c391 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,4 +1,4 @@ -import {type AtpAgent, ChatBskyGroupDefs} from '@atproto/api' +import {ChatBskyGroupDefs} from '@atproto/api' import {TID} from '@atproto/common-web' import {type $Typed, type Client} from '@atproto/lex' import { @@ -10,6 +10,7 @@ import {RichText} from '@bsky.app/sdk/richtext' import {t} from '@lingui/core/macro' import {type QueryClient} from '@tanstack/react-query' +import {type LinkResolvers} from '#/lib/api/resolve' import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants' import {isNetworkError} from '#/lib/strings/errors' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' @@ -47,6 +48,11 @@ interface PostOpts { * fallback keeps facet detection working when logged out. */ appviewClient: Client + /* + * A quoted chat invite link resolves its preview through the chat service, so + * the embed resolver needs the chat client alongside the appview one. + */ + chatClient: Client /* * The repo write itself (applyWrites) plus every record blob (images, * gallery items, link thumbnails, video captions) goes to the account's own @@ -55,15 +61,7 @@ interface PostOpts { pdsClient: Client } -/** - * The `agent` is only still here for the link/gif resolvers in `resolve.ts`, - * which read through it; drop the parameter when those move to the clients. - */ -export async function post( - agent: AtpAgent, - queryClient: QueryClient, - opts: PostOpts, -) { +export async function post(queryClient: QueryClient, opts: PostOpts) { const thread = opts.thread opts.onStateChange?.(t`Processing...`) @@ -95,7 +93,8 @@ export async function post( // Not awaited to avoid waterfalls. const rtPromise = resolveRT(opts.appviewClient, draft.richtext) const embedPromise = resolveEmbed( - agent, + opts.appviewClient, + opts.chatClient, opts.pdsClient, queryClient, draft, @@ -251,7 +250,8 @@ async function resolveReply(appviewClient: Client, replyTo: string) { } async function resolveEmbed( - agent: AtpAgent, + appviewClient: Client, + chatClient: Client, pdsClient: Client, queryClient: QueryClient, draft: PostDraft, @@ -259,8 +259,19 @@ async function resolveEmbed( ): Promise { if (draft.embed.quote) { const [resolvedMedia, resolvedQuote] = await Promise.all([ - resolveMedia(agent, pdsClient, queryClient, draft.embed, onStateChange), - resolveRecord(agent, queryClient, draft.embed.quote.uri), + resolveMedia( + appviewClient, + chatClient, + pdsClient, + queryClient, + draft.embed, + onStateChange, + ), + resolveRecord( + {appviewClient, chatClient}, + queryClient, + draft.embed.quote.uri, + ), ]) if (resolvedMedia) { return { @@ -278,7 +289,8 @@ async function resolveEmbed( } } const resolvedMedia = await resolveMedia( - agent, + appviewClient, + chatClient, pdsClient, queryClient, draft.embed, @@ -290,7 +302,7 @@ async function resolveEmbed( if (draft.embed.link) { const resolvedLink = await fetchResolveLinkQuery( queryClient, - agent, + {appviewClient, chatClient}, draft.embed.link.uri, ) if (resolvedLink.type === 'record') { @@ -309,7 +321,8 @@ async function resolveEmbed( } async function resolveMedia( - agent: AtpAgent, + appviewClient: Client, + chatClient: Client, pdsClient: Client, queryClient: QueryClient, embedDraft: EmbedDraft, @@ -423,11 +436,7 @@ async function resolveMedia( } if (embedDraft.media?.type === 'gif') { const gifDraft = embedDraft.media - const resolvedGif = await fetchResolveGifQuery( - queryClient, - agent, - gifDraft.gif, - ) + const resolvedGif = await fetchResolveGifQuery(queryClient, gifDraft.gif) let blob: app.bsky.embed.external.External['thumb'] if (resolvedGif.thumb) { onStateChange?.(t`Uploading link thumbnail...`) @@ -448,7 +457,7 @@ async function resolveMedia( if (embedDraft.link) { const resolvedLink = await fetchResolveLinkQuery( queryClient, - agent, + {appviewClient, chatClient}, embedDraft.link.uri, ) if (resolvedLink.type === 'external') { @@ -490,15 +499,16 @@ async function resolveMedia( } /* - * `resolve.ts` still resolves through the agent and returns legacy-typed refs; - * assert at the boundary until it moves to the clients. + * `resolve.ts` still returns legacy-typed views, so its strong refs carry plain + * strings where the record write wants the branded syntax types; assert at the + * boundary until those views come from the generated lexicons. */ async function resolveRecord( - agent: AtpAgent, + clients: LinkResolvers, queryClient: QueryClient, uri: string, ): Promise { - const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri) + const resolvedLink = await fetchResolveLinkQuery(queryClient, clients, uri) if (resolvedLink.type !== 'record') { throw Error(t`Expected uri to resolve to a record`) } diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index 67bc46284a..297388edb0 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -1,12 +1,13 @@ import { type AppBskyFeedDefs, type AppBskyGraphDefs, - type AtpAgent, type ComAtprotoRepoStrongRef, } from '@atproto/api' import {AtUri} from '@atproto/api' +import {type Client} from '@atproto/lex' +import {type AtUriString, type HandleString} from '@atproto/syntax' -import {DM_SERVICE_HEADERS, IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' +import {IMAGE_SIZE_CONFIG_2K_1MB} from '#/lib/constants' import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta' import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {downloadAndResize} from '#/lib/media/manip' @@ -29,6 +30,7 @@ import {type ComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery' import {type ChatInvitePreview} from '#/state/queries/join-links' import {type Gif} from '#/features/gifPicker/types' +import {app, chat, com} from '#/lexicons' import {createGIFDescription} from '../gif-alt-text' type ResolvedExternalLink = { @@ -94,8 +96,21 @@ export class EmbeddingDisabledError extends Error { } } +/** + * The clients a link resolution may need. + * + * Everything but the chat-invite branch is an appview read, and the chat invite + * preview goes through the chat client so it is proxied to the chat service. + * Both are passed together because the caller cannot know which branch a URL + * will take until it is parsed. + */ +export type LinkResolvers = { + appviewClient: Client + chatClient: Client +} + export async function resolveLink( - agent: AtpAgent, + {appviewClient, chatClient}: LinkResolvers, uri: string, ): Promise { if (isShortLink(uri)) { @@ -124,15 +139,17 @@ export async function resolveLink( const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) const did = await fetchDid(handleOrDid) const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey) - const res = await agent.app.bsky.feed.getFeedGenerator({feed}) + const data = await appviewClient.call(app.bsky.feed.getFeedGenerator, { + feed: feed, + }) return { type: 'record', record: { - uri: res.data.view.uri, - cid: res.data.view.cid, + uri: data.view.uri, + cid: data.view.cid, }, kind: 'feed', - view: res.data.view, + view: data.view, } } if (isBskyListUrl(uri)) { @@ -140,28 +157,29 @@ export async function resolveLink( const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) const did = await fetchDid(handleOrDid) const list = makeRecordUri(did, 'app.bsky.graph.list', rkey) - const res = await agent.app.bsky.graph.getList({list}) + const data = await appviewClient.call(app.bsky.graph.getList, { + list: list, + }) return { type: 'record', record: { - uri: res.data.list.uri, - cid: res.data.list.cid, + uri: data.list.uri, + cid: data.list.cid, }, kind: 'list', - view: res.data.list, + view: data.list, } } const chatInviteCode = getChatInviteCodeFromUrl(uri) if (chatInviteCode) { - const res = await agent.chat.bsky.group.getJoinLinkPreviews( - {codes: [chatInviteCode]}, - {headers: DM_SERVICE_HEADERS}, - ) + const data = await chatClient.call(chat.bsky.group.getJoinLinkPreviews, { + codes: [chatInviteCode], + }) return { type: 'chat-invite', uri, code: chatInviteCode, - view: res.data.joinLinkPreviews[0], + view: data.joinLinkPreviews[0], } } if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { @@ -173,15 +191,17 @@ export async function resolveLink( } const did = await fetchDid(parsed.name) const starterPack = createStarterPackUri({did, rkey: parsed.rkey}) - const res = await agent.app.bsky.graph.getStarterPack({starterPack}) + const data = await appviewClient.call(app.bsky.graph.getStarterPack, { + starterPack: starterPack as AtUriString, + }) return { type: 'record', record: { - uri: res.data.starterPack.uri, - cid: res.data.starterPack.cid, + uri: data.starterPack.uri, + cid: data.starterPack.cid, }, kind: 'starter-pack', - view: res.data.starterPack, + view: data.starterPack, } } return resolveExternal(uri) @@ -190,17 +210,17 @@ export async function resolveLink( async function getPost({uri}: {uri: string}) { const urip = new AtUri(uri) if (!urip.host.startsWith('did:')) { - const res = await agent.resolveHandle({ - handle: urip.host, - }) - // @ts-expect-error TODO new-sdk-migration - urip.host = res.data.did + const data = await appviewClient.call( + com.atproto.identity.resolveHandle, + {handle: urip.host as HandleString}, + ) + urip.host = data.did } - const res = await agent.getPosts({ + const data = await appviewClient.call(app.bsky.feed.getPosts, { uris: [urip.toString()], }) - if (res.success && res.data.posts[0]) { - return res.data.posts[0] + if (data.posts[0]) { + return data.posts[0] } throw new Error('getPost: post not found') } @@ -209,17 +229,17 @@ export async function resolveLink( async function fetchDid(handleOrDid: string) { let identifier = handleOrDid if (!identifier.startsWith('did:')) { - const res = await agent.resolveHandle({handle: identifier}) - identifier = res.data.did + const data = await appviewClient.call( + com.atproto.identity.resolveHandle, + {handle: identifier as HandleString}, + ) + identifier = data.did } return identifier } } -export async function resolveGif( - agent: AtpAgent, - gif: Gif, -): Promise { +export async function resolveGif(gif: Gif): Promise { const gifUrl = gif.media_formats.gif.url const params = new URLSearchParams() params.set('hh', String(gif.media_formats.gif.dims[1])) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9fd7d12b47..a490d92719 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -2,7 +2,6 @@ import {type Insets, Platform} from 'react-native' import {type AppBskyActorDefs, BSKY_LABELER_DID} from '@atproto/api' import {type Service} from '@atproto/lex' -import {type ProxyHeaderValue} from '#/state/session/agent' import {BLUESKY_PROXY_DID, CHAT_PROXY_DID, IS_DEV} from '#/env' export const LOCAL_DEV_SERVICE = @@ -241,7 +240,7 @@ export const DEV_ENV_APPVIEW_DID = `did:plc:dw4kbjf5mn7nhenabiqpkyh3` // always export const BLUESKY_PROXY_HEADER = { value: `${BLUESKY_PROXY_DID}#bsky_appview`, get() { - return this.value as ProxyHeaderValue + return this.value as Service }, set(value: string) { this.value = value @@ -257,9 +256,6 @@ export const BLUESKY_PROXY_HEADER = { * The DID comes from the env-configurable `CHAT_PROXY_DID` (via * `EXPO_PUBLIC_CHAT_PROXY_DID`) rather than a hard-coded constant, so the * target can be retargeted per environment. - * - * This is the client-level equivalent of {@link DM_SERVICE_HEADERS}, which - * carries the same value as a per-call header. */ export const CHAT_PROXY_SERVICE: Service = `${CHAT_PROXY_DID}#bsky_chat` @@ -275,10 +271,6 @@ export const CHAT_PROXY_SERVICE: Service = `${CHAT_PROXY_DID}#bsky_chat` */ export const MOD_PROXY_SERVICE: Service = `${BSKY_LABELER_DID}#atproto_labeler` -export const DM_SERVICE_HEADERS = { - 'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`, -} - /** * The notification service's proxy target, in the `did#service_id` form a lex * client's per-call `service` option takes. Passing it emits `atproto-proxy: diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index 68b424347b..b2d4c194e0 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -1,5 +1,7 @@ import {useMemo} from 'react' -import {AtpAgent, type ComAtprotoLabelDefs} from '@atproto/api' +import {type ComAtprotoLabelDefs} from '@atproto/api' +import {Client} from '@atproto/lex' +import {type DidString} from '@atproto/syntax' import { type InterpretedLabelValueDefinition, LABELS, @@ -105,9 +107,9 @@ export function isAppLabeler( | app.bsky.labeler.defs.LabelerViewDetailed, ): boolean { if (typeof labeler === 'string') { - return AtpAgent.appLabelers.includes(labeler) + return Client.appLabelers.includes(labeler as DidString) } - return AtpAgent.appLabelers.includes(labeler.creator.did) + return Client.appLabelers.includes(labeler.creator.did) } export function isLabelerSubscribed( diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index e9d336a67a..eab025665c 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -70,7 +70,7 @@ import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' -import {app} from '#/lexicons' +import {app, type chat, type com} from '#/lexicons' import * as bsky from '#/types/bsky' import {ChatStatusInfo} from './ChatStatusInfo' import {groupSystemMessages, type RenderItem} from './groupSystemMessages' @@ -537,10 +537,7 @@ export function MessagesList({ */ rt.detectFacetsWithoutResolution() - let embed: - | $Typed - | $Typed - | undefined + let embed: chat.bsky.convo.defs.MessageInput['embed'] let embedView: | $Typed | $Typed @@ -576,10 +573,15 @@ export function MessagesList({ if (post) { embed = { $type: 'app.bsky.embed.record', + /* + * `getPost` still returns an `@atproto/api` view, whose `uri` and + * `cid` are plain strings rather than the branded syntax types + * the lexicon input declares. + */ record: { uri: post.uri, cid: post.cid, - }, + } as com.atproto.repo.strongRef.Main, } embedView = { diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx index a8c9474410..8cff915390 100644 --- a/src/screens/Settings/components/ChangeHandleDialog.tsx +++ b/src/screens/Settings/components/ChangeHandleDialog.tsx @@ -27,7 +27,7 @@ import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {useServiceQuery} from '#/state/queries/service' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' -import {useAgent, useSession, useSessionApi} from '#/state/session' +import {useSession, useSessionApi} from '#/state/session' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' @@ -63,12 +63,12 @@ export function ChangeHandleDialog({ function ChangeHandleDialogInner() { const control = Dialog.useDialogContext() const {_} = useLingui() - const agent = useAgent() + const {currentAccount} = useSession() const { data: serviceInfo, error: serviceInfoError, refetch, - } = useServiceQuery(agent.serviceUrl.toString()) + } = useServiceQuery(currentAccount?.service ?? '') const [page, setPage] = useState<'provided-handle' | 'own-handle'>( 'provided-handle', diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index b1dc348e79..521e7f77ea 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -1,20 +1,17 @@ import { type $Typed, type AppBskyEmbedRecord, - type AtpAgent, type ChatBskyActorDefs, ChatBskyConvoDefs, type ChatBskyConvoGetLog, - type ChatBskyConvoSendMessage, type ChatBskyEmbedJoinLink, type ChatBskyGroupDefs, } from '@atproto/api' -import {XRPCError} from '@atproto/api' +import {type Client, XrpcResponseError} from '@atproto/lex' import {EventEmitter} from 'eventemitter3' import {nanoid} from 'nanoid/non-secure' import {networkRetry} from '#/lib/async/retry' -import {DM_SERVICE_HEADERS} from '#/lib/constants' import { isErrorMaybeAppPasswordPermissions, isNetworkError, @@ -52,6 +49,7 @@ import { parseConvoView, } from '#/components/dms/util' import {IS_NATIVE} from '#/env' +import {chat} from '#/lexicons' const logger = Logger.create(Logger.Context.ConversationAgent) @@ -105,7 +103,7 @@ function toDeletedMessageView( export class Convo { private id: string - private agent: AtpAgent + private chatClient: Client private events: MessagesEventBus private senderUserDid: string @@ -131,7 +129,7 @@ export class Convo { string, { id: string - message: ChatBskyConvoSendMessage.InputSchema['message'] + message: chat.bsky.convo.defs.MessageInput optimisticEmbedView?: | $Typed | $Typed @@ -166,9 +164,9 @@ export class Convo { constructor(params: ConvoParams) { this.id = nanoid(3) this.convoId = params.convoId - this.agent = params.agent + this.chatClient = params.chatClient this.events = params.events - this.senderUserDid = params.agent.assertDid + this.senderUserDid = params.chatClient.assertDid if (params.placeholderData) { this.setupPlaceholderData(params.placeholderData) @@ -197,6 +195,15 @@ export class Convo { this.subscribers.forEach(subscriber => subscriber()) } + /** + * Point the convo at a new chat client, for when the session bundle is + * replaced underneath it. Conversation state is unaffected: the same account + * is being served over a fresh session. + */ + updateClient(chatClient: Client) { + this.chatClient = chatClient + } + private subscribers: (() => void)[] = [] subscribe(subscriber: () => void) { @@ -720,13 +727,12 @@ export class Convo { this.pendingFetchConvo = (async () => { try { const response = await networkRetry(2, () => { - return this.agent.chat.bsky.convo.getConvo( - {convoId: this.convoId}, - {headers: DM_SERVICE_HEADERS}, - ) + return this.chatClient.call(chat.bsky.convo.getConvo, { + convoId: this.convoId, + }) }) - const convo = response.data.convo + const convo = response.convo return { convo, @@ -763,18 +769,15 @@ export class Convo { let cursor: string | undefined do { const result = await networkRetry(2, () => { - return this.agent.chat.bsky.convo.getConvoMembers( - { - convoId: this.convoId, - limit: 50, - cursor, - }, - {headers: DM_SERVICE_HEADERS}, - ) + return this.chatClient.call(chat.bsky.convo.getConvoMembers, { + convoId: this.convoId, + limit: 50, + cursor, + }) }) - cursor = result.data.cursor + cursor = result.cursor - for (const member of result.data.members) { + for (const member of result.members) { this.relatedProfiles.set(member.did, member) } } while (cursor) @@ -808,16 +811,13 @@ export class Convo { const nextCursor = this.oldestRev // for TS const response = await networkRetry(2, () => { - return this.agent.chat.bsky.convo.getMessages( - { - cursor: nextCursor, - convoId: this.convoId, - limit: IS_NATIVE ? 30 : 60, - }, - {headers: DM_SERVICE_HEADERS}, - ) + return this.chatClient.call(chat.bsky.convo.getMessages, { + cursor: nextCursor, + convoId: this.convoId, + limit: IS_NATIVE ? 30 : 60, + }) }) - const {cursor, messages, relatedProfiles} = response.data + const {cursor, messages, relatedProfiles} = response // Trust the cursor for pagination. We can't infer "no more pages" from a // short page: the server pages by raw rows but strips deleted messages @@ -1031,7 +1031,7 @@ export class Convo { private pendingMessageFailure: 'recoverable' | 'unrecoverable' | null = null sendMessage( - message: ChatBskyConvoSendMessage.InputSchema['message'], + message: chat.bsky.convo.defs.MessageInput, optimisticEmbedView?: | $Typed | $Typed, @@ -1165,14 +1165,10 @@ export class Convo { const {id, message} = pendingMessage - const response = await this.agent.chat.bsky.convo.sendMessage( - { - convoId: this.convoId, - message, - }, - {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, - ) - const res = response.data + const res = await this.chatClient.call(chat.bsky.convo.sendMessage, { + convoId: this.convoId, + message, + }) // remove from queue this.pendingMessages.delete(id) @@ -1197,8 +1193,15 @@ export class Convo { } } - private handleSendMessageFailure(e: Error | XRPCError) { - if (e instanceof XRPCError) { + /* + * The lex client throws `XrpcResponseError`, not `@atproto/api`'s + * `XRPCError`, so the status/message branch narrows on the lex class. Only + * a genuine server response carries a status: transport and internal lex + * failures are `XrpcInternalError`s and fall through to the generic arm, + * where `isNetworkError` keeps them out of the logs. + */ + private handleSendMessageFailure(e: Error | XrpcResponseError) { + if (e instanceof XrpcResponseError) { if (NETWORK_FAILURE_STATUSES.includes(e.status)) { this.pendingMessageFailure = 'recoverable' } else { @@ -1261,16 +1264,15 @@ export class Convo { ) try { - const {data} = await this.agent.chat.bsky.convo.sendMessageBatch( + const {items} = await this.chatClient.call( + chat.bsky.convo.sendMessageBatch, { items: messageArray.map(({message}) => ({ convoId: this.convoId, message, })), }, - {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, ) - const {items} = data /* * Insert into `newMessages` as soon as we have a real ID. That way, when @@ -1304,13 +1306,10 @@ export class Convo { try { await networkRetry(2, () => { - return this.agent.chat.bsky.convo.deleteMessageForSelf( - { - convoId: this.convoId, - messageId, - }, - {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, - ) + return this.chatClient.call(chat.bsky.convo.deleteMessageForSelf, { + convoId: this.convoId, + messageId, + }) }) } catch (err) { const e = err as Error @@ -1529,10 +1528,11 @@ export class Convo { try { logger.debug(`Adding reaction ${emoji} to message ${messageId}`) - const {data} = await this.agent.chat.bsky.convo.addReaction( - {messageId, value: emoji, convoId: this.convoId}, - {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, - ) + const data = await this.chatClient.call(chat.bsky.convo.addReaction, { + messageId, + value: emoji, + convoId: this.convoId, + }) if (ChatBskyConvoDefs.isMessageView(data.message)) { if (this.pastMessages.has(messageId)) { this.pastMessages.set(messageId, data.message) @@ -1594,10 +1594,11 @@ export class Convo { try { logger.debug(`Removing reaction ${emoji} from message ${messageId}`) - await this.agent.chat.bsky.convo.removeReaction( - {messageId, value: emoji, convoId: this.convoId}, - {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, - ) + await this.chatClient.call(chat.bsky.convo.removeReaction, { + messageId, + value: emoji, + convoId: this.convoId, + }) } catch (error) { if (restore) restore() throw error diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx index 69ad1e237c..b37b060701 100644 --- a/src/state/messages/convo/index.tsx +++ b/src/state/messages/convo/index.tsx @@ -28,7 +28,7 @@ import { } from '#/state/queries/messages/conversation' import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations' import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' -import {useAgent} from '#/state/session' +import {useChatClient} from '#/state/session' import {type GroupConvoMember} from '#/components/dms/util' export * from '#/state/messages/convo/util' @@ -80,7 +80,7 @@ export function ConvoProvider({ convoId, }: Pick & {children: React.ReactNode}) { const queryClient = useQueryClient() - const agent = useAgent() + const chatClient = useChatClient() const events = useMessagesEventBus() const [convo] = useState(() => { const placeholder = queryClient.getQueryData( @@ -88,7 +88,7 @@ export function ConvoProvider({ ) return new Convo({ convoId, - agent, + chatClient, events, placeholderData: placeholder ? {convo: placeholder} : undefined, }) @@ -96,6 +96,16 @@ export function ConvoProvider({ const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) const {mutate: markAsRead} = useMarkAsReadMutation() + /* + * The convo outlives the client it was constructed with: replacing the + * session bundle builds fresh clients over the new session and disposes the + * old ones, so a convo still holding the previous client would send through a + * dead session. + */ + useEffect(() => { + convo.updateClient(chatClient) + }, [convo, chatClient]) + const appState = useAppState() const isActive = appState === 'active' useFocusEffect( diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index e330d6eea3..5bc5777e82 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -1,19 +1,20 @@ import { type $Typed, type AppBskyEmbedRecord, - type AtpAgent, type ChatBskyActorDefs, type ChatBskyConvoDefs, - type ChatBskyConvoSendMessage, type ChatBskyEmbedJoinLink, } from '@atproto/api' +import {type Client} from '@atproto/lex' import {type MessagesEventBus} from '#/state/messages/events/agent' import {type ConvoWithDetails} from '#/components/dms/util' +import {type chat} from '#/lexicons' export type ConvoParams = { convoId: string - agent: AtpAgent + /** The chat client, which proxies `chat.bsky.*` to the chat service. */ + chatClient: Client events: MessagesEventBus placeholderData?: { convo: ChatBskyConvoDefs.ConvoView @@ -108,7 +109,7 @@ export type ConvoItem = type DeleteMessage = (messageId: string) => Promise type SendMessage = ( - message: ChatBskyConvoSendMessage.InputSchema['message'], + message: chat.bsky.convo.defs.MessageInput, optimisticEmbedView: | $Typed | $Typed diff --git a/src/state/messages/events/agent.ts b/src/state/messages/events/agent.ts index 56e6bed173..9bc7158187 100644 --- a/src/state/messages/events/agent.ts +++ b/src/state/messages/events/agent.ts @@ -1,9 +1,8 @@ -import {type AtpAgent, type ChatBskyConvoGetLog} from '@atproto/api' +import {type Client} from '@atproto/lex' import {EventEmitter} from 'eventemitter3' import {nanoid} from 'nanoid/non-secure' import {networkRetry} from '#/lib/async/retry' -import {DM_SERVICE_HEADERS} from '#/lib/constants' import { isErrorMaybeAppPasswordPermissions, isNetworkError, @@ -21,13 +20,14 @@ import { type MessagesEventBusParams, MessagesEventBusStatus, } from '#/state/messages/events/types' +import {chat} from '#/lexicons' const logger = Logger.create(Logger.Context.DMsAgent) export class MessagesEventBus { private id: string - private agent: AtpAgent + private chatClient: Client private emitter = new EventEmitter<{event: [MessagesEventBusEvent]}>() private status: MessagesEventBusStatus = MessagesEventBusStatus.Initializing @@ -37,11 +37,20 @@ export class MessagesEventBus { constructor(params: MessagesEventBusParams) { this.id = nanoid(3) - this.agent = params.agent + this.chatClient = params.chatClient this.init() } + /** + * Point the bus at a new chat client, for when the session bundle is + * replaced underneath it. The poll cursor and subscribers are unaffected: the + * same account is being served over a fresh session. + */ + updateClient(chatClient: Client) { + this.chatClient = chatClient + } + requestPollInterval(interval: number) { const id = nanoid() this.requestedPollIntervals.set(id, interval) @@ -260,14 +269,11 @@ export class MessagesEventBus { try { const response = await networkRetry(2, () => { - return this.agent.chat.bsky.convo.getLog( - {}, - {headers: DM_SERVICE_HEADERS}, - ) + return this.chatClient.call(chat.bsky.convo.getLog, {}) }) // throw new Error('UNCOMMENT TO TEST INIT FAILURE') - const {cursor} = response.data + const {cursor} = response // should always be defined if (cursor) { @@ -355,21 +361,18 @@ export class MessagesEventBus { // ) let needsEmit = false - let batch: ChatBskyConvoGetLog.OutputSchema['logs'] = [] + let batch: chat.bsky.convo.getLog.$OutputBody['logs'] = [] try { const response = await networkRetry(2, () => { - return this.agent.chat.bsky.convo.getLog( - { - cursor: this.latestRev, - }, - {headers: DM_SERVICE_HEADERS}, - ) + return this.chatClient.call(chat.bsky.convo.getLog, { + cursor: this.latestRev, + }) }) // throw new Error('UNCOMMENT TO TEST POLL FAILURE') - const {logs: events} = response.data + const {logs: events} = response for (const ev of events) { /* diff --git a/src/state/messages/events/index.tsx b/src/state/messages/events/index.tsx index 5d1e2ec505..d6e0abe600 100644 --- a/src/state/messages/events/index.tsx +++ b/src/state/messages/events/index.tsx @@ -2,7 +2,7 @@ import {createContext, useContext, useEffect, useState} from 'react' import {AppState} from 'react-native' import {MessagesEventBus} from '#/state/messages/events/agent' -import {useAgent, useSession} from '#/state/session' +import {useChatClient, useSession} from '#/state/session' const MessagesEventBusContext = createContext(null) MessagesEventBusContext.displayName = 'MessagesEventBusContext' @@ -42,14 +42,24 @@ export function MessagesEventBusProviderInner({ }: { children: React.ReactNode }) { - const agent = useAgent() + const chatClient = useChatClient() const [bus] = useState( () => new MessagesEventBus({ - agent, + chatClient, }), ) + /* + * The bus outlives the client it was constructed with: replacing the session + * bundle (account switch, cross-tab token sync, expiry rescue) builds fresh + * clients over the new session and disposes the old ones, so a bus still + * holding the previous client would poll through a dead session. + */ + useEffect(() => { + bus.updateClient(chatClient) + }, [bus, chatClient]) + useEffect(() => { bus.resume() diff --git a/src/state/messages/events/types.ts b/src/state/messages/events/types.ts index 67d6bdd452..2a743a07a2 100644 --- a/src/state/messages/events/types.ts +++ b/src/state/messages/events/types.ts @@ -1,7 +1,10 @@ -import {type AtpAgent, type ChatBskyConvoGetLog} from '@atproto/api' +import {type Client} from '@atproto/lex' + +import {type chat} from '#/lexicons' export type MessagesEventBusParams = { - agent: AtpAgent + /** The chat client, which proxies `chat.bsky.*` to the chat service. */ + chatClient: Client } export enum MessagesEventBusStatus { @@ -64,5 +67,5 @@ export type MessagesEventBusEvent = } | { type: 'logs' - logs: ChatBskyConvoGetLog.OutputSchema['logs'] + logs: chat.bsky.convo.getLog.$OutputBody['logs'] } diff --git a/src/state/queries/handle.ts b/src/state/queries/handle.ts index c400e8bd85..0aa0160158 100644 --- a/src/state/queries/handle.ts +++ b/src/state/queries/handle.ts @@ -1,10 +1,10 @@ import {useCallback} from 'react' -import {type HandleString} from '@atproto/syntax' +import {type DidString, type HandleString} from '@atproto/syntax' import {useMutation, useQueryClient} from '@tanstack/react-query' import {STALE} from '#/state/queries' -import {useAgent, usePdsClient} from '#/state/session' -import {com} from '#/lexicons' +import {useAppviewClient, usePdsClient} from '#/state/session' +import {app, com} from '#/lexicons' const handleQueryKeyRoot = 'handle' const fetchHandleQueryKey = (handleOrDid: string) => [ @@ -16,21 +16,24 @@ const fetchDidQueryKey = (handleOrDid: string) => [didQueryKeyRoot, handleOrDid] export function useFetchHandle() { const queryClient = useQueryClient() - const agent = useAgent() + const client = useAppviewClient() return useCallback( async (handleOrDid: string) => { if (handleOrDid.startsWith('did:')) { - const res = await queryClient.fetchQuery({ + const data = await queryClient.fetchQuery({ staleTime: STALE.MINUTES.FIVE, queryKey: fetchHandleQueryKey(handleOrDid), - queryFn: () => agent.getProfile({actor: handleOrDid}), + queryFn: () => + client.call(app.bsky.actor.getProfile, { + actor: handleOrDid as DidString, + }), }) - return res.data.handle + return data.handle } return handleOrDid }, - [queryClient, agent], + [queryClient, client], ) } @@ -42,7 +45,6 @@ export function useUpdateHandleMutation(opts?: { return useMutation({ mutationFn: async ({handle}: {handle: string}) => { - // `agent.updateHandle` was a pure alias for this method await client.call(com.atproto.identity.updateHandle, { // callers validate the handle before submitting handle: handle as HandleString, @@ -59,7 +61,7 @@ export function useUpdateHandleMutation(opts?: { export function useFetchDid() { const queryClient = useQueryClient() - const agent = useAgent() + const client = useAppviewClient() return useCallback( async (handleOrDid: string) => { @@ -69,13 +71,15 @@ export function useFetchDid() { queryFn: async () => { let identifier = handleOrDid if (!identifier.startsWith('did:')) { - const res = await agent.resolveHandle({handle: identifier}) - identifier = res.data.did + const data = await client.call(com.atproto.identity.resolveHandle, { + handle: identifier as HandleString, + }) + identifier = data.did } return identifier }, }) }, - [queryClient, agent], + [queryClient, client], ) } diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index c3d5ada4d2..44bc8adf02 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -36,7 +36,7 @@ import {moderatePost} from '#/lib/moderation/subjects' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' -import {useAgent, useAppviewClient} from '#/state/session' +import {useAppviewClient, useSession} from '#/state/session' import * as userActionHistory from '#/state/userActionHistory' import {KnownError} from '#/view/com/posts/PostFeedErrorMessage' import {useFeedTuners} from '../preferences/feed-tuners' @@ -151,7 +151,7 @@ export function usePostFeedQuery( f => f.pinned && f.value === 'following', ) ?? -1 const enableFollowingToDiscoverFallback = followingPinnedIndex === 0 - const agent = useAgent() + const {hasSession} = useSession() const client = useAppviewClient() const lastRun = useRef<{ data: InfiniteData @@ -214,7 +214,7 @@ export function usePostFeedQuery( * moderations happen later, which results in some posts being shown and * some not. */ - if (!agent.session) { + if (!hasSession) { assertSomePostsPassModeration( res.feed, preferences?.moderationPrefs || diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index fd6ba752de..0a0664d13b 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -1,6 +1,7 @@ import {useCallback} from 'react' import {type AppBskyActorDefs, type AppBskyFeedDefs, AtUri} from '@atproto/api' -import {type AtUriString} from '@atproto/syntax' +import {type Client} from '@atproto/lex' +import {type AtUriString, type HandleString} from '@atproto/syntax' import {deleteLike, deletePost, deleteRepost, like, repost} from '@bsky.app/sdk' import { type QueryClient, @@ -12,16 +13,11 @@ import { import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' import {updatePostShadow} from '#/state/cache/post-shadow' import {type Shadow} from '#/state/cache/types' -import { - useAgent, - useAppviewClient, - usePdsClient, - useSession, -} from '#/state/session' +import {useAppviewClient, usePdsClient, useSession} from '#/state/session' import * as userActionHistory from '#/state/userActionHistory' import {useAnalytics} from '#/analytics' import {type Metrics, toClout} from '#/analytics/metrics' -import {app} from '#/lexicons' +import {app, com} from '#/lexicons' import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' import {findProfileQueryData} from './profile' @@ -29,25 +25,15 @@ const RQKEY_ROOT = 'post' export const RQKEY = (postUri: string) => [RQKEY_ROOT, postUri] export function usePostQuery(uri: string | undefined) { - const agent = useAgent() + const client = useAppviewClient() return useQuery({ queryKey: RQKEY(uri || ''), queryFn: async () => { if (!uri) throw new Error('[unreachable] No URI provided') - const urip = new AtUri(uri) - - if (!urip.host.startsWith('did:')) { - const res = await agent.resolveHandle({ - handle: urip.host, - }) - // @ts-expect-error TODO new-sdk-migration - urip.host = res.data.did - } - - const res = await agent.getPosts({uris: [urip.toString()]}) - if (res.success && res.data.posts[0]) { - return res.data.posts[0] + const post = await fetchPost(client, uri) + if (post) { + return post } throw new Error('No data') @@ -56,6 +42,33 @@ export function usePostQuery(uri: string | undefined) { }) } +/** + * Read one post by AT-URI, resolving a handle authority first when the URI + * carries one. + * + * The appview still answers with `@atproto/api`-shaped views for the callers of + * these hooks, so the generated view is asserted across at this single + * boundary rather than at every consumer. + */ +async function fetchPost( + client: Client, + uri: string, +): Promise { + const urip = new AtUri(uri) + + if (!urip.host.startsWith('did:')) { + const data = await client.call(com.atproto.identity.resolveHandle, { + handle: urip.host as HandleString, + }) + urip.host = data.did + } + + const data = await client.call(app.bsky.feed.getPosts, { + uris: [urip.toString()], + }) + return data.posts[0] +} + export function precachePost( queryClient: QueryClient, uri: string, @@ -66,59 +79,42 @@ export function precachePost( export function useGetPost() { const queryClient = useQueryClient() - const agent = useAgent() + const client = useAppviewClient() return useCallback( async ({uri}: {uri: string}) => { return queryClient.fetchQuery({ queryKey: RQKEY(uri || ''), async queryFn() { - const urip = new AtUri(uri) - - if (!urip.host.startsWith('did:')) { - const res = await agent.resolveHandle({ - handle: urip.host, - }) - // @ts-expect-error TODO new-sdk-migration - urip.host = res.data.did - } - - const res = await agent.getPosts({ - uris: [urip.toString()], - }) - - if (res.success && res.data.posts[0]) { - return res.data.posts[0] + const post = await fetchPost(client, uri) + if (post) { + return post } throw new Error('useGetPost: post not found') }, }) }, - [queryClient, agent], + [queryClient, client], ) } export function useGetPosts() { const queryClient = useQueryClient() - const agent = useAgent() + const client = useAppviewClient() return useCallback( async ({uris}: {uris: string[]}) => { return queryClient.fetchQuery({ queryKey: RQKEY(uris.join(',') || ''), async queryFn() { - const res = await agent.getPosts({ - uris, + const data = await client.call(app.bsky.feed.getPosts, { + uris: uris as AtUriString[], }) - - if (res.success) { - return res.data.posts - } else { - throw new Error('useGetPosts failed') - } + // See the note on `fetchPost` about the view shapes. + return data.posts as AppBskyFeedDefs.PostView[] }, }) }, - [queryClient, agent], + [queryClient, client], ) } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 449458c075..33f7d2f8d1 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -39,7 +39,7 @@ import { type UsePreferencesQueryResponse, } from '#/state/queries/preferences/types' import {createQueryKey} from '#/state/queries/util' -import {useAgent, usePdsClient} from '#/state/session' +import {useAppviewClient, usePdsClient} from '#/state/session' import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation' import {useAgeAssurance} from '#/ageAssurance' import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' @@ -58,7 +58,7 @@ export const preferencesQueryKey = createQueryKey( export function usePreferencesQuery() { const client = usePdsClient() - const agent = useAgent() + const appviewClient = useAppviewClient() const aa = useAgeAssurance() const query = useQuery({ @@ -86,12 +86,12 @@ export function usePreferencesQuery() { * from a labeler would not affect server-attached labels until the * session bundle was rebuilt. * - * `applyLabelersToClient` writes to the agent, which is what stamps the - * header on the requests the wrapping appview client issues, and it - * drops the Bluesky moderation DID so the globally redacted authority is - * not also listed unredacted. + * The subscriptions go on the appview client, which is what stamps + * `atproto-accept-labelers` on its own requests. The Bluesky moderation + * DID is dropped so the globally redacted authority is not also listed + * unredacted. */ - applyLabelersToClient(agent, labelerDids) + applyLabelersToClient(appviewClient, labelerDids) /* * `BskyPreferences` is now the sdk's own type, so the assembled diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts index 246e7596b5..f7d237995e 100644 --- a/src/state/queries/preferences/moderation.ts +++ b/src/state/queries/preferences/moderation.ts @@ -1,5 +1,5 @@ import {useMemo} from 'react' -import {AtpAgent} from '@atproto/api' +import {Client} from '@atproto/lex' import {interpretLabelValueDefinitions} from '@bsky.app/sdk/moderation' import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' @@ -14,7 +14,7 @@ export function useMyLabelersQuery({ const prefs = usePreferencesQuery() let dids = Array.from( new Set( - AtpAgent.appLabelers.concat( + (Client.appLabelers as readonly string[]).concat( prefs.data?.moderationPrefs.labelers.map(l => l.did) || [], ), ), diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts index 2ccd0af863..aac9bacb12 100644 --- a/src/state/queries/resolve-link.ts +++ b/src/state/queries/resolve-link.ts @@ -1,9 +1,13 @@ -import {type AtpAgent} from '@atproto/api' import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query' -import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve' +import { + type LinkResolvers, + type ResolvedLink, + resolveGif, + resolveLink, +} from '#/lib/api/resolve' import {STALE} from '#/state/queries/index' -import {useAgent} from '#/state/session' +import {useAppviewClient, useChatClient} from '#/state/session' import {type Gif} from '#/features/gifPicker/types' export const RQKEY_LINK_ROOT = 'resolve-link' @@ -12,24 +16,25 @@ export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url] export const RQKEY_GIF_ROOT = 'resolve-gif' export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url] -export function resolveLinkQueryOptions(agent: AtpAgent, url: string) { +export function resolveLinkQueryOptions(clients: LinkResolvers, url: string) { return queryOptions({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY_LINK(url), - queryFn: () => resolveLink(agent, url), + queryFn: () => resolveLink(clients, url), }) } export function useResolveLinkQuery(url: string) { - const agent = useAgent() - return useQuery(resolveLinkQueryOptions(agent, url)) + const appviewClient = useAppviewClient() + const chatClient = useChatClient() + return useQuery(resolveLinkQueryOptions({appviewClient, chatClient}, url)) } export function fetchResolveLinkQuery( queryClient: QueryClient, - agent: AtpAgent, + clients: LinkResolvers, url: string, ) { - return queryClient.fetchQuery(resolveLinkQueryOptions(agent, url)) + return queryClient.fetchQuery(resolveLinkQueryOptions(clients, url)) } export function precacheResolveLinkQuery( queryClient: QueryClient, @@ -39,26 +44,25 @@ export function precacheResolveLinkQuery( queryClient.setQueryData(RQKEY_LINK(url), resolvedLink) } +/* + * GIF resolution is pure metadata work on a URL the picker already returned - + * it makes no atproto request - so it takes no client. + */ export function useResolveGifQuery(gif: Gif) { - const agent = useAgent() return useQuery({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY_GIF(gif.url), queryFn: async () => { - return await resolveGif(agent, gif) + return await resolveGif(gif) }, }) } -export function fetchResolveGifQuery( - queryClient: QueryClient, - agent: AtpAgent, - gif: Gif, -) { +export function fetchResolveGifQuery(queryClient: QueryClient, gif: Gif) { return queryClient.fetchQuery({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY_GIF(gif.url), queryFn: async () => { - return await resolveGif(agent, gif) + return await resolveGif(gif) }, }) } diff --git a/src/state/session/__tests__/bridge-agent-test.ts b/src/state/session/__tests__/bridge-agent-test.ts deleted file mode 100644 index cbd606d12a..0000000000 --- a/src/state/session/__tests__/bridge-agent-test.ts +++ /dev/null @@ -1,573 +0,0 @@ -import { - PasswordSession, - type PasswordSessionOptions, - type SessionData, -} from '@atproto/lex-password-session' -import {beforeEach, describe, expect, it, jest} from '@jest/globals' - -jest.mock('#/state/events', () => ({ - emitNetworkConfirmed: jest.fn(), - emitNetworkLost: jest.fn(), -})) - -jest.mock('jwt-decode', () => ({ - jwtDecode() { - return {scope: 'com.atproto.access'} - }, -})) - -import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' -import {sessionAccountToSessionData} from '../session-data' -import {type SessionAccount} from '../types' -import { - asFetch, - DID, - DIDDOC_PDS_HOST, - HANDLE, - json, - makeAccount, - makeDidDoc, - makeMockFetch, - type MockFetch, - PDS_HOST, - SERVICE, - urlsOf, -} from './mock-fetch' - -/** - * Build the manager + agent pair under test. - * - * The mock fetch is installed in both places it can be reached from: as the - * inner `PasswordSession`'s fetch (the authenticated path) and, via - * `setFetch`, as the manager's own fetch (the unauthenticated bypass path, - * which would otherwise use the real network-aware fetch). - */ -function setup({ - account = makeAccount(), - didDoc, - pdsUrl, - fetchMock = makeMockFetch(), - sessionOptions, -}: { - account?: SessionAccount - didDoc?: SessionData['didDoc'] - pdsUrl?: string - fetchMock?: MockFetch - sessionOptions?: PasswordSessionOptions -} = {}) { - const data: SessionData = { - ...sessionAccountToSessionData(account), - ...(didDoc ? {didDoc} : {}), - } - const inner = new PasswordSession(data, { - fetch: asFetch(fetchMock), - ...sessionOptions, - }) - const manager = new PasswordSessionManager(inner, { - service: account.service, - pdsUrl, - }) - manager.setFetch(asFetch(fetchMock)) - const agent = new BskyAppAgent(manager) - return {inner, manager, agent, fetchMock} -} - -function setupPublic(fetchMock: MockFetch = makeMockFetch()) { - const manager = new PasswordSessionManager(null, {service: SERVICE}) - manager.setFetch(asFetch(fetchMock)) - return {manager, agent: new BskyAppAgent(manager), fetchMock} -} - -describe('PasswordSessionManager getters', () => { - it('reads live SessionData through .session', () => { - const {agent} = setup() - expect(agent.session?.did).toBe(DID) - expect(agent.session?.handle).toBe(HANDLE) - expect(agent.session?.email).toBe('alice@example.com') - expect(agent.session?.emailConfirmed).toBe(true) - expect(agent.did).toBe(DID) - expect(agent.hasSession).toBe(true) - }) - - it('defaults active to true when the payload omits it', () => { - const {agent} = setup({account: makeAccount({active: undefined})}) - expect(agent.session?.active).toBe(true) - }) - - it('exposes serviceUrl from the constructor service', () => { - const {agent} = setup() - expect(agent.serviceUrl.toString()).toBe('https://bsky.social/') - }) - - it('derives pdsUrl/dispatchUrl from the didDoc', () => { - const {agent} = setup({didDoc: makeDidDoc(PDS_HOST)}) - expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`) - expect(agent.dispatchUrl.toString()).toBe(`${PDS_HOST}/`) - }) - - it('falls back to the stored pdsUrl when there is no didDoc', () => { - const {agent} = setup({pdsUrl: PDS_HOST}) - expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`) - expect(agent.dispatchUrl.toString()).toBe(`${PDS_HOST}/`) - }) - - it('prefers the didDoc PDS over the stored pdsUrl', () => { - const {agent} = setup({ - didDoc: makeDidDoc(DIDDOC_PDS_HOST), - pdsUrl: PDS_HOST, - }) - expect(agent.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`) - }) - - it('dispatchUrl falls back to serviceUrl with no PDS at all', () => { - const {agent} = setup() - expect(agent.pdsUrl).toBe(undefined) - expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/') - }) - - it('ignores an unparseable stored pdsUrl', () => { - const {agent} = setup({pdsUrl: 'not a url'}) - expect(agent.pdsUrl).toBe(undefined) - expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/') - }) - - it('matches the inner session on didDocs a strict validator would reject', () => { - /* - * No `id` on the document and a non-canonical service `type`: enough for - * isValidDidDoc/getPdsEndpoint to bail, but PasswordSession still routes - * here, so the bridge must agree or dispatchUrl lies about where requests - * go (and service-auth aud gets minted for the wrong host). - */ - const {agent} = setup({ - didDoc: { - service: [ - { - id: '#atproto_pds', - type: 'SomethingElse', - serviceEndpoint: DIDDOC_PDS_HOST, - }, - ], - }, - pdsUrl: PDS_HOST, - }) - expect(agent.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`) - expect(agent.dispatchUrl.toString()).toBe(`${DIDDOC_PDS_HOST}/`) - }) - - it('falls back to the stored pdsUrl when the didDoc has no PDS service', () => { - const {agent} = setup({ - didDoc: { - id: DID, - service: [ - { - id: '#bsky_notif', - type: 'BskyNotificationService', - serviceEndpoint: DIDDOC_PDS_HOST, - }, - ], - }, - pdsUrl: PDS_HOST, - }) - expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`) - }) - - it('falls back to the stored pdsUrl when the PDS endpoint does not parse', () => { - const {agent} = setup({ - didDoc: { - id: DID, - service: [ - { - id: '#atproto_pds', - type: 'AtprotoPersonalDataServer', - serviceEndpoint: 'not a url', - }, - ], - }, - pdsUrl: PDS_HOST, - }) - expect(agent.pdsUrl?.toString()).toBe(`${PDS_HOST}/`) - }) -}) - -describe('PasswordSessionManager.session identity', () => { - it('is stable across consecutive reads', () => { - const {agent} = setup() - expect(agent.session).toBe(agent.session) - }) - - it('is a new object after a refresh rotates tokens', async () => { - const {agent} = setup() - const before = agent.session - expect(before?.accessJwt).toBe('access-jwt') - await agent.sessionManager.refreshSession() - const after = agent.session - expect(after).not.toBe(before) - expect(after?.accessJwt).toBe('access-jwt-2') - expect(after).toBe(agent.session) - }) - - it('rejects writes to .session', () => { - const {agent} = setup() - expect(() => { - /* the whole point of the accessor: writes must not silently drift */ - agent.sessionManager.session = agent.session - }).toThrow('read-only') - }) - - it('rejects writes to .pdsUrl', () => { - const {agent} = setup() - expect(() => { - agent.sessionManager.pdsUrl = new URL(PDS_HOST) - }).toThrow('read-only') - }) -}) - -describe('PasswordSessionManager.fetchHandler routing', () => { - it('dispatches to the stored PDS before a refresh, then to the didDoc PDS', async () => { - const {manager, fetchMock} = setup({pdsUrl: PDS_HOST}) - - await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') - expect(urlsOf(fetchMock).at(-1)).toBe( - `${PDS_HOST}/xrpc/app.bsky.actor.getProfile`, - ) - - /* the refresh response carries a didDoc pointing at a different host */ - await manager.refreshSession() - expect(manager.pdsUrl?.toString()).toBe(`${DIDDOC_PDS_HOST}/`) - - await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') - expect(urlsOf(fetchMock).at(-1)).toBe( - `${DIDDOC_PDS_HOST}/xrpc/app.bsky.actor.getProfile`, - ) - }) - - it('attaches the session bearer token', async () => { - const seen: Headers[] = [] - const fetchMock = makeMockFetch({ - 'app.bsky.actor.getProfile': (_url, init) => { - seen.push(new Headers(init.headers)) - return json({}) - }, - }) - const {manager} = setup({fetchMock}) - await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') - expect(seen[0].get('authorization')).toBe('Bearer access-jwt') - }) - - it('bypasses the inner session when authorization is pre-set', async () => { - const seen: Headers[] = [] - const fetchMock = makeMockFetch({ - 'com.atproto.server.describeServer': (_url, init) => { - seen.push(new Headers(init.headers)) - return json({}) - }, - }) - const {manager} = setup({fetchMock, pdsUrl: PDS_HOST}) - - /* - * PasswordSession throws TypeError on a pre-set authorization header, so - * this path must never reach it. - */ - await expect( - manager.fetchHandler('/xrpc/com.atproto.server.describeServer', { - headers: {authorization: 'Bearer caller-supplied'}, - }), - ).resolves.toBeDefined() - - expect(seen.length).toBe(1) - /* the caller's header survives, and there is exactly one of them */ - expect(seen[0].get('authorization')).toBe('Bearer caller-supplied') - expect(urlsOf(fetchMock).at(-1)).toBe( - `${PDS_HOST}/xrpc/com.atproto.server.describeServer`, - ) - }) -}) - -describe('BskyAppAgent namespace requests', () => { - it('carries proxy, labeler and bearer headers to the dispatch host', async () => { - const seen: {url: string; headers: Headers}[] = [] - const fetchMock = makeMockFetch({ - 'app.bsky.actor.getProfile': (url, init) => { - seen.push({url, headers: new Headers(init.headers)}) - return json({did: DID, handle: HANDLE}) - }, - }) - const {agent} = setup({fetchMock, pdsUrl: PDS_HOST}) - agent.configureProxy('did:web:api.bsky.app#bsky_appview') - agent.configureLabelers(['did:plc:custom-labeler']) - - /* - * The request headers (what we assert) are captured by the fetch mock - * before the agent parses the response body. Response-body lexicon - * validation can throw in the jest environment (a multiformats CID mock - * quirk unrelated to the header composition under test), so we ignore any - * parse error here. - */ - await agent.app.bsky.actor.getProfile({actor: HANDLE}).catch(() => {}) - - expect(seen.length).toBe(1) - expect(seen[0].url.startsWith(`${PDS_HOST}/xrpc/`)).toBe(true) - expect(seen[0].headers.get('atproto-proxy')).toBe( - 'did:web:api.bsky.app#bsky_appview', - ) - expect(seen[0].headers.get('atproto-accept-labelers')).toContain( - 'did:plc:custom-labeler', - ) - expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt') - }) -}) - -describe('PasswordSessionManager.refreshSession', () => { - it('returns an old-shaped XRPC envelope with fresh tokens', async () => { - const {manager, fetchMock} = setup() - const res = await manager.refreshSession() - expect(res.success).toBe(true) - expect(res.data.accessJwt).toBe('access-jwt-2') - expect(res.data.refreshJwt).toBe('refresh-jwt-2') - expect(res.data.did).toBe(DID) - expect(res.data.handle).toBe(HANDLE) - expect( - urlsOf(fetchMock).some(u => - u.includes('com.atproto.server.refreshSession'), - ), - ).toBe(true) - }) - - it('throws when there is no live session', async () => { - const {manager} = setupPublic() - await expect(manager.refreshSession()).rejects.toThrow( - 'No session to refresh', - ) - }) -}) - -describe('PasswordSessionManager.resumeSession', () => { - const staleData = { - accessJwt: 'stale-access', - refreshJwt: 'stale-refresh', - handle: 'stale.test', - did: DID, - active: true, - } - - it('ignores its argument and returns fresh tokens from a refresh', async () => { - const {manager} = setup() - const res = await manager.resumeSession(staleData) - expect(res.data.accessJwt).toBe('access-jwt-2') - expect(res.data.refreshJwt).toBe('refresh-jwt-2') - expect(manager.session?.accessJwt).toBe('access-jwt-2') - }) - - it('is reachable through the agent and does not install the stale data', async () => { - const {agent} = setup() - await agent.resumeSession(staleData) - expect(agent.session?.accessJwt).toBe('access-jwt-2') - expect(agent.session?.handle).toBe(HANDLE) - }) -}) - -describe('PasswordSessionManager unsupported methods', () => { - it('refuses login()', async () => { - const {agent} = setup() - await expect( - agent.login({identifier: HANDLE, password: 'hunter2'}), - ).rejects.toThrow('Not supported on PasswordSessionManager') - }) - - it('refuses createAccount()', async () => { - const {agent} = setup() - await expect( - agent.createAccount({handle: HANDLE, email: 'a@b.c', password: 'x'}), - ).rejects.toThrow('Not supported on PasswordSessionManager') - }) -}) - -describe('PasswordSessionManager destroyed inner session', () => { - it('getters return undefined rather than throwing after logout', async () => { - const {agent, inner} = setup() - await agent.logout() - expect(inner.destroyed).toBe(true) - /* PasswordSession.did/.session throw once destroyed; the bridge must not */ - expect(() => agent.did).not.toThrow() - expect(agent.did).toBe(undefined) - expect(agent.session).toBe(undefined) - expect(agent.hasSession).toBe(false) - expect(agent.pdsUrl).toBe(undefined) - }) - - it('logout() is idempotent', async () => { - const {agent} = setup() - await agent.logout() - await expect(agent.logout()).resolves.toBeUndefined() - }) - - it('fetchHandler stops attaching auth once destroyed', async () => { - const seen: Headers[] = [] - const fetchMock = makeMockFetch({ - 'app.bsky.actor.getProfile': (_url, init) => { - seen.push(new Headers(init.headers)) - return json({}) - }, - }) - const {agent, manager} = setup({fetchMock}) - await agent.logout() - await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') - expect(seen.length).toBe(1) - expect(seen[0].get('authorization')).toBe(null) - }) -}) - -describe('BskyAppAgent.dispose', () => { - let ctx: ReturnType - - beforeEach(() => { - ctx = setup({pdsUrl: PDS_HOST}) - }) - - it('makes the session read as logged out', () => { - expect(ctx.agent.session).toBeDefined() - ctx.agent.dispose() - expect(ctx.agent.session).toBe(undefined) - expect(ctx.agent.did).toBe(undefined) - expect(ctx.agent.pdsUrl).toBe(undefined) - expect(ctx.agent.hasSession).toBe(false) - }) - - it('routes requests through the plain unauthenticated fetch', async () => { - const seen: Headers[] = [] - const fetchMock = makeMockFetch({ - 'app.bsky.actor.getProfile': (_url, init) => { - seen.push(new Headers(init.headers)) - return json({}) - }, - }) - const {agent, manager} = setup({fetchMock, pdsUrl: PDS_HOST}) - agent.dispose() - await manager.fetchHandler('/xrpc/app.bsky.actor.getProfile') - expect(seen.length).toBe(1) - expect(seen[0].get('authorization')).toBe(null) - /* dispatch falls back to the service, since pdsUrl now reads undefined */ - expect(urlsOf(fetchMock).at(-1)).toBe( - `${SERVICE}/xrpc/app.bsky.actor.getProfile`, - ) - }) - - it('leaves refreshSession unusable', async () => { - ctx.agent.dispose() - await expect(ctx.agent.sessionManager.refreshSession()).rejects.toThrow( - 'No session to refresh', - ) - }) -}) - -describe('public PasswordSessionManager (no inner session)', () => { - it('reads as logged out', () => { - const {agent} = setupPublic() - expect(agent.session).toBe(undefined) - expect(agent.did).toBe(undefined) - expect(agent.hasSession).toBe(false) - expect(agent.pdsUrl).toBe(undefined) - expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/') - }) - - it('dispatches to the service unauthenticated', async () => { - const seen: Headers[] = [] - const fetchMock = makeMockFetch({ - 'app.bsky.feed.getFeed': (_url, init) => { - seen.push(new Headers(init.headers)) - return json({}) - }, - }) - const {manager} = setupPublic(fetchMock) - await manager.fetchHandler('/xrpc/app.bsky.feed.getFeed') - expect(seen.length).toBe(1) - expect(seen[0].get('authorization')).toBe(null) - expect(urlsOf(fetchMock).at(-1)).toBe( - `${SERVICE}/xrpc/app.bsky.feed.getFeed`, - ) - }) -}) - -describe('PasswordSession lifecycle over mocked fetch', () => { - it('resume fast path: constructing does not hit the network', () => { - const fetchMock = makeMockFetch() - setup({fetchMock}) - expect(fetchMock.mock.calls.length).toBe(0) - }) - - it('a refresh fires onUpdated with fresh tokens', async () => { - const onUpdated = - jest.fn>() - const {manager} = setup({sessionOptions: {onUpdated}}) - await manager.refreshSession() - expect(onUpdated).toHaveBeenCalledTimes(1) - expect(manager.session?.accessJwt).toBe('access-jwt-2') - }) - - it('onDeleted fires when refresh returns a declared invalid-token error', async () => { - const onDeleted = - jest.fn>() - const onUpdated = - jest.fn>() - const fetchMock = makeMockFetch({ - 'com.atproto.server.refreshSession': () => - json({error: 'ExpiredToken', message: 'Token expired'}, 400), - }) - const {manager} = setup({fetchMock, sessionOptions: {onDeleted, onUpdated}}) - await expect(manager.refreshSession()).rejects.toBeDefined() - expect(onDeleted).toHaveBeenCalledTimes(1) - expect(onUpdated).not.toHaveBeenCalled() - /* and the bridge reads as logged out afterwards */ - expect(manager.session).toBe(undefined) - }) - - it('onUpdateFailure fires on a transient (500) refresh error, session preserved', async () => { - const onDeleted = - jest.fn>() - const onUpdateFailure = - jest.fn>() - const fetchMock = makeMockFetch({ - 'com.atproto.server.refreshSession': () => - json({error: 'InternalServerError'}, 500), - }) - const {manager} = setup({ - fetchMock, - sessionOptions: {onDeleted, onUpdateFailure}, - }) - /* - * PasswordSession.refresh() resolves with the unchanged data here; the - * bridge restores the old CredentialSession contract by rejecting. - */ - await expect(manager.refreshSession()).rejects.toThrow( - 'Failed to refresh session', - ) - expect(onUpdateFailure).toHaveBeenCalledTimes(1) - expect(onDeleted).not.toHaveBeenCalled() - expect(manager.session?.accessJwt).toBe('access-jwt') - }) - - it('rejects on a network error rather than reporting a no-op success', async () => { - const fetchMock = makeMockFetch({ - 'com.atproto.server.refreshSession': () => { - throw new TypeError('Network request failed') - }, - }) - const {manager} = setup({fetchMock}) - await expect(manager.refreshSession()).rejects.toThrow( - 'Failed to refresh session', - ) - /* the session survives, exactly as the old transient-failure path did */ - expect(manager.session?.accessJwt).toBe('access-jwt') - }) - - it('resumeSession rejects on a transient failure too', async () => { - const fetchMock = makeMockFetch({ - 'com.atproto.server.refreshSession': () => - json({error: 'InternalServerError'}, 500), - }) - const {agent} = setup({fetchMock}) - await expect(agent.resumeSession(agent.session!)).rejects.toThrow( - 'Failed to refresh session', - ) - }) -}) diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts index 019281f784..50cf70fede 100644 --- a/src/state/session/__tests__/clients-test.ts +++ b/src/state/session/__tests__/clients-test.ts @@ -13,26 +13,29 @@ jest.mock('jwt-decode', () => ({ }, })) -import {CHAT_PROXY_SERVICE} from '#/lib/constants' +import {BLUESKY_PROXY_HEADER, CHAT_PROXY_SERVICE} from '#/lib/constants' import {app, chat, com} from '#/lexicons' import {configureGlobalAppLabelers} from '../additional-moderation-authorities' -import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' import { - agentToAppviewClient, - agentToChatClient, - agentToPdsClient, + buildAppviewClient, + buildChatClient, + buildPdsClient, getUnauthenticatedThrowingClient, NotAuthenticatedError, + routeSessionToPds, } from '../clients' import {sessionAccountToSessionData} from '../session-data' import { asFetch, DID, + DIDDOC_PDS_HOST, HANDLE, json, makeAccount, + makeDidDoc, makeMockFetch, type MockFetch, + PDS_HOST, SERVICE, urlsOf, } from './mock-fetch' @@ -49,25 +52,16 @@ function makeProfileFetch(): MockFetch { }) } -/** An authenticated agent whose whole network path is the mock fetch. */ -function setup(fetchMock: MockFetch = makeProfileFetch()) { +/** A live `PasswordSession` whose whole network path is the mock fetch. */ +function makeSession(fetchMock: MockFetch, didDocPdsUrl?: string) { const account = makeAccount() - const inner = new PasswordSession(sessionAccountToSessionData(account), { - fetch: asFetch(fetchMock), - }) - const manager = new PasswordSessionManager(inner, { - service: account.service, - }) - manager.setFetch(asFetch(fetchMock)) - const agent = new BskyAppAgent(manager) - return {agent, fetchMock} -} - -/** A logged-out agent whose whole network path is the mock fetch. */ -function setupPublic(fetchMock: MockFetch = makeProfileFetch()) { - const manager = new PasswordSessionManager(null, {service: SERVICE}) - manager.setFetch(asFetch(fetchMock)) - return {agent: new BskyAppAgent(manager), fetchMock} + return new PasswordSession( + { + ...sessionAccountToSessionData(account), + ...(didDocPdsUrl ? {didDoc: makeDidDoc(didDocPdsUrl)} : {}), + }, + {fetch: asFetch(fetchMock)}, + ) } /** The `init` a mock fetch was called with for a given nsid. */ @@ -79,84 +73,56 @@ function initFor(mock: MockFetch, nsid: string): RequestInit | undefined { return call?.[1] } -describe('agentToAppviewClient', () => { +/** The headers a mock fetch was called with for a given nsid. */ +function headersFor(mock: MockFetch, nsid: string): Headers { + return new Headers(initFor(mock, nsid)?.headers) +} + +describe('buildAppviewClient', () => { let fetchMock: MockFetch beforeEach(() => { fetchMock = makeProfileFetch() + configureGlobalAppLabelers([]) }) - it('memoizes one client per agent', () => { - const {agent: agentA} = setup(fetchMock) - const {agent: agentB} = setup(fetchMock) - - const clientA1 = agentToAppviewClient(agentA) - const clientA2 = agentToAppviewClient(agentA) - const clientB = agentToAppviewClient(agentB) - - expect(clientA1).toBeInstanceOf(Client) - expect(clientA1).toBe(clientA2) - expect(clientA1).not.toBe(clientB) + it('passes through the session did', () => { + const client = buildAppviewClient(makeSession(fetchMock)) + expect(client).toBeInstanceOf(Client) + expect(client.did).toBe(DID) }) - it('passes through the agent did', () => { - const {agent} = setup(fetchMock) - expect(agentToAppviewClient(agent).did).toBe(DID) - }) + it('routes client.call through the session to the network', async () => { + const client = buildAppviewClient(makeSession(fetchMock)) - it('reflects an undefined did on a logged-out agent', () => { - const {agent} = setupPublic(fetchMock) - expect(agentToAppviewClient(agent).did).toBeUndefined() - }) - - it('routes client.call through the agent to the network', async () => { - const {agent} = setup(fetchMock) - - const body = await agentToAppviewClient(agent).call( - app.bsky.actor.getProfile, - { - actor: HANDLE, - }, - ) + const body = await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) expect(body.handle).toBe(HANDLE) - const call = fetchMock.mock.calls.find(c => { - const url = c[0] instanceof URL ? c[0].href : String(c[0]) - return url.includes('/xrpc/app.bsky.actor.getProfile') - }) - expect(call).toBeDefined() - const url = call![0] instanceof URL ? call![0].href : String(call![0]) - expect(url).toContain(`actor=${HANDLE}`) + expect(urlsOf(fetchMock).join()).toContain(`actor=${HANDLE}`) }) - it('emits the agent proxy header', async () => { - const {agent} = setup(fetchMock) - agent.configureProxy('did:web:api.bsky.app#bsky_appview') + it('emits the appview proxy header', async () => { + const client = buildAppviewClient(makeSession(fetchMock)) - await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { - actor: HANDLE, - }) + await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) - const init = initFor(fetchMock, 'app.bsky.actor.getProfile') - expect(new Headers(init?.headers).get('atproto-proxy')).toBe( - 'did:web:api.bsky.app#bsky_appview', + expect( + headersFor(fetchMock, 'app.bsky.actor.getProfile').get('atproto-proxy'), + ).toBe(BLUESKY_PROXY_HEADER.get()) + }) + + it('emits an account subscription exactly once', async () => { + const client = buildAppviewClient(makeSession(fetchMock)) + client.setLabelers(['did:plc:labeler']) + + await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) + + const labelers = headersFor(fetchMock, 'app.bsky.actor.getProfile').get( + 'atproto-accept-labelers', ) - }) - - it('emits the agent labeler header exactly once', async () => { - const {agent} = setup(fetchMock) - agent.configureLabelersHeader(['did:plc:labeler']) - - await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { - actor: HANDLE, - }) - - const init = initFor(fetchMock, 'app.bsky.actor.getProfile') - const labelers = new Headers(init?.headers).get('atproto-accept-labelers') - expect(labelers).toContain('did:plc:labeler') /* - * The client contributes no labelers of its own, so the agent's single - * entry must not be duplicated. + * The client is the only producer of this header now, so a duplicate would + * mean lex itself emitted the same DID twice. */ const entries = labelers! .split(',') @@ -164,25 +130,21 @@ describe('agentToAppviewClient', () => { expect(entries).toHaveLength(1) }) - it('does not duplicate a global app labeler set on both statics', async () => { + it('emits a global app labeler once, redacted', async () => { /* - * `configureGlobalAppLabelers` populates the agent AND the lex `Client` - * static, because clients built without a wrapped agent read only the - * latter. On this path both producers are in play for the same request, and - * neither dedupes against the other - the agent joins its list with the - * existing header string while lex collects into a `Set` keyed on the - * `;redact`-suffixed value. The appview client suppresses its `appLabelers` - * so exactly one producer contributes. + * The global static is the ONLY producer of the redacted authorities - no + * agent stamps them any more - and lex suffixes them with `;redact`. An + * account subscription that also listed the same DID would produce a second, + * non-redacting entry, which is what `applyLabelersToClient` filters against. */ configureGlobalAppLabelers(['did:plc:global-labeler']) - const {agent} = setup(fetchMock) + const client = buildAppviewClient(makeSession(fetchMock)) - await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { - actor: HANDLE, - }) + await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) - const init = initFor(fetchMock, 'app.bsky.actor.getProfile') - const labelers = new Headers(init?.headers).get('atproto-accept-labelers') + const labelers = headersFor(fetchMock, 'app.bsky.actor.getProfile').get( + 'atproto-accept-labelers', + ) const entries = labelers! .split(',') .map(l => l.trim()) @@ -191,100 +153,69 @@ describe('agentToAppviewClient', () => { }) it('sends the session access token', async () => { - const {agent} = setup(fetchMock) - - await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { - actor: HANDLE, - }) - - const init = initFor(fetchMock, 'app.bsky.actor.getProfile') - expect(new Headers(init?.headers).get('authorization')).toBe( - 'Bearer access-jwt', - ) - }) - - it('falls back to unauthenticated requests once the agent is disposed', async () => { - const {agent} = setup(fetchMock) - const client = agentToAppviewClient(agent) - agent.dispose() + const client = buildAppviewClient(makeSession(fetchMock)) await client.call(app.bsky.actor.getProfile, {actor: HANDLE}) - const init = initFor(fetchMock, 'app.bsky.actor.getProfile') - expect(new Headers(init?.headers).has('authorization')).toBe(false) - expect(client.did).toBeUndefined() + expect( + headersFor(fetchMock, 'app.bsky.actor.getProfile').get('authorization'), + ).toBe('Bearer access-jwt') }) }) -describe('agentToPdsClient', () => { +describe('buildPdsClient', () => { let fetchMock: MockFetch beforeEach(() => { fetchMock = makeProfileFetch() + configureGlobalAppLabelers([]) }) - it('memoizes one client per agent', () => { - const {agent: agentA} = setup(fetchMock) - const {agent: agentB} = setup(fetchMock) - - const clientA1 = agentToPdsClient(agentA) - const clientA2 = agentToPdsClient(agentA) - - expect(clientA1).toBeInstanceOf(Client) - expect(clientA1).toBe(clientA2) - expect(clientA1).not.toBe(agentToPdsClient(agentB)) - }) - - it('is a distinct client from the appview client for the same agent', () => { - const {agent} = setup(fetchMock) - expect(agentToPdsClient(agent)).not.toBe(agentToAppviewClient(agent)) - }) - - it('passes through the agent did', () => { - const {agent} = setup(fetchMock) - expect(agentToPdsClient(agent).did).toBe(DID) + it('is a distinct client from the appview client over the same session', () => { + const session = makeSession(fetchMock) + expect(buildPdsClient(session)).not.toBe(buildAppviewClient(session)) }) it('sends the session access token', async () => { - const {agent} = setup(fetchMock) - - await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) - - const init = initFor(fetchMock, 'com.atproto.server.getSession') - expect(new Headers(init?.headers).get('authorization')).toBe( - 'Bearer access-jwt', + await buildPdsClient(makeSession(fetchMock)).call( + com.atproto.server.getSession, + {}, ) + + expect( + headersFor(fetchMock, 'com.atproto.server.getSession').get( + 'authorization', + ), + ).toBe('Bearer access-jwt') }) - it('emits neither the proxy nor the labeler header the agent is configured with', async () => { + it('emits neither the proxy nor any labeler header', async () => { /* - * The load-bearing difference from the appview client: this client wraps the - * session manager, below the agent layer that sets both headers, so a - * request reaches the account's PDS instead of being proxied onward. + * The load-bearing difference from the appview client: a PDS request must + * reach the account host itself rather than being proxied onward, and it is + * not an appview read, so it carries no moderation authorities either. */ - const {agent} = setup(fetchMock) - agent.configureProxy('did:web:api.bsky.app#bsky_appview') - agent.configureLabelersHeader(['did:plc:labeler']) - /* nor the global authorities: a PDS call is not an appview read */ configureGlobalAppLabelers(['did:plc:global-labeler']) - await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) - - const headers = new Headers( - initFor(fetchMock, 'com.atproto.server.getSession')?.headers, + await buildPdsClient(makeSession(fetchMock)).call( + com.atproto.server.getSession, + {}, ) + + const headers = headersFor(fetchMock, 'com.atproto.server.getSession') expect(headers.get('atproto-proxy')).toBeNull() expect(headers.get('atproto-accept-labelers')).toBeNull() }) it('resolves the relative xrpc path against the account host', async () => { /* - * lex-client hands its fetchHandler an origin-less `/xrpc/` path; the - * session manager absolutizes it against dispatchUrl. + * lex hands its fetchHandler an origin-less `/xrpc/` path; the session + * absolutizes it against its didDoc endpoint or, absent one, its service. */ - const {agent} = setup(fetchMock) - - await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) + await buildPdsClient(makeSession(fetchMock)).call( + com.atproto.server.getSession, + {}, + ) expect(urlsOf(fetchMock)).toContain( `${SERVICE}/xrpc/com.atproto.server.getSession`, @@ -292,34 +223,26 @@ describe('agentToPdsClient', () => { }) }) -describe('agentToChatClient', () => { +describe('buildChatClient', () => { let fetchMock: MockFetch beforeEach(() => { fetchMock = makeProfileFetch() + configureGlobalAppLabelers([]) }) - it('memoizes one client per agent, distinct from the pds client', () => { - const {agent} = setup(fetchMock) - - const client = agentToChatClient(agent) - - expect(client).toBeInstanceOf(Client) - expect(client).toBe(agentToChatClient(agent)) - expect(client).not.toBe(agentToPdsClient(agent)) + it('is a distinct client from the pds client over the same session', () => { + const session = makeSession(fetchMock) + expect(buildChatClient(session)).not.toBe(buildPdsClient(session)) }) it('emits the chat proxy header exactly once, with the session token', async () => { - const {agent} = setup(fetchMock) - /* the stub body fails listConvos output validation; headers are recorded pre-parse */ - await agentToChatClient(agent) + await buildChatClient(makeSession(fetchMock)) .call(chat.bsky.convo.listConvos, {}) .catch(() => {}) - const headers = new Headers( - initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers, - ) + const headers = headersFor(fetchMock, 'chat.bsky.convo.listConvos') /* * An exact match, not `toContain`: `Headers` comma-joins repeated entries * for the same name, so a second contributor would show up here. @@ -328,20 +251,96 @@ describe('agentToChatClient', () => { expect(headers.get('authorization')).toBe('Bearer access-jwt') }) - it('does not emit the agent labeler header', async () => { - const {agent} = setup(fetchMock) - agent.configureLabelersHeader(['did:plc:labeler']) - /* nor the global authorities: a chat call is not an appview read */ + it('emits no labeler header', async () => { + /* the global authorities do not apply: a chat call is not an appview read */ configureGlobalAppLabelers(['did:plc:global-labeler']) - await agentToChatClient(agent) + await buildChatClient(makeSession(fetchMock)) .call(chat.bsky.convo.listConvos, {}) .catch(() => {}) - const headers = new Headers( - initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers, - ) - expect(headers.get('atproto-accept-labelers')).toBeNull() + expect( + headersFor(fetchMock, 'chat.bsky.convo.listConvos').get( + 'atproto-accept-labelers', + ), + ).toBeNull() + }) +}) + +describe('routeSessionToPds', () => { + let fetchMock: MockFetch + + beforeEach(() => { + fetchMock = makeProfileFetch() + }) + + it('sends a request to the pinned host rather than the login service', async () => { + /* + * The entryway case, and the reason this shim exists: an account whose + * service is `bsky.social` but whose PDS is elsewhere, with no didDoc yet + * (the synchronous resume fast path, i.e. the common cold start). Without + * the shim the session would resolve against its service and every request + * of that cold start would go to the entryway. + */ + const session = makeSession(fetchMock) + const client = buildPdsClient(routeSessionToPds(session, PDS_HOST)) + + await client.call(com.atproto.server.getSession, {}) + + expect(urlsOf(fetchMock)).toEqual([ + `${PDS_HOST}/xrpc/com.atproto.server.getSession`, + ]) + }) + + it('keeps the session auth lifecycle on the pinned host', async () => { + const session = makeSession(fetchMock) + const client = buildPdsClient(routeSessionToPds(session, PDS_HOST)) + + await client.call(com.atproto.server.getSession, {}) + + expect( + headersFor(fetchMock, 'com.atproto.server.getSession').get( + 'authorization', + ), + ).toBe('Bearer access-jwt') + }) + + it('passes through the session did', () => { + const session = makeSession(fetchMock) + expect(routeSessionToPds(session, PDS_HOST).did).toBe(DID) + }) + + it('pins the stored host even when the session carries a different didDoc endpoint', async () => { + /* + * The narrowing this shim accepts versus the session manager it replaces: + * the manager preferred a didDoc endpoint once one arrived, whereas an + * absolute URL handed to `session.fetchHandler` survives `new URL(path, + * base)` untouched, so the stored host wins for the bundle's lifetime. That + * only matters if the account's PDS moved, and the next cold start pins the + * newly persisted endpoint. + */ + const session = makeSession(fetchMock, DIDDOC_PDS_HOST) + const client = buildPdsClient(routeSessionToPds(session, PDS_HOST)) + + await client.call(com.atproto.server.getSession, {}) + + expect(urlsOf(fetchMock)).toEqual([ + `${PDS_HOST}/xrpc/com.atproto.server.getSession`, + ]) + }) + + it('lets the session route by didDoc when nothing is pinned', async () => { + /* + * The counterpart: a bundle built with no stored `pdsUrl` goes straight over + * the session, which resolves against its own didDoc endpoint. + */ + const client = buildPdsClient(makeSession(fetchMock, DIDDOC_PDS_HOST)) + + await client.call(com.atproto.server.getSession, {}) + + expect(urlsOf(fetchMock)).toEqual([ + `${DIDDOC_PDS_HOST}/xrpc/com.atproto.server.getSession`, + ]) }) }) diff --git a/src/state/session/__tests__/provider-clients-test.tsx b/src/state/session/__tests__/provider-clients-test.tsx index 58ea9ad6fe..d10af7a12b 100644 --- a/src/state/session/__tests__/provider-clients-test.tsx +++ b/src/state/session/__tests__/provider-clients-test.tsx @@ -72,11 +72,10 @@ import { useSessionApi, } from '#/state/session' import {type SessionApiContext} from '#/state/session/types' -import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' import { - agentToAppviewClient, - agentToChatClient, - agentToPdsClient, + buildAppviewClient, + buildChatClient, + buildPdsClient, getUnauthenticatedThrowingClient, } from '../clients' import {type SessionBundle} from '../session-core' @@ -92,22 +91,19 @@ type Clients = { } /** - * Build a bundle whose agent is a real `BskyAppAgent` over a real - * `PasswordSession`, since the client builders derive from the agent. Only the - * fields the provider reads are populated. + * Build a bundle over a real `PasswordSession`, with the three clients the + * provider serves. Only the fields the provider reads are populated. */ function makeBundle(account: SessionAccount): SessionBundle { const fetchMock = makeMockFetch() const session = new PasswordSession(sessionAccountToSessionData(account), { fetch: asFetch(fetchMock), }) - const manager = new PasswordSessionManager(session, { - service: account.service, - }) - manager.setFetch(asFetch(fetchMock)) return { session, - agent: new BskyAppAgent(manager), + appviewClient: buildAppviewClient(session), + pdsClient: buildPdsClient(session), + chatClient: buildChatClient(session), service: new URL(account.service), } } @@ -140,10 +136,10 @@ beforeEach(() => { }) describe('client hooks while logged out', () => { - it('serves the public agent for appview reads', () => { + it('serves the public client for appview reads', () => { const {clients} = renderClients() expect(clients().appview).toBeDefined() - /* the logged-out bundle's agent IS the public agent, so no separate branch */ + /* the logged-out bundle holds the public appview client itself */ expect(clients().appview.did).toBeUndefined() }) @@ -162,7 +158,7 @@ describe('client hooks while logged out', () => { }) describe('client hooks with a session', () => { - it('derives every surface from the session bundle agent', async () => { + it('serves every surface straight off the session bundle', async () => { const account = makeAccount() const bundle = makeBundle(account) const {api, clients} = renderClients() @@ -172,9 +168,9 @@ describe('client hooks with a session', () => { await api.login({} as never, 'LoginForm') }) - expect(clients().appview).toBe(agentToAppviewClient(bundle.agent)) - expect(clients().pds).toBe(agentToPdsClient(bundle.agent)) - expect(clients().chat).toBe(agentToChatClient(bundle.agent)) + expect(clients().appview).toBe(bundle.appviewClient) + expect(clients().pds).toBe(bundle.pdsClient) + expect(clients().chat).toBe(bundle.chatClient) }) it('serves the same clients from the maybe variants', async () => { diff --git a/src/state/session/__tests__/provider-refresh-session-test.tsx b/src/state/session/__tests__/provider-refresh-session-test.tsx index 6b1e10bdec..2b7bd1c4cb 100644 --- a/src/state/session/__tests__/provider-refresh-session-test.tsx +++ b/src/state/session/__tests__/provider-refresh-session-test.tsx @@ -63,7 +63,7 @@ jest.mock('../create-account', () => ({ import {Provider, useSession, useSessionApi} from '#/state/session' import {type SessionApiContext} from '#/state/session/types' -import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' +import {buildAppviewClient, buildChatClient, buildPdsClient} from '../clients' import {type SessionBundle} from '../session-core' import {sessionAccountToSessionData} from '../session-data' import { @@ -87,13 +87,11 @@ function makeBundle( const session = new PasswordSession(sessionAccountToSessionData(account), { fetch: asFetch(fetchMock), }) - const manager = new PasswordSessionManager(session, { - service: account.service, - }) - manager.setFetch(asFetch(fetchMock)) return { session, - agent: new BskyAppAgent(manager), + appviewClient: buildAppviewClient(session), + pdsClient: buildPdsClient(session), + chatClient: buildChatClient(session), service: new URL(account.service), } } diff --git a/src/state/session/__tests__/session-core-test.ts b/src/state/session/__tests__/session-core-test.ts index 52484dcf72..3503acd08a 100644 --- a/src/state/session/__tests__/session-core-test.ts +++ b/src/state/session/__tests__/session-core-test.ts @@ -59,17 +59,17 @@ jest.mock('#/analytics', () => ({ /* * `configureModerationForAccount` is synchronous (the labeler cache is a local * MMKV read), so it is not a prep await - but it still runs inside each factory - * with the freshly built bridge agent, before the awaited prep steps. The - * factory tests capture the agent with this mock, then inject a real 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. + * with the freshly built bundle, before the awaited prep steps. The factory + * tests capture the bundle with this mock, then inject a real 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 = - jest.fn<(agent: unknown, account: unknown) => void>() + jest.fn<(bundle: unknown, account: unknown) => void>() jest.mock('../moderation', () => ({ - configureModerationForAccount: (agent: unknown, account: unknown) => - mockConfigureModerationForAccount(agent, account), + configureModerationForAccount: (bundle: unknown, account: unknown) => + mockConfigureModerationForAccount(bundle, account), configureModerationForGuest: () => {}, })) @@ -90,7 +90,6 @@ jest.mock('jwt-decode', () => ({ }, })) -import {type BskyAppAgent} from '../bridge-agent' import { type AtpSessionEvent, buildBundle, @@ -365,22 +364,23 @@ describe('sessionAccountToSessionData', () => { }) describe('createSessionBundleFromStoredAccount', () => { - it('builds a bridge agent over one session', () => { + it('builds three clients over one session', async () => { const result = createSessionBundleFromStoredAccount( makeAccount(), jest.fn(), )! - /* the agent reads its identity straight through the shared session */ - expect(result.bundle.agent.session?.accessJwt).toBe('access-jwt') - expect(result.bundle.agent.did).toBe(DID) - expect(result.bundle.agent.sessionManager.session).toBe( - result.bundle.agent.session, - ) + /* every client reads its identity straight through the shared session */ + expect(result.bundle.session.session.accessJwt).toBe('access-jwt') + expect(result.bundle.appviewClient.did).toBe(DID) + expect(result.bundle.pdsClient.did).toBe(DID) + expect(result.bundle.chatClient.did).toBe(DID) expect(result.bundle.service.toString()).toBe(`${SERVICE}/`) disposeBundle(result.bundle) - /* disposal detaches the agent from the session */ - expect(result.bundle.agent.session).toBe(undefined) + /* disposal disables the transport every client shares */ + await expect( + result.bundle.session.fetchHandler('/xrpc/test', {}), + ).rejects.toThrow('session disposed') }) it('disposes a bundle rejected by the activation guard', async () => { @@ -836,15 +836,14 @@ describe('factory account snapshot after preparation', () => { * captured from the (synchronous) moderation call, and the rotation is * injected into the awaited AA prefetch. */ - let capturedAgent: BskyAppAgent | undefined + let capturedBundle: SessionBundle | undefined mockConfigureModerationForAccount.mockImplementationOnce( (bundle: unknown) => { - capturedAgent = (bundle as {agent: BskyAppAgent}).agent + capturedBundle = bundle as SessionBundle }, ) mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => { - /* routes through the bridge into the shared PasswordSession's refresh */ - await capturedAgent!.sessionManager.refreshSession() + await capturedBundle!.session.refresh() }) const fetchMock = makeMockFetch() @@ -902,14 +901,14 @@ describe('a session destroyed or rejected during preparation', () => { * way to drive a session to `destroyed` from outside, and it takes the same * `deleteSession` -> onDeleted -> destroyed path the real 401 rescue does. */ - let capturedAgent: BskyAppAgent | undefined + let capturedBundle: SessionBundle | undefined mockConfigureModerationForAccount.mockImplementationOnce( (bundle: unknown) => { - capturedAgent = (bundle as {agent: BskyAppAgent}).agent + capturedBundle = bundle as SessionBundle }, ) mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => { - await capturedAgent!.logout() + await capturedBundle!.session.logout() }) const fetchMock = makeMockFetch() @@ -927,15 +926,15 @@ describe('a session destroyed or rejected during preparation', () => { ).rejects.toThrow('Session was revoked while it was being prepared') /* the bundle the caller never received reads as logged out */ - expect(capturedAgent!.session).toBe(undefined) + expect(capturedBundle!.session.destroyed).toBe(true) }) }) it('resume: a prep rejection propagates and disposes the bundle', async () => { - let capturedAgent: BskyAppAgent | undefined + let capturedBundle: SessionBundle | undefined mockConfigureModerationForAccount.mockImplementationOnce( (bundle: unknown) => { - capturedAgent = (bundle as {agent: BskyAppAgent}).agent + capturedBundle = bundle as SessionBundle }, ) mockPrefetchAgeAssuranceServerData.mockImplementationOnce(() => @@ -952,7 +951,9 @@ describe('a session destroyed or rejected during preparation', () => { ).rejects.toThrow('prefetch blew up') /* the still-live session was disposed rather than left refreshing */ - expect(capturedAgent!.session).toBe(undefined) + await expect( + capturedBundle!.session.fetchHandler('/xrpc/test', {}), + ).rejects.toThrow('session disposed') }) }) @@ -964,7 +965,12 @@ describe('a session destroyed or rejected during preparation', () => { }) const session = new PasswordSession( sessionAccountToSessionData(makeAccount()), - {...hooks, fetch: asFetch(makeMockFetch())}, + /* + * The hooks' own `fetch` is what the kill switch disables, so it must not + * be overridden here. Nothing in this test reaches the network: the + * post-disposal `fetchHandler` call throws before dispatching. + */ + hooks, ) const bundle = buildBundle(session) registerBundleKillSwitch(bundle, hooks.kill) @@ -975,7 +981,9 @@ describe('a session destroyed or rejected during preparation', () => { ).rejects.toThrow('nope') expect(snapshot).not.toHaveBeenCalled() - expect(bundle.agent.session).toBe(undefined) + await expect(bundle.session.fetchHandler('/xrpc/test', {})).rejects.toThrow( + 'session disposed', + ) }) }) diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts index 3f3566c5c6..76d1039610 100644 --- a/src/state/session/additional-moderation-authorities.ts +++ b/src/state/session/additional-moderation-authorities.ts @@ -1,4 +1,3 @@ -import {AtpAgent} from '@atproto/api' import {Client} from '@atproto/lex' import {device} from '#/storage' @@ -83,27 +82,27 @@ export function configureAdditionalModerationAuthorities() { additionalLabelers = [] } + /* + * Merge with whatever is already on the static rather than replacing it, so + * `switchToBskyAppLabeler`'s entry survives. + */ const appLabelers = Array.from( - new Set([...AtpAgent.appLabelers, ...additionalLabelers]), + new Set([...Client.appLabelers, ...additionalLabelers]), ) configureGlobalAppLabelers(appLabelers) } /** - * Set the global app labelers on BOTH statics, so the agent-backed request path - * and any client built without a wrapped agent emit the same `;redact` - * authorities. + * Set the global app labelers on the lex `Client` static, which every client + * reads, so a request carries the same `;redact` authorities whether or not + * there is a session behind it. * - * Keeping the two in lockstep is what makes the duplicate-header hazard - * avoidable: the agent joins its own list with whatever the caller already put - * on the request, and lex appends the `Client` static on top of that, so a DID - * present in both would appear twice. The agent-wrapping clients therefore - * suppress their `appLabelers` (see `clients.ts`), which leaves exactly one - * producer per request while both statics stay populated for the paths that - * read only one of them. + * It is a single global producer by design. The PDS and chat clients opt out + * with `appLabelers: null` (see `clients.ts`) because those services take no + * moderation authorities, leaving exactly one producer on an appview request and + * none elsewhere. */ export function configureGlobalAppLabelers(dids: string[]) { - AtpAgent.configure({appLabelers: dids}) Client.configure({appLabelers: dids as `did:${string}:${string}`[]}) } diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts deleted file mode 100644 index 7246454ed2..0000000000 --- a/src/state/session/agent.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - Agent as BaseAgent, - type AtprotoServiceType, - type Did, -} from '@atproto/api' - -export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}` - -/** - * A bare `Agent` that applies a service-proxy header on construction. - * - * Used for the unauthenticated, service-specific calls that cannot go through - * the session agent (PDS detection, password reset, handle availability). - */ -export class Agent extends BaseAgent { - constructor( - proxyHeader: ProxyHeaderValue | null, - ...options: ConstructorParameters - ) { - super(...options) - if (proxyHeader) { - this.configureProxy(proxyHeader) - } - } -} diff --git a/src/state/session/bridge-agent.ts b/src/state/session/bridge-agent.ts deleted file mode 100644 index 0c692a6c17..0000000000 --- a/src/state/session/bridge-agent.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { - AtpAgent, - type AtpAgentLoginOpts, - type AtpSessionData, - type ComAtprotoServerCreateAccount, - type ComAtprotoServerCreateSession, - type ComAtprotoServerRefreshSession, - CredentialSession, -} from '@atproto/api' -import { - type PasswordSession, - type SessionData, -} from '@atproto/lex-password-session' - -import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants' -import {configureModerationForGuest} from './moderation' -import {networkAwareFetch} from './network' - -const UNSUPPORTED = - 'Not supported on PasswordSessionManager; use the session factories in session-core' - -/** - * Convert live `PasswordSession` session data into the `AtpSessionData` shape - * that `CredentialSession.session` consumers expect. - * - * The only real adaptation is `active`: `AtpSessionData` requires it, while the - * lexicon payload leaves it optional (absent means active, per the lexicon - * docs). - */ -function toAtpSessionData(d: SessionData): AtpSessionData { - return { - refreshJwt: d.refreshJwt, - accessJwt: d.accessJwt, - handle: d.handle, - did: d.did, - email: d.email, - emailConfirmed: d.emailConfirmed, - emailAuthFactor: d.emailAuthFactor, - active: d.active ?? true, - status: d.status, - } -} - -/** - * Parse a URL without throwing. - */ -function parseUrl(input: string): URL | undefined { - try { - return new URL(input) - } catch { - return undefined - } -} - -/** - * Read a property off an unknown value the way JS optional chaining would, - * without narrowing assumptions about the shape of a `LexMap`. - */ -function prop(value: unknown, key: string): unknown { - return typeof value === 'object' && value !== null - ? (value as Record)[key] - : undefined -} - -/** - * The PDS endpoint declared by a DID document, or `undefined`. - * - * This deliberately mirrors `extractPdsUrl` in `@atproto/lex-password-session`, - * which is the predicate `PasswordSession` uses to route its own requests: the - * first service entry whose `id` ends with `#atproto_pds`, taking its - * `serviceEndpoint` if it parses as a URL. It is looser than the - * `isValidDidDoc` + `getPdsEndpoint` pair from `@atproto/common-web` (no doc - * schema validation, no `type` check), and that is the point - a stricter - * predicate here would let `dispatchUrl` disagree with the host requests - * actually go to, which in turn mints service-auth tokens (video upload) for - * the wrong audience. - */ -function extractPdsUrl(didDoc: SessionData['didDoc']): URL | undefined { - const services = prop(didDoc, 'service') - if (!Array.isArray(services)) { - return undefined - } - /* - * `find`, not a scan: the inner session stops at the first `#atproto_pds` - * entry and gives up if its endpoint does not parse, rather than falling - * through to a later entry. - */ - const pds = services.find(service => { - const id = prop(service, 'id') - return typeof id === 'string' && id.endsWith('#atproto_pds') - }) - const endpoint = prop(pds, 'serviceEndpoint') - return typeof endpoint === 'string' ? parseUrl(endpoint) : undefined -} - -/** - * A `CredentialSession` whose auth core is a `PasswordSession`. - * - * This is the compat shim that lets a `PasswordSession` sit under `AtpAgent`: - * every call site that reads `agent.session`, `agent.pdsUrl`, - * `agent.dispatchUrl`, `agent.did` or calls `agent.resumeSession()` keeps - * working, while the actual tokens, refresh serialization and PDS routing live - * in the `PasswordSession` underneath. - * - * A `null` inner session means "logged out" - the public/guest agent. In that - * mode requests still go out (unauthenticated) through the inherited `fetch`. - */ -export class PasswordSessionManager extends CredentialSession { - #inner: PasswordSession | null - #storedPdsUrl: URL | undefined - #disposed = false - - /* - * Identity caches for the two pull-through accessors. Each holds the - * `SessionData` it was derived from so a repeated read returns the very same - * object (see the note on identity stability below). Keying on the whole - * `SessionData` rather than the individual field works because - * `PasswordSession` replaces the object wholesale on every rotation. - */ - #sessionSource: SessionData | undefined - #sessionValue: AtpSessionData | undefined - #pdsSource: SessionData | undefined - #pdsValue: URL | undefined - - constructor( - inner: PasswordSession | null, - {service, pdsUrl}: {service: string; pdsUrl?: string}, - ) { - /* - * `persistSession` is deliberately undefined: the inner `PasswordSession` - * owns persistence through its own hooks, and none of the inherited methods - * that would call this handler survive the overrides below. - */ - super(new URL(service), networkAwareFetch, undefined) - - this.#inner = inner - this.#storedPdsUrl = pdsUrl ? parseUrl(pdsUrl) : undefined - - /* - * `session` and `pdsUrl` are pull-through accessors over the inner session - * rather than mirrored values, installed here with `defineProperty` for two - * reasons. - * - * Why accessors at all: a mirror has to be written on every token rotation, - * and any missed write silently serves stale tokens. Pulling through cannot - * drift. - * - * Why `defineProperty` and not `get session()` in the class body: the - * parent declares `session` and `pdsUrl` as *properties*, and TypeScript - * rejects overriding a property with an accessor (TS2611). Installing them - * at runtime sidesteps that, and it is safe as long as - * `CredentialSession`'s emitted constructor does not assign either one - * (both are declaration-only), so there is nothing to clobber and no - * ordering hazard. - * - * For the same reason this class must NOT redeclare `session`/`pdsUrl` as - * fields: under `useDefineForClassFields` semantics (target esnext) a field - * declaration emits an own-property definition that would overwrite these - * accessors with `undefined`. - */ - Object.defineProperty(this, 'session', { - configurable: true, - get: () => this.#readSession(), - set: () => { - throw new Error('PasswordSessionManager.session is read-only') - }, - }) - Object.defineProperty(this, 'pdsUrl', { - configurable: true, - get: () => this.#readPdsUrl(), - set: () => { - throw new Error('PasswordSessionManager.pdsUrl is read-only') - }, - }) - } - - /** - * The inner session's live data, or `undefined` when there is nothing to read - * from. - * - * `PasswordSession`'s `session`/`did`/`handle` getters *throw* `Logged out` - * once the session has been destroyed. Every read in this class funnels - * through here so that failure mode can never escape into the app, which - * reads `agent.session` from render paths. - */ - #liveData(): SessionData | undefined { - if (this.#disposed || !this.#inner || this.#inner.destroyed) { - return undefined - } - return this.#inner.session - } - - /** - * The `session` accessor's implementation. - * - * Identity-cached on the source `SessionData`: consecutive reads with no - * intervening token rotation return the same object, and a rotation produces - * a new one. `CredentialSession` declares `session` as a plain field, so - * consumers are entitled to treat it as a value whose identity changes only - * when the session does; this class is read from render paths, and returning - * a freshly allocated object on every read would break that expectation for - * any memo, dependency array or reference comparison built on top of it. - */ - #readSession(): AtpSessionData | undefined { - const live = this.#liveData() - if (!live) { - this.#sessionSource = undefined - this.#sessionValue = undefined - return undefined - } - if (live !== this.#sessionSource) { - this.#sessionSource = live - this.#sessionValue = toAtpSessionData(live) - } - return this.#sessionValue - } - - /** - * The `pdsUrl` accessor's implementation. - * - * The DID document's PDS endpoint wins when there is one, derived with - * {@link extractPdsUrl} so this agrees exactly with the inner session's own - * routing. Before the first refresh delivers a didDoc (the non-expired resume - * fast path, which makes no network call) we fall back to the `pdsUrl` - * persisted on the account, so the very first requests still reach the right - * host - entryway accounts have `service: bsky.social` but live on a - * different PDS. - * - * Identity-cached on the didDoc for the same reason as `session`. - */ - #readPdsUrl(): URL | undefined { - const live = this.#liveData() - if (!live) { - this.#pdsSource = undefined - this.#pdsValue = undefined - return undefined - } - if (live !== this.#pdsSource) { - this.#pdsSource = live - this.#pdsValue = extractPdsUrl(live.didDoc) ?? this.#storedPdsUrl - } - return this.#pdsValue - } - - /* - * `did`, `hasSession` and `dispatchUrl` are deliberately NOT overridden: the - * inherited getters read `this.session` / `this.pdsUrl`, which resolve - * through the accessors above, so they are already live. - */ - - override async fetchHandler( - url: string, - init?: RequestInit, - ): Promise { - /* - * Absolutizing against `dispatchUrl` routes to the stored PDS on the resume - * fast path and to the didDoc PDS from the first refresh onwards, since - * `PasswordSession` resolves an already absolute URL against its own base - * as a no-op. - */ - const target = new URL(url, this.dispatchUrl) - const inner = this.#disposed ? null : this.#inner - - /* - * A caller that set its own `authorization` header bypasses the inner - * session entirely. This is mandatory: `PasswordSession.fetchHandler` - * throws `TypeError` on a pre-set authorization header rather than - * deferring to it. - * - * Bypassing also means these requests get no refresh-on-401 retry, since - * that lives in `PasswordSession.fetchHandler`. That is intentional: the - * caller supplied its own credential (a service-auth token, say), so - * rotating the session's tokens would not make the request any more likely - * to succeed on a retry. - */ - if ( - !inner || - inner.destroyed || - new Headers(init?.headers).has('authorization') - ) { - return (0, this.fetch)(target, init) - } - - /* - * `init ?? {}` because `PasswordSession.fetchHandler` reads `init.headers` - * unguarded, while the inherited signature makes `init` optional. - */ - return inner.fetchHandler(target.href, init ?? {}) - } - - /** - * Refresh the session, rejecting if nothing was refreshed. - * - * This restores the contract of the `CredentialSession.refreshSession` this - * class replaces, which rejected on any refresh failure. - * `PasswordSession.refresh()` does not: on a transient failure (a 500, a - * network error) it reports through `onUpdateFailure` and then *resolves* - * with the unchanged session data, reserving rejection for the cases where - * the session is definitively gone. Callers here read resolution as "tokens - * rotated" - `SignupQueued` refreshes and then re-checks the token scope, and - * the various verification dialogs refresh and then close - so a resolved - * no-op would silently loop or report success. - * - * The signal is the identity of the returned `SessionData`, not a field - * comparison: `PasswordSession` builds a brand new object on every successful - * rotation and returns the existing one untouched on a transient failure, so - * identity separates the two exactly. Comparing against the data captured - * immediately before the call also gets concurrent refreshes right - if - * another caller's refresh rotated the tokens while ours was queued behind it - * (`PasswordSession` serializes refreshes), the data we get back still - * differs from what we captured, which is a success for our caller. - */ - override async refreshSession(): Promise { - const inner = this.#disposed ? null : this.#inner - if (!inner || inner.destroyed) { - throw new Error('No session to refresh') - } - const before = this.#liveData() - const data = await inner.refresh() - if (data === before) { - throw new Error('Failed to refresh session') - } - /* - * Re-shape the lex payload into the `@atproto/api` XRPC response envelope. - * `headers` is empty because the inner session does not surface response - * headers, and no caller in this app reads them off a refresh. - */ - return { - success: true, - headers: {}, - data: { - accessJwt: data.accessJwt, - refreshJwt: data.refreshJwt, - handle: data.handle, - did: data.did, - didDoc: data.didDoc, - email: data.email, - emailConfirmed: data.emailConfirmed, - emailAuthFactor: data.emailAuthFactor, - active: data.active, - status: data.status, - }, - } - } - - /** - * Force a refresh, ignoring the passed-in session data. - * - * The inner session already owns its tokens, so there is nothing to install; - * every call site in the app uses `resumeSession` as "refresh my session - * now". The returned envelope is the refresh one, which is structurally a - * superset of `ComAtprotoServerGetSession.Response` (the shape `AtpAgent` - * advertises), so both layers stay type-correct. - * - * It inherits {@link PasswordSessionManager.refreshSession}'s contract, so it - * rejects rather than resolving when no tokens were rotated. - */ - override resumeSession( - _session: AtpSessionData, - ): Promise { - return this.refreshSession() - } - - override async logout(): Promise { - const inner = this.#inner - if (!inner || inner.destroyed) { - return - } - try { - await inner.logout() - } catch { - /* matches the parent, which swallows delete-session failures */ - } - } - - override login( - _opts: AtpAgentLoginOpts, - ): Promise { - return Promise.reject(new Error(UNSUPPORTED)) - } - - override createAccount( - _data: ComAtprotoServerCreateAccount.InputSchema, - _opts?: ComAtprotoServerCreateAccount.CallOptions, - ): Promise { - return Promise.reject(new Error(UNSUPPORTED)) - } - - /** - * Detach this manager from its inner session. - * - * All reads then behave as logged out and requests fall back to the - * unauthenticated `fetch` path. The inner session is left alone: it may still - * be shared, and logging out is a separate, explicit operation. - */ - dispose() { - this.#disposed = true - } -} - -/* - * Declaration merging to narrow the inherited `sessionManager` (typed as - * `CredentialSession` by `AtpAgent`) to the manager `BskyAppAgent` actually - * receives. A `declare` class field would be the direct way to say this, but - * babel's TypeScript transform rejects `declare` fields in this config, and a - * `get sessionManager()` override is forbidden because the parent declares it - * as a property (TS2611). The merge is sound: the constructor passes the - * manager straight to `super`, which assigns it. - */ -// eslint-disable-next-line typescript/no-unsafe-declaration-merging -export interface BskyAppAgent { - readonly sessionManager: PasswordSessionManager -} - -/** - * The app's `AtpAgent`, backed by a `PasswordSession`. - * - * Everything interesting lives in {@link PasswordSessionManager}; this exists - * so `useAgent()` consumers keep getting a real `AtpAgent` (proxy headers, - * labeler headers, the `app`/`com`/`chat` namespaces) and so the agent can be - * disposed alongside its session. - */ -export class BskyAppAgent extends AtpAgent { - constructor(manager: PasswordSessionManager) { - super(manager) - } - - dispose() { - this.sessionManager.dispose() - } -} - -/** Build the logged-out agent used for public/guest browsing. */ -export function createPublicAgent() { - configureModerationForGuest() // Side effect but only relevant for tests - - const agent = new BskyAppAgent( - new PasswordSessionManager(null, {service: PUBLIC_BSKY_SERVICE}), - ) - agent.configureProxy(BLUESKY_PROXY_HEADER.get()) - return agent -} diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index fab530aba2..026b2d8bd8 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -1,132 +1,102 @@ -import {type Client} from '@atproto/lex' +import {type Agent, type Client} from '@atproto/lex' +import {type PasswordSession} from '@atproto/lex-password-session' -import {CHAT_PROXY_SERVICE, PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import { + BLUESKY_PROXY_HEADER, + CHAT_PROXY_SERVICE, + PUBLIC_BSKY_SERVICE, +} from '#/lib/constants' import {createLexClient} from '#/lib/lexClient' -import {type BskyAppAgent} from './bridge-agent' import {networkAwareFetch} from './network' -/* - * One client per agent, per surface, so that repeated reads for the same agent - * return the same instance. Client identity is observable: a lex `Client` is - * passed to React Query `queryFn`s and read from render paths, so a freshly - * allocated client on every read would break any dependency array or reference - * comparison built on top of it. - * - * Keying on the agent also ties client lifetime to agent lifetime. A disposed - * agent's `fetchHandler` falls back to unauthenticated fetch, and session - * rotation builds a new agent rather than mutating the old one, so a client - * derived from a stale agent becomes unreachable exactly when its agent does. - */ -const appviewClients = new WeakMap() -const pdsClients = new WeakMap() -const chatClients = new WeakMap() - /** - * The appview {@link Client} for an agent, memoized per agent. + * Build the signed-in appview {@link Client}. * - * The wrapped handler is `agent.fetchHandler`, NOT - * `agent.sessionManager.fetchHandler`. The agent-level handler is where - * `atproto-proxy` and `atproto-accept-labelers` are set before the request is - * passed down to the session manager, which only adds authorization and PDS - * routing. Because the agent already emits both headers, the client is - * deliberately built with neither a `service` option nor labelers - setting - * either here would emit them a second time. + * {@link BLUESKY_PROXY_HEADER} is passed as the client's `service`, so lex sets + * `atproto-proxy: ` on every request and raw calls are proxied to + * the appview. Record helpers force `service: null`, so they still target the + * account host. * - * `appLabelers: null` suppresses the class-wide `Client.appLabelers` for this - * instance specifically. The static is populated (see - * `configureGlobalAppLabelers`) so that clients built without a wrapped agent - * carry the global authorities, but the agent already stamped those same DIDs - * onto the request, and lex would append its own copy on top: the agent joins - * its list with the existing header value while lex collects into a `Set` keyed - * on the suffixed string, so neither dedupes against the other and every global - * authority would appear twice. + * The class-wide `Client.appLabelers` static is deliberately NOT suppressed + * here: this client is the only producer of `atproto-accept-labelers` on an + * appview request now that no agent sits underneath it. The account's own + * subscriptions arrive separately, through `applyLabelersToClient` on the + * instance, and that function filters out the Bluesky moderation DID so the + * globally redacted authority is not also listed unredacted. + * + * No `fetch` option: a client built over a session uses that session's own + * fetch, which is `networkAwareFetch` wrapped in the disposal kill switch. */ -export function agentToAppviewClient(agent: BskyAppAgent): Client { - const existing = appviewClients.get(agent) - if (existing) { - return existing - } - const client = createLexClient( - { - get did() { - return agent.did - }, - fetchHandler: (path, init) => agent.fetchHandler(path, init), - }, - {appLabelers: null}, - ) - appviewClients.set(agent, client) - return client +export function buildAppviewClient(agent: Agent): Client { + return createLexClient(agent, {service: BLUESKY_PROXY_HEADER.get()}) } /** - * The account-host {@link Client} for an agent, memoized per agent. + * Build the signed-in account-host {@link Client}. * - * This wraps `agent.sessionManager.fetchHandler`, one layer below - * {@link agentToAppviewClient}. That layer does authorization and refresh-on-401 - * and resolves the request against `dispatchUrl` (the account's PDS), but it - * does NOT set `atproto-proxy` or `atproto-accept-labelers`, so requests reach - * the PDS itself rather than being proxied onward. That is the right transport - * for `com.atproto.*` repo/server/identity calls. + * No `service`, so no proxy header: `com.atproto.*` repo, server and identity + * calls reach the account's own PDS rather than being proxied onward. * - * No `service` option for the same reason: adding one would reintroduce the - * proxy header this client exists to avoid. `appLabelers: null` is the same - * kind of suppression: a PDS request is not an appview read, so it must carry no - * moderation authorities at all - without this it would start emitting the - * global `Client.appLabelers`. - * - * The handler is wrapped in a closure rather than passed by reference because - * `PasswordSessionManager.fetchHandler` reads `this`. Relative paths are - * intentional: lex-client hands its handler an origin-less - * `/xrpc/[?query]` path, which the session manager absolutizes against - * `dispatchUrl`. + * `appLabelers: null` suppresses the class-wide static for this instance. A PDS + * request is not an appview read, so it must carry no moderation authorities at + * all; without the suppression it would start emitting the global list. */ -export function agentToPdsClient(agent: BskyAppAgent): Client { - const existing = pdsClients.get(agent) - if (existing) { - return existing - } - const client = createLexClient( - { - get did() { - return agent.did - }, - fetchHandler: (path, init) => - agent.sessionManager.fetchHandler(path, init), - }, - {appLabelers: null}, - ) - pdsClients.set(agent, client) - return client +export function buildPdsClient(agent: Agent): Client { + return createLexClient(agent, {appLabelers: null}) } /** - * The chat {@link Client} for an agent, memoized per agent. + * Build the signed-in chat {@link Client}. * - * Same session-manager transport as {@link agentToPdsClient} - authorization - * and PDS routing, no agent-level proxy or labeler headers - but constructed - * with {@link CHAT_PROXY_SERVICE} as its `service`, so lex-client emits - * `atproto-proxy: ` on every request and `chat.bsky.*` - * calls are proxied to the chat service. `appLabelers: null` for the same - * reason as the PDS client: the chat service takes no moderation authorities. + * {@link CHAT_PROXY_SERVICE} (`${CHAT_PROXY_DID}#bsky_chat`, default + * `did:web:api.bsky.chat#bsky_chat`) is the client's `service`, so `chat.bsky.*` + * calls are proxied to the chat service. The DID is read from the + * env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can + * be retargeted per environment. + * + * `appLabelers: null` for the same reason as the PDS client: the chat service + * takes no moderation authorities. */ -export function agentToChatClient(agent: BskyAppAgent): Client { - const existing = chatClients.get(agent) - if (existing) { - return existing - } - const client = createLexClient( - { - get did() { - return agent.did - }, - fetchHandler: (path, init) => - agent.sessionManager.fetchHandler(path, init), +export function buildChatClient(agent: Agent): Client { + return createLexClient(agent, { + appLabelers: null, + service: CHAT_PROXY_SERVICE, + }) +} + +/** + * Wrap a session so requests resolve against a known PDS while auth and refresh + * stay with the session. + * + * This exists for the pre-didDoc window. `PasswordSession` resolves each request + * against `extractPdsUrl(didDoc) ?? service`, so before a refresh has delivered + * a didDoc it falls back to the login service - which for an entryway account + * (`service: bsky.social`, PDS elsewhere) is the wrong host. The synchronous + * resume fast path makes no network request at all, so that window covers every + * request of a cold start until something triggers a refresh. + * + * Absolutizing here is enough because `PasswordSession.fetchHandler` builds its + * URL with `new URL(path, base)`, which ignores the base for an already-absolute + * input. So an absolute URL passes through untouched, and the session's own + * didDoc routing still wins for any client built directly over it. + * + * The tradeoff is that this pins the STORED url for the bundle's lifetime, where + * the session would prefer a didDoc endpoint that arrived later. That is + * acceptable because the two only disagree if the account's PDS moved, and the + * next cold start persists (and therefore pins) the new endpoint. + */ +export function routeSessionToPds( + session: PasswordSession, + pdsUrl: string, +): Agent { + return { + get did() { + return session.did }, - {appLabelers: null, service: CHAT_PROXY_SERVICE}, - ) - chatClients.set(agent, client) - return client + fetchHandler(path, init) { + return session.fetchHandler(new URL(path, pdsUrl).href, init) + }, + } } /** Thrown when a write/auth-only client is used with no active session. */ @@ -164,20 +134,17 @@ let publicLexClient: Client | undefined * The unauthenticated {@link Client} for public reads, pointed at the public * appview. * - * A single module-level instance for the same identity-stability reason as - * {@link agentToAppviewClient}: there is no session to scope it to, so it lives - * for the lifetime of the process. Requests go through - * {@link networkAwareFetch} so public reads feed the app's reachability signal - * like authenticated ones do. + * A single module-level instance: there is no session to scope it to, so it + * lives for the lifetime of the process, and its identity is therefore stable + * enough for a React Query key. Requests go through {@link networkAwareFetch} so + * public reads feed the app's reachability signal like authenticated ones do. * - * Unlike the agent-wrapping clients, this one does NOT suppress - * `Client.appLabelers`: there is no agent underneath to stamp the header, so the - * class-wide static is the only producer and a logged-out read carries the same - * `;redact` moderation authorities an authenticated one does. - * - * That makes `configureModerationForGuest()` load-bearing rather than - * test-only - it is what populates the static before this client's first - * request. `createPublicSessionBundle` runs it while building the bundle. + * Like the session appview client, it carries the class-wide + * `Client.appLabelers`, so a logged-out read gets the same `;redact` moderation + * authorities an authenticated one does. That makes `configureModerationForGuest` + * load-bearing rather than test-only - it is what populates the static before + * this client's first request, and `createPublicSessionBundle` runs it while + * building the bundle. */ export function getPublicAppviewClient(): Client { return (publicLexClient ??= createLexClient({ diff --git a/src/state/session/create-account.ts b/src/state/session/create-account.ts index a14f50cff5..618ab1aa7a 100644 --- a/src/state/session/create-account.ts +++ b/src/state/session/create-account.ts @@ -10,7 +10,6 @@ import { import {networkRetry} from '#/lib/async/retry' import { - BLUESKY_PROXY_HEADER, DISCOVER_SAVED_FEED, IS_PROD_SERVICE, TIMELINE_SAVED_FEED, @@ -27,7 +26,6 @@ import { import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' import {features} from '#/analytics' import {type app} from '#/lexicons' -import {agentToAppviewClient, agentToPdsClient} from './clients' import {configureModerationForAccount} from './moderation' import { buildBundle, @@ -106,10 +104,10 @@ export async function createSessionBundleAndCreateAccount( setBirthdateForDid({did: earlyAccount.did, birthdate}) snoozeBirthdateUpdateAllowedForDid(earlyAccount.did) // Post-signup writes all target the account's own repo and actor store. - const pdsClient = agentToPdsClient(bundle.agent) + const pdsClient = bundle.pdsClient // Start the prefetch after seeding its synchronous birthdate inputs. const aa = prefetchAgeAssuranceServerData({ - appviewClient: agentToAppviewClient(bundle.agent), + appviewClient: bundle.appviewClient, accountClient: pdsClient, }) @@ -136,8 +134,6 @@ export async function createSessionBundleAndCreateAccount( }) } - bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get()) - // Preparation may auto-refresh the session while hooks are still disarmed. const account = await finishPreparation( bundle, diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 730cdd9808..d3045932d3 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -9,7 +9,6 @@ import { useState, useSyncExternalStore, } from 'react' -import {type AtpAgent} from '@atproto/api' import {type Client} from '@atproto/lex' import {type SessionData} from '@atproto/lex-password-session' @@ -18,14 +17,9 @@ import {useCloseAllActiveElements} from '#/state/util' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' import {IS_WEB} from '#/env' +import {com} from '#/lexicons' import {emitSessionDropped} from '../events' -import { - agentToAppviewClient, - agentToChatClient, - agentToPdsClient, - getPublicAppviewClient, - getUnauthenticatedThrowingClient, -} from './clients' +import {getPublicAppviewClient} from './clients' import {createSessionBundleAndCreateAccount} from './create-account' import {pickExpiryRescueCandidate} from './expiry-rescue' import {type Action, getInitialState, reducer, type State} from './reducer' @@ -451,13 +445,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { /* * Read the live bundle rather than the one captured by this render: a * dispatch that lands before the next render would otherwise leave this - * holding a disposed bundle, whose agent dispatches unauthenticated. + * holding a disposed bundle, whose clients dispatch through a disabled + * fetch. */ const bundle = store.getState().currentBundleState .bundle as unknown as SessionBundle const signal = cancelPendingTask() /* getSession targets the PDS; only the persisted account fields are patched. */ - const {data} = await bundle.agent.com.atproto.server.getSession() + const data = await bundle.pdsClient.call(com.atproto.server.getSession, {}) if (signal.aborted) return store.dispatch({ type: 'partial-refresh-session', @@ -478,9 +473,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { * Rotate the session's tokens and hand back the resulting account snapshot. * * Rejects when the rotation was a no-op, restoring the contract the - * `agent.resumeSession(agent.session!)` call sites were written against (the - * bridge agent's `refreshSession` override does the same, for the same - * reason). `PasswordSession.refresh()` resolves with the + * `agent.resumeSession(agent.session!)` call sites were written against. + * `PasswordSession.refresh()` resolves with the * unchanged `SessionData` on a transient failure - a 500 or a network error * reported through `onUpdateFailure` - and reserves rejection for a * definitively dead session. Callers here all read resolution as "tokens @@ -672,15 +666,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // @ts-expect-error window type is not declared, debug only // eslint-disable-next-line react-hooks/immutability - if (__DEV__ && IS_WEB) window.agent = bundle.agent + if (__DEV__ && IS_WEB) window.bundle = bundle const currentBundleRef = useRef(bundle) /* * Disposal is deferred to this post-commit effect deliberately: components may * still render against the outgoing bundle during the commit that swaps it, so - * tearing its agent down inline would pull the agent out from under them. The - * reducer's bundle-identity guard drops any events the not-yet-disposed session - * emits in that window. + * disabling its session inline would pull the transport out from under them. + * The reducer's bundle-identity guard drops any events the not-yet-disposed + * session emits in that window. */ useEffect(() => { if (currentBundleRef.current !== bundle) { @@ -753,30 +747,15 @@ export function useRequireAuth() { } /** - * The active session's agent, or the public agent when logged out. - */ -export function useAgent(): AtpAgent { - const bundle = useContext(BundleContext) - if (!bundle) { - throw Error('useAgent() must be below .') - } - return bundle.agent -} - -/** - * Client for appview reads. - * - * When logged out the bundle's agent is the public agent built by - * `createPublicAgent`, which is configured with the appview proxy and dispatches - * unauthenticated, so the logged-out fallback is the agent itself - there is no - * separate public branch here. + * Client for appview reads. Logged out, this is the bundle's public client, + * which dispatches unauthenticated against the public appview. */ export function useAppviewClient(): Client { const bundle = useContext(BundleContext) if (!bundle) { throw Error('useAppviewClient() must be below .') } - return agentToAppviewClient(bundle.agent) + return bundle.appviewClient } /** @@ -790,9 +769,7 @@ export function usePdsClient(): Client { if (!bundle) { throw Error('usePdsClient() must be below .') } - return bundle.session - ? agentToPdsClient(bundle.agent) - : getUnauthenticatedThrowingClient() + return bundle.pdsClient } /** @@ -804,9 +781,7 @@ export function useChatClient(): Client { if (!bundle) { throw Error('useChatClient() must be below .') } - return bundle.session - ? agentToChatClient(bundle.agent) - : getUnauthenticatedThrowingClient() + return bundle.chatClient } /** @@ -814,7 +789,7 @@ export function useChatClient(): Client { */ export function useMaybePdsClient(): Client | null { const bundle = useContext(BundleContext) - return bundle?.session ? agentToPdsClient(bundle.agent) : null + return bundle?.session ? bundle.pdsClient : null } /** @@ -822,7 +797,7 @@ export function useMaybePdsClient(): Client | null { */ export function useMaybeChatClient(): Client | null { const bundle = useContext(BundleContext) - return bundle?.session ? agentToChatClient(bundle.agent) : null + return bundle?.session ? bundle.chatClient : null } /** diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index b5b3a01af8..691aad4c81 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -1,7 +1,9 @@ -import {type AtpAgent, BSKY_LABELER_DID} from '@atproto/api' +import {BSKY_LABELER_DID} from '@atproto/api' import {type Client} from '@atproto/lex' +import {type DidString} from '@atproto/syntax' import {IS_TEST_USER} from '#/lib/constants' +import {com} from '#/lexicons' import {account as accountStorage} from '#/storage' import { configureAdditionalModerationAuthorities, @@ -9,6 +11,9 @@ import { } from './additional-moderation-authorities' import {type SessionAccount} from './types' +/** The moderation surface of a session bundle. */ +type ModerationSession = {appviewClient: Client} + /** * Cache an account's subscribed labeler DIDs. Called on every preferences * fetch, so the cache is eventually consistent with the server. @@ -31,25 +36,24 @@ export function readLabelers(did: string): string[] | undefined { } /** - * Apply an account's labeler subscriptions without duplicating the globally - * redacted Bluesky moderation authority. + * Apply an account's labeler subscriptions to the appview client, without + * duplicating the globally redacted Bluesky moderation authority. * * The Bluesky DID is filtered out because it already flows through the global - * `appLabelers`, which lex and the agent both emit with a `;redact` suffix. - * Listing it per-subscription would add a second, non-redacting entry for the - * same authority. + * `Client.appLabelers`, which lex emits with a `;redact` suffix. Listing it + * per-instance would add a second, non-redacting entry for the same authority: + * lex collects the two lists into a `Set` keyed on the suffixed string, so + * neither dedupes against the other. * - * Writes to the agent rather than the client: the agent-level fetch handler is - * what stamps `atproto-accept-labelers` on the requests the wrapping clients - * issue, so setting them here reaches every appview read. The bundle rework - * moves this to `appviewClient.setLabelers` once the agent is gone. + * Only the appview client takes subscriptions - the PDS and chat clients suppress + * labelers entirely (see clients.ts). */ export function applyLabelersToClient( - agent: AtpAgent, + client: Client, subscribedDids: string[], ) { - agent.configureLabelersHeader( - subscribedDids.filter(did => did !== BSKY_LABELER_DID), + client.setLabelers( + subscribedDids.filter(did => did !== BSKY_LABELER_DID) as DidString[], ) } @@ -66,7 +70,7 @@ export function configureModerationForGuest() { * in the same tick, before any request goes out. */ export function configureModerationForAccount( - bundle: {agent: AtpAgent; appviewClient?: Client}, + bundle: ModerationSession, account: SessionAccount, ) { // This global mutation is *only* OK because this code is only relevant for testing. @@ -74,13 +78,13 @@ export function configureModerationForAccount( switchToBskyAppLabeler() if (IS_TEST_USER(account.handle)) { // Test accounts may briefly use the production authority while this resolves. - void trySwitchToTestAppLabeler(bundle.agent) + void trySwitchToTestAppLabeler(bundle.appviewClient) } // The code below is actually relevant to production (and isn't global). const labelerDids = readLabelers(account.did) if (labelerDids) { - applyLabelersToClient(bundle.agent, labelerDids) + applyLabelersToClient(bundle.appviewClient, labelerDids) } else { // If there are no headers in the storage, we'll not send them on the initial requests. // If we wanted to fix this, we could block on the preferences query here. @@ -94,12 +98,14 @@ function switchToBskyAppLabeler() { } /** Resolve and install the test environment's moderation authority. */ -async function trySwitchToTestAppLabeler(agent: AtpAgent) { +async function trySwitchToTestAppLabeler(client: Client) { const did = ( - await agent - .resolveHandle({handle: 'mod-authority.test'}) + await client + .call(com.atproto.identity.resolveHandle, { + handle: 'mod-authority.test', + }) .catch(_ => undefined) - )?.data.did + )?.did if (did) { console.warn('USING TEST ENV MODERATION') configureGlobalAppLabelers([did]) diff --git a/src/state/session/session-core.ts b/src/state/session/session-core.ts index 7a9d1a22cc..6269cc7f29 100644 --- a/src/state/session/session-core.ts +++ b/src/state/session/session-core.ts @@ -1,21 +1,27 @@ +import {type Client} from '@atproto/lex' import { PasswordSession, type PasswordSessionOptions, type SessionData, } from '@atproto/lex-password-session' -import {BLUESKY_PROXY_HEADER, PUBLIC_BSKY_SERVICE} from '#/lib/constants' +import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {logger} from '#/logger' import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data' import {features} from '#/analytics' import { - BskyAppAgent, - createPublicAgent, - PasswordSessionManager, -} from './bridge-agent' -import {agentToAppviewClient, agentToPdsClient} from './clients' + buildAppviewClient, + buildChatClient, + buildPdsClient, + getPublicAppviewClient, + getUnauthenticatedThrowingClient, + routeSessionToPds, +} from './clients' import {addSessionErrorLog} from './logging' -import {configureModerationForAccount} from './moderation' +import { + configureModerationForAccount, + configureModerationForGuest, +} from './moderation' import {networkAwareFetch} from './network' import { isSessionExpired, @@ -46,10 +52,12 @@ function deriveServiceUrl(session: PasswordSession | null): URL { ) } -/** An `AtpAgent` bridged over one `PasswordSession`, the bundle's sole auth core. */ +/** The three clients over one `PasswordSession`, the bundle's sole auth core. */ export type SessionBundle = { session: PasswordSession - agent: BskyAppAgent + appviewClient: Client + pdsClient: Client + chatClient: Client readonly service: URL } @@ -63,38 +71,38 @@ const bundleKillSwitches = new WeakMap void>() /** * Register the lifecycle closure used by {@link disposeBundle}. * - * Disposing also detaches the bridge agent from its session, so a stale - * bundle's `agent.session` / `agent.pdsUrl` read as `undefined` rather than - * serving tokens the app has stopped tracking. + * Killing the hooks is the whole of disposal now: the clients hold no state of + * their own, and every request they make goes through the session's injected + * fetch, which the kill switch disables. */ export function registerBundleKillSwitch( bundle: SessionBundle, kill: () => void, ) { - bundleKillSwitches.set(bundle, () => { - kill() - bundle.agent.dispose() - }) + bundleKillSwitches.set(bundle, kill) } /** - * Wrap a session in the bridge agent. + * Build the three clients over a session. * - * `storedPdsUrl` seeds {@link PasswordSessionManager}'s PDS routing so requests - * made before the first refresh delivers a didDoc still reach the right host. - * Once a didDoc arrives the manager prefers its endpoint. + * `storedPdsUrl` pins PDS routing for requests made before a refresh has + * delivered a didDoc - see {@link routeSessionToPds}, which explains why the + * session's own routing is not sufficient in that window. With no stored url + * there is nothing better to pin to, so the clients go straight over the + * session and it resolves them against its own service. */ export function buildBundle( session: PasswordSession, storedPdsUrl?: string, ): SessionBundle { - const manager = new PasswordSessionManager(session, { - service: deriveServiceUrl(session).toString(), - pdsUrl: storedPdsUrl, - }) + const agent = storedPdsUrl + ? routeSessionToPds(session, storedPdsUrl) + : session return { session, - agent: new BskyAppAgent(manager), + appviewClient: buildAppviewClient(agent), + pdsClient: buildPdsClient(agent), + chatClient: buildChatClient(agent), get service() { return deriveServiceUrl(session) }, @@ -182,23 +190,36 @@ export function makeSessionHooks({ }) } -/** The agent exposed while logged out. */ +/** The clients exposed while logged out. */ export type PublicSessionBundle = { session: null - agent: BskyAppAgent + appviewClient: Client + pdsClient: Client + chatClient: Client readonly service: URL } /** - * Build the logged-out bundle. `createPublicAgent` installs the guest - * moderation authorities as part of building the agent, which is what populates - * the global `Client.appLabelers` that {@link getPublicAppviewClient} relies on - * for its labeler header. + * Build the logged-out bundle. + * + * `configureModerationForGuest` is what populates the global + * `Client.appLabelers` that {@link getPublicAppviewClient} reads for its labeler + * header, so it must run before the public client's first request. There is no + * agent stamping that header any more, which makes this call load-bearing rather + * than test-only: without it a logged-out read would carry no moderation + * authorities at all. + * + * The write surfaces get the throwing client rather than a public one, so an + * unauthenticated write fails legibly instead of 4xx-ing against public + * infrastructure. */ export function createPublicSessionBundle(): PublicSessionBundle { + configureModerationForGuest() return { session: null, - agent: createPublicAgent(), + appviewClient: getPublicAppviewClient(), + pdsClient: getUnauthenticatedThrowingClient(), + chatClient: getUnauthenticatedThrowingClient(), service: new URL(PUBLIC_BSKY_SERVICE), } } @@ -224,9 +245,8 @@ export function createPublicSessionBundle(): PublicSessionBundle { * Both failure modes dispose: the bundle is fully built by this point, and a * still-live session left behind would keep its refresh and dispatch paths * alive with nothing tracking it. (Disposal is a no-op for the destroyed case, - * where the session already refuses to refresh and the bridge agent already - * reads as logged out - but the two paths are indistinguishable to the caller, - * so both go through it.) + * where the session already refuses to refresh - but the two paths are + * indistinguishable to the caller, so both go through it.) */ export async function finishPreparation( bundle: SessionBundle, @@ -294,16 +314,10 @@ export async function createSessionBundleAndResume( configureModerationForAccount(bundle, earlyAccount) const aa = prefetchAgeAssuranceServerData({ - appviewClient: agentToAppviewClient(bundle.agent), - accountClient: agentToPdsClient(bundle.agent), + appviewClient: bundle.appviewClient, + accountClient: bundle.pdsClient, }) - /* - * The proxy header is applied after the PDS-targeting setup above, so those - * calls run without it. - */ - bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get()) - // Preparation may auto-refresh the session while hooks are still disarmed. const account = await finishPreparation( bundle, @@ -362,12 +376,10 @@ export async function createSessionBundleAndLogin( const gates = features.refresh({strategy: 'prefer-fresh-gates'}) configureModerationForAccount(bundle, earlyAccount) const aa = prefetchAgeAssuranceServerData({ - appviewClient: agentToAppviewClient(bundle.agent), - accountClient: agentToPdsClient(bundle.agent), + appviewClient: bundle.appviewClient, + accountClient: bundle.pdsClient, }) - bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get()) - // Preparation may auto-refresh the session while hooks are still disarmed. const account = await finishPreparation( bundle, @@ -403,7 +415,6 @@ export function createSessionBundleFromStoredAccount( bundle = buildBundle(session, storedAccount.pdsUrl) registerBundleKillSwitch(bundle, hooks.kill) configureModerationForAccount(bundle, storedAccount) - bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get()) const account = session.destroyed ? storedAccount diff --git a/src/state/session/session-data.ts b/src/state/session/session-data.ts index 2fab1cbd99..3ead91c173 100644 --- a/src/state/session/session-data.ts +++ b/src/state/session/session-data.ts @@ -1,4 +1,3 @@ -import {type AtpSessionData} from '@atproto/api' import {getPdsEndpoint, isValidDidDoc} from '@atproto/common-web' import {type SessionData} from '@atproto/lex-password-session' import {jwtDecode} from 'jwt-decode' @@ -81,27 +80,6 @@ export function sessionAccountToSessionData( } } -/** Convert a persisted account into data suitable for `AtpAgent`. */ -export function sessionAccountToSession( - account: SessionAccount, -): AtpSessionData { - return { - // Sorted in the same property order as when returned by BskyAgent (alphabetical). - accessJwt: account.accessJwt ?? '', - did: account.did, - email: account.email, - emailAuthFactor: account.emailAuthFactor, - emailConfirmed: account.emailConfirmed, - handle: account.handle, - refreshJwt: account.refreshJwt ?? '', - /** - * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188 - */ - active: account.active ?? true, - status: account.status, - } -} - export function isSessionExpired(account: SessionAccount) { return account.accessJwt ? isJwtExpired(account.accessJwt) : true } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 3f44040444..8108c41cd1 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -97,8 +97,8 @@ import {usePreferencesQuery} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' import {resolveLinkQueryOptions} from '#/state/queries/resolve-link' import { - useAgent, useAppviewClient, + useChatClient, usePdsClient, useSession, } from '#/state/session' @@ -280,8 +280,8 @@ export const ComposePost = ({ const videoMaxDurationMs = allow10MinuteVideos ? VIDEO_10_MINUTE_MAX_DURATION_MS : VIDEO_MAX_DURATION_MS - const agent = useAgent() const client = useAppviewClient() + const chatClient = useChatClient() const pdsClient = usePdsClient() const queryClient = useQueryClient() const currentDid = currentAccount!.did @@ -1003,7 +1003,7 @@ export const ComposePost = ({ .map(post => post.embed.link!.uri) const linkQueries = useQueries({ queries: linkUris.map(uri => ({ - ...resolveLinkQueryOptions(agent, uri), + ...resolveLinkQueryOptions({appviewClient: client, chatClient}, uri), enabled: false, })), }) @@ -1094,12 +1094,13 @@ export const ComposePost = ({ try { logger.info(`composer: posting...`) postUri = ( - await apilib.post(agent, queryClient, { + await apilib.post(queryClient, { thread: filteredThread, replyTo: replyTo?.uri, onStateChange: setPublishingStage, langs: currentLanguages, appviewClient: client, + chatClient, pdsClient, }) ).uris[0] @@ -1294,8 +1295,8 @@ export const ComposePost = ({ }, [ l, ax, - agent, client, + chatClient, pdsClient, canPost, isPublishing, diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 3b5ff9cdf2..34fa4f6db4 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -5,14 +5,13 @@ import {AppBskyDraftDefs, AtUri} from '@atproto/api' import {RichText} from '@bsky.app/sdk/richtext' import {nanoid} from 'nanoid/non-secure' -import {resolveLink} from '#/lib/api/resolve' +import {type LinkResolvers, resolveLink} from '#/lib/api/resolve' import {getDeviceName} from '#/lib/deviceName' import {getImageDim} from '#/lib/media/manip' 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 {createPublicAgent} from '#/state/session/bridge-agent' import { type ComposerState, type EmbedDraft, @@ -60,7 +59,10 @@ function parseVideoMimeType(localRefPath: string): string { * Convert ComposerState to server Draft format for saving. * Returns both the draft and a map of localRef paths to their source paths. */ -export async function composerStateToDraft(state: ComposerState): Promise<{ +export async function composerStateToDraft( + clients: LinkResolvers, + state: ComposerState, +): Promise<{ draft: AppBskyDraftDefs.Draft localRefPaths: Map }> { @@ -68,7 +70,7 @@ export async function composerStateToDraft(state: ComposerState): Promise<{ const posts: AppBskyDraftDefs.DraftPost[] = await Promise.all( state.thread.posts.map(post => { - return postDraftToServerPost(post, localRefPaths) + return postDraftToServerPost(clients, post, localRefPaths) }), ) @@ -94,6 +96,7 @@ export async function composerStateToDraft(state: ComposerState): Promise<{ * Convert a single PostDraft to server DraftPost format. */ async function postDraftToServerPost( + clients: LinkResolvers, post: PostDraft, localRefPaths: Map, ): Promise { @@ -138,10 +141,7 @@ async function postDraftToServerPost( // Add quote record embed if (post.embed.quote) { - const resolved = await resolveLink( - createPublicAgent(), - post.embed.quote.uri, - ) + const resolved = await resolveLink(clients, post.embed.quote.uri) if (resolved && resolved.type === 'record') { draftPost.embedRecords = [ { diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index 0108dbbbff..a620f0ec0f 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -7,7 +7,7 @@ import { import {isNetworkError} from '#/lib/strings/errors' import {matchXrpcError} from '#/lib/xrpc-error' -import {useAppviewClient} from '#/state/session' +import {useAppviewClient, useChatClient} from '#/state/session' import {type ComposerState} from '#/view/com/composer/state/composer' import {useAnalytics} from '#/analytics' import {getDeviceId} from '#/analytics/identifiers' @@ -121,6 +121,7 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{ */ export function useSaveDraftMutation() { const client = useAppviewClient() + const chatClient = useChatClient() const queryClient = useQueryClient() return useMutation({ @@ -136,8 +137,10 @@ export function useSaveDraftMutation() { originalLocalRefs: Set | undefined }> => { // Convert composer state to server draft format - const {draft: apiDraft, localRefPaths} = - await composerStateToDraft(composerState) + const {draft: apiDraft, localRefPaths} = await composerStateToDraft( + {appviewClient: client, chatClient}, + composerState, + ) /* * `composerStateToDraft` builds the draft against the `@atproto/api` * types, whose string fields are unbranded, so it is asserted once here diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index f3f7743b7e..e9e3436d1c 100644 --- a/src/view/com/testing/TestCtrls.e2e.tsx +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -3,7 +3,7 @@ import {LogBox, Pressable, TextInput, View} from 'react-native' import {useQueryClient} from '@tanstack/react-query' import {BLUESKY_PROXY_HEADER} from '#/lib/constants' -import {useAgent, useSessionApi} from '#/state/session' +import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useOnboardingDispatch} from '#/state/shell/onboarding' import {navigate} from '../../../Navigation' @@ -31,7 +31,6 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'} let hasConfiguredProxy = false export function TestCtrls() { - const agent = useAgent() const queryClient = useQueryClient() const {logoutEveryAccount, login} = useSessionApi() const onboardingDispatch = useOnboardingDispatch() @@ -74,8 +73,13 @@ export function TestCtrls() { autoCapitalize="none" onSubmitEditing={() => { const header = `${proxyHeader}#bsky_appview` + /* + * The appview client reads `BLUESKY_PROXY_HEADER.get()` when the + * bundle builds it (see clients.ts), so setting the mutable constant + * retargets the proxy for the sign-ins below without reconfiguring + * anything: the gate above means no bundle exists yet. + */ BLUESKY_PROXY_HEADER.set(header) - agent.configureProxy(header as any) hasConfiguredProxy = true setIsProxyConfigured(true) }}