[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 AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetConfig, type AppBskyAgeassuranceGetConfig,
type AppBskyAgeassuranceGetState, type AppBskyAgeassuranceGetState,
AtpAgent,
type ChatBskyActorDeclaration, type ChatBskyActorDeclaration,
} from '@atproto/api' } from '@atproto/api'
import {type Client} from '@atproto/lex' import {type Client} from '@atproto/lex'
@@ -15,7 +14,6 @@ import {persistQueryClient} from '@tanstack/react-query-persist-client'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage' import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import { import {
@@ -24,6 +22,7 @@ import {
} from '#/state/birthdate' } from '#/state/birthdate'
import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration' import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration'
import {useAppviewClient, usePdsClient, useSession} from '#/state/session' import {useAppviewClient, usePdsClient, useSession} from '#/state/session'
import {getPublicAppviewClient} from '#/state/session/clients'
import {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const' import {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const'
import * as debug from '#/ageAssurance/debug' import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger' import {logger} from '#/ageAssurance/logger'
@@ -98,11 +97,14 @@ export function setBirthdateForDid({
export const configQueryKey = ['config'] export const configQueryKey = ['config']
export async function getConfig() { export async function getConfig() {
if (debug.enabled) return debug.resolve(debug.config) if (debug.enabled) return debug.resolve(debug.config)
const agent = new AtpAgent({ /*
service: PUBLIC_BSKY_SERVICE, * 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
const res = await agent.app.bsky.ageassurance.getConfig() * process-wide public client rather than a bundle one.
return res.data */
return (await getPublicAppviewClient().call(
app.bsky.ageassurance.getConfig,
)) as AppBskyAgeassuranceGetConfig.OutputSchema
} }
export function getConfigFromCache(): export function getConfigFromCache():
| AppBskyAgeassuranceGetConfig.OutputSchema | AppBskyAgeassuranceGetConfig.OutputSchema
+6 -6
View File
@@ -1,5 +1,5 @@
import {type AppBskyFeedDefs, AtpAgent, jsonStringToLex} from '@atproto/api' import {type AppBskyFeedDefs, jsonStringToLex} from '@atproto/api'
import {type Client, type XrpcRequestParams} from '@atproto/lex' import {Client, type XrpcRequestParams} from '@atproto/lex'
import { import {
getAppLanguageAsContentLanguage, getAppLanguageAsContentLanguage,
@@ -115,12 +115,12 @@ async function loggedOutFetch({
}): Promise<app.bsky.feed.getFeed.$OutputBody | null> { }): Promise<app.bsky.feed.getFeed.$OutputBody | null> {
let contentLangs = getAppLanguageAsContentLanguage() let contentLangs = getAppLanguageAsContentLanguage()
/** /*
* Copied from our root `Agent` class * This request is hand-rolled rather than issued through a client, so it has
* @see https://github.com/bluesky-social/atproto/blob/60df3fc652b00cdff71dd9235d98a7a4bb828f05/packages/api/src/agent.ts#L120 * to reproduce the header lex would have emitted from the global static.
*/ */
const labelersHeader = { const labelersHeader = {
'atproto-accept-labelers': AtpAgent.appLabelers 'atproto-accept-labelers': Client.appLabelers
.map(l => `${l};redact`) .map(l => `${l};redact`)
.join(', '), .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 {TID} from '@atproto/common-web'
import {type $Typed, type Client} from '@atproto/lex' import {type $Typed, type Client} from '@atproto/lex'
import { import {
@@ -10,6 +10,7 @@ import {RichText} from '@bsky.app/sdk/richtext'
import {t} from '@lingui/core/macro' import {t} from '@lingui/core/macro'
import {type QueryClient} from '@tanstack/react-query' import {type QueryClient} from '@tanstack/react-query'
import {type LinkResolvers} from '#/lib/api/resolve'
import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants' import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
@@ -47,6 +48,11 @@ interface PostOpts {
* fallback keeps facet detection working when logged out. * fallback keeps facet detection working when logged out.
*/ */
appviewClient: Client 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, * The repo write itself (applyWrites) plus every record blob (images,
* gallery items, link thumbnails, video captions) goes to the account's own * gallery items, link thumbnails, video captions) goes to the account's own
@@ -55,15 +61,7 @@ interface PostOpts {
pdsClient: Client pdsClient: Client
} }
/** export async function post(queryClient: QueryClient, opts: PostOpts) {
* 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,
) {
const thread = opts.thread const thread = opts.thread
opts.onStateChange?.(t`Processing...`) opts.onStateChange?.(t`Processing...`)
@@ -95,7 +93,8 @@ export async function post(
// Not awaited to avoid waterfalls. // Not awaited to avoid waterfalls.
const rtPromise = resolveRT(opts.appviewClient, draft.richtext) const rtPromise = resolveRT(opts.appviewClient, draft.richtext)
const embedPromise = resolveEmbed( const embedPromise = resolveEmbed(
agent, opts.appviewClient,
opts.chatClient,
opts.pdsClient, opts.pdsClient,
queryClient, queryClient,
draft, draft,
@@ -251,7 +250,8 @@ async function resolveReply(appviewClient: Client, replyTo: string) {
} }
async function resolveEmbed( async function resolveEmbed(
agent: AtpAgent, appviewClient: Client,
chatClient: Client,
pdsClient: Client, pdsClient: Client,
queryClient: QueryClient, queryClient: QueryClient,
draft: PostDraft, draft: PostDraft,
@@ -259,8 +259,19 @@ async function resolveEmbed(
): Promise<app.bsky.feed.post.Main['embed']> { ): Promise<app.bsky.feed.post.Main['embed']> {
if (draft.embed.quote) { if (draft.embed.quote) {
const [resolvedMedia, resolvedQuote] = await Promise.all([ const [resolvedMedia, resolvedQuote] = await Promise.all([
resolveMedia(agent, pdsClient, queryClient, draft.embed, onStateChange), resolveMedia(
resolveRecord(agent, queryClient, draft.embed.quote.uri), appviewClient,
chatClient,
pdsClient,
queryClient,
draft.embed,
onStateChange,
),
resolveRecord(
{appviewClient, chatClient},
queryClient,
draft.embed.quote.uri,
),
]) ])
if (resolvedMedia) { if (resolvedMedia) {
return { return {
@@ -278,7 +289,8 @@ async function resolveEmbed(
} }
} }
const resolvedMedia = await resolveMedia( const resolvedMedia = await resolveMedia(
agent, appviewClient,
chatClient,
pdsClient, pdsClient,
queryClient, queryClient,
draft.embed, draft.embed,
@@ -290,7 +302,7 @@ async function resolveEmbed(
if (draft.embed.link) { if (draft.embed.link) {
const resolvedLink = await fetchResolveLinkQuery( const resolvedLink = await fetchResolveLinkQuery(
queryClient, queryClient,
agent, {appviewClient, chatClient},
draft.embed.link.uri, draft.embed.link.uri,
) )
if (resolvedLink.type === 'record') { if (resolvedLink.type === 'record') {
@@ -309,7 +321,8 @@ async function resolveEmbed(
} }
async function resolveMedia( async function resolveMedia(
agent: AtpAgent, appviewClient: Client,
chatClient: Client,
pdsClient: Client, pdsClient: Client,
queryClient: QueryClient, queryClient: QueryClient,
embedDraft: EmbedDraft, embedDraft: EmbedDraft,
@@ -423,11 +436,7 @@ async function resolveMedia(
} }
if (embedDraft.media?.type === 'gif') { if (embedDraft.media?.type === 'gif') {
const gifDraft = embedDraft.media const gifDraft = embedDraft.media
const resolvedGif = await fetchResolveGifQuery( const resolvedGif = await fetchResolveGifQuery(queryClient, gifDraft.gif)
queryClient,
agent,
gifDraft.gif,
)
let blob: app.bsky.embed.external.External['thumb'] let blob: app.bsky.embed.external.External['thumb']
if (resolvedGif.thumb) { if (resolvedGif.thumb) {
onStateChange?.(t`Uploading link thumbnail...`) onStateChange?.(t`Uploading link thumbnail...`)
@@ -448,7 +457,7 @@ async function resolveMedia(
if (embedDraft.link) { if (embedDraft.link) {
const resolvedLink = await fetchResolveLinkQuery( const resolvedLink = await fetchResolveLinkQuery(
queryClient, queryClient,
agent, {appviewClient, chatClient},
embedDraft.link.uri, embedDraft.link.uri,
) )
if (resolvedLink.type === 'external') { if (resolvedLink.type === 'external') {
@@ -490,15 +499,16 @@ async function resolveMedia(
} }
/* /*
* `resolve.ts` still resolves through the agent and returns legacy-typed refs; * `resolve.ts` still returns legacy-typed views, so its strong refs carry plain
* assert at the boundary until it moves to the clients. * 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( async function resolveRecord(
agent: AtpAgent, clients: LinkResolvers,
queryClient: QueryClient, queryClient: QueryClient,
uri: string, uri: string,
): Promise<com.atproto.repo.strongRef.Main> { ): Promise<com.atproto.repo.strongRef.Main> {
const resolvedLink = await fetchResolveLinkQuery(queryClient, agent, uri) const resolvedLink = await fetchResolveLinkQuery(queryClient, clients, uri)
if (resolvedLink.type !== 'record') { if (resolvedLink.type !== 'record') {
throw Error(t`Expected uri to resolve to a record`) throw Error(t`Expected uri to resolve to a record`)
} }
+54 -34
View File
@@ -1,12 +1,13 @@
import { import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
type AppBskyGraphDefs, type AppBskyGraphDefs,
type AtpAgent,
type ComAtprotoRepoStrongRef, type ComAtprotoRepoStrongRef,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} 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 {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {resolveShortLink} from '#/lib/link-meta/resolve-short-link' import {resolveShortLink} from '#/lib/link-meta/resolve-short-link'
import {downloadAndResize} from '#/lib/media/manip' import {downloadAndResize} from '#/lib/media/manip'
@@ -29,6 +30,7 @@ import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery'
import {type ChatInvitePreview} from '#/state/queries/join-links' import {type ChatInvitePreview} from '#/state/queries/join-links'
import {type Gif} from '#/features/gifPicker/types' import {type Gif} from '#/features/gifPicker/types'
import {app, chat, com} from '#/lexicons'
import {createGIFDescription} from '../gif-alt-text' import {createGIFDescription} from '../gif-alt-text'
type ResolvedExternalLink = { 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( export async function resolveLink(
agent: AtpAgent, {appviewClient, chatClient}: LinkResolvers,
uri: string, uri: string,
): Promise<ResolvedLink> { ): Promise<ResolvedLink> {
if (isShortLink(uri)) { if (isShortLink(uri)) {
@@ -124,15 +139,17 @@ export async function resolveLink(
const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean)
const did = await fetchDid(handleOrDid) const did = await fetchDid(handleOrDid)
const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey) 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 { return {
type: 'record', type: 'record',
record: { record: {
uri: res.data.view.uri, uri: data.view.uri,
cid: res.data.view.cid, cid: data.view.cid,
}, },
kind: 'feed', kind: 'feed',
view: res.data.view, view: data.view,
} }
} }
if (isBskyListUrl(uri)) { if (isBskyListUrl(uri)) {
@@ -140,28 +157,29 @@ export async function resolveLink(
const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean) const [_0, handleOrDid, _1, rkey] = uri.split('/').filter(Boolean)
const did = await fetchDid(handleOrDid) const did = await fetchDid(handleOrDid)
const list = makeRecordUri(did, 'app.bsky.graph.list', rkey) 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 { return {
type: 'record', type: 'record',
record: { record: {
uri: res.data.list.uri, uri: data.list.uri,
cid: res.data.list.cid, cid: data.list.cid,
}, },
kind: 'list', kind: 'list',
view: res.data.list, view: data.list,
} }
} }
const chatInviteCode = getChatInviteCodeFromUrl(uri) const chatInviteCode = getChatInviteCodeFromUrl(uri)
if (chatInviteCode) { if (chatInviteCode) {
const res = await agent.chat.bsky.group.getJoinLinkPreviews( const data = await chatClient.call(chat.bsky.group.getJoinLinkPreviews, {
{codes: [chatInviteCode]}, codes: [chatInviteCode],
{headers: DM_SERVICE_HEADERS}, })
)
return { return {
type: 'chat-invite', type: 'chat-invite',
uri, uri,
code: chatInviteCode, code: chatInviteCode,
view: res.data.joinLinkPreviews[0], view: data.joinLinkPreviews[0],
} }
} }
if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) { if (isBskyStartUrl(uri) || isBskyStarterPackUrl(uri)) {
@@ -173,15 +191,17 @@ export async function resolveLink(
} }
const did = await fetchDid(parsed.name) const did = await fetchDid(parsed.name)
const starterPack = createStarterPackUri({did, rkey: parsed.rkey}) 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 { return {
type: 'record', type: 'record',
record: { record: {
uri: res.data.starterPack.uri, uri: data.starterPack.uri,
cid: res.data.starterPack.cid, cid: data.starterPack.cid,
}, },
kind: 'starter-pack', kind: 'starter-pack',
view: res.data.starterPack, view: data.starterPack,
} }
} }
return resolveExternal(uri) return resolveExternal(uri)
@@ -190,17 +210,17 @@ export async function resolveLink(
async function getPost({uri}: {uri: string}) { async function getPost({uri}: {uri: string}) {
const urip = new AtUri(uri) const urip = new AtUri(uri)
if (!urip.host.startsWith('did:')) { if (!urip.host.startsWith('did:')) {
const res = await agent.resolveHandle({ const data = await appviewClient.call(
handle: urip.host, com.atproto.identity.resolveHandle,
}) {handle: urip.host as HandleString},
// @ts-expect-error TODO new-sdk-migration )
urip.host = res.data.did urip.host = data.did
} }
const res = await agent.getPosts({ const data = await appviewClient.call(app.bsky.feed.getPosts, {
uris: [urip.toString()], uris: [urip.toString()],
}) })
if (res.success && res.data.posts[0]) { if (data.posts[0]) {
return res.data.posts[0] return data.posts[0]
} }
throw new Error('getPost: post not found') throw new Error('getPost: post not found')
} }
@@ -209,17 +229,17 @@ export async function resolveLink(
async function fetchDid(handleOrDid: string) { async function fetchDid(handleOrDid: string) {
let identifier = handleOrDid let identifier = handleOrDid
if (!identifier.startsWith('did:')) { if (!identifier.startsWith('did:')) {
const res = await agent.resolveHandle({handle: identifier}) const data = await appviewClient.call(
identifier = res.data.did com.atproto.identity.resolveHandle,
{handle: identifier as HandleString},
)
identifier = data.did
} }
return identifier return identifier
} }
} }
export async function resolveGif( export async function resolveGif(gif: Gif): Promise<ResolvedExternalLink> {
agent: AtpAgent,
gif: Gif,
): Promise<ResolvedExternalLink> {
const gifUrl = gif.media_formats.gif.url const gifUrl = gif.media_formats.gif.url
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('hh', String(gif.media_formats.gif.dims[1])) 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 AppBskyActorDefs, BSKY_LABELER_DID} from '@atproto/api'
import {type Service} from '@atproto/lex' import {type Service} from '@atproto/lex'
import {type ProxyHeaderValue} from '#/state/session/agent'
import {BLUESKY_PROXY_DID, CHAT_PROXY_DID, IS_DEV} from '#/env' import {BLUESKY_PROXY_DID, CHAT_PROXY_DID, IS_DEV} from '#/env'
export const LOCAL_DEV_SERVICE = export const LOCAL_DEV_SERVICE =
@@ -241,7 +240,7 @@ export const DEV_ENV_APPVIEW_DID = `did:plc:dw4kbjf5mn7nhenabiqpkyh3` // always
export const BLUESKY_PROXY_HEADER = { export const BLUESKY_PROXY_HEADER = {
value: `${BLUESKY_PROXY_DID}#bsky_appview`, value: `${BLUESKY_PROXY_DID}#bsky_appview`,
get() { get() {
return this.value as ProxyHeaderValue return this.value as Service
}, },
set(value: string) { set(value: string) {
this.value = value this.value = value
@@ -257,9 +256,6 @@ export const BLUESKY_PROXY_HEADER = {
* The DID comes from the env-configurable `CHAT_PROXY_DID` (via * The DID comes from the env-configurable `CHAT_PROXY_DID` (via
* `EXPO_PUBLIC_CHAT_PROXY_DID`) rather than a hard-coded constant, so the * `EXPO_PUBLIC_CHAT_PROXY_DID`) rather than a hard-coded constant, so the
* target can be retargeted per environment. * 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` 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 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 * 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: * 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 {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 { import {
type InterpretedLabelValueDefinition, type InterpretedLabelValueDefinition,
LABELS, LABELS,
@@ -105,9 +107,9 @@ export function isAppLabeler(
| app.bsky.labeler.defs.LabelerViewDetailed, | app.bsky.labeler.defs.LabelerViewDetailed,
): boolean { ): boolean {
if (typeof labeler === 'string') { 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( export function isLabelerSubscribed(
@@ -70,7 +70,7 @@ import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' 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 * as bsky from '#/types/bsky'
import {ChatStatusInfo} from './ChatStatusInfo' import {ChatStatusInfo} from './ChatStatusInfo'
import {groupSystemMessages, type RenderItem} from './groupSystemMessages' import {groupSystemMessages, type RenderItem} from './groupSystemMessages'
@@ -537,10 +537,7 @@ export function MessagesList({
*/ */
rt.detectFacetsWithoutResolution() rt.detectFacetsWithoutResolution()
let embed: let embed: chat.bsky.convo.defs.MessageInput['embed']
| $Typed<AppBskyEmbedRecord.Main>
| $Typed<ChatBskyEmbedJoinLink.Main>
| undefined
let embedView: let embedView:
| $Typed<AppBskyEmbedRecord.View> | $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View> | $Typed<ChatBskyEmbedJoinLink.View>
@@ -576,10 +573,15 @@ export function MessagesList({
if (post) { if (post) {
embed = { embed = {
$type: 'app.bsky.embed.record', $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: { record: {
uri: post.uri, uri: post.uri,
cid: post.cid, cid: post.cid,
}, } as com.atproto.repo.strongRef.Main,
} }
embedView = { embedView = {
@@ -27,7 +27,7 @@ import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useServiceQuery} from '#/state/queries/service' import {useServiceQuery} from '#/state/queries/service'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 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 {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' import {atoms as a, native, useBreakpoints, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
@@ -63,12 +63,12 @@ export function ChangeHandleDialog({
function ChangeHandleDialogInner() { function ChangeHandleDialogInner() {
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const {currentAccount} = useSession()
const { const {
data: serviceInfo, data: serviceInfo,
error: serviceInfoError, error: serviceInfoError,
refetch, refetch,
} = useServiceQuery(agent.serviceUrl.toString()) } = useServiceQuery(currentAccount?.service ?? '')
const [page, setPage] = useState<'provided-handle' | 'own-handle'>( const [page, setPage] = useState<'provided-handle' | 'own-handle'>(
'provided-handle', 'provided-handle',
+52 -51
View File
@@ -1,20 +1,17 @@
import { import {
type $Typed, type $Typed,
type AppBskyEmbedRecord, type AppBskyEmbedRecord,
type AtpAgent,
type ChatBskyActorDefs, type ChatBskyActorDefs,
ChatBskyConvoDefs, ChatBskyConvoDefs,
type ChatBskyConvoGetLog, type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
type ChatBskyEmbedJoinLink, type ChatBskyEmbedJoinLink,
type ChatBskyGroupDefs, type ChatBskyGroupDefs,
} from '@atproto/api' } from '@atproto/api'
import {XRPCError} from '@atproto/api' import {type Client, XrpcResponseError} from '@atproto/lex'
import {EventEmitter} from 'eventemitter3' import {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import { import {
isErrorMaybeAppPasswordPermissions, isErrorMaybeAppPasswordPermissions,
isNetworkError, isNetworkError,
@@ -52,6 +49,7 @@ import {
parseConvoView, parseConvoView,
} from '#/components/dms/util' } from '#/components/dms/util'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {chat} from '#/lexicons'
const logger = Logger.create(Logger.Context.ConversationAgent) const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -105,7 +103,7 @@ function toDeletedMessageView(
export class Convo { export class Convo {
private id: string private id: string
private agent: AtpAgent private chatClient: Client
private events: MessagesEventBus private events: MessagesEventBus
private senderUserDid: string private senderUserDid: string
@@ -131,7 +129,7 @@ export class Convo {
string, string,
{ {
id: string id: string
message: ChatBskyConvoSendMessage.InputSchema['message'] message: chat.bsky.convo.defs.MessageInput
optimisticEmbedView?: optimisticEmbedView?:
| $Typed<AppBskyEmbedRecord.View> | $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View> | $Typed<ChatBskyEmbedJoinLink.View>
@@ -166,9 +164,9 @@ export class Convo {
constructor(params: ConvoParams) { constructor(params: ConvoParams) {
this.id = nanoid(3) this.id = nanoid(3)
this.convoId = params.convoId this.convoId = params.convoId
this.agent = params.agent this.chatClient = params.chatClient
this.events = params.events this.events = params.events
this.senderUserDid = params.agent.assertDid this.senderUserDid = params.chatClient.assertDid
if (params.placeholderData) { if (params.placeholderData) {
this.setupPlaceholderData(params.placeholderData) this.setupPlaceholderData(params.placeholderData)
@@ -197,6 +195,15 @@ export class Convo {
this.subscribers.forEach(subscriber => subscriber()) 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)[] = [] private subscribers: (() => void)[] = []
subscribe(subscriber: () => void) { subscribe(subscriber: () => void) {
@@ -720,13 +727,12 @@ export class Convo {
this.pendingFetchConvo = (async () => { this.pendingFetchConvo = (async () => {
try { try {
const response = await networkRetry(2, () => { const response = await networkRetry(2, () => {
return this.agent.chat.bsky.convo.getConvo( return this.chatClient.call(chat.bsky.convo.getConvo, {
{convoId: this.convoId}, convoId: this.convoId,
{headers: DM_SERVICE_HEADERS}, })
)
}) })
const convo = response.data.convo const convo = response.convo
return { return {
convo, convo,
@@ -763,18 +769,15 @@ export class Convo {
let cursor: string | undefined let cursor: string | undefined
do { do {
const result = await networkRetry(2, () => { const result = await networkRetry(2, () => {
return this.agent.chat.bsky.convo.getConvoMembers( return this.chatClient.call(chat.bsky.convo.getConvoMembers, {
{
convoId: this.convoId, convoId: this.convoId,
limit: 50, limit: 50,
cursor, cursor,
},
{headers: DM_SERVICE_HEADERS},
)
}) })
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) this.relatedProfiles.set(member.did, member)
} }
} while (cursor) } while (cursor)
@@ -808,16 +811,13 @@ export class Convo {
const nextCursor = this.oldestRev // for TS const nextCursor = this.oldestRev // for TS
const response = await networkRetry(2, () => { const response = await networkRetry(2, () => {
return this.agent.chat.bsky.convo.getMessages( return this.chatClient.call(chat.bsky.convo.getMessages, {
{
cursor: nextCursor, cursor: nextCursor,
convoId: this.convoId, convoId: this.convoId,
limit: IS_NATIVE ? 30 : 60, limit: IS_NATIVE ? 30 : 60,
},
{headers: DM_SERVICE_HEADERS},
)
}) })
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 // 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 // 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 private pendingMessageFailure: 'recoverable' | 'unrecoverable' | null = null
sendMessage( sendMessage(
message: ChatBskyConvoSendMessage.InputSchema['message'], message: chat.bsky.convo.defs.MessageInput,
optimisticEmbedView?: optimisticEmbedView?:
| $Typed<AppBskyEmbedRecord.View> | $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View>, | $Typed<ChatBskyEmbedJoinLink.View>,
@@ -1165,14 +1165,10 @@ export class Convo {
const {id, message} = pendingMessage const {id, message} = pendingMessage
const response = await this.agent.chat.bsky.convo.sendMessage( const res = await this.chatClient.call(chat.bsky.convo.sendMessage, {
{
convoId: this.convoId, convoId: this.convoId,
message, message,
}, })
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
)
const res = response.data
// remove from queue // remove from queue
this.pendingMessages.delete(id) 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)) { if (NETWORK_FAILURE_STATUSES.includes(e.status)) {
this.pendingMessageFailure = 'recoverable' this.pendingMessageFailure = 'recoverable'
} else { } else {
@@ -1261,16 +1264,15 @@ export class Convo {
) )
try { try {
const {data} = await this.agent.chat.bsky.convo.sendMessageBatch( const {items} = await this.chatClient.call(
chat.bsky.convo.sendMessageBatch,
{ {
items: messageArray.map(({message}) => ({ items: messageArray.map(({message}) => ({
convoId: this.convoId, convoId: this.convoId,
message, 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 * Insert into `newMessages` as soon as we have a real ID. That way, when
@@ -1304,13 +1306,10 @@ export class Convo {
try { try {
await networkRetry(2, () => { await networkRetry(2, () => {
return this.agent.chat.bsky.convo.deleteMessageForSelf( return this.chatClient.call(chat.bsky.convo.deleteMessageForSelf, {
{
convoId: this.convoId, convoId: this.convoId,
messageId, messageId,
}, })
{encoding: 'application/json', headers: DM_SERVICE_HEADERS},
)
}) })
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
@@ -1529,10 +1528,11 @@ export class Convo {
try { try {
logger.debug(`Adding reaction ${emoji} to message ${messageId}`) logger.debug(`Adding reaction ${emoji} to message ${messageId}`)
const {data} = await this.agent.chat.bsky.convo.addReaction( const data = await this.chatClient.call(chat.bsky.convo.addReaction, {
{messageId, value: emoji, convoId: this.convoId}, messageId,
{encoding: 'application/json', headers: DM_SERVICE_HEADERS}, value: emoji,
) convoId: this.convoId,
})
if (ChatBskyConvoDefs.isMessageView(data.message)) { if (ChatBskyConvoDefs.isMessageView(data.message)) {
if (this.pastMessages.has(messageId)) { if (this.pastMessages.has(messageId)) {
this.pastMessages.set(messageId, data.message) this.pastMessages.set(messageId, data.message)
@@ -1594,10 +1594,11 @@ export class Convo {
try { try {
logger.debug(`Removing reaction ${emoji} from message ${messageId}`) logger.debug(`Removing reaction ${emoji} from message ${messageId}`)
await this.agent.chat.bsky.convo.removeReaction( await this.chatClient.call(chat.bsky.convo.removeReaction, {
{messageId, value: emoji, convoId: this.convoId}, messageId,
{encoding: 'application/json', headers: DM_SERVICE_HEADERS}, value: emoji,
) convoId: this.convoId,
})
} catch (error) { } catch (error) {
if (restore) restore() if (restore) restore()
throw error throw error
+13 -3
View File
@@ -28,7 +28,7 @@ import {
} from '#/state/queries/messages/conversation' } from '#/state/queries/messages/conversation'
import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations' import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' 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' import {type GroupConvoMember} from '#/components/dms/util'
export * from '#/state/messages/convo/util' export * from '#/state/messages/convo/util'
@@ -80,7 +80,7 @@ export function ConvoProvider({
convoId, convoId,
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) { }: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const chatClient = useChatClient()
const events = useMessagesEventBus() const events = useMessagesEventBus()
const [convo] = useState(() => { const [convo] = useState(() => {
const placeholder = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>( const placeholder = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
@@ -88,7 +88,7 @@ export function ConvoProvider({
) )
return new Convo({ return new Convo({
convoId, convoId,
agent, chatClient,
events, events,
placeholderData: placeholder ? {convo: placeholder} : undefined, placeholderData: placeholder ? {convo: placeholder} : undefined,
}) })
@@ -96,6 +96,16 @@ export function ConvoProvider({
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
const {mutate: markAsRead} = useMarkAsReadMutation() 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 appState = useAppState()
const isActive = appState === 'active' const isActive = appState === 'active'
useFocusEffect( useFocusEffect(
+5 -4
View File
@@ -1,19 +1,20 @@
import { import {
type $Typed, type $Typed,
type AppBskyEmbedRecord, type AppBskyEmbedRecord,
type AtpAgent,
type ChatBskyActorDefs, type ChatBskyActorDefs,
type ChatBskyConvoDefs, type ChatBskyConvoDefs,
type ChatBskyConvoSendMessage,
type ChatBskyEmbedJoinLink, type ChatBskyEmbedJoinLink,
} from '@atproto/api' } from '@atproto/api'
import {type Client} from '@atproto/lex'
import {type MessagesEventBus} from '#/state/messages/events/agent' import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type ConvoWithDetails} from '#/components/dms/util' import {type ConvoWithDetails} from '#/components/dms/util'
import {type chat} from '#/lexicons'
export type ConvoParams = { export type ConvoParams = {
convoId: string convoId: string
agent: AtpAgent /** The chat client, which proxies `chat.bsky.*` to the chat service. */
chatClient: Client
events: MessagesEventBus events: MessagesEventBus
placeholderData?: { placeholderData?: {
convo: ChatBskyConvoDefs.ConvoView convo: ChatBskyConvoDefs.ConvoView
@@ -108,7 +109,7 @@ export type ConvoItem =
type DeleteMessage = (messageId: string) => Promise<void> type DeleteMessage = (messageId: string) => Promise<void>
type SendMessage = ( type SendMessage = (
message: ChatBskyConvoSendMessage.InputSchema['message'], message: chat.bsky.convo.defs.MessageInput,
optimisticEmbedView: optimisticEmbedView:
| $Typed<AppBskyEmbedRecord.View> | $Typed<AppBskyEmbedRecord.View>
| $Typed<ChatBskyEmbedJoinLink.View> | $Typed<ChatBskyEmbedJoinLink.View>
+19 -16
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 {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import { import {
isErrorMaybeAppPasswordPermissions, isErrorMaybeAppPasswordPermissions,
isNetworkError, isNetworkError,
@@ -21,13 +20,14 @@ import {
type MessagesEventBusParams, type MessagesEventBusParams,
MessagesEventBusStatus, MessagesEventBusStatus,
} from '#/state/messages/events/types' } from '#/state/messages/events/types'
import {chat} from '#/lexicons'
const logger = Logger.create(Logger.Context.DMsAgent) const logger = Logger.create(Logger.Context.DMsAgent)
export class MessagesEventBus { export class MessagesEventBus {
private id: string private id: string
private agent: AtpAgent private chatClient: Client
private emitter = new EventEmitter<{event: [MessagesEventBusEvent]}>() private emitter = new EventEmitter<{event: [MessagesEventBusEvent]}>()
private status: MessagesEventBusStatus = MessagesEventBusStatus.Initializing private status: MessagesEventBusStatus = MessagesEventBusStatus.Initializing
@@ -37,11 +37,20 @@ export class MessagesEventBus {
constructor(params: MessagesEventBusParams) { constructor(params: MessagesEventBusParams) {
this.id = nanoid(3) this.id = nanoid(3)
this.agent = params.agent this.chatClient = params.chatClient
this.init() 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) { requestPollInterval(interval: number) {
const id = nanoid() const id = nanoid()
this.requestedPollIntervals.set(id, interval) this.requestedPollIntervals.set(id, interval)
@@ -260,14 +269,11 @@ export class MessagesEventBus {
try { try {
const response = await networkRetry(2, () => { const response = await networkRetry(2, () => {
return this.agent.chat.bsky.convo.getLog( return this.chatClient.call(chat.bsky.convo.getLog, {})
{},
{headers: DM_SERVICE_HEADERS},
)
}) })
// throw new Error('UNCOMMENT TO TEST INIT FAILURE') // throw new Error('UNCOMMENT TO TEST INIT FAILURE')
const {cursor} = response.data const {cursor} = response
// should always be defined // should always be defined
if (cursor) { if (cursor) {
@@ -355,21 +361,18 @@ export class MessagesEventBus {
// ) // )
let needsEmit = false let needsEmit = false
let batch: ChatBskyConvoGetLog.OutputSchema['logs'] = [] let batch: chat.bsky.convo.getLog.$OutputBody['logs'] = []
try { try {
const response = await networkRetry(2, () => { const response = await networkRetry(2, () => {
return this.agent.chat.bsky.convo.getLog( return this.chatClient.call(chat.bsky.convo.getLog, {
{
cursor: this.latestRev, cursor: this.latestRev,
}, })
{headers: DM_SERVICE_HEADERS},
)
}) })
// throw new Error('UNCOMMENT TO TEST POLL FAILURE') // throw new Error('UNCOMMENT TO TEST POLL FAILURE')
const {logs: events} = response.data const {logs: events} = response
for (const ev of events) { 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 {AppState} from 'react-native'
import {MessagesEventBus} from '#/state/messages/events/agent' 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) const MessagesEventBusContext = createContext<MessagesEventBus | null>(null)
MessagesEventBusContext.displayName = 'MessagesEventBusContext' MessagesEventBusContext.displayName = 'MessagesEventBusContext'
@@ -42,14 +42,24 @@ export function MessagesEventBusProviderInner({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const agent = useAgent() const chatClient = useChatClient()
const [bus] = useState( const [bus] = useState(
() => () =>
new MessagesEventBus({ 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(() => { useEffect(() => {
bus.resume() 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 = { export type MessagesEventBusParams = {
agent: AtpAgent /** The chat client, which proxies `chat.bsky.*` to the chat service. */
chatClient: Client
} }
export enum MessagesEventBusStatus { export enum MessagesEventBusStatus {
@@ -64,5 +67,5 @@ export type MessagesEventBusEvent =
} }
| { | {
type: 'logs' 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 {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 {useMutation, useQueryClient} from '@tanstack/react-query'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useAgent, usePdsClient} from '#/state/session' import {useAppviewClient, usePdsClient} from '#/state/session'
import {com} from '#/lexicons' import {app, com} from '#/lexicons'
const handleQueryKeyRoot = 'handle' const handleQueryKeyRoot = 'handle'
const fetchHandleQueryKey = (handleOrDid: string) => [ const fetchHandleQueryKey = (handleOrDid: string) => [
@@ -16,21 +16,24 @@ const fetchDidQueryKey = (handleOrDid: string) => [didQueryKeyRoot, handleOrDid]
export function useFetchHandle() { export function useFetchHandle() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const client = useAppviewClient()
return useCallback( return useCallback(
async (handleOrDid: string) => { async (handleOrDid: string) => {
if (handleOrDid.startsWith('did:')) { if (handleOrDid.startsWith('did:')) {
const res = await queryClient.fetchQuery({ const data = await queryClient.fetchQuery({
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
queryKey: fetchHandleQueryKey(handleOrDid), 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 return handleOrDid
}, },
[queryClient, agent], [queryClient, client],
) )
} }
@@ -42,7 +45,6 @@ export function useUpdateHandleMutation(opts?: {
return useMutation({ return useMutation({
mutationFn: async ({handle}: {handle: string}) => { mutationFn: async ({handle}: {handle: string}) => {
// `agent.updateHandle` was a pure alias for this method
await client.call(com.atproto.identity.updateHandle, { await client.call(com.atproto.identity.updateHandle, {
// callers validate the handle before submitting // callers validate the handle before submitting
handle: handle as HandleString, handle: handle as HandleString,
@@ -59,7 +61,7 @@ export function useUpdateHandleMutation(opts?: {
export function useFetchDid() { export function useFetchDid() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const client = useAppviewClient()
return useCallback( return useCallback(
async (handleOrDid: string) => { async (handleOrDid: string) => {
@@ -69,13 +71,15 @@ export function useFetchDid() {
queryFn: async () => { queryFn: async () => {
let identifier = handleOrDid let identifier = handleOrDid
if (!identifier.startsWith('did:')) { if (!identifier.startsWith('did:')) {
const res = await agent.resolveHandle({handle: identifier}) const data = await client.call(com.atproto.identity.resolveHandle, {
identifier = res.data.did handle: identifier as HandleString,
})
identifier = data.did
} }
return identifier 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 {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' 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 * as userActionHistory from '#/state/userActionHistory'
import {KnownError} from '#/view/com/posts/PostFeedErrorMessage' import {KnownError} from '#/view/com/posts/PostFeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners' import {useFeedTuners} from '../preferences/feed-tuners'
@@ -151,7 +151,7 @@ export function usePostFeedQuery(
f => f.pinned && f.value === 'following', f => f.pinned && f.value === 'following',
) ?? -1 ) ?? -1
const enableFollowingToDiscoverFallback = followingPinnedIndex === 0 const enableFollowingToDiscoverFallback = followingPinnedIndex === 0
const agent = useAgent() const {hasSession} = useSession()
const client = useAppviewClient() const client = useAppviewClient()
const lastRun = useRef<{ const lastRun = useRef<{
data: InfiniteData<FeedPageUnselected> data: InfiniteData<FeedPageUnselected>
@@ -214,7 +214,7 @@ export function usePostFeedQuery(
* moderations happen later, which results in some posts being shown and * moderations happen later, which results in some posts being shown and
* some not. * some not.
*/ */
if (!agent.session) { if (!hasSession) {
assertSomePostsPassModeration( assertSomePostsPassModeration(
res.feed, res.feed,
preferences?.moderationPrefs || preferences?.moderationPrefs ||
+46 -50
View File
@@ -1,6 +1,7 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {type AppBskyActorDefs, type AppBskyFeedDefs, AtUri} from '@atproto/api' 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 {deleteLike, deletePost, deleteRepost, like, repost} from '@bsky.app/sdk'
import { import {
type QueryClient, type QueryClient,
@@ -12,16 +13,11 @@ import {
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
import {updatePostShadow} from '#/state/cache/post-shadow' import {updatePostShadow} from '#/state/cache/post-shadow'
import {type Shadow} from '#/state/cache/types' import {type Shadow} from '#/state/cache/types'
import { import {useAppviewClient, usePdsClient, useSession} from '#/state/session'
useAgent,
useAppviewClient,
usePdsClient,
useSession,
} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory' import * as userActionHistory from '#/state/userActionHistory'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {type Metrics, toClout} from '#/analytics/metrics' import {type Metrics, toClout} from '#/analytics/metrics'
import {app} from '#/lexicons' import {app, com} from '#/lexicons'
import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
import {findProfileQueryData} from './profile' import {findProfileQueryData} from './profile'
@@ -29,25 +25,15 @@ const RQKEY_ROOT = 'post'
export const RQKEY = (postUri: string) => [RQKEY_ROOT, postUri] export const RQKEY = (postUri: string) => [RQKEY_ROOT, postUri]
export function usePostQuery(uri: string | undefined) { export function usePostQuery(uri: string | undefined) {
const agent = useAgent() const client = useAppviewClient()
return useQuery<AppBskyFeedDefs.PostView>({ return useQuery<AppBskyFeedDefs.PostView>({
queryKey: RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
queryFn: async () => { queryFn: async () => {
if (!uri) throw new Error('[unreachable] No URI provided') if (!uri) throw new Error('[unreachable] No URI provided')
const urip = new AtUri(uri) const post = await fetchPost(client, uri)
if (post) {
if (!urip.host.startsWith('did:')) { return post
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]
} }
throw new Error('No data') 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( export function precachePost(
queryClient: QueryClient, queryClient: QueryClient,
uri: string, uri: string,
@@ -66,59 +79,42 @@ export function precachePost(
export function useGetPost() { export function useGetPost() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const client = useAppviewClient()
return useCallback( return useCallback(
async ({uri}: {uri: string}) => { async ({uri}: {uri: string}) => {
return queryClient.fetchQuery({ return queryClient.fetchQuery({
queryKey: RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
async queryFn() { async queryFn() {
const urip = new AtUri(uri) const post = await fetchPost(client, uri)
if (post) {
if (!urip.host.startsWith('did:')) { return post
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]
} }
throw new Error('useGetPost: post not found') throw new Error('useGetPost: post not found')
}, },
}) })
}, },
[queryClient, agent], [queryClient, client],
) )
} }
export function useGetPosts() { export function useGetPosts() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const client = useAppviewClient()
return useCallback( return useCallback(
async ({uris}: {uris: string[]}) => { async ({uris}: {uris: string[]}) => {
return queryClient.fetchQuery({ return queryClient.fetchQuery({
queryKey: RQKEY(uris.join(',') || ''), queryKey: RQKEY(uris.join(',') || ''),
async queryFn() { async queryFn() {
const res = await agent.getPosts({ const data = await client.call(app.bsky.feed.getPosts, {
uris, uris: uris as AtUriString[],
}) })
// See the note on `fetchPost` about the view shapes.
if (res.success) { return data.posts as AppBskyFeedDefs.PostView[]
return res.data.posts
} else {
throw new Error('useGetPosts failed')
}
}, },
}) })
}, },
[queryClient, agent], [queryClient, client],
) )
} }
+7 -7
View File
@@ -39,7 +39,7 @@ import {
type UsePreferencesQueryResponse, type UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types' } from '#/state/queries/preferences/types'
import {createQueryKey} from '#/state/queries/util' 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 {applyLabelersToClient, saveLabelers} from '#/state/session/moderation'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
@@ -58,7 +58,7 @@ export const preferencesQueryKey = createQueryKey(
export function usePreferencesQuery() { export function usePreferencesQuery() {
const client = usePdsClient() const client = usePdsClient()
const agent = useAgent() const appviewClient = useAppviewClient()
const aa = useAgeAssurance() const aa = useAgeAssurance()
const query = useQuery({ const query = useQuery({
@@ -86,12 +86,12 @@ export function usePreferencesQuery() {
* from a labeler would not affect server-attached labels until the * from a labeler would not affect server-attached labels until the
* session bundle was rebuilt. * session bundle was rebuilt.
* *
* `applyLabelersToClient` writes to the agent, which is what stamps the * The subscriptions go on the appview client, which is what stamps
* header on the requests the wrapping appview client issues, and it * `atproto-accept-labelers` on its own requests. The Bluesky moderation
* drops the Bluesky moderation DID so the globally redacted authority is * DID is dropped so the globally redacted authority is not also listed
* not also listed unredacted. * unredacted.
*/ */
applyLabelersToClient(agent, labelerDids) applyLabelersToClient(appviewClient, labelerDids)
/* /*
* `BskyPreferences` is now the sdk's own type, so the assembled * `BskyPreferences` is now the sdk's own type, so the assembled
+2 -2
View File
@@ -1,5 +1,5 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api' import {Client} from '@atproto/lex'
import {interpretLabelValueDefinitions} from '@bsky.app/sdk/moderation' import {interpretLabelValueDefinitions} from '@bsky.app/sdk/moderation'
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
@@ -14,7 +14,7 @@ export function useMyLabelersQuery({
const prefs = usePreferencesQuery() const prefs = usePreferencesQuery()
let dids = Array.from( let dids = Array.from(
new Set( new Set(
AtpAgent.appLabelers.concat( (Client.appLabelers as readonly string[]).concat(
prefs.data?.moderationPrefs.labelers.map(l => l.did) || [], 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 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 {STALE} from '#/state/queries/index'
import {useAgent} from '#/state/session' import {useAppviewClient, useChatClient} from '#/state/session'
import {type Gif} from '#/features/gifPicker/types' import {type Gif} from '#/features/gifPicker/types'
export const RQKEY_LINK_ROOT = 'resolve-link' 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_ROOT = 'resolve-gif'
export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url] 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({ return queryOptions({
staleTime: STALE.HOURS.ONE, staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_LINK(url), queryKey: RQKEY_LINK(url),
queryFn: () => resolveLink(agent, url), queryFn: () => resolveLink(clients, url),
}) })
} }
export function useResolveLinkQuery(url: string) { export function useResolveLinkQuery(url: string) {
const agent = useAgent() const appviewClient = useAppviewClient()
return useQuery(resolveLinkQueryOptions(agent, url)) const chatClient = useChatClient()
return useQuery(resolveLinkQueryOptions({appviewClient, chatClient}, url))
} }
export function fetchResolveLinkQuery( export function fetchResolveLinkQuery(
queryClient: QueryClient, queryClient: QueryClient,
agent: AtpAgent, clients: LinkResolvers,
url: string, url: string,
) { ) {
return queryClient.fetchQuery(resolveLinkQueryOptions(agent, url)) return queryClient.fetchQuery(resolveLinkQueryOptions(clients, url))
} }
export function precacheResolveLinkQuery( export function precacheResolveLinkQuery(
queryClient: QueryClient, queryClient: QueryClient,
@@ -39,26 +44,25 @@ export function precacheResolveLinkQuery(
queryClient.setQueryData(RQKEY_LINK(url), resolvedLink) 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) { export function useResolveGifQuery(gif: Gif) {
const agent = useAgent()
return useQuery({ return useQuery({
staleTime: STALE.HOURS.ONE, staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_GIF(gif.url), queryKey: RQKEY_GIF(gif.url),
queryFn: async () => { queryFn: async () => {
return await resolveGif(agent, gif) return await resolveGif(gif)
}, },
}) })
} }
export function fetchResolveGifQuery( export function fetchResolveGifQuery(queryClient: QueryClient, gif: Gif) {
queryClient: QueryClient,
agent: AtpAgent,
gif: Gif,
) {
return queryClient.fetchQuery({ return queryClient.fetchQuery({
staleTime: STALE.HOURS.ONE, staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_GIF(gif.url), queryKey: RQKEY_GIF(gif.url),
queryFn: async () => { 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',
)
})
})
+184 -185
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 {app, chat, com} from '#/lexicons'
import {configureGlobalAppLabelers} from '../additional-moderation-authorities' import {configureGlobalAppLabelers} from '../additional-moderation-authorities'
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import { import {
agentToAppviewClient, buildAppviewClient,
agentToChatClient, buildChatClient,
agentToPdsClient, buildPdsClient,
getUnauthenticatedThrowingClient, getUnauthenticatedThrowingClient,
NotAuthenticatedError, NotAuthenticatedError,
routeSessionToPds,
} from '../clients' } from '../clients'
import {sessionAccountToSessionData} from '../session-data' import {sessionAccountToSessionData} from '../session-data'
import { import {
asFetch, asFetch,
DID, DID,
DIDDOC_PDS_HOST,
HANDLE, HANDLE,
json, json,
makeAccount, makeAccount,
makeDidDoc,
makeMockFetch, makeMockFetch,
type MockFetch, type MockFetch,
PDS_HOST,
SERVICE, SERVICE,
urlsOf, urlsOf,
} from './mock-fetch' } from './mock-fetch'
@@ -49,25 +52,16 @@ function makeProfileFetch(): MockFetch {
}) })
} }
/** An authenticated agent whose whole network path is the mock fetch. */ /** A live `PasswordSession` whose whole network path is the mock fetch. */
function setup(fetchMock: MockFetch = makeProfileFetch()) { function makeSession(fetchMock: MockFetch, didDocPdsUrl?: string) {
const account = makeAccount() const account = makeAccount()
const inner = new PasswordSession(sessionAccountToSessionData(account), { return new PasswordSession(
fetch: asFetch(fetchMock), {
}) ...sessionAccountToSessionData(account),
const manager = new PasswordSessionManager(inner, { ...(didDocPdsUrl ? {didDoc: makeDidDoc(didDocPdsUrl)} : {}),
service: account.service, },
}) {fetch: asFetch(fetchMock)},
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}
} }
/** The `init` a mock fetch was called with for a given nsid. */ /** 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] 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 let fetchMock: MockFetch
beforeEach(() => { beforeEach(() => {
fetchMock = makeProfileFetch() fetchMock = makeProfileFetch()
configureGlobalAppLabelers([])
}) })
it('memoizes one client per agent', () => { it('passes through the session did', () => {
const {agent: agentA} = setup(fetchMock) const client = buildAppviewClient(makeSession(fetchMock))
const {agent: agentB} = setup(fetchMock) expect(client).toBeInstanceOf(Client)
expect(client.did).toBe(DID)
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 agent did', () => { it('routes client.call through the session to the network', async () => {
const {agent} = setup(fetchMock) const client = buildAppviewClient(makeSession(fetchMock))
expect(agentToAppviewClient(agent).did).toBe(DID)
})
it('reflects an undefined did on a logged-out agent', () => { const body = await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
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,
},
)
expect(body.handle).toBe(HANDLE) expect(body.handle).toBe(HANDLE)
const call = fetchMock.mock.calls.find(c => { expect(urlsOf(fetchMock).join()).toContain(`actor=${HANDLE}`)
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}`)
}) })
it('emits the agent proxy header', async () => { it('emits the appview proxy header', async () => {
const {agent} = setup(fetchMock) const client = buildAppviewClient(makeSession(fetchMock))
agent.configureProxy('did:web:api.bsky.app#bsky_appview')
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
actor: HANDLE,
expect(
headersFor(fetchMock, 'app.bsky.actor.getProfile').get('atproto-proxy'),
).toBe(BLUESKY_PROXY_HEADER.get())
}) })
const init = initFor(fetchMock, 'app.bsky.actor.getProfile') it('emits an account subscription exactly once', async () => {
expect(new Headers(init?.headers).get('atproto-proxy')).toBe( const client = buildAppviewClient(makeSession(fetchMock))
'did:web:api.bsky.app#bsky_appview', 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 * The client is the only producer of this header now, so a duplicate would
* entry must not be duplicated. * mean lex itself emitted the same DID twice.
*/ */
const entries = labelers! const entries = labelers!
.split(',') .split(',')
@@ -164,25 +130,21 @@ describe('agentToAppviewClient', () => {
expect(entries).toHaveLength(1) 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` * The global static is the ONLY producer of the redacted authorities - no
* static, because clients built without a wrapped agent read only the * agent stamps them any more - and lex suffixes them with `;redact`. An
* latter. On this path both producers are in play for the same request, and * account subscription that also listed the same DID would produce a second,
* neither dedupes against the other - the agent joins its list with the * non-redacting entry, which is what `applyLabelersToClient` filters against.
* 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.
*/ */
configureGlobalAppLabelers(['did:plc:global-labeler']) configureGlobalAppLabelers(['did:plc:global-labeler'])
const {agent} = setup(fetchMock) const client = buildAppviewClient(makeSession(fetchMock))
await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
actor: HANDLE,
})
const init = initFor(fetchMock, 'app.bsky.actor.getProfile') const labelers = headersFor(fetchMock, 'app.bsky.actor.getProfile').get(
const labelers = new Headers(init?.headers).get('atproto-accept-labelers') 'atproto-accept-labelers',
)
const entries = labelers! const entries = labelers!
.split(',') .split(',')
.map(l => l.trim()) .map(l => l.trim())
@@ -191,100 +153,69 @@ describe('agentToAppviewClient', () => {
}) })
it('sends the session access token', async () => { it('sends the session access token', async () => {
const {agent} = setup(fetchMock) const client = buildAppviewClient(makeSession(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()
await client.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(
expect(new Headers(init?.headers).has('authorization')).toBe(false) headersFor(fetchMock, 'app.bsky.actor.getProfile').get('authorization'),
expect(client.did).toBeUndefined() ).toBe('Bearer access-jwt')
}) })
}) })
describe('agentToPdsClient', () => { describe('buildPdsClient', () => {
let fetchMock: MockFetch let fetchMock: MockFetch
beforeEach(() => { beforeEach(() => {
fetchMock = makeProfileFetch() fetchMock = makeProfileFetch()
configureGlobalAppLabelers([])
}) })
it('memoizes one client per agent', () => { it('is a distinct client from the appview client over the same session', () => {
const {agent: agentA} = setup(fetchMock) const session = makeSession(fetchMock)
const {agent: agentB} = setup(fetchMock) expect(buildPdsClient(session)).not.toBe(buildAppviewClient(session))
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('sends the session access token', async () => { it('sends the session access token', async () => {
const {agent} = setup(fetchMock) await buildPdsClient(makeSession(fetchMock)).call(
com.atproto.server.getSession,
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',
) )
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 * The load-bearing difference from the appview client: a PDS request must
* session manager, below the agent layer that sets both headers, so a * reach the account host itself rather than being proxied onward, and it is
* request reaches the account's PDS instead of being proxied onward. * 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']) configureGlobalAppLabelers(['did:plc:global-labeler'])
await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) await buildPdsClient(makeSession(fetchMock)).call(
com.atproto.server.getSession,
const headers = new Headers( {},
initFor(fetchMock, 'com.atproto.server.getSession')?.headers,
) )
const headers = headersFor(fetchMock, 'com.atproto.server.getSession')
expect(headers.get('atproto-proxy')).toBeNull() expect(headers.get('atproto-proxy')).toBeNull()
expect(headers.get('atproto-accept-labelers')).toBeNull() expect(headers.get('atproto-accept-labelers')).toBeNull()
}) })
it('resolves the relative xrpc path against the account host', async () => { it('resolves the relative xrpc path against the account host', async () => {
/* /*
* lex-client hands its fetchHandler an origin-less `/xrpc/<nsid>` path; the * lex hands its fetchHandler an origin-less `/xrpc/<nsid>` path; the session
* session manager absolutizes it against dispatchUrl. * absolutizes it against its didDoc endpoint or, absent one, its service.
*/ */
const {agent} = setup(fetchMock) await buildPdsClient(makeSession(fetchMock)).call(
com.atproto.server.getSession,
await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) {},
)
expect(urlsOf(fetchMock)).toContain( expect(urlsOf(fetchMock)).toContain(
`${SERVICE}/xrpc/com.atproto.server.getSession`, `${SERVICE}/xrpc/com.atproto.server.getSession`,
@@ -292,34 +223,26 @@ describe('agentToPdsClient', () => {
}) })
}) })
describe('agentToChatClient', () => { describe('buildChatClient', () => {
let fetchMock: MockFetch let fetchMock: MockFetch
beforeEach(() => { beforeEach(() => {
fetchMock = makeProfileFetch() fetchMock = makeProfileFetch()
configureGlobalAppLabelers([])
}) })
it('memoizes one client per agent, distinct from the pds client', () => { it('is a distinct client from the pds client over the same session', () => {
const {agent} = setup(fetchMock) const session = makeSession(fetchMock)
expect(buildChatClient(session)).not.toBe(buildPdsClient(session))
const client = agentToChatClient(agent)
expect(client).toBeInstanceOf(Client)
expect(client).toBe(agentToChatClient(agent))
expect(client).not.toBe(agentToPdsClient(agent))
}) })
it('emits the chat proxy header exactly once, with the session token', async () => { 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 */ /* the stub body fails listConvos output validation; headers are recorded pre-parse */
await agentToChatClient(agent) await buildChatClient(makeSession(fetchMock))
.call(chat.bsky.convo.listConvos, {}) .call(chat.bsky.convo.listConvos, {})
.catch(() => {}) .catch(() => {})
const headers = new Headers( const headers = headersFor(fetchMock, 'chat.bsky.convo.listConvos')
initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers,
)
/* /*
* An exact match, not `toContain`: `Headers` comma-joins repeated entries * An exact match, not `toContain`: `Headers` comma-joins repeated entries
* for the same name, so a second contributor would show up here. * 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') expect(headers.get('authorization')).toBe('Bearer access-jwt')
}) })
it('does not emit the agent labeler header', async () => { it('emits no labeler header', async () => {
const {agent} = setup(fetchMock) /* the global authorities do not apply: a chat call is not an appview read */
agent.configureLabelersHeader(['did:plc:labeler'])
/* nor the global authorities: a chat call is not an appview read */
configureGlobalAppLabelers(['did:plc:global-labeler']) configureGlobalAppLabelers(['did:plc:global-labeler'])
await agentToChatClient(agent) await buildChatClient(makeSession(fetchMock))
.call(chat.bsky.convo.listConvos, {}) .call(chat.bsky.convo.listConvos, {})
.catch(() => {}) .catch(() => {})
const headers = new Headers( expect(
initFor(fetchMock, 'chat.bsky.convo.listConvos')?.headers, headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
) 'atproto-accept-labelers',
expect(headers.get('atproto-accept-labelers')).toBeNull() ),
).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, useSessionApi,
} from '#/state/session' } from '#/state/session'
import {type SessionApiContext} from '#/state/session/types' import {type SessionApiContext} from '#/state/session/types'
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import { import {
agentToAppviewClient, buildAppviewClient,
agentToChatClient, buildChatClient,
agentToPdsClient, buildPdsClient,
getUnauthenticatedThrowingClient, getUnauthenticatedThrowingClient,
} from '../clients' } from '../clients'
import {type SessionBundle} from '../session-core' import {type SessionBundle} from '../session-core'
@@ -92,22 +91,19 @@ type Clients = {
} }
/** /**
* Build a bundle whose agent is a real `BskyAppAgent` over a real * Build a bundle over a real `PasswordSession`, with the three clients the
* `PasswordSession`, since the client builders derive from the agent. Only the * provider serves. Only the fields the provider reads are populated.
* fields the provider reads are populated.
*/ */
function makeBundle(account: SessionAccount): SessionBundle { function makeBundle(account: SessionAccount): SessionBundle {
const fetchMock = makeMockFetch() const fetchMock = makeMockFetch()
const session = new PasswordSession(sessionAccountToSessionData(account), { const session = new PasswordSession(sessionAccountToSessionData(account), {
fetch: asFetch(fetchMock), fetch: asFetch(fetchMock),
}) })
const manager = new PasswordSessionManager(session, {
service: account.service,
})
manager.setFetch(asFetch(fetchMock))
return { return {
session, session,
agent: new BskyAppAgent(manager), appviewClient: buildAppviewClient(session),
pdsClient: buildPdsClient(session),
chatClient: buildChatClient(session),
service: new URL(account.service), service: new URL(account.service),
} }
} }
@@ -140,10 +136,10 @@ beforeEach(() => {
}) })
describe('client hooks while logged out', () => { 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() const {clients} = renderClients()
expect(clients().appview).toBeDefined() 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() expect(clients().appview.did).toBeUndefined()
}) })
@@ -162,7 +158,7 @@ describe('client hooks while logged out', () => {
}) })
describe('client hooks with a session', () => { 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 account = makeAccount()
const bundle = makeBundle(account) const bundle = makeBundle(account)
const {api, clients} = renderClients() const {api, clients} = renderClients()
@@ -172,9 +168,9 @@ describe('client hooks with a session', () => {
await api.login({} as never, 'LoginForm') await api.login({} as never, 'LoginForm')
}) })
expect(clients().appview).toBe(agentToAppviewClient(bundle.agent)) expect(clients().appview).toBe(bundle.appviewClient)
expect(clients().pds).toBe(agentToPdsClient(bundle.agent)) expect(clients().pds).toBe(bundle.pdsClient)
expect(clients().chat).toBe(agentToChatClient(bundle.agent)) expect(clients().chat).toBe(bundle.chatClient)
}) })
it('serves the same clients from the maybe variants', async () => { 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 {Provider, useSession, useSessionApi} from '#/state/session'
import {type SessionApiContext} from '#/state/session/types' 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 {type SessionBundle} from '../session-core'
import {sessionAccountToSessionData} from '../session-data' import {sessionAccountToSessionData} from '../session-data'
import { import {
@@ -87,13 +87,11 @@ function makeBundle(
const session = new PasswordSession(sessionAccountToSessionData(account), { const session = new PasswordSession(sessionAccountToSessionData(account), {
fetch: asFetch(fetchMock), fetch: asFetch(fetchMock),
}) })
const manager = new PasswordSessionManager(session, {
service: account.service,
})
manager.setFetch(asFetch(fetchMock))
return { return {
session, session,
agent: new BskyAppAgent(manager), appviewClient: buildAppviewClient(session),
pdsClient: buildPdsClient(session),
chatClient: buildChatClient(session),
service: new URL(account.service), service: new URL(account.service),
} }
} }
@@ -59,17 +59,17 @@ jest.mock('#/analytics', () => ({
/* /*
* `configureModerationForAccount` is synchronous (the labeler cache is a local * `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 * 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 * with the freshly built bundle, before the awaited prep steps. The factory
* factory tests capture the agent with this mock, then inject a real refresh * tests capture the bundle with this mock, then inject a real refresh into the
* into the awaited AA prefetch so a token rotation happens during prep, before * awaited AA prefetch so a token rotation happens during prep, before arm().
* arm(). The default is a no-op so other tests are unaffected. * The default is a no-op so other tests are unaffected.
* (jest requires out-of-scope factory references to be `mock`-prefixed.) * (jest requires out-of-scope factory references to be `mock`-prefixed.)
*/ */
const mockConfigureModerationForAccount = const mockConfigureModerationForAccount =
jest.fn<(agent: unknown, account: unknown) => void>() jest.fn<(bundle: unknown, account: unknown) => void>()
jest.mock('../moderation', () => ({ jest.mock('../moderation', () => ({
configureModerationForAccount: (agent: unknown, account: unknown) => configureModerationForAccount: (bundle: unknown, account: unknown) =>
mockConfigureModerationForAccount(agent, account), mockConfigureModerationForAccount(bundle, account),
configureModerationForGuest: () => {}, configureModerationForGuest: () => {},
})) }))
@@ -90,7 +90,6 @@ jest.mock('jwt-decode', () => ({
}, },
})) }))
import {type BskyAppAgent} from '../bridge-agent'
import { import {
type AtpSessionEvent, type AtpSessionEvent,
buildBundle, buildBundle,
@@ -365,22 +364,23 @@ describe('sessionAccountToSessionData', () => {
}) })
describe('createSessionBundleFromStoredAccount', () => { describe('createSessionBundleFromStoredAccount', () => {
it('builds a bridge agent over one session', () => { it('builds three clients over one session', async () => {
const result = createSessionBundleFromStoredAccount( const result = createSessionBundleFromStoredAccount(
makeAccount(), makeAccount(),
jest.fn(), jest.fn(),
)! )!
/* the agent reads its identity straight through the shared session */ /* every client reads its identity straight through the shared session */
expect(result.bundle.agent.session?.accessJwt).toBe('access-jwt') expect(result.bundle.session.session.accessJwt).toBe('access-jwt')
expect(result.bundle.agent.did).toBe(DID) expect(result.bundle.appviewClient.did).toBe(DID)
expect(result.bundle.agent.sessionManager.session).toBe( expect(result.bundle.pdsClient.did).toBe(DID)
result.bundle.agent.session, expect(result.bundle.chatClient.did).toBe(DID)
)
expect(result.bundle.service.toString()).toBe(`${SERVICE}/`) expect(result.bundle.service.toString()).toBe(`${SERVICE}/`)
disposeBundle(result.bundle) disposeBundle(result.bundle)
/* disposal detaches the agent from the session */ /* disposal disables the transport every client shares */
expect(result.bundle.agent.session).toBe(undefined) await expect(
result.bundle.session.fetchHandler('/xrpc/test', {}),
).rejects.toThrow('session disposed')
}) })
it('disposes a bundle rejected by the activation guard', async () => { 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 * captured from the (synchronous) moderation call, and the rotation is
* injected into the awaited AA prefetch. * injected into the awaited AA prefetch.
*/ */
let capturedAgent: BskyAppAgent | undefined let capturedBundle: SessionBundle | undefined
mockConfigureModerationForAccount.mockImplementationOnce( mockConfigureModerationForAccount.mockImplementationOnce(
(bundle: unknown) => { (bundle: unknown) => {
capturedAgent = (bundle as {agent: BskyAppAgent}).agent capturedBundle = bundle as SessionBundle
}, },
) )
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => { mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
/* routes through the bridge into the shared PasswordSession's refresh */ await capturedBundle!.session.refresh()
await capturedAgent!.sessionManager.refreshSession()
}) })
const fetchMock = makeMockFetch() 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 * way to drive a session to `destroyed` from outside, and it takes the same
* `deleteSession` -> onDeleted -> destroyed path the real 401 rescue does. * `deleteSession` -> onDeleted -> destroyed path the real 401 rescue does.
*/ */
let capturedAgent: BskyAppAgent | undefined let capturedBundle: SessionBundle | undefined
mockConfigureModerationForAccount.mockImplementationOnce( mockConfigureModerationForAccount.mockImplementationOnce(
(bundle: unknown) => { (bundle: unknown) => {
capturedAgent = (bundle as {agent: BskyAppAgent}).agent capturedBundle = bundle as SessionBundle
}, },
) )
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => { mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => {
await capturedAgent!.logout() await capturedBundle!.session.logout()
}) })
const fetchMock = makeMockFetch() 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') ).rejects.toThrow('Session was revoked while it was being prepared')
/* the bundle the caller never received reads as logged out */ /* 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 () => { it('resume: a prep rejection propagates and disposes the bundle', async () => {
let capturedAgent: BskyAppAgent | undefined let capturedBundle: SessionBundle | undefined
mockConfigureModerationForAccount.mockImplementationOnce( mockConfigureModerationForAccount.mockImplementationOnce(
(bundle: unknown) => { (bundle: unknown) => {
capturedAgent = (bundle as {agent: BskyAppAgent}).agent capturedBundle = bundle as SessionBundle
}, },
) )
mockPrefetchAgeAssuranceServerData.mockImplementationOnce(() => mockPrefetchAgeAssuranceServerData.mockImplementationOnce(() =>
@@ -952,7 +951,9 @@ describe('a session destroyed or rejected during preparation', () => {
).rejects.toThrow('prefetch blew up') ).rejects.toThrow('prefetch blew up')
/* the still-live session was disposed rather than left refreshing */ /* 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( const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()), 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) const bundle = buildBundle(session)
registerBundleKillSwitch(bundle, hooks.kill) registerBundleKillSwitch(bundle, hooks.kill)
@@ -975,7 +981,9 @@ describe('a session destroyed or rejected during preparation', () => {
).rejects.toThrow('nope') ).rejects.toThrow('nope')
expect(snapshot).not.toHaveBeenCalled() 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 {Client} from '@atproto/lex'
import {device} from '#/storage' import {device} from '#/storage'
@@ -83,27 +82,27 @@ export function configureAdditionalModerationAuthorities() {
additionalLabelers = [] additionalLabelers = []
} }
/*
* Merge with whatever is already on the static rather than replacing it, so
* `switchToBskyAppLabeler`'s entry survives.
*/
const appLabelers = Array.from( const appLabelers = Array.from(
new Set([...AtpAgent.appLabelers, ...additionalLabelers]), new Set<string>([...Client.appLabelers, ...additionalLabelers]),
) )
configureGlobalAppLabelers(appLabelers) configureGlobalAppLabelers(appLabelers)
} }
/** /**
* Set the global app labelers on BOTH statics, so the agent-backed request path * Set the global app labelers on the lex `Client` static, which every client
* and any client built without a wrapped agent emit the same `;redact` * reads, so a request carries the same `;redact` authorities whether or not
* authorities. * there is a session behind it.
* *
* Keeping the two in lockstep is what makes the duplicate-header hazard * It is a single global producer by design. The PDS and chat clients opt out
* avoidable: the agent joins its own list with whatever the caller already put * with `appLabelers: null` (see `clients.ts`) because those services take no
* on the request, and lex appends the `Client` static on top of that, so a DID * moderation authorities, leaving exactly one producer on an appview request and
* present in both would appear twice. The agent-wrapping clients therefore * none elsewhere.
* 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.
*/ */
export function configureGlobalAppLabelers(dids: string[]) { export function configureGlobalAppLabelers(dids: string[]) {
AtpAgent.configure({appLabelers: dids})
Client.configure({appLabelers: dids as `did:${string}:${string}`[]}) 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
}
+87 -120
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 {createLexClient} from '#/lib/lexClient'
import {type BskyAppAgent} from './bridge-agent'
import {networkAwareFetch} from './network' 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 * {@link BLUESKY_PROXY_HEADER} is passed as the client's `service`, so lex sets
* `agent.sessionManager.fetchHandler`. The agent-level handler is where * `atproto-proxy: <that value>` on every request and raw calls are proxied to
* `atproto-proxy` and `atproto-accept-labelers` are set before the request is * the appview. Record helpers force `service: null`, so they still target the
* passed down to the session manager, which only adds authorization and PDS * account host.
* 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.
* *
* `appLabelers: null` suppresses the class-wide `Client.appLabelers` for this * The class-wide `Client.appLabelers` static is deliberately NOT suppressed
* instance specifically. The static is populated (see * here: this client is the only producer of `atproto-accept-labelers` on an
* `configureGlobalAppLabelers`) so that clients built without a wrapped agent * appview request now that no agent sits underneath it. The account's own
* carry the global authorities, but the agent already stamped those same DIDs * subscriptions arrive separately, through `applyLabelersToClient` on the
* onto the request, and lex would append its own copy on top: the agent joins * instance, and that function filters out the Bluesky moderation DID so the
* its list with the existing header value while lex collects into a `Set` keyed * globally redacted authority is not also listed unredacted.
* on the suffixed string, so neither dedupes against the other and every global *
* authority would appear twice. * 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 { export function buildAppviewClient(agent: Agent): Client {
const existing = appviewClients.get(agent) return createLexClient(agent, {service: BLUESKY_PROXY_HEADER.get()})
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
} }
/** /**
* 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 * No `service`, so no proxy header: `com.atproto.*` repo, server and identity
* {@link agentToAppviewClient}. That layer does authorization and refresh-on-401 * calls reach the account's own PDS rather than being proxied onward.
* 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` option for the same reason: adding one would reintroduce the * `appLabelers: null` suppresses the class-wide static for this instance. A PDS
* proxy header this client exists to avoid. `appLabelers: null` is the same * request is not an appview read, so it must carry no moderation authorities at
* kind of suppression: a PDS request is not an appview read, so it must carry no * all; without the suppression it would start emitting the global list.
* 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`.
*/ */
export function agentToPdsClient(agent: BskyAppAgent): Client { export function buildPdsClient(agent: Agent): Client {
const existing = pdsClients.get(agent) return createLexClient(agent, {appLabelers: null})
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
} }
/** /**
* 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 * {@link CHAT_PROXY_SERVICE} (`${CHAT_PROXY_DID}#bsky_chat`, default
* and PDS routing, no agent-level proxy or labeler headers - but constructed * `did:web:api.bsky.chat#bsky_chat`) is the client's `service`, so `chat.bsky.*`
* with {@link CHAT_PROXY_SERVICE} as its `service`, so lex-client emits * calls are proxied to the chat service. The DID is read from the
* `atproto-proxy: <CHAT_PROXY_SERVICE>` on every request and `chat.bsky.*` * env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can
* calls are proxied to the chat service. `appLabelers: null` for the same * be retargeted per environment.
* reason as the PDS client: the chat service takes no moderation authorities. *
* `appLabelers: null` for the same reason as the PDS client: the chat service
* takes no moderation authorities.
*/ */
export function agentToChatClient(agent: BskyAppAgent): Client { export function buildChatClient(agent: Agent): Client {
const existing = chatClients.get(agent) return createLexClient(agent, {
if (existing) { appLabelers: null,
return existing service: CHAT_PROXY_SERVICE,
} })
const client = createLexClient( }
{
/**
* 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() { get did() {
return agent.did return session.did
}, },
fetchHandler: (path, init) => fetchHandler(path, init) {
agent.sessionManager.fetchHandler(path, init), return session.fetchHandler(new URL(path, pdsUrl).href, init)
}, },
{appLabelers: null, service: CHAT_PROXY_SERVICE}, }
)
chatClients.set(agent, client)
return client
} }
/** Thrown when a write/auth-only client is used with no active session. */ /** 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 * The unauthenticated {@link Client} for public reads, pointed at the public
* appview. * appview.
* *
* A single module-level instance for the same identity-stability reason as * A single module-level instance: there is no session to scope it to, so it
* {@link agentToAppviewClient}: there is no session to scope it to, so it lives * lives for the lifetime of the process, and its identity is therefore stable
* for the lifetime of the process. Requests go through * enough for a React Query key. Requests go through {@link networkAwareFetch} so
* {@link networkAwareFetch} so public reads feed the app's reachability signal * public reads feed the app's reachability signal like authenticated ones do.
* like authenticated ones do.
* *
* Unlike the agent-wrapping clients, this one does NOT suppress * Like the session appview client, it carries the class-wide
* `Client.appLabelers`: there is no agent underneath to stamp the header, so the * `Client.appLabelers`, so a logged-out read gets the same `;redact` moderation
* class-wide static is the only producer and a logged-out read carries the same * authorities an authenticated one does. That makes `configureModerationForGuest`
* `;redact` moderation authorities an authenticated one does. * load-bearing rather than test-only - it is what populates the static before
* * this client's first request, and `createPublicSessionBundle` runs it while
* That makes `configureModerationForGuest()` load-bearing rather than * building the bundle.
* test-only - it is what populates the static before this client's first
* request. `createPublicSessionBundle` runs it while building the bundle.
*/ */
export function getPublicAppviewClient(): Client { export function getPublicAppviewClient(): Client {
return (publicLexClient ??= createLexClient({ return (publicLexClient ??= createLexClient({
+2 -6
View File
@@ -10,7 +10,6 @@ import {
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import { import {
BLUESKY_PROXY_HEADER,
DISCOVER_SAVED_FEED, DISCOVER_SAVED_FEED,
IS_PROD_SERVICE, IS_PROD_SERVICE,
TIMELINE_SAVED_FEED, TIMELINE_SAVED_FEED,
@@ -27,7 +26,6 @@ import {
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {features} from '#/analytics' import {features} from '#/analytics'
import {type app} from '#/lexicons' import {type app} from '#/lexicons'
import {agentToAppviewClient, agentToPdsClient} from './clients'
import {configureModerationForAccount} from './moderation' import {configureModerationForAccount} from './moderation'
import { import {
buildBundle, buildBundle,
@@ -106,10 +104,10 @@ export async function createSessionBundleAndCreateAccount(
setBirthdateForDid({did: earlyAccount.did, birthdate}) setBirthdateForDid({did: earlyAccount.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(earlyAccount.did) snoozeBirthdateUpdateAllowedForDid(earlyAccount.did)
// Post-signup writes all target the account's own repo and actor store. // 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. // Start the prefetch after seeding its synchronous birthdate inputs.
const aa = prefetchAgeAssuranceServerData({ const aa = prefetchAgeAssuranceServerData({
appviewClient: agentToAppviewClient(bundle.agent), appviewClient: bundle.appviewClient,
accountClient: pdsClient, 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. // Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation( const account = await finishPreparation(
bundle, bundle,
+18 -43
View File
@@ -9,7 +9,6 @@ import {
useState, useState,
useSyncExternalStore, useSyncExternalStore,
} from 'react' } from 'react'
import {type AtpAgent} from '@atproto/api'
import {type Client} from '@atproto/lex' import {type Client} from '@atproto/lex'
import {type SessionData} from '@atproto/lex-password-session' import {type SessionData} from '@atproto/lex-password-session'
@@ -18,14 +17,9 @@ import {useCloseAllActiveElements} from '#/state/util'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
import {IS_WEB} from '#/env' import {IS_WEB} from '#/env'
import {com} from '#/lexicons'
import {emitSessionDropped} from '../events' import {emitSessionDropped} from '../events'
import { import {getPublicAppviewClient} from './clients'
agentToAppviewClient,
agentToChatClient,
agentToPdsClient,
getPublicAppviewClient,
getUnauthenticatedThrowingClient,
} from './clients'
import {createSessionBundleAndCreateAccount} from './create-account' import {createSessionBundleAndCreateAccount} from './create-account'
import {pickExpiryRescueCandidate} from './expiry-rescue' import {pickExpiryRescueCandidate} from './expiry-rescue'
import {type Action, getInitialState, reducer, type State} from './reducer' 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 * Read the live bundle rather than the one captured by this render: a
* dispatch that lands before the next render would otherwise leave this * 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 const bundle = store.getState().currentBundleState
.bundle as unknown as SessionBundle .bundle as unknown as SessionBundle
const signal = cancelPendingTask() const signal = cancelPendingTask()
/* getSession targets the PDS; only the persisted account fields are patched. */ /* 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 if (signal.aborted) return
store.dispatch({ store.dispatch({
type: 'partial-refresh-session', 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. * Rotate the session's tokens and hand back the resulting account snapshot.
* *
* Rejects when the rotation was a no-op, restoring the contract the * Rejects when the rotation was a no-op, restoring the contract the
* `agent.resumeSession(agent.session!)` call sites were written against (the * `agent.resumeSession(agent.session!)` call sites were written against.
* bridge agent's `refreshSession` override does the same, for the same * `PasswordSession.refresh()` resolves with the
* reason). `PasswordSession.refresh()` resolves with the
* unchanged `SessionData` on a transient failure - a 500 or a network error * unchanged `SessionData` on a transient failure - a 500 or a network error
* reported through `onUpdateFailure` - and reserves rejection for a * reported through `onUpdateFailure` - and reserves rejection for a
* definitively dead session. Callers here all read resolution as "tokens * 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 // @ts-expect-error window type is not declared, debug only
// eslint-disable-next-line react-hooks/immutability // 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) const currentBundleRef = useRef(bundle)
/* /*
* Disposal is deferred to this post-commit effect deliberately: components may * Disposal is deferred to this post-commit effect deliberately: components may
* still render against the outgoing bundle during the commit that swaps it, so * 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 * disabling its session inline would pull the transport out from under them.
* reducer's bundle-identity guard drops any events the not-yet-disposed session * The reducer's bundle-identity guard drops any events the not-yet-disposed
* emits in that window. * session emits in that window.
*/ */
useEffect(() => { useEffect(() => {
if (currentBundleRef.current !== bundle) { if (currentBundleRef.current !== bundle) {
@@ -753,30 +747,15 @@ export function useRequireAuth() {
} }
/** /**
* The active session's agent, or the public agent when logged out. * Client for appview reads. Logged out, this is the bundle's public client,
*/ * which dispatches unauthenticated against the public appview.
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.
*/ */
export function useAppviewClient(): Client { export function useAppviewClient(): Client {
const bundle = useContext(BundleContext) const bundle = useContext(BundleContext)
if (!bundle) { if (!bundle) {
throw Error('useAppviewClient() must be below <SessionProvider>.') throw Error('useAppviewClient() must be below <SessionProvider>.')
} }
return agentToAppviewClient(bundle.agent) return bundle.appviewClient
} }
/** /**
@@ -790,9 +769,7 @@ export function usePdsClient(): Client {
if (!bundle) { if (!bundle) {
throw Error('usePdsClient() must be below <SessionProvider>.') throw Error('usePdsClient() must be below <SessionProvider>.')
} }
return bundle.session return bundle.pdsClient
? agentToPdsClient(bundle.agent)
: getUnauthenticatedThrowingClient()
} }
/** /**
@@ -804,9 +781,7 @@ export function useChatClient(): Client {
if (!bundle) { if (!bundle) {
throw Error('useChatClient() must be below <SessionProvider>.') throw Error('useChatClient() must be below <SessionProvider>.')
} }
return bundle.session return bundle.chatClient
? agentToChatClient(bundle.agent)
: getUnauthenticatedThrowingClient()
} }
/** /**
@@ -814,7 +789,7 @@ export function useChatClient(): Client {
*/ */
export function useMaybePdsClient(): Client | null { export function useMaybePdsClient(): Client | null {
const bundle = useContext(BundleContext) 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 { export function useMaybeChatClient(): Client | null {
const bundle = useContext(BundleContext) 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 Client} from '@atproto/lex'
import {type DidString} from '@atproto/syntax'
import {IS_TEST_USER} from '#/lib/constants' import {IS_TEST_USER} from '#/lib/constants'
import {com} from '#/lexicons'
import {account as accountStorage} from '#/storage' import {account as accountStorage} from '#/storage'
import { import {
configureAdditionalModerationAuthorities, configureAdditionalModerationAuthorities,
@@ -9,6 +11,9 @@ import {
} from './additional-moderation-authorities' } from './additional-moderation-authorities'
import {type SessionAccount} from './types' 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 * Cache an account's subscribed labeler DIDs. Called on every preferences
* fetch, so the cache is eventually consistent with the server. * 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 * Apply an account's labeler subscriptions to the appview client, without
* redacted Bluesky moderation authority. * duplicating the globally redacted Bluesky moderation authority.
* *
* The Bluesky DID is filtered out because it already flows through the global * 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. * `Client.appLabelers`, which lex emits with a `;redact` suffix. Listing it
* Listing it per-subscription would add a second, non-redacting entry for the * per-instance would add a second, non-redacting entry for the same authority:
* 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 * Only the appview client takes subscriptions - the PDS and chat clients suppress
* what stamps `atproto-accept-labelers` on the requests the wrapping clients * labelers entirely (see clients.ts).
* issue, so setting them here reaches every appview read. The bundle rework
* moves this to `appviewClient.setLabelers` once the agent is gone.
*/ */
export function applyLabelersToClient( export function applyLabelersToClient(
agent: AtpAgent, client: Client,
subscribedDids: string[], subscribedDids: string[],
) { ) {
agent.configureLabelersHeader( client.setLabelers(
subscribedDids.filter(did => did !== BSKY_LABELER_DID), 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. * in the same tick, before any request goes out.
*/ */
export function configureModerationForAccount( export function configureModerationForAccount(
bundle: {agent: AtpAgent; appviewClient?: Client}, bundle: ModerationSession,
account: SessionAccount, account: SessionAccount,
) { ) {
// This global mutation is *only* OK because this code is only relevant for testing. // This global mutation is *only* OK because this code is only relevant for testing.
@@ -74,13 +78,13 @@ export function configureModerationForAccount(
switchToBskyAppLabeler() switchToBskyAppLabeler()
if (IS_TEST_USER(account.handle)) { if (IS_TEST_USER(account.handle)) {
// Test accounts may briefly use the production authority while this resolves. // 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). // The code below is actually relevant to production (and isn't global).
const labelerDids = readLabelers(account.did) const labelerDids = readLabelers(account.did)
if (labelerDids) { if (labelerDids) {
applyLabelersToClient(bundle.agent, labelerDids) applyLabelersToClient(bundle.appviewClient, labelerDids)
} else { } else {
// If there are no headers in the storage, we'll not send them on the initial requests. // 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. // 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. */ /** Resolve and install the test environment's moderation authority. */
async function trySwitchToTestAppLabeler(agent: AtpAgent) { async function trySwitchToTestAppLabeler(client: Client) {
const did = ( const did = (
await agent await client
.resolveHandle({handle: 'mod-authority.test'}) .call(com.atproto.identity.resolveHandle, {
handle: 'mod-authority.test',
})
.catch(_ => undefined) .catch(_ => undefined)
)?.data.did )?.did
if (did) { if (did) {
console.warn('USING TEST ENV MODERATION') console.warn('USING TEST ENV MODERATION')
configureGlobalAppLabelers([did]) configureGlobalAppLabelers([did])
+59 -48
View File
@@ -1,21 +1,27 @@
import {type Client} from '@atproto/lex'
import { import {
PasswordSession, PasswordSession,
type PasswordSessionOptions, type PasswordSessionOptions,
type SessionData, type SessionData,
} from '@atproto/lex-password-session' } 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 {logger} from '#/logger'
import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data' import {prefetchAgeAssuranceServerData} from '#/ageAssurance/data'
import {features} from '#/analytics' import {features} from '#/analytics'
import { import {
BskyAppAgent, buildAppviewClient,
createPublicAgent, buildChatClient,
PasswordSessionManager, buildPdsClient,
} from './bridge-agent' getPublicAppviewClient,
import {agentToAppviewClient, agentToPdsClient} from './clients' getUnauthenticatedThrowingClient,
routeSessionToPds,
} from './clients'
import {addSessionErrorLog} from './logging' import {addSessionErrorLog} from './logging'
import {configureModerationForAccount} from './moderation' import {
configureModerationForAccount,
configureModerationForGuest,
} from './moderation'
import {networkAwareFetch} from './network' import {networkAwareFetch} from './network'
import { import {
isSessionExpired, 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 = { export type SessionBundle = {
session: PasswordSession session: PasswordSession
agent: BskyAppAgent appviewClient: Client
pdsClient: Client
chatClient: Client
readonly service: URL readonly service: URL
} }
@@ -63,38 +71,38 @@ const bundleKillSwitches = new WeakMap<SessionBundle, () => void>()
/** /**
* Register the lifecycle closure used by {@link disposeBundle}. * Register the lifecycle closure used by {@link disposeBundle}.
* *
* Disposing also detaches the bridge agent from its session, so a stale * Killing the hooks is the whole of disposal now: the clients hold no state of
* bundle's `agent.session` / `agent.pdsUrl` read as `undefined` rather than * their own, and every request they make goes through the session's injected
* serving tokens the app has stopped tracking. * fetch, which the kill switch disables.
*/ */
export function registerBundleKillSwitch( export function registerBundleKillSwitch(
bundle: SessionBundle, bundle: SessionBundle,
kill: () => void, kill: () => void,
) { ) {
bundleKillSwitches.set(bundle, () => { bundleKillSwitches.set(bundle, kill)
kill()
bundle.agent.dispose()
})
} }
/** /**
* Wrap a session in the bridge agent. * Build the three clients over a session.
* *
* `storedPdsUrl` seeds {@link PasswordSessionManager}'s PDS routing so requests * `storedPdsUrl` pins PDS routing for requests made before a refresh has
* made before the first refresh delivers a didDoc still reach the right host. * delivered a didDoc - see {@link routeSessionToPds}, which explains why the
* Once a didDoc arrives the manager prefers its endpoint. * 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( export function buildBundle(
session: PasswordSession, session: PasswordSession,
storedPdsUrl?: string, storedPdsUrl?: string,
): SessionBundle { ): SessionBundle {
const manager = new PasswordSessionManager(session, { const agent = storedPdsUrl
service: deriveServiceUrl(session).toString(), ? routeSessionToPds(session, storedPdsUrl)
pdsUrl: storedPdsUrl, : session
})
return { return {
session, session,
agent: new BskyAppAgent(manager), appviewClient: buildAppviewClient(agent),
pdsClient: buildPdsClient(agent),
chatClient: buildChatClient(agent),
get service() { get service() {
return deriveServiceUrl(session) 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 = { export type PublicSessionBundle = {
session: null session: null
agent: BskyAppAgent appviewClient: Client
pdsClient: Client
chatClient: Client
readonly service: URL readonly service: URL
} }
/** /**
* Build the logged-out bundle. `createPublicAgent` installs the guest * Build the logged-out bundle.
* moderation authorities as part of building the agent, which is what populates *
* the global `Client.appLabelers` that {@link getPublicAppviewClient} relies on * `configureModerationForGuest` is what populates the global
* for its labeler header. * `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 { export function createPublicSessionBundle(): PublicSessionBundle {
configureModerationForGuest()
return { return {
session: null, session: null,
agent: createPublicAgent(), appviewClient: getPublicAppviewClient(),
pdsClient: getUnauthenticatedThrowingClient(),
chatClient: getUnauthenticatedThrowingClient(),
service: new URL(PUBLIC_BSKY_SERVICE), 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 * 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 * 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, * 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 * where the session already refuses to refresh - but the two paths are
* reads as logged out - but the two paths are indistinguishable to the caller, * indistinguishable to the caller, so both go through it.)
* so both go through it.)
*/ */
export async function finishPreparation<T>( export async function finishPreparation<T>(
bundle: SessionBundle, bundle: SessionBundle,
@@ -294,16 +314,10 @@ export async function createSessionBundleAndResume(
configureModerationForAccount(bundle, earlyAccount) configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({ const aa = prefetchAgeAssuranceServerData({
appviewClient: agentToAppviewClient(bundle.agent), appviewClient: bundle.appviewClient,
accountClient: agentToPdsClient(bundle.agent), 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. // Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation( const account = await finishPreparation(
bundle, bundle,
@@ -362,12 +376,10 @@ export async function createSessionBundleAndLogin(
const gates = features.refresh({strategy: 'prefer-fresh-gates'}) const gates = features.refresh({strategy: 'prefer-fresh-gates'})
configureModerationForAccount(bundle, earlyAccount) configureModerationForAccount(bundle, earlyAccount)
const aa = prefetchAgeAssuranceServerData({ const aa = prefetchAgeAssuranceServerData({
appviewClient: agentToAppviewClient(bundle.agent), appviewClient: bundle.appviewClient,
accountClient: agentToPdsClient(bundle.agent), accountClient: bundle.pdsClient,
}) })
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
// Preparation may auto-refresh the session while hooks are still disarmed. // Preparation may auto-refresh the session while hooks are still disarmed.
const account = await finishPreparation( const account = await finishPreparation(
bundle, bundle,
@@ -403,7 +415,6 @@ export function createSessionBundleFromStoredAccount(
bundle = buildBundle(session, storedAccount.pdsUrl) bundle = buildBundle(session, storedAccount.pdsUrl)
registerBundleKillSwitch(bundle, hooks.kill) registerBundleKillSwitch(bundle, hooks.kill)
configureModerationForAccount(bundle, storedAccount) configureModerationForAccount(bundle, storedAccount)
bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get())
const account = session.destroyed const account = session.destroyed
? storedAccount ? storedAccount
-22
View File
@@ -1,4 +1,3 @@
import {type AtpSessionData} from '@atproto/api'
import {getPdsEndpoint, isValidDidDoc} from '@atproto/common-web' import {getPdsEndpoint, isValidDidDoc} from '@atproto/common-web'
import {type SessionData} from '@atproto/lex-password-session' import {type SessionData} from '@atproto/lex-password-session'
import {jwtDecode} from 'jwt-decode' 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) { export function isSessionExpired(account: SessionAccount) {
return account.accessJwt ? isJwtExpired(account.accessJwt) : true 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 {useProfileQuery} from '#/state/queries/profile'
import {resolveLinkQueryOptions} from '#/state/queries/resolve-link' import {resolveLinkQueryOptions} from '#/state/queries/resolve-link'
import { import {
useAgent,
useAppviewClient, useAppviewClient,
useChatClient,
usePdsClient, usePdsClient,
useSession, useSession,
} from '#/state/session' } from '#/state/session'
@@ -280,8 +280,8 @@ export const ComposePost = ({
const videoMaxDurationMs = allow10MinuteVideos const videoMaxDurationMs = allow10MinuteVideos
? VIDEO_10_MINUTE_MAX_DURATION_MS ? VIDEO_10_MINUTE_MAX_DURATION_MS
: VIDEO_MAX_DURATION_MS : VIDEO_MAX_DURATION_MS
const agent = useAgent()
const client = useAppviewClient() const client = useAppviewClient()
const chatClient = useChatClient()
const pdsClient = usePdsClient() const pdsClient = usePdsClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentDid = currentAccount!.did const currentDid = currentAccount!.did
@@ -1003,7 +1003,7 @@ export const ComposePost = ({
.map(post => post.embed.link!.uri) .map(post => post.embed.link!.uri)
const linkQueries = useQueries({ const linkQueries = useQueries({
queries: linkUris.map(uri => ({ queries: linkUris.map(uri => ({
...resolveLinkQueryOptions(agent, uri), ...resolveLinkQueryOptions({appviewClient: client, chatClient}, uri),
enabled: false, enabled: false,
})), })),
}) })
@@ -1094,12 +1094,13 @@ export const ComposePost = ({
try { try {
logger.info(`composer: posting...`) logger.info(`composer: posting...`)
postUri = ( postUri = (
await apilib.post(agent, queryClient, { await apilib.post(queryClient, {
thread: filteredThread, thread: filteredThread,
replyTo: replyTo?.uri, replyTo: replyTo?.uri,
onStateChange: setPublishingStage, onStateChange: setPublishingStage,
langs: currentLanguages, langs: currentLanguages,
appviewClient: client, appviewClient: client,
chatClient,
pdsClient, pdsClient,
}) })
).uris[0] ).uris[0]
@@ -1294,8 +1295,8 @@ export const ComposePost = ({
}, [ }, [
l, l,
ax, ax,
agent,
client, client,
chatClient,
pdsClient, pdsClient,
canPost, canPost,
isPublishing, isPublishing,
+8 -8
View File
@@ -5,14 +5,13 @@ import {AppBskyDraftDefs, AtUri} from '@atproto/api'
import {RichText} from '@bsky.app/sdk/richtext' import {RichText} from '@bsky.app/sdk/richtext'
import {nanoid} from 'nanoid/non-secure' 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 {getDeviceName} from '#/lib/deviceName'
import {getImageDim} from '#/lib/media/manip' import {getImageDim} from '#/lib/media/manip'
import {mimeToExt} from '#/lib/media/video/util' import {mimeToExt} from '#/lib/media/video/util'
import {shortenLinks} from '#/lib/strings/rich-text-manip' import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {type ComposerImage} from '#/state/gallery' import {type ComposerImage} from '#/state/gallery'
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util' import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
import {createPublicAgent} from '#/state/session/bridge-agent'
import { import {
type ComposerState, type ComposerState,
type EmbedDraft, type EmbedDraft,
@@ -60,7 +59,10 @@ function parseVideoMimeType(localRefPath: string): string {
* Convert ComposerState to server Draft format for saving. * Convert ComposerState to server Draft format for saving.
* Returns both the draft and a map of localRef paths to their source paths. * 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 draft: AppBskyDraftDefs.Draft
localRefPaths: Map<string, string> localRefPaths: Map<string, string>
}> { }> {
@@ -68,7 +70,7 @@ export async function composerStateToDraft(state: ComposerState): Promise<{
const posts: AppBskyDraftDefs.DraftPost[] = await Promise.all( const posts: AppBskyDraftDefs.DraftPost[] = await Promise.all(
state.thread.posts.map(post => { 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. * Convert a single PostDraft to server DraftPost format.
*/ */
async function postDraftToServerPost( async function postDraftToServerPost(
clients: LinkResolvers,
post: PostDraft, post: PostDraft,
localRefPaths: Map<string, string>, localRefPaths: Map<string, string>,
): Promise<AppBskyDraftDefs.DraftPost> { ): Promise<AppBskyDraftDefs.DraftPost> {
@@ -138,10 +141,7 @@ async function postDraftToServerPost(
// Add quote record embed // Add quote record embed
if (post.embed.quote) { if (post.embed.quote) {
const resolved = await resolveLink( const resolved = await resolveLink(clients, post.embed.quote.uri)
createPublicAgent(),
post.embed.quote.uri,
)
if (resolved && resolved.type === 'record') { if (resolved && resolved.type === 'record') {
draftPost.embedRecords = [ draftPost.embedRecords = [
{ {
@@ -7,7 +7,7 @@ import {
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {matchXrpcError} from '#/lib/xrpc-error' 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 {type ComposerState} from '#/view/com/composer/state/composer'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {getDeviceId} from '#/analytics/identifiers' import {getDeviceId} from '#/analytics/identifiers'
@@ -121,6 +121,7 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
*/ */
export function useSaveDraftMutation() { export function useSaveDraftMutation() {
const client = useAppviewClient() const client = useAppviewClient()
const chatClient = useChatClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
@@ -136,8 +137,10 @@ export function useSaveDraftMutation() {
originalLocalRefs: Set<string> | undefined originalLocalRefs: Set<string> | undefined
}> => { }> => {
// Convert composer state to server draft format // Convert composer state to server draft format
const {draft: apiDraft, localRefPaths} = const {draft: apiDraft, localRefPaths} = await composerStateToDraft(
await composerStateToDraft(composerState) {appviewClient: client, chatClient},
composerState,
)
/* /*
* `composerStateToDraft` builds the draft against the `@atproto/api` * `composerStateToDraft` builds the draft against the `@atproto/api`
* types, whose string fields are unbranded, so it is asserted once here * 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 {useQueryClient} from '@tanstack/react-query'
import {BLUESKY_PROXY_HEADER} from '#/lib/constants' 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 {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useOnboardingDispatch} from '#/state/shell/onboarding' import {useOnboardingDispatch} from '#/state/shell/onboarding'
import {navigate} from '../../../Navigation' import {navigate} from '../../../Navigation'
@@ -31,7 +31,6 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
let hasConfiguredProxy = false let hasConfiguredProxy = false
export function TestCtrls() { export function TestCtrls() {
const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {logoutEveryAccount, login} = useSessionApi() const {logoutEveryAccount, login} = useSessionApi()
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
@@ -74,8 +73,13 @@ export function TestCtrls() {
autoCapitalize="none" autoCapitalize="none"
onSubmitEditing={() => { onSubmitEditing={() => {
const header = `${proxyHeader}#bsky_appview` 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) BLUESKY_PROXY_HEADER.set(header)
agent.configureProxy(header as any)
hasConfiguredProxy = true hasConfiguredProxy = true
setIsProxyConfigured(true) setIsProxyConfigured(true)
}} }}