[SDK] Delete the bridge agent and rework the session bundle onto lex clients (#11385)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-13 22:26:22 +03:00
committed by GitHub
parent 59d2bf09d7
commit 54da80dfb0
38 changed files with 812 additions and 1851 deletions
+9 -7
View File
@@ -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
+6 -6
View File
@@ -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<app.bsky.feed.getFeed.$OutputBody | null> {
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(', '),
}
+37 -27
View File
@@ -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<app.bsky.feed.post.Main['embed']> {
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<com.atproto.repo.strongRef.Main> {
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`)
}
+54 -34
View File
@@ -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<ResolvedLink> {
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<ResolvedExternalLink> {
export async function resolveGif(gif: Gif): Promise<ResolvedExternalLink> {
const gifUrl = gif.media_formats.gif.url
const params = new URLSearchParams()
params.set('hh', String(gif.media_formats.gif.dims[1]))
+1 -9
View File
@@ -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:
+5 -3
View File
@@ -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(
@@ -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<AppBskyEmbedRecord.Main>
| $Typed<ChatBskyEmbedJoinLink.Main>
| undefined
let embed: chat.bsky.convo.defs.MessageInput['embed']
let embedView:
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
@@ -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 = {
@@ -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',
+62 -61
View File
@@ -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<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
@@ -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<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>,
@@ -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
+13 -3
View File
@@ -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<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const queryClient = useQueryClient()
const agent = useAgent()
const chatClient = useChatClient()
const events = useMessagesEventBus()
const [convo] = useState(() => {
const placeholder = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
@@ -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(
+5 -4
View File
@@ -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<void>
type SendMessage = (
message: ChatBskyConvoSendMessage.InputSchema['message'],
message: chat.bsky.convo.defs.MessageInput,
optimisticEmbedView:
| $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>
+20 -17
View File
@@ -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) {
/*
+13 -3
View File
@@ -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<MessagesEventBus | null>(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()
+6 -3
View File
@@ -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']
}
+17 -13
View File
@@ -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],
)
}
+3 -3
View File
@@ -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<FeedPageUnselected>
@@ -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 ||
+46 -50
View File
@@ -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<AppBskyFeedDefs.PostView>({
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<AppBskyFeedDefs.PostView | undefined> {
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],
)
}
+7 -7
View File
@@ -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
+2 -2
View File
@@ -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) || [],
),
),
+21 -17
View File
@@ -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)
},
})
}
@@ -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<typeof setup>
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<NonNullable<PasswordSessionOptions['onUpdated']>>()
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<NonNullable<PasswordSessionOptions['onDeleted']>>()
const onUpdated =
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
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<NonNullable<PasswordSessionOptions['onDeleted']>>()
const onUpdateFailure =
jest.fn<NonNullable<PasswordSessionOptions['onUpdateFailure']>>()
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',
)
})
})
+185 -186
View File
@@ -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/<nsid>` path; the
* session manager absolutizes it against dispatchUrl.
* lex hands its fetchHandler an origin-less `/xrpc/<nsid>` 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`,
])
})
})
@@ -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 () => {
@@ -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),
}
}
@@ -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',
)
})
})
@@ -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<string>([...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}`[]})
}
-25
View File
@@ -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<typeof BaseAgent>
) {
super(...options)
if (proxyHeader) {
this.configureProxy(proxyHeader)
}
}
}
-442
View File
@@ -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<string, unknown>)[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<Response> {
/*
* 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<ComAtprotoServerRefreshSession.Response> {
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<ComAtprotoServerRefreshSession.Response> {
return this.refreshSession()
}
override async logout(): Promise<void> {
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<ComAtprotoServerCreateSession.Response> {
return Promise.reject(new Error(UNSUPPORTED))
}
override createAccount(
_data: ComAtprotoServerCreateAccount.InputSchema,
_opts?: ComAtprotoServerCreateAccount.CallOptions,
): Promise<ComAtprotoServerCreateAccount.Response> {
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
}
+89 -122
View File
@@ -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<BskyAppAgent, Client>()
const pdsClients = new WeakMap<BskyAppAgent, Client>()
const chatClients = new WeakMap<BskyAppAgent, Client>()
/**
* 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: <that value>` 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/<nsid>[?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: <CHAT_PROXY_SERVICE>` 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({
+2 -6
View File
@@ -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,
+18 -43
View File
@@ -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 <SessionProvider>.')
}
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 <SessionProvider>.')
}
return agentToAppviewClient(bundle.agent)
return bundle.appviewClient
}
/**
@@ -790,9 +769,7 @@ export function usePdsClient(): Client {
if (!bundle) {
throw Error('usePdsClient() must be below <SessionProvider>.')
}
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 <SessionProvider>.')
}
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
}
/**
+26 -20
View File
@@ -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])
+59 -48
View File
@@ -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<SessionBundle, () => 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<T>(
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
-22
View File
@@ -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
}
+6 -5
View File
@@ -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,
+8 -8
View File
@@ -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<string, string>
}> {
@@ -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<string, string>,
): Promise<AppBskyDraftDefs.DraftPost> {
@@ -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 = [
{
@@ -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<string> | 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
+7 -3
View File
@@ -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)
}}