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