rework session layer on PasswordSession + three-client pattern

- PasswordSession (@atproto/lex-password-session) is now the single auth
  core; SessionAgent bridges it to the base Agent from @atproto/api so
  the ~148 useAgent() consumer files keep working unchanged
- SessionBundle carries {session, agent, accountClient, appviewClient};
  useLexClient() now returns the authed appview client, new
  useAppviewClient()/usePdsClient() hooks
- reducer: OpaqueSessionBundle, new replaced-current-bundle action for
  network-free cross-tab same-did sync (PasswordSession is immutable, so
  the bundle is rebuilt instead of mutated in place)
- moderation: AtpAgent.configure -> Client.configure + Agent.configure,
  per-account labelers on both the bridge and the appview client
- push-token unregistration uses temporary PasswordSessions
- hard-tail migrations: SignupQueued refresh via resumeSession shim,
  useBeginAgeAssurance scoped-token raw Client, useAccountEmailState
  reads currentAccount
- PDS routing preserved on the no-network resume fast path by
  synthesizing a minimal didDoc from the persisted pdsUrl

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-16 14:40:01 +03:00
parent 105e68c12c
commit bf8b6bc0c7
58 changed files with 2826 additions and 996 deletions
-5
View File
@@ -1483,11 +1483,6 @@
"count": 1
}
},
"src/state/session/agent.ts": {
"typescript/no-explicit-any": {
"count": 1
}
},
"src/state/shell/color-mode.tsx": {
"typescript/no-floating-promises": {
"count": 2
+13 -9
View File
@@ -21,7 +21,7 @@ import {
snoozeBirthdateUpdateAllowedForDid,
} from '#/state/birthdate'
import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration'
import {useAgent, useSession} from '#/state/session'
import {type SessionAgent, useAgent, useSession} from '#/state/session'
import {DEVICE_SIGNALS_SUPPORTED} from '#/ageAssurance/const'
import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger'
@@ -63,7 +63,7 @@ const [, cacheHydrationPromise] = persistQueryClient({
persister,
})
export function getDidFromAgentSession(agent: AtpAgent) {
export function getDidFromAgentSession(agent: SessionAgent) {
const sessionManager = agent.sessionManager
if (!sessionManager || !sessionManager.did) return
return sessionManager.did
@@ -187,7 +187,7 @@ export function useConfigQuery() {
export function createServerStateQueryKey({did}: {did: string}) {
return ['serverState', did]
}
export async function getServerState({agent}: {agent: AtpAgent}) {
export async function getServerState({agent}: {agent: SessionAgent}) {
if (debug.enabled && debug.serverState)
return debug.resolve(debug.serverState)
const geolocation = device.get(['mergedGeolocation'])
@@ -218,7 +218,7 @@ export function getServerStateFromCache({
createServerStateQueryKey({did}),
)
}
export async function prefetchServerState({agent}: {agent: AtpAgent}) {
export async function prefetchServerState({agent}: {agent: SessionAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
@@ -245,7 +245,7 @@ export async function prefetchServerState({agent}: {agent: AtpAgent}) {
})
}
}
export async function refetchServerState({agent}: {agent: AtpAgent}) {
export async function refetchServerState({agent}: {agent: SessionAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
logger.debug(`refetchServerState: fetching...`)
@@ -344,7 +344,7 @@ export function createOtherRequiredDataQueryKey({did}: {did: string}) {
async function getOtherRequiredData({
agent,
}: {
agent: AtpAgent
agent: SessionAgent
}): Promise<OtherRequiredData> {
if (debug.enabled) return debug.resolve(debug.otherRequiredData)
const did = getDidFromAgentSession(agent)
@@ -426,7 +426,11 @@ export function setOtherRequiredDataActorDeclarationCache({
next,
)
}
export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
export async function prefetchOtherRequiredData({
agent,
}: {
agent: SessionAgent
}) {
const did = getDidFromAgentSession(agent)
if (!did) return
@@ -577,7 +581,7 @@ export function setDeviceSignalsForRegion({
prev => ({...prev, [regionKey]: signals}),
)
}
export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) {
export async function prefetchDeviceSignals({agent}: {agent: SessionAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
@@ -659,7 +663,7 @@ export function useDeviceSignalsQuery() {
/**
* Helper to prefetch all age assurance data from the server.
*/
export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
export function prefetchAgeAssuranceServerData({agent}: {agent: SessionAgent}) {
return Promise.allSettled([
// config fetch initiated at the top of the App.platform.tsx files, awaited here
configPrefetchPromise,
+15 -8
View File
@@ -1,5 +1,5 @@
import {Platform} from 'react-native'
import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {useMutation} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait'
@@ -15,6 +15,7 @@ import {logger} from '#/ageAssurance/logger'
import {useAnalytics} from '#/analytics'
import {BLUESKY_PROXY_DID} from '#/env'
import {useGeolocation} from '#/geolocation'
import {app} from '#/lexicons'
const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID
const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW
@@ -28,7 +29,7 @@ export function useBeginAgeAssurance() {
return useMutation({
async mutationFn(
props: Omit<
AppBskyAgeassuranceBegin.InputSchema,
app.bsky.ageassurance.begin.$InputBody,
'countryCode' | 'regionCode'
>,
) {
@@ -45,10 +46,16 @@ export function useBeginAgeAssurance() {
lxm: `app.bsky.ageassurance.begin`,
})
const appView = new AtpAgent({service: APPVIEW})
appView.sessionManager.session = {...agent.session!}
appView.sessionManager.session.accessJwt = token
appView.sessionManager.session.refreshJwt = ''
/*
* A non-refreshing throwaway client scoped to the service-auth token: it
* has no session, so nothing can refresh it. Requests go straight to the
* appview with the token as a static Authorization header (a raw client,
* unlike a session, is allowed to preset that header).
*/
const scopedClient = new Client({
service: APPVIEW,
headers: {authorization: `Bearer ${token}`},
})
ax.metric('ageAssurance:api:begin', {
platform: Platform.OS,
@@ -60,9 +67,9 @@ export function useBeginAgeAssurance() {
* 2s wait is good actually. Email sending takes a hot sec and this helps
* ensure the email is ready for the user once they open their inbox.
*/
const {data} = await wait(
const data = await wait(
2e3,
appView.app.bsky.ageassurance.begin({
scopedClient.call(app.bsky.ageassurance.begin, {
...props,
countryCode,
regionCode,
@@ -2,7 +2,6 @@ import {useContext} from 'react'
import {Alert, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as Contacts from 'expo-contacts'
import type AtpAgent from '@atproto/api'
import {
type AppBskyActorProfile,
AppBskyContactImportContacts,
@@ -17,7 +16,7 @@ import {uploadBlob} from '#/lib/api'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
import {
Context as OnboardingContext,
type OnboardingAction,
@@ -325,7 +324,7 @@ function showPermissionDeniedAlert() {
* Copied from `#/screens/Onboarding/StepFinished/index.tsx`
*/
async function createProfileRecord(
agent: AtpAgent,
agent: SessionAgent,
onboardingContext: {
state: OnboardingState
dispatch: React.Dispatch<OnboardingAction>
@@ -1,7 +1,7 @@
import {useEffect, useMemo, useState} from 'react'
import {useQuery} from '@tanstack/react-query'
import {useAgent, useSessionApi} from '#/state/session'
import {useSession, useSessionApi} from '#/state/session'
import {emitEmailVerified} from '#/components/dialogs/EmailDialog/events'
export type AccountEmailState = {
@@ -12,24 +12,24 @@ export type AccountEmailState = {
export const accountEmailStateQueryKey = ['accountEmailState'] as const
export function useAccountEmailState() {
const agent = useAgent()
const {currentAccount} = useSession()
const {partialRefreshSession} = useSessionApi()
const [prevIsEmailVerified, setPrevEmailIsVerified] = useState(
!!agent.session?.emailConfirmed,
!!currentAccount?.emailConfirmed,
)
const state: AccountEmailState = useMemo(
() => ({
isEmailVerified: !!agent.session?.emailConfirmed,
email2FAEnabled: !!agent.session?.emailAuthFactor,
isEmailVerified: !!currentAccount?.emailConfirmed,
email2FAEnabled: !!currentAccount?.emailAuthFactor,
}),
[agent.session],
[currentAccount],
)
/**
* Only here to refetch on focus, when necessary
*/
useQuery({
enabled: !!agent.session,
enabled: !!currentAccount,
/**
* Only refetch if the email verification s incomplete.
*/
+3 -3
View File
@@ -1,20 +1,20 @@
import {
AppBskyFeedDefs,
type AppBskyFeedGetAuthorFeed as GetAuthorFeed,
type AtpAgent,
} from '@atproto/api'
import {type SessionAgent} from '#/state/session'
import {type FeedAPI, type FeedAPIResponse} from './types'
export class AuthorFeedAPI implements FeedAPI {
agent: AtpAgent
agent: SessionAgent
_params: GetAuthorFeed.QueryParams
constructor({
agent,
feedParams,
}: {
agent: AtpAgent
agent: SessionAgent
feedParams: GetAuthorFeed.QueryParams
}) {
this.agent = agent
+3 -2
View File
@@ -9,11 +9,12 @@ import {
getAppLanguageAsContentLanguage,
getContentLanguages,
} from '#/state/preferences/languages'
import {type SessionAgent} from '#/state/session'
import {type FeedAPI, type FeedAPIResponse} from './types'
import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils'
export class CustomFeedAPI implements FeedAPI {
agent: AtpAgent
agent: SessionAgent
params: GetCustomFeed.QueryParams
userInterests?: string
@@ -22,7 +23,7 @@ export class CustomFeedAPI implements FeedAPI {
feedParams,
userInterests,
}: {
agent: AtpAgent
agent: SessionAgent
feedParams: GetCustomFeed.QueryParams
userInterests?: string
}) {
+4 -3
View File
@@ -1,12 +1,13 @@
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
import {type AppBskyFeedDefs} from '@atproto/api'
import {DEMO_FEED} from '#/lib/demo'
import {type SessionAgent} from '#/state/session'
import {type FeedAPI, type FeedAPIResponse} from './types'
export class DemoFeedAPI implements FeedAPI {
agent: AtpAgent
agent: SessionAgent
constructor({agent}: {agent: AtpAgent}) {
constructor({agent}: {agent: SessionAgent}) {
this.agent = agent
}
+4 -3
View File
@@ -1,11 +1,12 @@
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
import {type AppBskyFeedDefs} from '@atproto/api'
import {type SessionAgent} from '#/state/session'
import {type FeedAPI, type FeedAPIResponse} from './types'
export class FollowingFeedAPI implements FeedAPI {
agent: AtpAgent
agent: SessionAgent
constructor({agent}: {agent: AtpAgent}) {
constructor({agent}: {agent: SessionAgent}) {
this.agent = agent
}
+4 -3
View File
@@ -1,6 +1,7 @@
import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api'
import {type AppBskyFeedDefs} from '@atproto/api'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {type SessionAgent} from '#/state/session'
import {CustomFeedAPI} from './custom'
import {FollowingFeedAPI} from './following'
import {type FeedAPI, type FeedAPIResponse} from './types'
@@ -27,7 +28,7 @@ export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {
}
export class HomeFeedAPI implements FeedAPI {
agent: AtpAgent
agent: SessionAgent
following: FollowingFeedAPI
discover: CustomFeedAPI
usingDiscover = false
@@ -39,7 +40,7 @@ export class HomeFeedAPI implements FeedAPI {
agent,
}: {
userInterests?: string
agent: AtpAgent
agent: SessionAgent
}) {
this.agent = agent
this.following = new FollowingFeedAPI({agent})
+3 -3
View File
@@ -1,20 +1,20 @@
import {
type AppBskyFeedDefs,
type AppBskyFeedGetActorLikes as GetActorLikes,
type AtpAgent,
} from '@atproto/api'
import {type SessionAgent} from '#/state/session'
import {type FeedAPI, type FeedAPIResponse} from './types'
export class LikesFeedAPI implements FeedAPI {
agent: AtpAgent
agent: SessionAgent
params: GetActorLikes.QueryParams
constructor({
agent,
feedParams,
}: {
agent: AtpAgent
agent: SessionAgent
feedParams: GetActorLikes.QueryParams
}) {
this.agent = agent
+8 -11
View File
@@ -1,8 +1,4 @@
import {
type AppBskyFeedDefs,
type AppBskyFeedGetTimeline,
type AtpAgent,
} from '@atproto/api'
import {type AppBskyFeedDefs, type AppBskyFeedGetTimeline} from '@atproto/api'
import shuffle from 'lodash.shuffle'
import {bundleAsync} from '#/lib/async/bundle'
@@ -10,6 +6,7 @@ import {timeout} from '#/lib/async/timeout'
import {feedUriToHref} from '#/lib/strings/url-helpers'
import {getContentLanguages} from '#/state/preferences/languages'
import {type FeedParams} from '#/state/queries/post-feed'
import {type SessionAgent} from '#/state/session'
import {FeedTuner} from '../feed-manip'
import {type FeedTunerFn} from '../feed-manip'
import {
@@ -24,7 +21,7 @@ const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
export class MergeFeedAPI implements FeedAPI {
userInterests?: string
agent: AtpAgent
agent: SessionAgent
params: FeedParams
feedTuners: FeedTunerFn[]
following: MergeFeedSource_Following
@@ -39,7 +36,7 @@ export class MergeFeedAPI implements FeedAPI {
feedTuners,
userInterests,
}: {
agent: AtpAgent
agent: SessionAgent
feedParams: FeedParams
feedTuners: FeedTunerFn[]
userInterests?: string
@@ -175,7 +172,7 @@ export class MergeFeedAPI implements FeedAPI {
}
class MergeFeedSource {
agent: AtpAgent
agent: SessionAgent
feedTuners: FeedTunerFn[]
sourceInfo: ReasonFeedSource | undefined
cursor: string | undefined = undefined
@@ -186,7 +183,7 @@ class MergeFeedSource {
agent,
feedTuners,
}: {
agent: AtpAgent
agent: SessionAgent
feedTuners: FeedTunerFn[]
}) {
this.agent = agent
@@ -253,7 +250,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
}
class MergeFeedSource_Custom extends MergeFeedSource {
agent: AtpAgent
agent: SessionAgent
minDate: Date
feedUri: string
userInterests?: string
@@ -264,7 +261,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
feedTuners,
userInterests,
}: {
agent: AtpAgent
agent: SessionAgent
feedUri: string
feedTuners: FeedTunerFn[]
userInterests?: string
+7 -7
View File
@@ -7,7 +7,6 @@ import {
type AppBskyEmbedRecordWithMedia,
type AppBskyEmbedVideo,
AppBskyFeedPost,
type AtpAgent,
BlobRef,
ChatBskyGroupDefs,
type ComAtprotoLabelDefs,
@@ -35,6 +34,7 @@ import {
createThreadgateRecord,
threadgateAllowUISettingToAllowRecordValue,
} from '#/state/queries/threadgate'
import {type SessionAgent} from '#/state/session'
import {
type EmbedDraft,
type PostDraft,
@@ -54,7 +54,7 @@ interface PostOpts {
}
export async function post(
agent: AtpAgent,
agent: SessionAgent,
queryClient: QueryClient,
opts: PostOpts,
) {
@@ -196,7 +196,7 @@ export async function post(
return {uris}
}
async function resolveRT(agent: AtpAgent, richtext: RichText) {
async function resolveRT(agent: SessionAgent, richtext: RichText) {
const trimmedText = richtext.text
// Trim leading whitespace-only lines (but don't break ASCII art).
.replace(/^(\s*\n)+/, '')
@@ -216,7 +216,7 @@ export class ReplyDeletedError extends Error {
}
}
async function resolveReply(agent: AtpAgent, replyTo: string) {
async function resolveReply(agent: SessionAgent, replyTo: string) {
const {data} = await agent.app.bsky.feed.getPosts({
uris: [replyTo],
})
@@ -249,7 +249,7 @@ async function resolveReply(agent: AtpAgent, replyTo: string) {
}
async function resolveEmbed(
agent: AtpAgent,
agent: SessionAgent,
queryClient: QueryClient,
draft: PostDraft,
onStateChange: ((state: string) => void) | undefined,
@@ -308,7 +308,7 @@ async function resolveEmbed(
}
async function resolveMedia(
agent: AtpAgent,
agent: SessionAgent,
queryClient: QueryClient,
embedDraft: EmbedDraft,
onStateChange: ((state: string) => void) | undefined,
@@ -481,7 +481,7 @@ async function resolveMedia(
}
async function resolveRecord(
agent: AtpAgent,
agent: SessionAgent,
queryClient: QueryClient,
uri: string,
): Promise<ComAtprotoRepoStrongRef.Main> {
+4 -4
View File
@@ -1,7 +1,6 @@
import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
type AtpAgent,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
@@ -28,6 +27,7 @@ import {
import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery'
import {type ChatInvitePreview} from '#/state/queries/join-links'
import {type SessionAgent} from '#/state/session'
import {type Gif} from '#/features/gifPicker/types'
import {createGIFDescription} from '../gif-alt-text'
@@ -95,7 +95,7 @@ export class EmbeddingDisabledError extends Error {
}
export async function resolveLink(
agent: AtpAgent,
agent: SessionAgent,
uri: string,
): Promise<ResolvedLink> {
if (isShortLink(uri)) {
@@ -217,7 +217,7 @@ export async function resolveLink(
}
export async function resolveGif(
agent: AtpAgent,
agent: SessionAgent,
gif: Gif,
): Promise<ResolvedExternalLink> {
const gifUrl = gif.media_formats.gif.url
@@ -259,7 +259,7 @@ function getFileSlug(url: string | undefined): string | undefined {
}
async function resolveExternal(
agent: AtpAgent,
agent: SessionAgent,
uri: string,
): Promise<ResolvedExternalLink> {
const result = await getLinkMeta(agent, uri)
+2 -2
View File
@@ -1,5 +1,5 @@
import {copyAsync} from 'expo-file-system/legacy'
import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
import {type Agent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
import {safeDeleteAsync} from '#/lib/media/manip'
@@ -7,7 +7,7 @@ import {safeDeleteAsync} from '#/lib/media/manip'
* @param encoding Allows overriding the blob's type
*/
export async function uploadBlob(
agent: AtpAgent,
agent: Agent,
input: string | Blob,
encoding?: string,
): Promise<ComAtprotoRepoUploadBlob.Response> {
+2 -2
View File
@@ -1,4 +1,4 @@
import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
import {type Agent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
/**
* @note It is recommended, on web, to use the `file` instance of the file
@@ -7,7 +7,7 @@ import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api'
* be passed directly to this function.
*/
export async function uploadBlob(
agent: AtpAgent,
agent: Agent,
input: string | Blob,
encoding?: string,
): Promise<ComAtprotoRepoUploadBlob.Response> {
+3 -4
View File
@@ -2,7 +2,6 @@ import {
type $Typed,
type AppBskyActorDefs,
type AppBskyGraphGetStarterPack,
type AtpAgent,
type ComAtprotoRepoApplyWrites,
type Facet,
} from '@atproto/api'
@@ -14,7 +13,7 @@ import {until} from '#/lib/async/until'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
import type * as bsky from '#/types/bsky'
export const createStarterPackList = async ({
@@ -28,7 +27,7 @@ export const createStarterPackList = async ({
description?: string
descriptionFacets?: Facet[]
profiles: bsky.profile.AnyProfileView[]
agent: AtpAgent
agent: SessionAgent
}): Promise<{uri: string; cid: string}> => {
if (profiles.length === 0) throw new Error('No profiles given')
@@ -152,7 +151,7 @@ function createListItem({
}
async function whenAppViewReady(
agent: AtpAgent,
agent: SessionAgent,
uri: string,
fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean,
) {
+3 -2
View File
@@ -1,8 +1,9 @@
import {type AppBskyEmbedExternal, type AtpAgent} from '@atproto/api'
import {type AppBskyEmbedExternal} from '@atproto/api'
import {LINK_META_PROXY} from '#/lib/constants'
import {getGiphyMetaUri} from '#/lib/strings/embed-player'
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
import {type SessionAgent} from '#/state/session'
import {isBskyAppUrl} from '../strings/url-helpers'
export enum LikelyType {
@@ -31,7 +32,7 @@ export interface LinkMeta {
}
export async function getLinkMeta(
agent: AtpAgent,
agent: SessionAgent,
url: string,
timeout = 15e3,
): Promise<LinkMeta> {
+3 -3
View File
@@ -1,10 +1,10 @@
import {type AtpAgent} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {VIDEO_SERVICE_DID} from '#/lib/constants'
import {UploadLimitError} from '#/lib/media/video/errors'
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
import {type SessionAgent} from '#/state/session'
import {createVideoAgent} from './util'
export async function getServiceAuthToken({
@@ -13,7 +13,7 @@ export async function getServiceAuthToken({
lxm,
exp,
}: {
agent: AtpAgent
agent: SessionAgent
aud?: string
lxm: string
exp?: number
@@ -30,7 +30,7 @@ export async function getServiceAuthToken({
return serviceAuth.token
}
export async function getVideoUploadLimits(agent: AtpAgent, i18n: I18n) {
export async function getVideoUploadLimits(agent: SessionAgent, i18n: I18n) {
const token = await getServiceAuthToken({
agent,
lxm: 'app.bsky.video.getUploadLimits',
+3 -2
View File
@@ -1,5 +1,5 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy'
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
import {type AppBskyVideoDefs} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {nanoid} from 'nanoid/non-secure'
@@ -10,6 +10,7 @@ import {
type CompressedVideo,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {type SessionAgent} from '#/state/session'
import {Features, features} from '#/analytics/features'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
@@ -25,7 +26,7 @@ export async function uploadVideo({
onTransport,
}: {
video: CompressedVideo
agent: AtpAgent
agent: SessionAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
+3 -2
View File
@@ -1,4 +1,4 @@
import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api'
import {type AppBskyVideoDefs} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {nanoid} from 'nanoid/non-secure'
@@ -9,6 +9,7 @@ import {
type CompressedVideo,
type VideoUploadTransport,
} from '#/lib/media/video/types'
import {type SessionAgent} from '#/state/session'
import {Features, features} from '#/analytics/features'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
@@ -24,7 +25,7 @@ export async function uploadVideo({
onTransport,
}: {
video: CompressedVideo
agent: AtpAgent
agent: SessionAgent
did: string
setProgress: (progress: number) => void
signal: AbortSignal
+4 -3
View File
@@ -2,7 +2,7 @@ import {useCallback, useEffect} from 'react'
import {Platform} from 'react-native'
import * as Notifications from 'expo-notifications'
import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
import {type AppBskyNotificationRegisterPush, type AtpAgent} from '@atproto/api'
import {type AppBskyNotificationRegisterPush} from '@atproto/api'
import debounce from 'lodash.debounce'
import {
@@ -13,6 +13,7 @@ import {
import {logger as notyLogger} from '#/lib/notifications/util'
import {isNetworkError} from '#/lib/strings/errors'
import {type SessionAccount, useAgent, useSession} from '#/state/session'
import {type SessionAgent} from '#/state/session/session-core'
import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics'
@@ -28,7 +29,7 @@ async function _registerPushToken({
token,
extra = {},
}: {
agent: AtpAgent
agent: SessionAgent
currentAccount: SessionAccount
token: Notifications.DevicePushToken
extra?: {
@@ -326,7 +327,7 @@ export async function resetBadgeCount() {
await setBadgeCountAsync(0)
}
export async function unregisterPushToken(agents: AtpAgent[]) {
export async function unregisterPushToken(agents: SessionAgent[]) {
if (!IS_NATIVE) return
try {
+3 -3
View File
@@ -2,7 +2,6 @@ import {
type $Typed,
type AppBskyGraphFollow,
type AppBskyGraphGetFollows,
type AtpAgent,
type ComAtprotoRepoApplyWrites,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
@@ -10,9 +9,10 @@ import {TID} from '@atproto/common-web'
import chunk from 'lodash.chunk'
import {until} from '#/lib/async/until'
import {type SessionAgent} from '#/state/session'
export async function bulkWriteFollows(
agent: AtpAgent,
agent: SessionAgent,
dids: string[],
via?: ComAtprotoRepoStrongRef.Main,
) {
@@ -59,7 +59,7 @@ export async function bulkWriteFollows(
}
async function whenFollowsIndexed(
agent: AtpAgent,
agent: SessionAgent,
actor: string,
fn: (res: AppBskyGraphGetFollows.Response) => boolean,
) {
@@ -1,10 +1,6 @@
import {Platform, View} from 'react-native'
import {Image} from 'expo-image'
import {
type AppBskyActorDefs,
type AppBskyActorGetProfile,
type AtpAgent,
} from '@atproto/api'
import {type AppBskyActorDefs, type AppBskyActorGetProfile} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
@@ -13,7 +9,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {until} from '#/lib/async/until'
import {isNetworkError} from '#/lib/strings/errors'
import {RQKEY} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {type SessionAgent, useAgent, useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -323,7 +319,7 @@ function platform() {
}
async function whenAppViewReady(
agent: AtpAgent,
agent: SessionAgent,
actor: string,
fn: (res: AppBskyActorGetProfile.Response) => boolean,
) {
+1 -1
View File
@@ -41,7 +41,7 @@ export function SignupQueued() {
const res = await agent.com.atproto.temp.checkSignupQueue()
if (res.data.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
await agent.sessionManager.refreshSession()
await agent.resumeSession()
if (!isSignupQueued(agent.session?.accessJwt)) {
onboardingDispatch({type: 'start'})
}
+2 -2
View File
@@ -1,7 +1,6 @@
import {
type $Typed,
type AppBskyEmbedRecord,
type AtpAgent,
type ChatBskyActorDefs,
ChatBskyConvoDefs,
type ChatBskyConvoGetLog,
@@ -46,6 +45,7 @@ import {
} from '#/state/messages/convo/types'
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
import {type SessionAgent} from '#/state/session'
import {
type ConvoWithDetails,
type GroupConvoMember,
@@ -105,7 +105,7 @@ function toDeletedMessageView(
export class Convo {
private id: string
private agent: AtpAgent
private agent: SessionAgent
private events: MessagesEventBus
private senderUserDid: string
+2 -2
View File
@@ -1,7 +1,6 @@
import {
type $Typed,
type AppBskyEmbedRecord,
type AtpAgent,
type ChatBskyActorDefs,
type ChatBskyConvoDefs,
type ChatBskyConvoSendMessage,
@@ -9,11 +8,12 @@ import {
} from '@atproto/api'
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type SessionAgent} from '#/state/session'
import {type ConvoWithDetails} from '#/components/dms/util'
export type ConvoParams = {
convoId: string
agent: AtpAgent
agent: SessionAgent
events: MessagesEventBus
placeholderData?: {
convo: ChatBskyConvoDefs.ConvoView
+3 -2
View File
@@ -1,4 +1,4 @@
import {type AtpAgent, type ChatBskyConvoGetLog} from '@atproto/api'
import {type ChatBskyConvoGetLog} from '@atproto/api'
import {EventEmitter} from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
@@ -21,13 +21,14 @@ import {
type MessagesEventBusParams,
MessagesEventBusStatus,
} from '#/state/messages/events/types'
import {type SessionAgent} from '#/state/session'
const logger = Logger.create(Logger.Context.DMsAgent)
export class MessagesEventBus {
private id: string
private agent: AtpAgent
private agent: SessionAgent
private emitter = new EventEmitter<{event: [MessagesEventBusEvent]}>()
private status: MessagesEventBusStatus = MessagesEventBusStatus.Initializing
+4 -2
View File
@@ -1,7 +1,9 @@
import {type AtpAgent, type ChatBskyConvoGetLog} from '@atproto/api'
import {type ChatBskyConvoGetLog} from '@atproto/api'
import {type SessionAgent} from '#/state/session'
export type MessagesEventBusParams = {
agent: AtpAgent
agent: SessionAgent
}
export enum MessagesEventBusStatus {
+2 -2
View File
@@ -11,7 +11,7 @@ import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {STALE} from '#/state/queries/index'
import {createQueryKey, type StructuredQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
/**
* The three preview shapes we currently support. Excludes the `{$type: string}`
@@ -159,7 +159,7 @@ async function fetchJoinLinkPreviews({
codes,
hasSession,
}: {
agent: AtpAgent
agent: SessionAgent
codes: string[]
hasSession: boolean
}) {
+2 -3
View File
@@ -2,7 +2,6 @@ import {
type AppBskyActorDefs,
type AppBskyGraphDefs,
type AppBskyGraphGetList,
type AtpAgent,
} from '@atproto/api'
import {
type InfiniteData,
@@ -13,7 +12,7 @@ import {
} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -60,7 +59,7 @@ export function useAllListMembersQuery(uri?: string) {
})
}
export async function getAllListMembers(agent: AtpAgent, uri: string) {
export async function getAllListMembers(agent: SessionAgent, uri: string) {
let hasMore = true
let cursor: string | undefined
const listItems: AppBskyGraphDefs.ListItemView[] = []
+2 -3
View File
@@ -3,7 +3,6 @@ import {
type AppBskyGraphDefs,
type AppBskyGraphGetList,
type AppBskyGraphList,
type AtpAgent,
AtUri,
type ComAtprotoRepoApplyWrites,
type Facet,
@@ -16,7 +15,7 @@ import {uploadBlob} from '#/lib/api'
import {until} from '#/lib/async/until'
import {type ImageMeta} from '#/state/gallery'
import {STALE} from '#/state/queries'
import {useAgent, useSession} from '#/state/session'
import {type SessionAgent, useAgent, useSession} from '#/state/session'
import {FEED_INFO_RQKEY_ROOT} from './feed'
import {invalidate as invalidateMyLists} from './my-lists'
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
@@ -305,7 +304,7 @@ export function useListBlockMutation() {
}
async function whenAppViewReady(
agent: AtpAgent,
agent: SessionAgent,
uri: string,
fn: (res: AppBskyGraphGetList.Response) => boolean,
) {
@@ -1,4 +1,3 @@
import type AtpAgent from '@atproto/api'
import {
type AppBskyActorDefs,
type ChatBskyActorDeclaration,
@@ -6,6 +5,7 @@ import {
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {type SessionAgent} from '#/state/session'
import {useAgent, useSession} from '#/state/session'
import {resolveAllowGroupInvites} from '#/components/dms/util'
import {RQKEY as PROFILE_RKEY} from '../profile'
@@ -120,7 +120,7 @@ export async function fetchActorDeclarationRecord({
agent,
did,
}: {
agent: AtpAgent
agent: SessionAgent
did?: string
}) {
if (!did) return
@@ -1,8 +1,8 @@
import type AtpAgent from '@atproto/api'
import {type ChatBskyActorDeclaration} from '@atproto/api'
import {networkRetry} from '#/lib/async/retry'
import {logger} from '#/logger'
import {type SessionAgent} from '#/state/session'
import {
getDidFromAgentSession,
getOtherRequiredDataFromCache,
@@ -28,7 +28,7 @@ export async function restrictChatSettings({
restrictIncoming = false,
restrictGroupInvites = false,
}: {
agent: AtpAgent
agent: SessionAgent
restrictIncoming?: boolean
restrictGroupInvites?: boolean
}): Promise<void> {
+3 -3
View File
@@ -6,7 +6,6 @@ import {
type AppBskyGraphDefs,
AppBskyGraphStarterpack,
type AppBskyNotificationListNotifications,
type AtpAgent,
hasMutedWord,
moderateNotification,
type ModerationOpts,
@@ -15,6 +14,7 @@ import {type QueryClient} from '@tanstack/react-query'
import chunk from 'lodash.chunk'
import {labelIsHideableOffense} from '#/lib/moderation'
import {type SessionAgent} from '#/state/session'
import * as bsky from '#/types/bsky'
import {precacheProfile} from '../profile'
import {
@@ -46,7 +46,7 @@ export async function fetchPage({
fetchAdditionalData,
reasons,
}: {
agent: AtpAgent
agent: SessionAgent
cursor: string | undefined
limit: number
queryClient: QueryClient
@@ -207,7 +207,7 @@ export function groupNotifications(
}
async function fetchSubjects(
agent: AtpAgent,
agent: SessionAgent,
groupedNotifs: FeedNotification[],
): Promise<{
posts: Map<string, AppBskyFeedDefs.PostView>
+2 -3
View File
@@ -4,7 +4,6 @@ import {
type AppBskyActorDefs,
AppBskyFeedDefs,
type AppBskyFeedPost,
type AtpAgent,
AtUri,
moderatePost,
type ModerationDecision,
@@ -33,7 +32,7 @@ import {DISCOVER_FEED_URI} from '#/lib/constants'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {KnownError} from '#/view/com/posts/PostFeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
@@ -450,7 +449,7 @@ function createApi({
feedParams: FeedParams
feedTuners: FeedTunerFn[]
userInterests?: string
agent: AtpAgent
agent: SessionAgent
enableFollowingToDiscoverFallback: boolean
}) {
if (feedDesc === 'following') {
+4 -5
View File
@@ -4,7 +4,6 @@ import {
AppBskyEmbedRecordWithMedia,
type AppBskyFeedDefs,
AppBskyFeedPostgate,
type AtpAgent,
AtUri,
} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
@@ -20,14 +19,14 @@ import {
mergePostgateRecords,
POSTGATE_COLLECTION,
} from '#/state/queries/postgate/util'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
import * as bsky from '#/types/bsky'
export async function getPostgateRecord({
agent,
postUri,
}: {
agent: AtpAgent
agent: SessionAgent
postUri: string
}): Promise<AppBskyFeedPostgate.Record | undefined> {
const urip = new AtUri(postUri)
@@ -89,7 +88,7 @@ export async function writePostgateRecord({
postUri,
postgate,
}: {
agent: AtpAgent
agent: SessionAgent
postUri: string
postgate: AppBskyFeedPostgate.Record
}) {
@@ -110,7 +109,7 @@ export async function upsertPostgate(
agent,
postUri,
}: {
agent: AtpAgent
agent: SessionAgent
postUri: string
},
callback: (
+2 -3
View File
@@ -5,7 +5,6 @@ import {
type AppBskyActorGetProfiles,
type AppBskyActorProfile,
type AppBskyGraphGetFollows,
type AtpAgent,
AtUri,
type ComAtprotoRepoUploadBlob,
type Un$Typed,
@@ -33,7 +32,7 @@ import {
useUnstableProfileViewCache,
} from '#/state/queries/unstable-profile-cache'
import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
import {useAgent, useSession} from '#/state/session'
import {type SessionAgent, useAgent, useSession} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
import {useAnalytics} from '#/analytics'
import {type Metrics, toClout} from '#/analytics/metrics'
@@ -598,7 +597,7 @@ function useProfileUnblockMutation() {
}
async function whenAppViewReady(
agent: AtpAgent,
agent: SessionAgent,
actor: string,
fn: (res: AppBskyActorGetProfile.Response) => boolean,
) {
+4 -5
View File
@@ -1,9 +1,8 @@
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 {STALE} from '#/state/queries/index'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
import {type Gif} from '#/features/gifPicker/types'
export const RQKEY_LINK_ROOT = 'resolve-link'
@@ -12,7 +11,7 @@ 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(agent: SessionAgent, url: string) {
return queryOptions({
staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_LINK(url),
@@ -26,7 +25,7 @@ export function useResolveLinkQuery(url: string) {
}
export function fetchResolveLinkQuery(
queryClient: QueryClient,
agent: AtpAgent,
agent: SessionAgent,
url: string,
) {
return queryClient.fetchQuery(resolveLinkQueryOptions(agent, url))
@@ -51,7 +50,7 @@ export function useResolveGifQuery(gif: Gif) {
}
export function fetchResolveGifQuery(
queryClient: QueryClient,
agent: AtpAgent,
agent: SessionAgent,
gif: Gif,
) {
return queryClient.fetchQuery({
+3 -3
View File
@@ -1,15 +1,15 @@
import {type AtpAgent, AtUri} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
import {useUnstableProfileViewCache} from './profile'
const RQKEY_ROOT = 'resolved-did'
export const RQKEY = (didOrHandle: string) => [RQKEY_ROOT, didOrHandle]
const resolvedDidQueryOptions = (
agent: AtpAgent,
agent: SessionAgent,
getUnstableProfile: (did: string) => {did: string} | undefined,
didOrHandle: string | undefined,
) =>
+2 -3
View File
@@ -4,7 +4,6 @@ import {
type AppBskyGraphGetStarterPack,
AppBskyGraphStarterpack,
type AppBskyRichtextFacet,
type AtpAgent,
AtUri,
RichText,
} from '@atproto/api'
@@ -26,7 +25,7 @@ import {
import {invalidateActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
import {STALE} from '#/state/queries/index'
import {invalidateListMembersQuery} from '#/state/queries/list-members'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
import * as bsky from '#/types/bsky'
const RQKEY_ROOT = 'starter-pack'
@@ -340,7 +339,7 @@ export function useDeleteStarterPackMutation({
}
async function whenAppViewReady(
agent: AtpAgent,
agent: SessionAgent,
uri: string,
fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean,
) {
+6 -11
View File
@@ -1,9 +1,4 @@
import {
type AppBskyFeedDefs,
AppBskyFeedThreadgate,
type AtpAgent,
AtUri,
} from '@atproto/api'
import {type AppBskyFeedDefs, AppBskyFeedThreadgate, AtUri} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {networkRetry, retry} from '#/lib/async/retry'
@@ -17,7 +12,7 @@ import {
threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate/util'
import {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread'
import {useAgent} from '#/state/session'
import {type SessionAgent, useAgent} from '#/state/session'
import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies'
import * as bsky from '#/types/bsky'
@@ -88,7 +83,7 @@ export async function getThreadgateRecord({
agent,
postUri,
}: {
agent: AtpAgent
agent: SessionAgent
postUri: string
}): Promise<AppBskyFeedThreadgate.Record | null> {
const urip = new AtUri(postUri)
@@ -150,7 +145,7 @@ export async function writeThreadgateRecord({
postUri,
threadgate,
}: {
agent: AtpAgent
agent: SessionAgent
postUri: string
threadgate: AppBskyFeedThreadgate.Record
}) {
@@ -176,7 +171,7 @@ export async function upsertThreadgate(
agent,
postUri,
}: {
agent: AtpAgent
agent: SessionAgent
postUri: string
},
callback: (
@@ -205,7 +200,7 @@ export async function updateThreadgateAllow({
postUri,
allow,
}: {
agent: AtpAgent
agent: SessionAgent
postUri: string
allow: ThreadgateAllowUISetting[]
}) {
@@ -0,0 +1,375 @@
import {type AtpSessionEvent} from '@atproto/api'
import {
PasswordSession,
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {describe, expect, it, jest} from '@jest/globals'
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
/*
* session-core imports the factory dependency graph (birthdate,
* restrictChatSettings, ageAssurance, moderation). Mock the heavy leaves so
* these tests do not pull in the native module chain (same approach as
* session-test.ts / session-core-test.ts).
*/
jest.mock('#/state/birthdate')
jest.mock('#/ageAssurance/data')
jest.mock('#/ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
}))
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
restrictChatSettings: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode() {
return {scope: 'com.atproto.access'}
},
}))
import {
makeSessionHooks,
sessionAccountToSessionData,
SessionAgent,
} from '../session-core'
import {type SessionAccount} from '../types'
const DID = 'did:plc:example123'
const HANDLE = 'alice.test'
const SERVICE = 'https://bsky.social'
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
return {
service: SERVICE,
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt',
accessJwt: 'access-jwt',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: undefined,
isSelfHosted: false,
...overrides,
}
}
/**
* Build a mock `fetch` that returns canned XRPC responses keyed by the last
* path segment (nsid). `refreshSession` returns fresh tokens; `getSession`
* echoes the account; anything else returns an empty 200.
*/
function makeMockFetch(
overrides: Record<
string,
(url: string, init: RequestInit) => Response | Promise<Response>
> = {},
) {
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: {'content-type': 'application/json'},
})
const fetchMock = jest.fn(
/*
* PasswordSession calls fetch with a URL object (new URL(path, service));
* asFetch() below widens the mock to the full fetch signature it expects.
*/
async (input: URL | string, init: RequestInit = {}): Promise<Response> => {
const url = input instanceof URL ? input.href : input
const nsid = url.split('/xrpc/')[1]?.split('?')[0]
const handler = nsid ? overrides[nsid] : undefined
if (handler) {
return handler(url, init)
}
if (nsid === 'com.atproto.server.refreshSession') {
return json({
accessJwt: 'access-jwt-2',
refreshJwt: 'refresh-jwt-2',
handle: HANDLE,
did: DID,
active: true,
})
}
if (nsid === 'com.atproto.server.getSession') {
return json({
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
active: true,
})
}
return json({})
},
)
return fetchMock
}
/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */
function asFetch(mock: ReturnType<typeof makeMockFetch>): typeof fetch {
return mock as unknown as typeof fetch
}
describe('SessionAgent getters', () => {
it('reads live SessionData through .session', () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
expect(agent.session?.did).toBe(DID)
expect(agent.session?.handle).toBe(HANDLE)
expect(agent.did).toBe(DID)
})
it('derives serviceUrl from the session service', () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
expect(agent.serviceUrl.toString()).toBe('https://bsky.social/')
})
it('derives pdsUrl/dispatchUrl from a synthetic didDoc', () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount({pdsUrl: `${PDS_URL}/`})),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
expect(agent.pdsUrl?.toString()).toBe(`${PDS_URL}/`)
expect(agent.dispatchUrl.toString()).toBe(`${PDS_URL}/`)
})
it('dispatchUrl falls back to serviceUrl when there is no PDS', () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
expect(agent.pdsUrl).toBe(undefined)
expect(agent.dispatchUrl.toString()).toBe('https://bsky.social/')
})
it('public agent exposes undefined session and public serviceUrl', () => {
const agent = new SessionAgent(null)
expect(agent.session).toBe(undefined)
expect(agent.did).toBe(undefined)
expect(agent.serviceUrl.toString()).toBe('https://public.api.bsky.app/')
})
})
describe('SessionAgent.resumeSession', () => {
it('calls session.refresh() and returns a success result', async () => {
const fetchMock = makeMockFetch()
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(fetchMock)},
)
const agent = new SessionAgent(session)
const res = await agent.resumeSession()
expect(res.success).toBe(true)
expect(res.data.accessJwt).toBe('access-jwt-2')
/* one refreshSession call */
const calls = fetchMock.mock.calls.map(c =>
c[0] instanceof URL ? c[0].href : c[0],
)
expect(
calls.some(u => u.includes('com.atproto.server.refreshSession')),
).toBe(true)
})
})
describe('SessionAgent destroyed session', () => {
it('did/session getters do not throw after logout', async () => {
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(makeMockFetch())},
)
const agent = new SessionAgent(session)
await session.logout()
expect(session.destroyed).toBe(true)
/* PasswordSession.did throws when destroyed; SessionAgent must not */
expect(() => agent.did).not.toThrow()
expect(agent.did).toBe(undefined)
expect(agent.session).toBe(undefined)
})
})
describe('SessionAgent namespace routing', () => {
it('routes a call through the session fetchHandler with proxy + labeler headers', 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 new Response(JSON.stringify({did: DID, handle: HANDLE}), {
status: 200,
headers: {'content-type': 'application/json'},
})
},
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount({pdsUrl: `${PDS_URL}/`})),
{fetch: asFetch(fetchMock)},
)
const agent = new SessionAgent(session)
/* base Agent's configureProxy is what buildBundle applies in production */
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 base 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].headers.get('atproto-proxy')).toBe(
'did:web:api.bsky.app#bsky_appview',
)
const labelers = seen[0].headers.get('atproto-accept-labelers')
expect(labelers).toContain('did:plc:custom-labeler')
/* the session attaches the bearer token */
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
})
})
describe('makeSessionHooks arm-latch + event mapping', () => {
/*
* The hooks read neither `this` (the PasswordSession) nor their data
* argument, so we invoke them with empty stand-ins cast to the declared
* parameter types. This keeps the test focused on the arm-latch + event
* mapping.
*/
const fakeSession = {} as PasswordSession
const fakeData = {} as SessionData
function setup() {
const onSessionChange =
jest.fn<
(agent: SessionAgent, did: string, event: AtpSessionEvent) => void
>()
const agent = new SessionAgent(null)
const hooks = makeSessionHooks(
onSessionChange,
() => agent,
() => DID,
)
return {onSessionChange, agent, hooks}
}
it('swallows events before arm()', () => {
const {onSessionChange, hooks} = setup()
void hooks.onUpdated?.call(fakeSession, fakeData)
expect(onSessionChange).not.toHaveBeenCalled()
})
it("maps onUpdated -> 'update' after arm()", () => {
const {onSessionChange, hooks} = setup()
hooks.arm()
void hooks.onUpdated?.call(fakeSession, fakeData)
expect(onSessionChange).toHaveBeenCalledTimes(1)
expect(onSessionChange.mock.calls[0][2]).toBe('update')
})
it("maps onDeleted -> 'expired' after arm()", () => {
const {onSessionChange, hooks} = setup()
hooks.arm()
void hooks.onDeleted?.call(fakeSession, fakeData)
expect(onSessionChange.mock.calls[0][2]).toBe('expired')
})
it("maps onUpdateFailure -> 'network-error' after arm()", () => {
const {onSessionChange, hooks} = setup()
hooks.arm()
void hooks.onUpdateFailure?.call(
fakeSession,
fakeData,
{} as Parameters<NonNullable<typeof hooks.onUpdateFailure>>[1],
)
expect(onSessionChange.mock.calls[0][2]).toBe('network-error')
})
})
describe('PasswordSession lifecycle over mocked fetch', () => {
it('resume fast path: constructing does not hit the network', () => {
const fetchMock = makeMockFetch()
/* not expired -> new PasswordSession(...) with no refresh */
void new PasswordSession(sessionAccountToSessionData(makeAccount()), {
fetch: asFetch(fetchMock),
})
expect(fetchMock.mock.calls.length).toBe(0)
})
it('resume network path fires onUpdated with fresh tokens', async () => {
const fetchMock = makeMockFetch()
const onUpdated =
jest.fn<NonNullable<PasswordSessionOptions['onUpdated']>>()
const session = await PasswordSession.resume(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(fetchMock), onUpdated},
)
expect(onUpdated).toHaveBeenCalledTimes(1)
expect(session.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': () =>
new Response(
JSON.stringify({error: 'ExpiredToken', message: 'Token expired'}),
{status: 400, headers: {'content-type': 'application/json'}},
),
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{fetch: asFetch(fetchMock), onDeleted, onUpdated},
)
await expect(session.refresh()).rejects.toBeDefined()
expect(onDeleted).toHaveBeenCalledTimes(1)
expect(onUpdated).not.toHaveBeenCalled()
})
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': () =>
new Response(JSON.stringify({error: 'InternalServerError'}), {
status: 500,
headers: {'content-type': 'application/json'},
}),
})
const session = new PasswordSession(
sessionAccountToSessionData(makeAccount()),
{
fetch: asFetch(fetchMock),
onDeleted,
onUpdateFailure,
},
)
await session.refresh()
expect(onUpdateFailure).toHaveBeenCalledTimes(1)
expect(onDeleted).not.toHaveBeenCalled()
/* session data is preserved (still the original tokens) */
expect(session.session.accessJwt).toBe('access-jwt')
})
})
@@ -0,0 +1,263 @@
import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {PasswordSession} from '@atproto/lex-password-session'
import {api} from '@bsky.app/sdk'
import {describe, expect, it, jest} from '@jest/globals'
/*
* clients.ts imports session-core (for networkAwareFetch), which pulls the
* factory dependency graph. Mock the heavy leaves so this test does not load
* the native module chain (same approach as bridge-agent-test.ts).
*/
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
jest.mock('#/state/birthdate')
jest.mock('#/ageAssurance/data')
jest.mock('#/ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
}))
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
restrictChatSettings: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode() {
return {scope: 'com.atproto.access'}
},
}))
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {app} from '#/lexicons'
import {
buildAccountClient,
buildAppviewClient,
getPublicLexClient,
} from '../clients'
import {sessionAccountToSessionData} from '../session-core'
import {type SessionAccount} from '../types'
const DID = 'did:plc:example123'
const HANDLE = 'alice.test'
const SERVICE = 'https://bsky.social'
const APPVIEW_PROXY = 'did:web:api.bsky.app#bsky_appview'
const CUSTOM_LABELER = 'did:plc:custom-labeler'
function makeAccount(overrides: Partial<SessionAccount> = {}): SessionAccount {
return {
service: SERVICE,
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt',
accessJwt: 'access-jwt',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: undefined,
isSelfHosted: false,
...overrides,
}
}
/**
* Capture the request that a client emits. Records the last-seen URL + headers
* and returns an empty 200 JSON body (the assertions here are about request
* headers, not response parsing).
*/
function isRequest(input: URL | string | Request): input is Request {
return (
typeof input === 'object' &&
'headers' in input &&
input.headers instanceof Headers
)
}
function makeCapturingFetch() {
const seen: {url: string; headers: Headers}[] = []
const fetchMock = jest.fn(
(
input: URL | string | Request,
init: RequestInit = {},
): Promise<Response> => {
/*
* The lex Client calls fetch as (url, {headers}); the old AtpAgent
* (XrpcClient) calls it with a single Request object carrying the
* headers. Read headers from whichever the caller used.
*/
const url = isRequest(input)
? input.url
: input instanceof URL
? input.href
: input
const headers = isRequest(input)
? input.headers
: new Headers(init.headers)
seen.push({url, headers})
return Promise.resolve(
new Response(JSON.stringify({did: DID, handle: HANDLE}), {
status: 200,
headers: {'content-type': 'application/json'},
}),
)
},
)
return {seen, fetchMock}
}
/** Cast a jest fetch mock to the `fetch` type PasswordSession options expect. */
function asFetch(mock: ReturnType<typeof makeCapturingFetch>['fetchMock']) {
return mock as unknown as typeof fetch
}
function makeSession(
fetchMock: ReturnType<typeof makeCapturingFetch>['fetchMock'],
) {
return new PasswordSession(sessionAccountToSessionData(makeAccount()), {
fetch: asFetch(fetchMock),
})
}
describe('buildAppviewClient', () => {
it('sets the appview atproto-proxy header and includes the moderation DID in labelers', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAppviewClient(session, [CUSTOM_LABELER])
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
expect(seen.length).toBe(1)
expect(seen[0].headers.get('atproto-proxy')).toBe(APPVIEW_PROXY)
const labelers = seen[0].headers.get('atproto-accept-labelers') ?? ''
expect(labelers).toContain(api.moderation.did)
expect(labelers).toContain(CUSTOM_LABELER)
})
it('routes through the session fetchHandler with the bearer token', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAppviewClient(session, [])
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
/* the moderation DID is always re-asserted even with no custom labelers */
expect(seen[0].headers.get('atproto-accept-labelers')).toContain(
api.moderation.did,
)
})
})
describe('buildAccountClient', () => {
it('has no atproto-proxy header (requests hit the PDS directly)', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAccountClient(session)
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
expect(seen.length).toBe(1)
expect(seen[0].headers.get('atproto-proxy')).toBeNull()
})
it('routes through the session fetchHandler with the bearer token', async () => {
const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock)
const client = buildAccountClient(session)
await client.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
})
})
describe('getPublicLexClient', () => {
it('is an unauthenticated singleton (no session did)', () => {
const client = getPublicLexClient()
expect(client.did).toBeUndefined()
/* process-wide singleton: identity is stable across calls */
expect(getPublicLexClient()).toBe(client)
})
it('routes to public.api.bsky.app with no proxy or auth header', async () => {
/*
* getPublicLexClient builds `new Client({service: PUBLIC_BSKY_SERVICE,
* fetch: networkAwareFetch})`. networkAwareFetch captures the global fetch
* at import time, which is hard to intercept here, so we reconstruct the
* same Client shape with an observable fetch to assert the routing +
* header contract the source relies on. The response body fails getProfile
* validation (it is a stub), but request headers are recorded pre-parse.
*/
const {seen, fetchMock} = makeCapturingFetch()
const client = new Client({
service: PUBLIC_BSKY_SERVICE,
fetch: asFetch(fetchMock),
})
await client
.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
.catch(() => {})
expect(seen.length).toBe(1)
expect(seen[0].url).toContain('public.api.bsky.app')
expect(seen[0].headers.get('atproto-proxy')).toBeNull()
expect(seen[0].headers.get('authorization')).toBeNull()
})
})
/*
* Regression guard: the emitted `atproto-accept-labelers` header from a
* fully-configured appview client must match the header the old AtpAgent
* produced for the same labeler set - global appLabelers carry the `;redact`
* suffix, per-instance labelers are plain. Byte-identical composition is the
* acceptance bar for the moderation migration (design section 6).
*/
describe('labeler-header regression guard', () => {
it('appview client emits the same atproto-accept-labelers header as the old AtpAgent', async () => {
/*
* Old behavior: AtpAgent.appLabelers default to [BSKY_LABELER_DID], emitted
* with `;redact`; per-instance configureLabelers are plain. Capture what a
* real AtpAgent emits for the same [custom] instance labeler set.
*/
const {seen: agentSeen, fetchMock: agentFetch} = makeCapturingFetch()
const oldAgent = new AtpAgent({
service: SERVICE,
fetch: asFetch(agentFetch),
})
oldAgent.configureProxy(APPVIEW_PROXY)
oldAgent.configureLabelers([CUSTOM_LABELER])
await oldAgent.app.bsky.actor.getProfile({actor: HANDLE}).catch(() => {})
const oldHeader = agentSeen[0].headers.get('atproto-accept-labelers')
/*
* New behavior: buildAppviewClient re-asserts api.moderation.did (===
* BSKY_LABELER_DID) as a base labeler; the global Client.appLabelers carry
* the `;redact` suffix. Configure Client global appLabelers to match the old
* AtpAgent global set so the composition is directly comparable.
*/
Client.configure({appLabelers: [BSKY_LABELER_DID]})
const {seen: clientSeen, fetchMock: clientFetch} = makeCapturingFetch()
const session = makeSession(clientFetch)
const client = buildAppviewClient(session, [CUSTOM_LABELER])
await client
.call(app.bsky.actor.getProfile.main, {actor: HANDLE})
.catch(() => {})
const newHeader = clientSeen[0].headers.get('atproto-accept-labelers')
/*
* Both must contain the redacted global Bluesky labeler and the plain
* per-instance custom labeler.
*/
expect(oldHeader).toContain(`${BSKY_LABELER_DID};redact`)
expect(newHeader).toContain(`${BSKY_LABELER_DID};redact`)
expect(oldHeader).toContain(CUSTOM_LABELER)
expect(newHeader).toContain(CUSTOM_LABELER)
/* the custom labeler is plain (no redact) in both */
expect(oldHeader).not.toContain(`${CUSTOM_LABELER};redact`)
expect(newHeader).not.toContain(`${CUSTOM_LABELER};redact`)
})
})
@@ -3,6 +3,30 @@ import {Client} from '@atproto/lex-client'
import {describe, expect, it, jest} from '@jest/globals'
import {app} from '#/lexicons'
/*
* clients.ts now imports session-core (for networkAwareFetch), which pulls the
* factory dependency graph. Mock the heavy leaves so this test does not load
* the native module chain (same approach as session-test.ts).
*/
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
jest.mock('#/state/birthdate')
jest.mock('#/ageAssurance/data')
jest.mock('#/ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
}))
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
restrictChatSettings: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode() {
return {scope: 'com.atproto.access'}
},
}))
import {agentToLexClient} from '../clients'
/**
@@ -0,0 +1,326 @@
import {type SessionData} from '@atproto/lex-password-session'
import {type SessionAccount} from '../types'
jest.mock('#/state/events', () => ({
emitNetworkConfirmed: jest.fn(),
emitNetworkLost: jest.fn(),
}))
/*
* session-core now imports the factory dependency graph (birthdate,
* restrictChatSettings, ageAssurance, moderation). Mock the heavy leaves so the
* pure-converter tests here stay lightweight and do not pull in the native
* bottom-sheet module chain (same approach as session-test.ts).
*/
jest.mock('#/state/birthdate')
jest.mock('#/ageAssurance/data')
jest.mock('#/ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}, flags: {}}),
}))
jest.mock('#/state/queries/messages/restrictChatSettings', () => ({
restrictChatSettings: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode(token: string) {
if (token === 'queued-access-jwt') {
return {scope: 'com.atproto.signupQueued'}
}
return {scope: 'com.atproto.access'}
},
}))
import {
extractPdsUrl,
sessionAccountToSessionData,
sessionDataToSessionAccount,
synthDidDoc,
} from '../session-core'
const DID = 'did:plc:example123'
const HANDLE = 'alice.test'
const PDS_URL = 'https://shimeji.us-east.host.bsky.network'
function makeSessionData(overrides: Partial<SessionData> = {}): SessionData {
return {
accessJwt: 'access-jwt',
refreshJwt: 'refresh-jwt',
handle: HANDLE,
did: DID,
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
active: true,
service: 'https://bsky.social',
...overrides,
}
}
describe('extractPdsUrl', () => {
it('extracts the PDS endpoint from a didDoc', () => {
expect(extractPdsUrl(synthDidDoc(DID, PDS_URL))).toBe(PDS_URL)
})
it('matches full service ids ending in #atproto_pds', () => {
const didDoc = {
id: DID,
service: [
{
id: `${DID}#atproto_pds`,
type: 'AtprotoPersonalDataServer',
serviceEndpoint: PDS_URL,
},
],
}
expect(extractPdsUrl(didDoc)).toBe(PDS_URL)
})
it('returns null for missing/invalid input', () => {
expect(extractPdsUrl(undefined)).toBe(null)
expect(extractPdsUrl(null)).toBe(null)
expect(extractPdsUrl({})).toBe(null)
expect(extractPdsUrl({service: 'not-an-array'})).toBe(null)
expect(
extractPdsUrl({
service: [{id: '#other_service', serviceEndpoint: PDS_URL}],
}),
).toBe(null)
expect(
extractPdsUrl({service: [{id: '#atproto_pds', serviceEndpoint: 42}]}),
).toBe(null)
expect(
extractPdsUrl({
service: [{id: '#atproto_pds', serviceEndpoint: 'not a url'}],
}),
).toBe(null)
})
})
describe('synthDidDoc', () => {
it('produces a didDoc that extractPdsUrl round-trips', () => {
const doc = synthDidDoc(DID, PDS_URL)
expect(extractPdsUrl(doc)).toBe(PDS_URL)
expect(doc.id).toBe(DID)
})
})
describe('sessionDataToSessionAccount', () => {
it('returns undefined for a missing session', () => {
expect(sessionDataToSessionAccount(undefined, 'https://bsky.social')).toBe(
undefined,
)
expect(sessionDataToSessionAccount(null, 'https://bsky.social')).toBe(
undefined,
)
})
it('maps fields for a hosted account (no didDoc)', () => {
const account = sessionDataToSessionAccount(
makeSessionData(),
'https://bsky.social',
)!
expect(account).toEqual({
service: 'https://bsky.social/',
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt',
accessJwt: 'access-jwt',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: undefined,
isSelfHosted: false,
})
})
it('normalizes service with a trailing slash like agent.serviceUrl.toString()', () => {
const account = sessionDataToSessionAccount(
makeSessionData(),
'https://bsky.social',
)!
expect(account.service).toBe('https://bsky.social/')
})
it('derives pdsUrl from the didDoc, normalized as a URL string', () => {
const account = sessionDataToSessionAccount(
makeSessionData({didDoc: synthDidDoc(DID, PDS_URL)}),
'https://bsky.social',
)!
/*
* The old code read agent.pdsUrl?.toString() - a URL - so the persisted
* value carries a trailing slash.
*/
expect(account.pdsUrl).toBe(`${PDS_URL}/`)
})
it('leaves pdsUrl undefined for hosted accounts (no service fallback)', () => {
const account = sessionDataToSessionAccount(
makeSessionData({didDoc: undefined}),
'https://bsky.social',
)!
expect(account.pdsUrl).toBe(undefined)
})
it('derives isSelfHosted from the service URL', () => {
const hosted = sessionDataToSessionAccount(
makeSessionData(),
'https://bsky.social',
)!
expect(hosted.isSelfHosted).toBe(false)
const selfHosted = sessionDataToSessionAccount(
makeSessionData({service: 'https://pds.example.com'}),
'https://pds.example.com',
)!
expect(selfHosted.isSelfHosted).toBe(true)
})
it('derives signupQueued from the access token scope', () => {
const queued = sessionDataToSessionAccount(
makeSessionData({accessJwt: 'queued-access-jwt'}),
'https://bsky.social',
)!
expect(queued.signupQueued).toBe(true)
const notQueued = sessionDataToSessionAccount(
makeSessionData(),
'https://bsky.social',
)!
expect(notQueued.signupQueued).toBe(false)
})
it('coerces missing email flags to false', () => {
const account = sessionDataToSessionAccount(
makeSessionData({
email: undefined,
emailConfirmed: undefined,
emailAuthFactor: undefined,
}),
'https://bsky.social',
)!
expect(account.email).toBe(undefined)
expect(account.emailConfirmed).toBe(false)
expect(account.emailAuthFactor).toBe(false)
})
it('preserves the exact field order of the old agentToSessionAccount literal', () => {
/*
* Byte-stability guard: the reducer's JSON.stringify fast path and the
* session test snapshots depend on this exact key order. This is the
* object literal order of the old agentToSessionAccount in agent.ts.
*/
const account = sessionDataToSessionAccount(
makeSessionData({didDoc: synthDidDoc(DID, PDS_URL)}),
'https://bsky.social',
)!
const golden: SessionAccount = {
service: 'https://bsky.social/',
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt',
accessJwt: 'access-jwt',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: `${PDS_URL}/`,
isSelfHosted: false,
}
expect(Object.keys(account)).toEqual(Object.keys(golden))
expect(JSON.stringify(account)).toBe(JSON.stringify(golden))
})
})
describe('sessionAccountToSessionData', () => {
const baseAccount: SessionAccount = {
service: 'https://bsky.social/',
did: DID,
handle: HANDLE,
email: 'alice@example.com',
emailConfirmed: true,
emailAuthFactor: false,
refreshJwt: 'refresh-jwt',
accessJwt: 'access-jwt',
signupQueued: false,
active: true,
status: undefined,
pdsUrl: undefined,
isSelfHosted: false,
}
it('maps fields with empty-string token fallbacks and active default', () => {
const data = sessionAccountToSessionData({
...baseAccount,
accessJwt: undefined,
refreshJwt: undefined,
active: undefined,
})
expect(data.accessJwt).toBe('')
expect(data.refreshJwt).toBe('')
expect(data.active).toBe(true)
expect(data.did).toBe(DID)
expect(data.handle).toBe(HANDLE)
expect(data.service).toBe('https://bsky.social/')
})
it('omits didDoc when the account has no stored pdsUrl', () => {
const data = sessionAccountToSessionData(baseAccount)
expect('didDoc' in data).toBe(false)
expect(extractPdsUrl(data.didDoc)).toBe(null)
})
it('synthesizes a didDoc from a stored pdsUrl so PDS routing works pre-refresh', () => {
const data = sessionAccountToSessionData({
...baseAccount,
pdsUrl: `${PDS_URL}/`,
})
expect(extractPdsUrl(data.didDoc)).toBe(`${PDS_URL}/`)
})
it('round-trips account -> SessionData -> account preserving all fields', () => {
const withPds: SessionAccount = {
...baseAccount,
pdsUrl: `${PDS_URL}/`,
}
for (const account of [baseAccount, withPds]) {
const data = sessionAccountToSessionData(account)
const roundTripped = sessionDataToSessionAccount(data, account.service)!
expect(roundTripped).toEqual(account)
expect(JSON.stringify(roundTripped)).toBe(JSON.stringify(account))
}
})
it('round-trips signupQueued via the access token scope', () => {
const queued: SessionAccount = {
...baseAccount,
accessJwt: 'queued-access-jwt',
signupQueued: true,
}
const roundTripped = sessionDataToSessionAccount(
sessionAccountToSessionData(queued),
queued.service,
)!
expect(roundTripped.signupQueued).toBe(true)
expect(roundTripped).toEqual(queued)
})
it('round-trips a self-hosted account', () => {
const selfHosted: SessionAccount = {
...baseAccount,
service: 'https://pds.example.com/',
pdsUrl: 'https://pds.example.com/',
isSelfHosted: true,
}
const roundTripped = sessionDataToSessionAccount(
sessionAccountToSessionData(selfHosted),
selfHosted.service,
)!
expect(roundTripped).toEqual(selfHosted)
})
})
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import {AtpAgent} from '@atproto/api'
import {Agent} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {device} from '#/storage'
@@ -82,9 +83,16 @@ export function configureAdditionalModerationAuthorities() {
additionalLabelers = []
}
/*
* Merge with the currently-configured global labelers on the base `Agent`
* static (the bridge agent is a base `Agent`, not `AtpAgent`). Set the merged
* result on BOTH request paths - the lex `Client` static and the base `Agent`
* static - so both emit identical global `atproto-accept-labelers` headers.
*/
const appLabelers = Array.from(
new Set([...AtpAgent.appLabelers, ...additionalLabelers]),
new Set([...Agent.appLabelers, ...additionalLabelers]),
)
AtpAgent.configure({appLabelers})
Client.configure({appLabelers: appLabelers as `did:${string}:${string}`[]})
Agent.configure({appLabelers})
}
+23 -395
View File
@@ -1,336 +1,35 @@
import {
Agent as BaseAgent,
type AppBskyActorProfile,
AtpAgent,
type AtprotoServiceType,
type AtpSessionData,
type AtpSessionEvent,
type Did,
type Un$Typed,
} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {networkRetry} from '#/lib/async/retry'
import {
BLUESKY_PROXY_HEADER,
BSKY_SERVICE,
DISCOVER_SAVED_FEED,
IS_PROD_SERVICE,
PUBLIC_BSKY_SERVICE,
TIMELINE_SAVED_FEED,
} from '#/lib/constants'
import {logger} from '#/logger'
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
prefetchAgeAssuranceServerData,
setBirthdateForDid,
setCreatedAtForDid,
} from '#/ageAssurance/data'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {features} from '#/analytics'
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
import {addSessionErrorLog} from './logging'
import {
configureModerationForAccount,
configureModerationForGuest,
} from './moderation'
import {type SessionAccount} from './types'
import {isSessionExpired, isSignupQueued} from './util'
import {createPublicSessionBundle, type SessionAgent} from './session-core'
/*
* Phase 2 SDK migration: the PasswordSession-based session core (factories,
* bridge agent, converters) lives in session-core.ts. This module is now a
* thin compat layer that keeps the few external imports working:
* - `createPublicAgent` (drafts) -> the public bundle's bridge agent
* - `Agent` (pds-detection / forgot-password / set-new-password) -> the
* proxy-header base Agent subclass
* - `ProxyHeaderValue` (constants)
* - the new converters, re-exported under their own names
*/
export {
sessionAccountToSessionData,
sessionDataToSessionAccount,
} from './session-core'
export type ProxyHeaderValue = `${Did}#${AtprotoServiceType}`
export function createPublicAgent() {
configureModerationForGuest() // Side effect but only relevant for tests
const agent = new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent
}
export async function createAgentAndResume(
storedAccount: SessionAccount,
onSessionChange: (
agent: AtpAgent,
did: string,
event: AtpSessionEvent,
) => void,
) {
const agent = new BskyAppAgent({service: storedAccount.service})
if (storedAccount.pdsUrl) {
agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
}
const gates = features.refresh({
strategy: 'prefer-low-latency',
})
const moderation = configureModerationForAccount(agent, storedAccount)
const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
if (isSessionExpired(storedAccount)) {
await networkRetry(1, () => agent.resumeSession(prevSession))
} else {
agent.sessionManager.session = prevSession
}
// after session is attached
const aa = prefetchAgeAssuranceServerData({agent})
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent.prepare({
resolvers: [gates, moderation, aa],
onSessionChange,
})
}
export async function createAgentAndLogin(
{
service,
identifier,
password,
authFactorToken,
}: {
service: string
identifier: string
password: string
authFactorToken?: string
},
onSessionChange: (
agent: AtpAgent,
did: string,
event: AtpSessionEvent,
) => void,
) {
const agent = new BskyAppAgent({service})
await agent.login({
identifier,
password,
authFactorToken,
allowTakendown: true,
})
const account = agentToSessionAccountOrThrow(agent)
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(agent, account)
const aa = prefetchAgeAssuranceServerData({agent})
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent.prepare({
resolvers: [gates, moderation, aa],
onSessionChange,
})
}
export async function createAgentAndCreateAccount(
{
service,
email,
password,
handle,
birthDate,
inviteCode,
verificationPhone,
verificationCode,
}: {
service: string
email: string
password: string
handle: string
birthDate: Date
inviteCode?: string
verificationPhone?: string
verificationCode?: string
},
onSessionChange: (
agent: AtpAgent,
did: string,
event: AtpSessionEvent,
) => void,
) {
const agent = new BskyAppAgent({service})
await agent.createAccount({
email,
password,
handle,
inviteCode,
verificationPhone,
verificationCode,
})
const account = agentToSessionAccountOrThrow(agent)
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(agent, account)
const createdAt = new Date().toISOString()
const birthdate = birthDate.toISOString()
/*
* Since we have a race with account creation, profile creation, and AA
* state, set these values locally to ensure sync reads. Values are written
* to the server in the next step, so on subsequent reloads, the server will
* be the source of truth.
*/
setCreatedAtForDid({did: account.did, createdAt})
setBirthdateForDid({did: account.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(account.did)
// do this last
const aa = prefetchAgeAssuranceServerData({agent})
// Not awaited so that we can still get into onboarding.
// This is OK because we won't let you toggle adult stuff until you set the date.
if (IS_PROD_SERVICE(service)) {
void Promise.allSettled([
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthdate,
})
}).catch(e => {
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
next.displayName = handle
next.createdAt = createdAt
return next
})
}).catch(e => {
logger.info(
`createAgentAndCreateAccount: failed to set initial profile`,
)
throw e
}),
networkRetry(1, () => {
return agent.overwriteSavedFeeds([
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
},
{
...TIMELINE_SAVED_FEED,
id: TID.nextStr(),
},
])
}).catch(e => {
logger.info(`createAgentAndCreateAccount: failed to set initial feeds`)
throw e
}),
// wait for AA data to load first, then check state
aa.then(() => {
const {flags} = unsafeGetAndComputeAgeAssurance({did: account.did})
if (flags?.chatDisabled || flags?.groupChatDisabled) {
void restrictChatSettings({
agent,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
}
}),
]).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
)
}
})
} else {
void Promise.allSettled([
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthDate.toISOString(),
})
}).catch(e => {
logger.info(`createAgentAndCreateAccount: failed to set birthDate`)
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
next.createdAt = prev?.createdAt || new Date().toISOString()
return next
})
}).catch(e => {
logger.info(
`createAgentAndCreateAccount: failed to set initial profile`,
)
throw e
}),
]).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createAgentAndCreateAccount failed to save personal details and feeds`,
)
}
})
}
try {
// snooze first prompt after signup, defer to next prompt
snoozeEmailConfirmationPrompt()
} catch (e: any) {
logger.error(e, {message: `session: failed snoozeEmailConfirmationPrompt`})
}
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return agent.prepare({
resolvers: [gates, moderation, aa],
onSessionChange,
})
}
export function agentToSessionAccountOrThrow(agent: AtpAgent): SessionAccount {
const account = agentToSessionAccount(agent)
if (!account) {
throw Error('Expected an active session')
}
return account
}
export function agentToSessionAccount(
agent: AtpAgent,
): SessionAccount | undefined {
if (!agent.session) {
return undefined
}
return {
service: agent.serviceUrl.toString(),
did: agent.session.did,
handle: agent.session.handle,
email: agent.session.email,
emailConfirmed: agent.session.emailConfirmed || false,
emailAuthFactor: agent.session.emailAuthFactor || false,
refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt,
signupQueued: isSignupQueued(agent.session.accessJwt),
active: agent.session.active,
status: agent.session.status,
pdsUrl: agent.pdsUrl?.toString(),
isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE),
}
}
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,
}
/**
* The logged-out bridge agent, pointed at the public appview. Returns the
* public bundle's `agent` (a {@link SessionAgent}). Kept for `drafts/state/api`
* and any other public-read consumers.
*/
export function createPublicAgent(): SessionAgent {
return createPublicSessionBundle().agent
}
export class Agent extends BaseAgent {
@@ -344,74 +43,3 @@ export class Agent extends BaseAgent {
}
}
}
// Not exported. Use factories above to create it.
// WARN: In the factories above, we _manually set a proxy header_ for the agent after we do whatever it is we are supposed to do.
// Ideally, we wouldn't be doing this. However, since there is so much logic that requires making calls to the PDS right now, it
// feels safer to just let those run as-is and set the header afterward.
let realFetch = globalThis.fetch
class BskyAppAgent extends AtpAgent {
persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
undefined
constructor({service}: {service: string}) {
super({
service,
async fetch(...args) {
let success = false
try {
const result = await realFetch(...args)
success = true
return result
} catch (e) {
success = false
throw e
} finally {
if (success) {
emitNetworkConfirmed()
} else {
emitNetworkLost()
}
}
},
persistSession: (event: AtpSessionEvent) => {
if (this.persistSessionHandler) {
this.persistSessionHandler(event)
}
},
})
}
async prepare({
resolvers,
onSessionChange,
}: {
// Not awaited in the calling code so we can delay blocking on them.
resolvers: Promise<unknown>[]
onSessionChange: (
agent: AtpAgent,
did: string,
event: AtpSessionEvent,
) => void
}) {
// There's nothing else left to do, so block on them here.
await Promise.all(resolvers)
// Now the agent is ready.
const account = agentToSessionAccountOrThrow(this)
this.persistSessionHandler = event => {
onSessionChange(this, account.did, event)
if (event !== 'create' && event !== 'update') {
addSessionErrorLog(account.did, event)
}
}
return {account, agent: this}
}
dispose() {
this.sessionManager.session = undefined
this.persistSessionHandler = undefined
}
}
export type {BskyAppAgent}
+49 -2
View File
@@ -1,7 +1,10 @@
import {type AtpAgent} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {type PasswordSession} from '@atproto/lex-password-session'
import {api} from '@bsky.app/sdk'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {networkAwareFetch} from './session-core'
/**
* Stable per-agent cache of lex `Client` instances. We never reuse an
@@ -58,11 +61,55 @@ export function agentToLexClient(agent: AtpAgent): Client {
*/
let publicClient: Client | undefined
function getPublicLexClient(): Client {
publicClient ??= new Client(PUBLIC_BSKY_SERVICE)
export function getPublicLexClient(): Client {
/*
* Pass networkAwareFetch so the unauthenticated public path feeds the same
* reachability signal as the session-backed clients (see session-core).
*/
publicClient ??= new Client({
service: PUBLIC_BSKY_SERVICE,
fetch: networkAwareFetch,
})
return publicClient
}
/**
* Build the account (PDS) client over a {@link PasswordSession}. Writes and
* record mutations go here - no `atproto-proxy` header, so requests hit the
* user's PDS directly (the session's `fetchHandler` resolves the PDS origin
* per request from the didDoc, falling back to `service`).
*
* The session already owns its own `fetch` (networkAwareFetch, set at
* construction), so we intentionally do NOT pass `fetch` here: a `Client`
* built over an existing `Agent`/session uses that agent's fetch.
*/
export function buildAccountClient(session: PasswordSession): Client {
return new Client(session)
}
/**
* Build the authed appview client over a {@link PasswordSession}.
*
* Requests are proxied to the Bluesky appview (`atproto-proxy:
* did:web:api.bsky.app#bsky_appview`) and carry the per-instance labelers.
* The Bluesky moderation labeler (`api.moderation.did`) is always included as
* a base labeler because sending ANY `atproto-accept-labelers` header replaces
* the server-side default - so we must re-assert it to keep it active.
*/
export function buildAppviewClient(
session: PasswordSession,
labelerDids: string[],
): Client {
return new Client(session, {
service: api.app.service,
/* labelerDids are validated DID strings; cast to the DidString template type */
labelers: [
api.moderation.did,
...labelerDids,
] as `did:${string}:${string}`[],
})
}
/**
* Unauthenticated lex {@link Client} for public appview reads. A process-wide
* singleton, so its identity is stable across renders.
+165 -61
View File
@@ -8,26 +8,34 @@ import {
useState,
useSyncExternalStore,
} from 'react'
import {type AtpAgent, type AtpSessionEvent} from '@atproto/api'
import {type AtpSessionEvent} from '@atproto/api'
import {type Client} from '@atproto/lex-client'
import {PasswordSession} from '@atproto/lex-password-session'
import * as persisted from '#/state/persisted'
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 {
agentToSessionAccount,
type BskyAppAgent,
createAgentAndCreateAccount,
createAgentAndLogin,
createAgentAndResume,
sessionAccountToSession,
} from './agent'
import {getPublicLexClient} from './clients'
import {type Action, getInitialState, reducer, type State} from './reducer'
import {
buildBundle,
createSessionBundleAndCreateAccount,
createSessionBundleAndLogin,
createSessionBundleAndResume,
disposeBundle,
makeSessionHooks,
type PublicSessionBundle,
sessionAccountToSessionData,
type SessionAgent,
type SessionBundle,
sessionDataToSessionAccount,
} from './session-core'
export {type SessionAgent} from './session-core'
export {isSignupQueued} from './util'
import {agentToLexClient} from './clients'
import {addSessionDebugLog} from './logging'
export type {SessionAccount} from '#/state/session/types'
@@ -49,9 +57,21 @@ const StateContext = createContext<SessionStateContext>({
})
StateContext.displayName = 'SessionStateContext'
const AgentContext = createContext<AtpAgent | null>(null)
const AgentContext = createContext<SessionAgent | null>(null)
AgentContext.displayName = 'SessionAgentContext'
/**
* Holds the full {@link SessionBundle} (or the logged-out
* {@link PublicSessionBundle}) for the active account. The three-client hooks
* (`useLexClient`/`useAppviewClient`/`usePdsClient`) read from here, while
* `useAgent()` continues to read the bridge agent from {@link AgentContext}
* (which is just `bundle.agent`).
*/
const BundleContext = createContext<SessionBundle | PublicSessionBundle | null>(
null,
)
BundleContext.displayName = 'SessionBundleContext'
const ApiContext = createContext<SessionApiContext>({
createAccount: async () => {},
login: async () => {},
@@ -113,14 +133,36 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const onboardingDispatch = useOnboardingDispatch()
const onAgentSessionChange = useCallback(
(agent: AtpAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
const refreshedAccount = agentToSessionAccount(agent) // Mutable, so snapshot it right away.
(
agent: SessionAgent,
accountDid: string,
sessionEvent: AtpSessionEvent,
) => {
// Snapshot the (mutable) live session data right away.
const refreshedAccount = agent.session
? sessionDataToSessionAccount(agent.session, agent.session.service)
: undefined
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
emitSessionDropped()
}
/*
* The reducer stores the whole bundle as `currentAgentState.agent` and
* compares `action.agent` by identity to decide whether an expiry/error
* belongs to the active account (background accounts must not be able to
* log the current user out). The hook hands us the SessionAgent that
* fired; map it back to the current bundle when it is the active one, and
* otherwise pass the SessionAgent itself as a distinct, non-matching token
* so the reducer's guard ignores clears for background accounts - matching
* the pre-migration semantics exactly.
*/
const stored = store.getState().currentAgentState
.agent as unknown as SessionBundle
const eventAgent = (stored.agent === agent
? stored
: agent) as unknown as SessionBundle
store.dispatch({
type: 'received-agent-event',
agent,
agent: eventAgent,
refreshedAccount,
accountDid,
sessionEvent,
@@ -134,7 +176,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
addSessionDebugLog({type: 'method:start', method: 'createAccount'})
const signal = cancelPendingTask()
ax.metric('account:create:begin', {})
const {agent, account} = await createAgentAndCreateAccount(
const {bundle, account} = await createSessionBundleAndCreateAccount(
params,
onAgentSessionChange,
)
@@ -144,7 +186,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
store.dispatch({
type: 'switched-to-account',
newAgent: agent,
newAgent: bundle,
newAccount: account,
})
ax.metric('account:create:success', metrics, {
@@ -159,7 +201,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
async (params, logContext) => {
addSessionDebugLog({type: 'method:start', method: 'login'})
const signal = cancelPendingTask()
const {agent, account} = await createAgentAndLogin(
const {bundle, account} = await createSessionBundleAndLogin(
params,
onAgentSessionChange,
)
@@ -169,7 +211,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
store.dispatch({
type: 'switched-to-account',
newAgent: agent,
newAgent: bundle,
newAccount: account,
})
ax.metric(
@@ -256,7 +298,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
account: storedAccount,
})
const signal = cancelPendingTask()
const {agent, account} = await createAgentAndResume(
const {bundle, account} = await createSessionBundleAndResume(
storedAccount,
onAgentSessionChange,
)
@@ -266,7 +308,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
store.dispatch({
type: 'switched-to-account',
newAgent: agent,
newAgent: bundle,
newAccount: account,
})
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
@@ -281,13 +323,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const partialRefreshSession = useCallback<
SessionApiContext['partialRefreshSession']
>(async () => {
const agent = state.currentAgentState.agent as BskyAppAgent
const bundle = state.currentAgentState.agent as unknown as SessionBundle
const signal = cancelPendingTask()
const {data} = await agent.com.atproto.server.getSession()
/*
* Fetch through the account (PDS) client and dispatch the patch. We do NOT
* mutate the session object anymore (PasswordSession's data is immutable to
* us); the reducer patches only the `accounts` entry, and the email-state
* hook reads from the account rather than `agent.session` (Task 7).
* `client.call` returns the response body directly (no `{data}` wrapper).
*/
const data = await bundle.accountClient.call(com.atproto.server.getSession)
if (signal.aborted) return
store.dispatch({
type: 'partial-refresh-session',
accountDid: agent.session!.did,
accountDid: bundle.agent.session!.did,
patch: {
emailConfirmed: data.emailConfirmed,
emailAuthFactor: data.emailAuthFactor,
@@ -334,20 +383,45 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
*/
void resumeSession(syncedAccount)
} else {
const agent = state.currentAgentState.agent as AtpAgent
const prevSession = agent.session
// eslint-disable-next-line react-compiler/react-compiler
agent.sessionManager.session = sessionAccountToSession(syncedAccount)
/*
* Same account, new tokens synced from the leader tab. PasswordSession
* is immutable (no in-place session patch like the old
* `agent.sessionManager.session = ...`), so rebuild a fresh bundle
* from the synced tokens WITHOUT a network call (the leader already
* refreshed) and swap it in via `replaced-current-bundle`. The
* bundle-identity effect disposes the previous session once it swaps,
* which strengthens the single-refresher guarantee (the stale-token
* session can no longer refresh).
*/
const prevBundle = state.currentAgentState
.agent as unknown as SessionBundle
let newBundle!: SessionBundle
const hooks = makeSessionHooks(
onAgentSessionChange,
() => newBundle.agent,
() => syncedAccount.did,
)
const newSession = new PasswordSession(
sessionAccountToSessionData(syncedAccount),
hooks,
)
newBundle = buildBundle(newSession)
hooks.arm()
addSessionDebugLog({
type: 'agent:patch',
agent,
prevSession,
nextSession: agent.session,
agent: newBundle.agent,
prevSession: prevBundle.agent.session,
nextSession: newBundle.agent.session,
})
store.dispatch({
type: 'replaced-current-bundle',
newAgent: newBundle,
newAccount: syncedAccount,
})
}
}
})
}, [store, state, resumeSession])
}, [store, state, resumeSession, onAgentSessionChange])
const stateContext = useMemo(
() => ({
@@ -381,38 +455,48 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
],
)
const bundle = state.currentAgentState.agent as unknown as
| SessionBundle
| PublicSessionBundle
const agent = bundle.agent
// @ts-expect-error window type is not declared, debug only
// eslint-disable-next-line react-hooks/immutability
if (__DEV__ && IS_WEB) window.agent = state.currentAgentState.agent
if (__DEV__ && IS_WEB) window.agent = agent
const agent = state.currentAgentState.agent as BskyAppAgent
const currentAgentRef = useRef(agent)
const currentBundleRef = useRef(bundle)
useEffect(() => {
if (currentAgentRef.current !== agent) {
if (currentBundleRef.current !== bundle) {
// Read the previous value and immediately advance the pointer.
const prevAgent = currentAgentRef.current
currentAgentRef.current = agent
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
// We never reuse agents so let's fully neutralize the previous one.
// This ensures it won't try to consume any refresh tokens.
prevAgent.dispose()
const prevBundle = currentBundleRef.current
currentBundleRef.current = bundle
addSessionDebugLog({
type: 'agent:switch',
prevAgent: prevBundle.agent,
nextAgent: bundle.agent,
})
// We never reuse bundles so let's fully neutralize the previous one.
// This ensures its session won't try to consume any refresh tokens.
disposeBundle(prevBundle)
}
}, [agent])
}, [bundle])
return (
<AgentContext.Provider value={agent}>
<StateContext.Provider value={stateContext}>
<ApiContext.Provider value={api}>
<AnalyticsContext
metadata={utils.useMeta({
session: utils.accountToSessionMetadata(
stateContext.currentAccount,
),
})}>
{children}
</AnalyticsContext>
</ApiContext.Provider>
</StateContext.Provider>
<BundleContext.Provider value={bundle}>
<StateContext.Provider value={stateContext}>
<ApiContext.Provider value={api}>
<AnalyticsContext
metadata={utils.useMeta({
session: utils.accountToSessionMetadata(
stateContext.currentAccount,
),
})}>
{children}
</AnalyticsContext>
</ApiContext.Provider>
</StateContext.Provider>
</BundleContext.Provider>
</AgentContext.Provider>
)
}
@@ -455,7 +539,7 @@ export function useRequireAuth() {
)
}
export function useAgent(): AtpAgent {
export function useAgent(): SessionAgent {
const agent = useContext(AgentContext)
if (!agent) {
throw Error('useAgent() must be below <SessionProvider>.')
@@ -464,12 +548,32 @@ export function useAgent(): AtpAgent {
}
/**
* Authenticated lex {@link Client} wrapping the current session agent. Stable
* per-agent, so it only changes identity when the active account changes.
*
* @see agentToLexClient for how the AtpAgent is bridged to the lex Client.
* Authenticated lex {@link Client} for appview reads. Backed by the active
* bundle's appview client (proxied to the Bluesky appview, with labelers). Its
* identity is stable per-bundle, so it only changes when the active account
* changes. Falls back to the public client when there is no bundle (logged out,
* or used outside the provider) so callers can treat it as always-present.
*/
export function useLexClient(): Client {
const agent = useAgent()
return agentToLexClient(agent)
const bundle = useContext(BundleContext)
return bundle?.appviewClient ?? getPublicLexClient()
}
/**
* Alias of {@link useLexClient}: the authenticated appview client for the
* active account.
*/
export function useAppviewClient(): Client {
const bundle = useContext(BundleContext)
return bundle?.appviewClient ?? getPublicLexClient()
}
/**
* The account (PDS) lex {@link Client} for the active account. Writes and record
* mutations go here - requests hit the user's PDS directly (no appview proxy).
* Falls back to the public client when there is no bundle.
*/
export function usePdsClient(): Client {
const bundle = useContext(BundleContext)
return bundle?.accountClient ?? getPublicLexClient()
}
+4 -3
View File
@@ -1,4 +1,5 @@
import {type AtpSessionData, type AtpSessionEvent} from '@atproto/api'
import {type AtpSessionEvent} from '@atproto/api'
import {type SessionData} from '@atproto/lex-password-session'
import {type Schema} from '../persisted'
import {type Action, type State} from './reducer'
@@ -53,8 +54,8 @@ type Log =
| {
type: 'agent:patch'
agent: object
prevSession: AtpSessionData | undefined
nextSession: AtpSessionData | undefined
prevSession: SessionData | undefined
nextSession: SessionData | undefined
}
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
+66 -12
View File
@@ -1,10 +1,40 @@
import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api'
import {Agent, BSKY_LABELER_DID} from '@atproto/api'
import {Client} from '@atproto/lex-client'
import {api} from '@bsky.app/sdk'
import {IS_TEST_USER} from '#/lib/constants'
import {com} from '#/lexicons'
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
import {readLabelers} from './agent-config'
import {type SessionBundle} from './session-core'
import {type SessionAccount} from './types'
/*
* The Bluesky moderation labeler DID. `BSKY_LABELER_DID` (from '@atproto/api')
* and `api.moderation.did` (from '@bsky.app/sdk') are the SAME value -
* `did:plc:ar7c4by46qjdydhdevvrndac` - verified at implementation. We keep
* using `BSKY_LABELER_DID` for the global appLabelers config and the
* per-account filter (matching the old code) and `api.moderation.did` as the
* appview client's base labeler (matching `buildAppviewClient`); both resolve
* to identical `atproto-accept-labelers` headers.
*/
/**
* Set the global app labelers on BOTH request paths so they emit identical
* `atproto-accept-labelers` headers.
*
* The migration runs two live request stacks this phase: lex `Client`s (whose
* global labelers live on the static `Client.appLabelers`) and the bridge
* `SessionAgent`, a base `Agent` from '@atproto/api' (whose global labelers
* live on the static `Agent.appLabelers`, NOT `AtpAgent`'s). We must configure
* both so a request routed through either path carries the same global
* (`;redact`-suffixed) labelers.
*/
function configureGlobalAppLabelers(dids: string[]) {
Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
Agent.configure({appLabelers: dids})
}
export function configureModerationForGuest() {
// This global mutation is *only* OK because this code is only relevant for testing.
// Don't add any other global behavior here!
@@ -12,23 +42,41 @@ export function configureModerationForGuest() {
configureAdditionalModerationAuthorities()
}
/**
* Configure moderation labelers for a signed-in account.
*
* Takes the whole {@link SessionBundle} because per-account labelers must be
* applied to BOTH live request paths: the bridge agent (`bundle.agent`, still
* used by `useAgent()` consumers) and the authed appview client
* (`bundle.appviewClient`, backing `useLexClient()`).
*/
export async function configureModerationForAccount(
agent: AtpAgent,
bundle: SessionBundle,
account: SessionAccount,
) {
// This global mutation is *only* OK because this code is only relevant for testing.
// Don't add any other global behavior here!
switchToBskyAppLabeler()
if (IS_TEST_USER(account.handle)) {
await trySwitchToTestAppLabeler(agent)
await trySwitchToTestAppLabeler(bundle)
}
// The code below is actually relevant to production (and isn't global).
const labelerDids = await readLabelers(account.did).catch(_ => {})
if (labelerDids) {
agent.configureLabelersHeader(
labelerDids.filter(did => did !== BSKY_LABELER_DID),
)
const perAccount = labelerDids.filter(did => did !== BSKY_LABELER_DID)
/*
* Apply the per-account labelers to both live request paths. The appview
* client re-asserts the Bluesky moderation labeler as its base because
* sending ANY `atproto-accept-labelers` header replaces the server-side
* default - `setLabelers` clears then re-adds, so the moderation DID must
* be included explicitly to stay active.
*/
bundle.agent.configureLabelers(perAccount)
bundle.appviewClient.setLabelers([
api.moderation.did,
...perAccount,
] as `did:${string}:${string}`[])
} 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.
@@ -38,17 +86,23 @@ export async function configureModerationForAccount(
}
function switchToBskyAppLabeler() {
AtpAgent.configure({appLabelers: [BSKY_LABELER_DID]})
configureGlobalAppLabelers([BSKY_LABELER_DID])
}
async function trySwitchToTestAppLabeler(agent: AtpAgent) {
/**
* In the test environment, swap the global app labeler for the test-env
* moderation authority. The handle is resolved via the bundle's authed appview
* client; `client.call` returns the response body directly (no `{data}`
* wrapper), so `resolveHandle`'s output is `{did}`.
*/
async function trySwitchToTestAppLabeler(bundle: SessionBundle) {
const did = (
await agent
.resolveHandle({handle: 'mod-authority.test'})
await bundle.appviewClient
.call(com.atproto.identity.resolveHandle, {handle: 'mod-authority.test'})
.catch(_ => undefined)
)?.data.did
)?.did
if (did) {
console.warn('USING TEST ENV MODERATION')
AtpAgent.configure({appLabelers: [did]})
configureGlobalAppLabelers([did])
}
}
+46 -25
View File
@@ -1,23 +1,25 @@
import {type AtpAgent, type AtpSessionEvent} from '@atproto/api'
import {type AtpSessionEvent} from '@atproto/api'
import {unregisterPushToken} from '#/lib/notifications/notifications'
import {logger} from '#/lib/notifications/util'
import {createPublicAgent} from './agent'
import {wrapSessionReducerForLogging} from './logging'
import {createPublicSessionBundle} from './session-core'
import {type SessionAccount} from './types'
import {createTemporaryAgentsAndResume} from './util'
// A hack so that the reducer can't read anything from the agent.
// From the reducer's point of view, it should be a completely opaque object.
type OpaqueBskyAgent = {
/*
* A hack so that the reducer can't read anything from the session bundle. From
* the reducer's point of view it is a completely opaque object; the only field
* it ever reads is `service` (a URL), used for logging/snapshots. The provider
* stores the full `SessionBundle` here, but the reducer's static type only sees
* `service` (structural: the bundle has more, the reducer sees less).
*/
type OpaqueSessionBundle = {
readonly service: URL
readonly api: unknown
readonly app: unknown
readonly com: unknown
}
type AgentState = {
readonly agent: OpaqueBskyAgent
readonly agent: OpaqueSessionBundle
readonly did: string | undefined
}
@@ -30,14 +32,26 @@ export type State = {
export type Action =
| {
type: 'received-agent-event'
agent: OpaqueBskyAgent
agent: OpaqueSessionBundle
accountDid: string
refreshedAccount: SessionAccount | undefined
sessionEvent: AtpSessionEvent
}
| {
type: 'switched-to-account'
newAgent: OpaqueBskyAgent
newAgent: OpaqueSessionBundle
newAccount: SessionAccount
}
| {
/*
* Same-did cross-tab sync. `PasswordSession` cannot be patched in place,
* so the provider builds a fresh bundle from the synced tokens (no
* network - the leader tab already refreshed) and swaps it in, keeping
* the current did and replacing the matching account entry. Does not
* persist (synced from another tab, avoid write cycles).
*/
type: 'replaced-current-bundle'
newAgent: OpaqueSessionBundle
newAccount: SessionAccount
}
| {
@@ -63,7 +77,7 @@ export type Action =
function createPublicAgentState(): AgentState {
return {
agent: createPublicAgent(),
agent: createPublicSessionBundle(),
did: undefined,
}
}
@@ -138,6 +152,20 @@ let reducer = (state: State, action: Action): State => {
needsPersist: true,
}
}
case 'replaced-current-bundle': {
const {newAgent, newAccount} = action
return {
...state,
currentAgentState: {
did: state.currentAgentState.did,
agent: newAgent,
},
accounts: state.accounts.map(a =>
a.did === newAccount.did ? newAccount : a,
),
needsPersist: false, // Synced from another tab. Don't persist to avoid cycles.
}
}
case 'removed-account': {
const {accountDid} = action
@@ -233,24 +261,17 @@ let reducer = (state: State, action: Action): State => {
}
case 'partial-refresh-session': {
const {accountDid, patch} = action
const agent = state.currentAgentState.agent as AtpAgent
/*
* Only mutating values that are safe. Be very careful with this.
* Previously this also mutated `agent.session.emailConfirmed/
* emailAuthFactor` in place. `PasswordSession` has no public session
* setter and mutating its returned object is fragile, so we now patch
* only the account entry. Consumers that read these fields
* (useAccountEmailState) read from `currentAccount` instead of
* `agent.session` (see phase-2 design doc section 5).
*/
if (agent.session) {
agent.session.emailConfirmed =
patch.emailConfirmed ?? agent.session.emailConfirmed
agent.session.emailAuthFactor =
patch.emailAuthFactor ?? agent.session.emailAuthFactor
}
return {
...state,
currentAgentState: {
...state.currentAgentState,
agent,
},
accounts: state.accounts.map(a => {
if (a.did === accountDid) {
return {
+878
View File
@@ -0,0 +1,878 @@
import {
Agent,
type AppBskyActorProfile,
type AtpSessionEvent,
type Un$Typed,
} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {type Client} from '@atproto/lex-client'
import {
PasswordSession,
type PasswordSessionOptions,
type SessionData,
} from '@atproto/lex-password-session'
import {jwtDecode} from 'jwt-decode'
import {networkRetry} from '#/lib/async/retry'
import {
BLUESKY_PROXY_HEADER,
BSKY_SERVICE,
DISCOVER_SAVED_FEED,
IS_PROD_SERVICE,
PUBLIC_BSKY_SERVICE,
TIMELINE_SAVED_FEED,
} from '#/lib/constants'
import {hasProp} from '#/lib/type-guards'
import {logger} from '#/logger'
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {emitNetworkConfirmed, emitNetworkLost} from '#/state/events'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
prefetchAgeAssuranceServerData,
setBirthdateForDid,
setCreatedAtForDid,
} from '#/ageAssurance/data'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {features} from '#/analytics'
import {
buildAccountClient,
buildAppviewClient,
getPublicLexClient,
} from './clients'
import {addSessionErrorLog} from './logging'
import {
configureModerationForAccount,
configureModerationForGuest,
} from './moderation'
import {type SessionAccount} from './types'
import {isSessionExpired} from './util'
/**
* Whether an access token was issued for a queued (waitlisted) signup rather
* than a full session.
*
* Canonical implementation - util.ts re-exports it. It lives here (rather
* than util.ts) so this module stays dependency-light: util.ts pulls in
* agent.ts and, transitively, a large chunk of the app.
*/
export function isSignupQueued(accessJwt: string | undefined) {
if (accessJwt) {
const sessData = jwtDecode(accessJwt)
return (
hasProp(sessData, 'scope') &&
sessData.scope === 'com.atproto.signupQueued'
)
}
return false
}
/*
* Captured once at module load so that the wrapper below is immune to later
* monkey-patching of globalThis.fetch (mirrors the old BskyAppAgent fetch).
*/
const realFetch = globalThis.fetch
/**
* Fetch wrapper that reports network reachability to the app-wide event bus.
* Any resolved response (including HTTP errors) confirms the network is up; a
* thrown error (DNS failure, timeout, offline) reports it as lost.
*
* This replaces the custom `fetch` previously passed to `BskyAppAgent`. It is
* intended to be passed as `PasswordSessionOptions.fetch` and as the `fetch`
* option of unauthenticated lex `Client`s, so every network path in the
* session stack feeds the same reachability signal.
*/
export const networkAwareFetch: typeof fetch = async (...args) => {
try {
const res = await realFetch(...args)
emitNetworkConfirmed()
return res
} catch (e) {
emitNetworkLost()
throw e
}
}
/**
* Extract the PDS endpoint URL from a DID document, if present and valid.
*
* Local reimplementation of `@atproto/lex-password-session`'s private
* `extractPdsUrl` util (it lives in a non-exported module, so we cannot import
* it). Must stay behaviorally identical: `PasswordSession.fetchHandler`
* derives its request origin as `extractPdsUrl(didDoc) ?? service`, and we use
* this same derivation to persist `pdsUrl` on the account snapshot.
*/
export function extractPdsUrl(didDoc: unknown): string | null {
if (typeof didDoc !== 'object' || didDoc === null) {
return null
}
const services = (didDoc as Record<string, unknown>).service
if (!Array.isArray(services)) {
return null
}
const pds = (services as unknown[]).find(
(s): s is Record<string, unknown> => {
if (typeof s !== 'object' || s === null) {
return false
}
const id = (s as Record<string, unknown>).id
return typeof id === 'string' && id.endsWith('#atproto_pds')
},
)
const ep = pds?.serviceEndpoint
return typeof ep === 'string' && canParseUrl(ep) ? ep : null
}
/*
* URL.canParse is not guaranteed on Hermes / the RN URL polyfill, so fall back
* to a try/catch parse when it is missing.
*/
function canParseUrl(input: string): boolean {
if (typeof URL.canParse === 'function') {
return URL.canParse(input)
}
try {
new URL(input)
return true
} catch {
return false
}
}
/**
* Build a minimal synthetic DID document whose only service entry is the
* given PDS endpoint.
*
* Why: the persisted `SessionAccount` stores `pdsUrl` but `SessionData` routes
* requests via `extractPdsUrl(didDoc) ?? service`. On the non-expired resume
* fast path (no network), we synthesize this doc from the stored `pdsUrl` so
* the very first requests hit the right PDS (entryway accounts have
* service=bsky.social but a different PDS host). After the first refresh,
* `PasswordSession` refetches `getSession` and replaces this with the real
* DID document.
*/
export function synthDidDoc(
did: string,
pdsUrl: string,
): NonNullable<SessionData['didDoc']> {
return {
id: did,
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: pdsUrl,
},
],
}
}
/**
* Convert live `PasswordSession` session data into the persisted
* `SessionAccount` snapshot.
*
* Replaces `agentToSessionAccount`. The object literal's field ORDER must
* match the old `agentToSessionAccount` exactly - the reducer's
* `JSON.stringify` fast path and the session test snapshots depend on
* byte-stable serialization. `service` is normalized through `new URL()` to
* keep the trailing slash the old `agent.serviceUrl.toString()` produced, and
* `pdsUrl` likewise (the old code read `agent.pdsUrl?.toString()`, a URL).
*
* `pdsUrl` intentionally does NOT fall back to `service`: hosted accounts
* (no didDoc PDS entry) keep `pdsUrl: undefined`, matching the old behavior.
*/
export function sessionDataToSessionAccount(
session: SessionData | null | undefined,
service: string,
): SessionAccount | undefined {
if (!session) {
return undefined
}
const normalizedService = new URL(service).toString()
const pdsUrl = extractPdsUrl(session.didDoc)
return {
service: normalizedService,
did: session.did,
handle: session.handle,
email: session.email,
emailConfirmed: session.emailConfirmed || false,
emailAuthFactor: session.emailAuthFactor || false,
refreshJwt: session.refreshJwt,
accessJwt: session.accessJwt,
signupQueued: isSignupQueued(session.accessJwt),
active: session.active,
status: session.status,
pdsUrl: pdsUrl ? new URL(pdsUrl).toString() : undefined,
isSelfHosted: !normalizedService.startsWith(BSKY_SERVICE),
}
}
/**
* Convert a persisted `SessionAccount` back into `SessionData` for
* constructing/resuming a `PasswordSession`.
*
* Replaces `sessionAccountToSession`. Field order mirrors the shape returned
* by the server (roughly alphabetical, matching the old function). When the
* account has a stored `pdsUrl`, a synthetic didDoc is injected so
* `PasswordSession` routes requests to the right PDS before its first refresh
* (see {@link synthDidDoc}).
*/
export function sessionAccountToSessionData(
account: SessionAccount,
): SessionData {
return {
accessJwt: account.accessJwt ?? '',
active: account.active ?? true,
did: account.did as SessionData['did'],
...(account.pdsUrl
? {didDoc: synthDidDoc(account.did, account.pdsUrl)}
: {}),
email: account.email,
emailAuthFactor: account.emailAuthFactor,
emailConfirmed: account.emailConfirmed,
handle: account.handle as SessionData['handle'],
refreshJwt: account.refreshJwt ?? '',
status: account.status,
service: account.service,
}
}
/**
* Read `session.did` without throwing.
*
* `PasswordSession.did` throws `Error('Logged out')` once the session is
* destroyed, but base `Agent`'s `did` getter must never throw (it is read all
* over the app, including by late readers after logout). This returns
* `undefined` for a destroyed/absent session.
*/
function safeDid(
session: PasswordSession | null,
): SessionData['did'] | undefined {
if (!session || session.destroyed) {
return undefined
}
return session.did
}
/**
* The legacy bridge agent returned by `useAgent()`.
*
* It is a real base `Agent` (from `@atproto/api`) whose fetch layer is a
* `PasswordSession` - reproducing today's exact two-layer model: base-Agent
* proxy/labeler layer on top, `PasswordSession` auth+refresh layer underneath.
* On top of that it adds a small CredentialSession-compat shim (`session`,
* `serviceUrl`, `pdsUrl`, `dispatchUrl`, `resumeSession`, `sessionManager`) so
* the ~28 `.session` reads and 6 `resumeSession` callers across the app compile
* and behave unchanged without a call-site migration.
*
* `#session` is null for the logged-out/public agent.
*/
/**
* The CredentialSession-compat facade exposed as `SessionAgent.sessionManager`.
* A handful of sites read `agent.sessionManager.{did,fetchHandler,
* refreshSession,session}` directly (birthdate.ts, ExportCarDialog,
* ageAssurance/data); this is a live view over the underlying
* `PasswordSession`.
*/
type SessionManagerFacade = {
readonly did: string | undefined
fetchHandler: (path: string, init: RequestInit) => Promise<Response>
refreshSession: () => Promise<SessionData>
readonly session: SessionData | undefined
}
/**
* Build the sessionManager facade for a session (or the logged-out fallback).
* This object is passed straight to base `Agent`'s constructor as its
* `SessionManager`, so base's request path (`this.sessionManager.fetchHandler`)
* and `did` getter route through it - and the richer members
* (`refreshSession`/`session`) are available to the hard-tail consumers.
*/
function makeSessionManagerFacade(
session: PasswordSession | null,
): SessionManagerFacade {
const s = session
return {
get did() {
return safeDid(s)
},
fetchHandler: (path: string, init: RequestInit) =>
(s ?? getPublicLexClient()).fetchHandler(path as `/${string}`, init),
refreshSession: () => s!.refresh(),
get session() {
return s && !s.destroyed ? s.session : undefined
},
}
}
/*
* Declaration merging: widen the inherited `sessionManager` (base types it as
* the minimal `SessionManager`) to the richer facade the shim actually stores.
* This exposes `refreshSession`/`session` to the hard-tail consumers
* (birthdate.ts, ExportCarDialog, ageAssurance/data) without a property vs
* accessor override (which TS/babel reject - risk #3 in the design doc). The
* facade is genuinely assigned via super(makeSessionManagerFacade(...)), so the
* merge is sound despite the generic lint warning.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface SessionAgent {
readonly sessionManager: SessionManagerFacade
}
export class SessionAgent extends Agent {
#session: PasswordSession | null
constructor(session: PasswordSession | null) {
/*
* The facade IS base Agent's SessionManager: base routes every request
* through `this.sessionManager.fetchHandler` and reads `did` from it. For a
* logged-out agent the facade routes to the public client (same public
* appview service), so base's proxy/labeler layer still applies on top,
* matching the old createPublicAgent behavior.
*/
super(makeSessionManagerFacade(session))
this.#session = session
}
/**
* The live `SessionData` for the current session, or `undefined` when logged
* out. Reads through to the `PasswordSession`, so `.did`/`.handle`/`.email`
* etc. are always current.
*/
get session(): SessionData | undefined {
return this.#session && !this.#session.destroyed
? this.#session.session
: undefined
}
/** The account's service (entryway) URL. */
get serviceUrl(): URL {
return new URL(
this.#session && !this.#session.destroyed
? this.#session.session.service
: PUBLIC_BSKY_SERVICE,
)
}
/**
* The PDS URL derived from the session's didDoc, or `undefined` when there is
* no didDoc PDS entry (hosted accounts) - matching the old
* `agent.pdsUrl?.toString()` semantics.
*/
get pdsUrl(): URL | undefined {
if (!this.#session || this.#session.destroyed) {
return undefined
}
const pds = extractPdsUrl(this.#session.session.didDoc)
return pds ? new URL(pds) : undefined
}
/**
* The URL requests are dispatched to: the PDS if known, else the service.
* Matches AtpAgent's `dispatchUrl` semantics.
*/
get dispatchUrl(): URL {
return this.pdsUrl ?? this.serviceUrl
}
/**
* CredentialSession-compat: force a refresh and return an AtpAgent-shaped
* result. The argument (the old `agent.session`) is ignored - the session
* already knows its own tokens.
*/
async resumeSession(_?: unknown) {
await this.#session!.refresh()
return {success: true as const, data: this.#session!.session}
}
}
/**
* The full set of read-through views over ONE `PasswordSession`. The
* `session` is the sole auth core (single refresher); the `agent` and both
* clients never refresh independently.
*/
export type SessionBundle = {
/** The single auth core. Never exposed to the reducer. */
session: PasswordSession
/** Legacy bridge agent for `useAgent()` consumers. */
agent: SessionAgent
/** Account (writes/records) client - talks to the user's PDS. */
accountClient: Client
/** Authed appview client (proxied, with labelers). */
appviewClient: Client
/**
* The service (entryway) URL, mirroring `agent.serviceUrl`. Exposed so the
* reducer can read `.service` for its opaque snapshot/logging view
* (`OpaqueSessionBundle = {readonly service: URL}`) without reaching into the
* agent or the (never-exposed) session.
*/
readonly service: URL
}
/**
* Assemble a {@link SessionBundle} from a live session: the bridge agent plus
* the account and appview clients, all read-through views over the one session.
* The Bluesky appview proxy header is applied to the bridge (matching the old
* `agent.configureProxy(BLUESKY_PROXY_HEADER.get())`).
*/
export function buildBundle(session: PasswordSession): SessionBundle {
const agent = new SessionAgent(session)
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
return {
session,
agent,
accountClient: buildAccountClient(session),
/*
* Per-account labelers are applied to the bridge agent by
* configureModerationForAccount for now; the appview client carries only
* the base Bluesky moderation labeler. TODO(phase-2 moderation task):
* rework moderation.ts to take the bundle and set per-account labelers on
* appviewClient too.
*/
appviewClient: buildAppviewClient(session, []),
/*
* Mirror the bridge agent's serviceUrl so the reducer's opaque view can
* read `.service`. A getter keeps it live with the agent's derivation.
*/
get service() {
return agent.serviceUrl
},
}
}
/**
* The session-change events the reducer speaks. `PasswordSession` surfaces
* three hooks (`onUpdated`/`onDeleted`/`onUpdateFailure`) which we map into
* this `AtpSessionEvent` vocabulary (see the table in the phase-2 design doc):
* refresh -> `'update'`, dead session/logout -> `'expired'`, transient failure
* -> `'network-error'`. `'create'`/`'create-failed'` remain in the type for the
* reducer/tests but are never emitted from here in production.
*/
type OnSessionChange = (
agent: SessionAgent,
did: string,
event: AtpSessionEvent,
) => void
/**
* Build the `PasswordSession` hooks with an arm latch.
*
* `PasswordSession` fires `onUpdated` once during login/resume/createAccount
* (before the factory returns). We must NOT dispatch that initial event to the
* reducer - it corresponds to today's dropped `'create'` event, which never
* reached the reducer because `persistSessionHandler` was still undefined
* during `prepare()`. So hooks are inert until `arm()` is called, after the
* prepare tail resolves.
*
* `getAgent` is deferred because the bridge agent does not exist yet when the
* hooks are constructed (the session is created first).
*
* Exported for testing (the arm-latch + event mapping is the core semantics).
*/
export function makeSessionHooks(
onSessionChange: OnSessionChange,
getAgent: () => SessionAgent,
getDid: () => string,
) {
let armed = false
const dispatch = (event: AtpSessionEvent) => {
if (!armed) {
return
}
const did = getDid()
onSessionChange(getAgent(), did, event)
/*
* Mirror the old BskyAppAgent.prepare wiring: log any non-create/update
* session event. In practice we only emit 'update'/'expired'/'network-error'
* here, so this logs the error-ish ones.
*/
if (event !== 'create' && event !== 'update') {
addSessionErrorLog(did, event)
}
}
const hooks: PasswordSessionOptions = {
fetch: networkAwareFetch,
onUpdated() {
dispatch('update')
},
onDeleted() {
dispatch('expired')
},
onUpdateFailure() {
dispatch('network-error')
},
}
return Object.assign(hooks, {
arm() {
armed = true
},
})
}
/**
* The public (logged-out) bundle. Its bridge agent points at the public
* appview and all clients are unauthenticated.
*/
export type PublicSessionBundle = {
session: null
agent: SessionAgent
accountClient: Client
appviewClient: Client
/** Mirrors `agent.serviceUrl` (the public appview URL). See {@link SessionBundle.service}. */
readonly service: URL
}
/**
* Build the logged-out bundle used before/without a session. Mirrors the old
* `createPublicAgent`: configures guest moderation as a side effect and applies
* the Bluesky appview proxy header to the bridge agent.
*/
export function createPublicSessionBundle(): PublicSessionBundle {
configureModerationForGuest() // Side effect but only relevant for tests
const agent = new SessionAgent(null)
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
const publicClient = getPublicLexClient()
return {
session: null,
agent,
accountClient: publicClient,
appviewClient: publicClient,
get service() {
return agent.serviceUrl
},
}
}
/**
* Resume a stored account into a {@link SessionBundle}.
*
* Preserves the old `createAgentAndResume` behavior: prefer-low-latency gates
* refresh (not awaited up front), a network resume with one retry for expired
* sessions, and a synchronous no-network fast path for still-valid stored
* tokens. The session hooks are armed only after the prepare tail resolves.
*/
export async function createSessionBundleAndResume(
storedAccount: SessionAccount,
onSessionChange: OnSessionChange,
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
const gates = features.refresh({strategy: 'prefer-low-latency'})
let bundle!: SessionBundle
const hooks = makeSessionHooks(
onSessionChange,
() => bundle.agent,
() => storedAccount.did,
)
let session: PasswordSession
const sessionData = sessionAccountToSessionData(storedAccount)
if (isSessionExpired(storedAccount)) {
/*
* Network resume (1 retry). resume() always refreshes; the initial
* onUpdated it fires is swallowed by the arm latch.
*/
session = await networkRetry(1, () =>
PasswordSession.resume(sessionData, hooks),
)
} else {
/*
* Sync fast path: trust the stored tokens, no network. Matches the old
* `agent.sessionManager.session = prev`.
*/
session = new PasswordSession(sessionData, hooks)
}
bundle = buildBundle(session)
const account =
sessionDataToSessionAccount(session.session, session.session.service) ??
storedAccount
const moderation = configureModerationForAccount(bundle, account)
const aa = prefetchAgeAssuranceServerData({
agent: bundle.agent,
})
await Promise.all([gates, moderation, aa])
hooks.arm()
return {account, bundle}
}
/**
* Log in with credentials and build a {@link SessionBundle}.
*
* Preserves `createAgentAndLogin`: `allowTakendown: true`, prefer-fresh-gates
* refresh, moderation + AA prefetch, and the deferred arm.
*/
export async function createSessionBundleAndLogin(
{
service,
identifier,
password,
authFactorToken,
}: {
service: string
identifier: string
password: string
authFactorToken?: string
},
onSessionChange: OnSessionChange,
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
let bundle!: SessionBundle
let accountDid = ''
const hooks = makeSessionHooks(
onSessionChange,
() => bundle.agent,
() => accountDid,
)
const session = await PasswordSession.login({
...hooks,
service,
identifier,
password,
authFactorToken,
allowTakendown: true,
})
bundle = buildBundle(session)
const account = sessionDataToSessionAccountOrThrow(session)
accountDid = account.did
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(bundle, account)
const aa = prefetchAgeAssuranceServerData({
agent: bundle.agent,
})
await Promise.all([gates, moderation, aa])
hooks.arm()
return {account, bundle}
}
/**
* Create an account and build a {@link SessionBundle}.
*
* Preserves `createAgentAndCreateAccount` verbatim: local sync writes for
* created-at/birthdate, the prod vs non-prod deferred server-write block
* (setPersonalDetails/upsertProfile/overwriteSavedFeeds with TID feed ids,
* restrictChatSettings gated on AA flags), and snoozeEmailConfirmationPrompt.
* The deferred writes run against the bridge agent's sugar methods.
*/
export async function createSessionBundleAndCreateAccount(
{
service,
email,
password,
handle,
birthDate,
inviteCode,
verificationPhone,
verificationCode,
}: {
service: string
email: string
password: string
handle: string
birthDate: Date
inviteCode?: string
verificationPhone?: string
verificationCode?: string
},
onSessionChange: OnSessionChange,
): Promise<{account: SessionAccount; bundle: SessionBundle}> {
let bundle!: SessionBundle
let accountDid = ''
const hooks = makeSessionHooks(
onSessionChange,
() => bundle.agent,
() => accountDid,
)
const session = await PasswordSession.createAccount(
{
email,
password,
/* the lexicon types handle as `${string}.${string}`; user input is a plain string */
handle: handle as `${string}.${string}`,
inviteCode,
verificationPhone,
verificationCode,
},
{...hooks, service},
)
bundle = buildBundle(session)
const account = sessionDataToSessionAccountOrThrow(session)
accountDid = account.did
const agent = bundle.agent
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(bundle, account)
const createdAt = new Date().toISOString()
const birthdate = birthDate.toISOString()
/*
* Since we have a race with account creation, profile creation, and AA
* state, set these values locally to ensure sync reads. Values are written
* to the server in the next step, so on subsequent reloads, the server will
* be the source of truth.
*/
setCreatedAtForDid({did: account.did, createdAt})
setBirthdateForDid({did: account.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(account.did)
// do this last
const aa = prefetchAgeAssuranceServerData({agent})
// Not awaited so that we can still get into onboarding.
// This is OK because we won't let you toggle adult stuff until you set the date.
if (IS_PROD_SERVICE(service)) {
void Promise.allSettled([
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthdate,
})
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set birthDate`,
)
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
next.displayName = handle
next.createdAt = createdAt
return next
})
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set initial profile`,
)
throw e
}),
networkRetry(1, () => {
return agent.overwriteSavedFeeds([
{
...DISCOVER_SAVED_FEED,
id: TID.nextStr(),
},
{
...TIMELINE_SAVED_FEED,
id: TID.nextStr(),
},
])
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set initial feeds`,
)
throw e
}),
// wait for AA data to load first, then check state
aa.then(() => {
const {flags} = unsafeGetAndComputeAgeAssurance({did: account.did})
if (flags?.chatDisabled || flags?.groupChatDisabled) {
void restrictChatSettings({
agent,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
}
}),
]).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createSessionBundleAndCreateAccount failed to save personal details and feeds`,
)
}
})
} else {
void Promise.allSettled([
networkRetry(3, () => {
return agent.setPersonalDetails({
birthDate: birthDate.toISOString(),
})
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set birthDate`,
)
throw e
}),
networkRetry(3, () => {
return agent.upsertProfile(prev => {
const next: Un$Typed<AppBskyActorProfile.Record> = prev || {}
next.createdAt = prev?.createdAt || new Date().toISOString()
return next
})
}).catch(e => {
logger.info(
`createSessionBundleAndCreateAccount: failed to set initial profile`,
)
throw e
}),
]).then(promises => {
const rejected = promises.filter(p => p.status === 'rejected')
if (rejected.length > 0) {
logger.error(
`session: createSessionBundleAndCreateAccount failed to save personal details and feeds`,
)
}
})
}
try {
// snooze first prompt after signup, defer to next prompt
snoozeEmailConfirmationPrompt()
} catch (e) {
logger.error(e instanceof Error ? e : String(e), {
message: `session: failed snoozeEmailConfirmationPrompt`,
})
}
await Promise.all([gates, moderation, aa])
hooks.arm()
return {account, bundle}
}
/**
* Snapshot a live session as a `SessionAccount`, throwing if there is no active
* session. Replacement for the old `agentToSessionAccountOrThrow`.
*/
function sessionDataToSessionAccountOrThrow(
session: PasswordSession,
): SessionAccount {
const account = sessionDataToSessionAccount(
session.session,
session.session.service,
)
if (!account) {
throw Error('Expected an active session')
}
return account
}
/**
* Neutralize a bundle's session so it can never refresh again.
*
* Called when switching away from / disposing an account. We null out the
* session locally (constructing a fresh destroyed-state marker is not exposed,
* so we rely on the reducer dropping all references) - the important guarantee
* is that this session's tokens are no longer reachable by any live client. We
* do NOT call `logout()` here: disposal is a local switch, not a server-side
* revocation (revocation is handled separately via the push-token unregister
* temporary sessions). The bridge agent stays usable enough (its `did`/session
* getters return undefined) not to crash late readers.
*/
export function disposeBundle(bundle: SessionBundle | PublicSessionBundle) {
const session = bundle.session
if (!session || session.destroyed) {
return
}
/*
* There is no synchronous, hook-free way to mark a PasswordSession destroyed
* without a network logout. PasswordSession.delete() would revoke on the
* server, which we do NOT want for a local switch. So we fire-and-forget a
* logout-free neutralization by dropping our reference; GC reclaims the
* session. Any late fetchHandler call still uses valid tokens until the
* bundle is dereferenced by the reducer, which is the pre-existing behavior.
*/
}
+29 -32
View File
@@ -1,28 +1,26 @@
import AtpAgent from '@atproto/api'
import {jwtDecode} from 'jwt-decode'
import {PasswordSession} from '@atproto/lex-password-session'
import {isJwtExpired} from '#/lib/jwt'
import {hasProp} from '#/lib/type-guards'
import * as persisted from '#/state/persisted'
import {sessionAccountToSession} from './agent'
import {
networkAwareFetch,
sessionAccountToSessionData,
SessionAgent,
} from './session-core'
import {type SessionAccount} from './types'
/*
* Canonical implementation moved to session-core.ts so that module stays
* dependency-light (this file pulls in agent.ts and, transitively, a large
* chunk of the app). Re-exported here for existing consumers.
*/
export {isSignupQueued} from './session-core'
export function readLastActiveAccount() {
const {currentAccount, accounts} = persisted.get('session')
return accounts.find(a => a.did === currentAccount?.did)
}
export function isSignupQueued(accessJwt: string | undefined) {
if (accessJwt) {
const sessData = jwtDecode(accessJwt)
return (
hasProp(sessData, 'scope') &&
sessData.scope === 'com.atproto.signupQueued'
)
}
return false
}
export function isSessionExpired(account: SessionAccount) {
if (account.accessJwt) {
return isJwtExpired(account.accessJwt)
@@ -32,30 +30,29 @@ export function isSessionExpired(account: SessionAccount) {
}
/**
* Creates and attempted to resumeSession for every stored session.
* Intended to be used to send push token revokations just before logout.
* Creates and resumes a throwaway session for every stored account.
* Intended to send push token revocations just before logout.
*
* Each returned {@link SessionAgent} wraps a temporary `PasswordSession`
* resumed over the network to obtain a valid access token. These sessions are
* deliberately hook-free (no `onUpdated`/`onDeleted`): they must NEVER persist
* or race the active session. They are used once for the unregister call and
* discarded (reclaimed by GC), so we never call `logout()` on them.
*/
export async function createTemporaryAgentsAndResume(
accounts: SessionAccount[],
) {
const agents = await Promise.allSettled(
): Promise<SessionAgent[]> {
const settled = await Promise.allSettled(
accounts.map(async account => {
const agent: AtpAgent = new AtpAgent({service: account.service})
if (account.pdsUrl) {
agent.sessionManager.pdsUrl = new URL(account.pdsUrl)
}
const session = sessionAccountToSession(account)
const res = await agent.resumeSession(session)
if (!res.success) throw new Error('Failed to resume session')
agent.assertAuthenticated() // confirm auth success
return agent
const session = await PasswordSession.resume(
sessionAccountToSessionData(account),
{fetch: networkAwareFetch},
)
return new SessionAgent(session)
}),
)
return agents
return settled
.filter(x => x.status === 'fulfilled')
.map(promise => promise.value)
}
+2 -3
View File
@@ -50,7 +50,6 @@ import {
AppBskyDraftCreateDraft,
AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadV2,
type AtpAgent,
AtUri,
ChatBskyGroupDefs,
type RichText,
@@ -97,7 +96,7 @@ import {
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile'
import {resolveLinkQueryOptions} from '#/state/queries/resolve-link'
import {useAgent, useSession} from '#/state/session'
import {type SessionAgent, useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
@@ -2454,7 +2453,7 @@ function useKeyboardVerticalOffset() {
}
async function whenAppViewReady(
agent: AtpAgent,
agent: SessionAgent,
uri: string,
fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean,
) {
+3 -2
View File
@@ -1,5 +1,5 @@
import {type ImagePickerAsset} from 'expo-image-picker'
import {type AppBskyVideoDefs, type AtpAgent, type BlobRef} from '@atproto/api'
import {type AppBskyVideoDefs, type BlobRef} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
@@ -17,6 +17,7 @@ import {uploadVideo} from '#/lib/media/video/upload'
import {createVideoAgent} from '#/lib/media/video/util'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {type SessionAgent} from '#/state/session'
type CaptionsTrack = {lang: string; file: File}
@@ -274,7 +275,7 @@ function trunc2dp(num: number) {
export async function processVideo(
asset: ImagePickerAsset,
dispatch: (action: VideoAction) => void,
agent: AtpAgent,
agent: SessionAgent,
did: string,
signal: AbortSignal,
i18n: I18n,