diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 5ffe12abbc..1d22db2747 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -848,9 +848,6 @@ }, "typescript/no-misused-promises": { "count": 1 - }, - "typescript/no-unsafe-member-access": { - "count": 1 } }, "src/screens/E2E/SharedPreferencesTesterScreen.tsx": { diff --git a/src/ageAssurance/useBeginAgeAssurance.ts b/src/ageAssurance/useBeginAgeAssurance.ts index 28a4591747..e6e79e348d 100644 --- a/src/ageAssurance/useBeginAgeAssurance.ts +++ b/src/ageAssurance/useBeginAgeAssurance.ts @@ -1,5 +1,4 @@ import {Platform} from 'react-native' -import {type AppBskyAgeassuranceBegin, AtpAgent} from '@atproto/api' import {useMutation} from '@tanstack/react-query' import {wait} from '#/lib/async/wait' @@ -9,26 +8,28 @@ import { PUBLIC_APPVIEW_DID, } from '#/lib/constants' import {isNetworkError} from '#/lib/hooks/useCleanError' -import {useAgent} from '#/state/session' +import {createLexClient} from '#/lib/lexClient' +import {usePdsClient} from '#/state/session' import {usePatchAgeAssuranceServerState} from '#/ageAssurance' import {logger} from '#/ageAssurance/logger' import {useAnalytics} from '#/analytics' import {BLUESKY_PROXY_DID} from '#/env' import {useGeolocation} from '#/geolocation' +import {app, com} from '#/lexicons' const IS_DEV_ENV = BLUESKY_PROXY_DID !== PUBLIC_APPVIEW_DID const APPVIEW = IS_DEV_ENV ? DEV_ENV_APPVIEW : PUBLIC_APPVIEW export function useBeginAgeAssurance() { const ax = useAnalytics() - const agent = useAgent() + const pdsClient = usePdsClient() const geolocation = useGeolocation() const patchAgeAssuranceStateResponse = usePatchAgeAssuranceServerState() return useMutation({ async mutationFn( props: Omit< - AppBskyAgeassuranceBegin.InputSchema, + app.bsky.ageassurance.begin.$InputBody, 'countryCode' | 'regionCode' >, ) { @@ -38,17 +39,22 @@ export function useBeginAgeAssurance() { throw new Error(`Geolocation not available, cannot init age assurance.`) } - const { - data: {token}, - } = await agent.com.atproto.server.getServiceAuth({ + const {token} = await pdsClient.call(com.atproto.server.getServiceAuth, { aud: BLUESKY_PROXY_DID, 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 single-use client scoped to the service-auth token: it has no session, + * so nothing can refresh it, and the request goes straight to the appview + * with the token as a static `authorization` header. A raw client is + * allowed to preset that header where a session-backed one is not, which + * also makes the old `refreshJwt = ''` clone unnecessary. + */ + const scopedClient = createLexClient({ + service: APPVIEW, + headers: {authorization: `Bearer ${token}`}, + }) ax.metric('ageAssurance:api:begin', { platform: Platform.OS, @@ -60,9 +66,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, diff --git a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx index 9d74b0c6dc..052c3a4731 100644 --- a/src/components/ageAssurance/AgeAssuranceInitDialog.tsx +++ b/src/components/ageAssurance/AgeAssuranceInitDialog.tsx @@ -1,6 +1,6 @@ import {useState} from 'react' import {View} from 'react-native' -import {XRPCError} from '@atproto/api' +import {XrpcResponseError} from '@atproto/lex' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -14,6 +14,7 @@ import { import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useTLDs} from '#/lib/hooks/useTLDs' import {isEmailMaybeInvalid} from '#/lib/strings/email' +import {matchXrpcError} from '#/lib/xrpc-error' import {type AppLanguage} from '#/locale/languages' import {useLanguagePrefs} from '#/state/preferences' import {useSession} from '#/state/session' @@ -33,6 +34,7 @@ import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' import {useBeginAgeAssurance} from '#/ageAssurance/useBeginAgeAssurance' import {useAnalytics} from '#/analytics' +import {app} from '#/lexicons' export {useDialogControl} from '#/components/Dialog/context' @@ -139,30 +141,37 @@ function Inner() { msg`Something went wrong, please try again`, ) - if (e instanceof XRPCError) { - if (e.error === 'InvalidEmail') { - error = _( - msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, - ) - ax.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) - } else if (e.error === 'DidTooLong') { - error = ( - <> - - We're having issues initializing the age assurance process for - your account. Please{' '} - - contact support - {' '} - for assistance. - - - ) - ax.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) - } else { - ax.metric('ageAssurance:initDialogError', {code: 'other'}) + if (e instanceof XrpcResponseError) { + switch (matchXrpcError(e, app.bsky.ageassurance.begin)) { + case 'InvalidEmail': + error = _( + msg`Please enter a valid, non-temporary email address. You may need to access this email in the future.`, + ) + ax.metric('ageAssurance:initDialogError', {code: 'InvalidEmail'}) + break + case 'DidTooLong': + error = ( + <> + + We're having issues initializing the age assurance process for + your account. Please{' '} + + contact support + {' '} + for assistance. + + + ) + ax.metric('ageAssurance:initDialogError', {code: 'DidTooLong'}) + break + default: + /* + * An undeclared code keeps the generic message rather than surfacing + * the server's text, as the old `e.error` fallthrough did. + */ + ax.metric('ageAssurance:initDialogError', {code: 'other'}) } } else { const {clean, raw} = cleanError(e) diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts index 67466be926..00472be52f 100644 --- a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts +++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts @@ -1,13 +1,15 @@ import {useMutation} from '@tanstack/react-query' -import {useAgent, useSession} from '#/state/session' +import {usePdsClient, useSession, useSessionApi} from '#/state/session' +import {com} from '#/lexicons' export function useConfirmEmail({ onSuccess, onError, }: {onSuccess?: () => void; onError?: () => void} = {}) { - const agent = useAgent() + const pdsClient = usePdsClient() const {currentAccount} = useSession() + const {refreshSession} = useSessionApi() return useMutation({ mutationFn: async ({token}: {token: string}) => { @@ -15,12 +17,12 @@ export function useConfirmEmail({ throw new Error('No email found for the current account') } - await agent.com.atproto.server.confirmEmail({ + await pdsClient.call(com.atproto.server.confirmEmail, { email: currentAccount.email.trim(), token: token.trim(), }) // will update session state at root of app - await agent.resumeSession(agent.session!) + await refreshSession() }, onSuccess, onError, diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts index 358bf86544..d00e487474 100644 --- a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts +++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts @@ -1,10 +1,12 @@ import {useMutation} from '@tanstack/react-query' -import {useAgent, useSession} from '#/state/session' +import {usePdsClient, useSession, useSessionApi} from '#/state/session' +import {com} from '#/lexicons' export function useManageEmail2FA() { - const agent = useAgent() + const pdsClient = usePdsClient() const {currentAccount} = useSession() + const {refreshSession} = useSessionApi() return useMutation({ mutationFn: async ({ @@ -17,13 +19,13 @@ export function useManageEmail2FA() { throw new Error('No email found for the current account') } - await agent.com.atproto.server.updateEmail({ + await pdsClient.call(com.atproto.server.updateEmail, { email: currentAccount.email, emailAuthFactor: enabled, token, }) // will update session state at root of app - await agent.resumeSession(agent.session!) + await refreshSession() }, }) } diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts index 2ec1eb6dc2..7293227f59 100644 --- a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts +++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts @@ -1,19 +1,26 @@ +import {type Client} from '@atproto/lex' import {useMutation} from '@tanstack/react-query' -import {useAgent} from '#/state/session' +import {usePdsClient, useSessionApi} from '#/state/session' import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' +import {com} from '#/lexicons' async function updateEmailAndRefreshSession( - agent: ReturnType, + pdsClient: Client, + refreshSession: () => Promise, email: string, token?: string, ) { - await agent.com.atproto.server.updateEmail({email: email.trim(), token}) - await agent.resumeSession(agent.session!) + await pdsClient.call(com.atproto.server.updateEmail, { + email: email.trim(), + token, + }) + await refreshSession() } export function useUpdateEmail() { - const agent = useAgent() + const pdsClient = usePdsClient() + const {refreshSession} = useSessionApi() const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() return useMutation< @@ -23,7 +30,12 @@ export function useUpdateEmail() { >({ mutationFn: async ({email, token}: {email: string; token?: string}) => { if (token) { - await updateEmailAndRefreshSession(agent, email, token) + await updateEmailAndRefreshSession( + pdsClient, + refreshSession, + email, + token, + ) return { status: 'success', } @@ -34,7 +46,12 @@ export function useUpdateEmail() { status: 'tokenRequired', } } else { - await updateEmailAndRefreshSession(agent, email, token) + await updateEmailAndRefreshSession( + pdsClient, + refreshSession, + email, + token, + ) return { status: 'success', } diff --git a/src/features/liveNow/index.tsx b/src/features/liveNow/index.tsx index 525e189ace..a9a14fc2d3 100644 --- a/src/features/liveNow/index.tsx +++ b/src/features/liveNow/index.tsx @@ -23,7 +23,7 @@ import { useMaybeProfileShadow, } from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useAgent, usePdsClient, useSession} from '#/state/session' +import {usePdsClient, useSession} from '#/state/session' import {useTickEveryMinute} from '#/state/shell' import {useDialogContext} from '#/components/Dialog' import * as Toast from '#/components/Toast' @@ -194,7 +194,6 @@ export function useLiveLinkMetaQuery(url: string | null) { const liveNowConfig = useLiveNowConfig() const {_} = useLingui() - const agent = useAgent() return useQuery({ enabled: !!url, queryKey: ['link-meta', url], @@ -212,7 +211,7 @@ export function useLiveLinkMetaQuery(url: string | null) { ) } - return await getLinkMeta(agent, url) + return await getLinkMeta(url) }, }) } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index e52d4ec4fc..f13c227857 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -32,7 +32,6 @@ import {app, com} from '#/lexicons' import * as bsky from '#/types/bsky' import {createGIFDescription} from '../gif-alt-text' import {computeCid} from './computeCid' -import {fromLegacyBlobRef} from './legacy-blob' import {uploadBlob} from './upload-blob' export {uploadBlob} @@ -410,10 +409,11 @@ async function resolveMedia( return { $type: 'app.bsky.embed.video', /* - * The video pipeline still reads its blob off the legacy agent, so - * normalize it to the lex shape before it reaches the lex write. + * The video blob is a plain lex blob from the video pipeline + * (getJobStatus, in composer state/video). Its structural shape matches + * the lexicon blob field and the CID hasher (see computeCid). */ - video: fromLegacyBlobRef(videoDraft.pendingPublish.blobRef), + video: videoDraft.pendingPublish.blobRef, alt: videoDraft.altText || undefined, captions: captions.length === 0 ? undefined : captions, aspectRatio, diff --git a/src/lib/api/legacy-blob.ts b/src/lib/api/legacy-blob.ts index a98e375d20..b60acadfe4 100644 --- a/src/lib/api/legacy-blob.ts +++ b/src/lib/api/legacy-blob.ts @@ -14,18 +14,3 @@ import {type BlobRef as LexBlobRef} from '@atproto/lex' export function toLegacyBlobRef(blob: LexBlobRef): BlobRef { return BlobRef.fromJsonRef(blob as Parameters[0]) } - -/** - * Normalize a legacy `BlobRef` class instance to the plain-JSON lex blob shape. - * - * Required for any blob that reaches a lex write: the lex serializer walks - * plain objects, so a class instance goes on the wire with its internal - * `original` field and no `$type`. `ipld()` yields exactly the lex shape, and - * hashes identically (see `computeCid.test.ts` case 2b). - * - * Only the video pipeline still needs this - it reads its blob off the legacy - * agent (`app.bsky.video.getJobStatus`). Drop it when the video client moves. - */ -export function fromLegacyBlobRef(blob: BlobRef): LexBlobRef { - return blob.ipld() -} diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index 476c0f1a20..67bc46284a 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -184,7 +184,7 @@ export async function resolveLink( view: res.data.starterPack, } } - return resolveExternal(agent, uri) + return resolveExternal(uri) // Forked from useGetPost. TODO: move into RQ. async function getPost({uri}: {uri: string}) { @@ -258,11 +258,8 @@ function getFileSlug(url: string | undefined): string | undefined { return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined } -async function resolveExternal( - agent: AtpAgent, - uri: string, -): Promise { - const result = await getLinkMeta(agent, uri) +async function resolveExternal(uri: string): Promise { + const result = await getLinkMeta(uri) return { type: 'external', uri: result.url, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 156712c809..9fd7d12b47 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -279,9 +279,14 @@ export const DM_SERVICE_HEADERS = { 'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`, } -export const BLUESKY_NOTIF_SERVICE_HEADERS = { - 'atproto-proxy': `${BLUESKY_PROXY_DID}#bsky_notif`, -} +/** + * The notification service's proxy target, in the `did#service_id` form a lex + * client's per-call `service` option takes. Passing it emits `atproto-proxy: + * ` on that one request, which is what routes push registration to + * the notification service (replaces the old + * `BLUESKY_NOTIF_SERVICE_HEADERS`). + */ +export const NOTIF_SERVICE: Service = `${BLUESKY_PROXY_DID}#bsky_notif` export const webLinks = { tos: `https://bsky.social/about/support/tos`, diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts index c282a0c4c3..ee96d6db43 100644 --- a/src/lib/link-meta/link-meta.ts +++ b/src/lib/link-meta/link-meta.ts @@ -1,4 +1,4 @@ -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' @@ -31,7 +31,6 @@ export interface LinkMeta { } export async function getLinkMeta( - agent: AtpAgent, url: string, timeout = 15e3, ): Promise { @@ -80,9 +79,7 @@ export async function getLinkMeta( try { const response = await fetch( - `${LINK_META_PROXY(agent.serviceUrl.toString() || '')}${encodeURIComponent( - url, - )}`, + `${LINK_META_PROXY('')}${encodeURIComponent(url)}`, {signal: controller.signal}, ) diff --git a/src/lib/media/video/multipart/types.ts b/src/lib/media/video/multipart/types.ts index 16817778c3..a0cfd216e3 100644 --- a/src/lib/media/video/multipart/types.ts +++ b/src/lib/media/video/multipart/types.ts @@ -1,3 +1,5 @@ +import {type app} from '#/lexicons' + /** * One part of a multipart upload. `partNumber` is 1-indexed to match the S3 * convention the backend uses. @@ -57,13 +59,13 @@ export type UploadStatusResponse = { expiresAt: string state: UploadState completedJobId?: string - jobStatus?: import('@atproto/api').AppBskyVideoDefs.JobStatus + jobStatus?: app.bsky.video.defs.JobStatus failureReason?: string } export type FinishUploadResponse = { completedJobId: string - jobStatus: import('@atproto/api').AppBskyVideoDefs.JobStatus + jobStatus: app.bsky.video.defs.JobStatus } export type AbortUploadResponse = Pick< diff --git a/src/lib/media/video/multipart/upload.ts b/src/lib/media/video/multipart/upload.ts index 2b45e6d2c6..d3e5210873 100644 --- a/src/lib/media/video/multipart/upload.ts +++ b/src/lib/media/video/multipart/upload.ts @@ -1,9 +1,10 @@ -import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api' +import {type Client} from '@atproto/lex' import {nanoid} from 'nanoid/non-secure' import {AbortError} from '#/lib/async/cancelable' import {type CompressedVideo} from '#/lib/media/video/types' import {shouldRetryError} from '#/lib/strings/errors' +import {type app} from '#/lexicons' import {getServiceAuthToken} from '../upload.shared' import {mimeToExt} from '../util' import { @@ -29,19 +30,22 @@ export class MultipartFallbackError extends Error {} export async function uploadVideoMultipart({ video, - agent, + client, + dispatchUrl, setProgress, signal, onStarted, }: { video: CompressedVideo - agent: AtpAgent + client: Client + /** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */ + dispatchUrl: string | URL setProgress: (progress: number) => void signal: AbortSignal onStarted?: () => void -}): Promise { +}): Promise { throwIfAborted(signal) - const tokenProvider = createTokenProvider(agent, signal) + const tokenProvider = createTokenProvider(client, dispatchUrl, signal) const token = await tokenProvider.get() const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}` let session @@ -134,7 +138,7 @@ async function finishAndRecover({ getToken: (forceRefresh?: boolean) => Promise signal: AbortSignal resendMissingParts: (receivedPartNumbers: number[]) => Promise -}): Promise { +}): Promise { let createdFailures = 0 let forceTokenRefresh = true while (true) { @@ -224,7 +228,7 @@ async function abortThenFallbackOrResolve( jobId: string, token: string, cause: unknown, -): Promise { +): Promise { const result = await abortUploadWithRetry(jobId, token) if (result.state === 'aborted') { throw new MultipartFallbackError( @@ -264,7 +268,11 @@ async function abortUploadWithRetry(jobId: string, token: string) { throw lastError } -function createTokenProvider(agent: AtpAgent, signal: AbortSignal) { +function createTokenProvider( + client: Client, + dispatchUrl: string | URL, + signal: AbortSignal, +) { let token: string | undefined let expiresAt = 0 let refresh: Promise | undefined @@ -273,7 +281,7 @@ function createTokenProvider(agent: AtpAgent, signal: AbortSignal) { if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token if (!refresh) { const exp = Math.floor(Date.now() / 1000) + 60 * 30 - refresh = getServiceAuthTokenWithRetry(agent, exp, signal) + refresh = getServiceAuthTokenWithRetry(client, dispatchUrl, exp, signal) .then(nextToken => { token = nextToken expiresAt = exp * 1000 @@ -290,7 +298,8 @@ function createTokenProvider(agent: AtpAgent, signal: AbortSignal) { } async function getServiceAuthTokenWithRetry( - agent: AtpAgent, + client: Client, + dispatchUrl: string | URL, exp: number, signal: AbortSignal, ) { @@ -299,7 +308,8 @@ async function getServiceAuthTokenWithRetry( throwIfAborted(signal) try { return await getServiceAuthToken({ - agent, + client, + dispatchUrl, lxm: 'com.atproto.repo.uploadBlob', exp, }) diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts index f8aaa1249b..9e8d99a185 100644 --- a/src/lib/media/video/upload.shared.ts +++ b/src/lib/media/video/upload.shared.ts @@ -1,44 +1,62 @@ -import {type AtpAgent} from '@atproto/api' +import {type Client} from '@atproto/lex' +import {type DidString, type NsidString} from '@atproto/syntax' 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 {createVideoAgent} from './util' +import {app, com} from '#/lexicons' +import {createVideoServiceClient} from './util' export async function getServiceAuthToken({ - agent, + client, + dispatchUrl, aud, lxm, exp, }: { - agent: AtpAgent + client: Client + /** + * The account's dispatch URL (the old `agent.dispatchUrl`: its PDS, falling + * back to the account service). Only needed when `aud` is omitted, so the + * default audience can be derived from the PDS host. A lex {@link Client} does + * not expose this - it resolves the PDS per request internally - so the caller, + * which holds the session, passes it in. + */ + dispatchUrl?: string | URL aud?: string - lxm: string + lxm: NsidString exp?: number }) { - const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) - if (!pdsAud) { - throw new Error('Agent does not have a PDS URL') + let resolvedAud = aud + if (!resolvedAud) { + if (!dispatchUrl) { + throw new Error('Missing service auth audience: no aud or dispatchUrl') + } + const pdsAud = getServiceAuthAudFromUrl(dispatchUrl) + if (!pdsAud) { + throw new Error('Agent does not have a PDS URL') + } + resolvedAud = pdsAud } - const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ - aud: aud ?? pdsAud, + const {token} = await client.call(com.atproto.server.getServiceAuth, { + aud: resolvedAud as DidString, lxm, exp, }) - return serviceAuth.token + return token } -export async function getVideoUploadLimits(agent: AtpAgent, i18n: I18n) { +export async function getVideoUploadLimits(client: Client, i18n: I18n) { const token = await getServiceAuthToken({ - agent, + client, lxm: 'app.bsky.video.getUploadLimits', aud: VIDEO_SERVICE_DID, }) - const videoAgent = createVideoAgent() - const {data: limits} = await videoAgent.app.bsky.video - .getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}}) + const videoClient = createVideoServiceClient(token) + const limits = await videoClient + .call(app.bsky.video.getUploadLimits) .catch(err => { if (err instanceof Error) { throw new UploadLimitError(err.message) diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts index b91ad7a153..287c8f09c6 100644 --- a/src/lib/media/video/upload.ts +++ b/src/lib/media/video/upload.ts @@ -1,5 +1,5 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy' -import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api' +import {type Client} from '@atproto/lex' import {type I18n} from '@lingui/core' import {msg} from '@lingui/core/macro' import {nanoid} from 'nanoid/non-secure' @@ -11,13 +11,15 @@ import { type VideoUploadTransport, } from '#/lib/media/video/types' import {Features, features} from '#/analytics/features' +import {type app} from '#/lexicons' import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload' import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' import {createVideoEndpointUrl, mimeToExt} from './util' export async function uploadVideo({ video, - agent, + client, + dispatchUrl, did, setProgress, signal, @@ -25,7 +27,9 @@ export async function uploadVideo({ onTransport, }: { video: CompressedVideo - agent: AtpAgent + client: Client + /** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */ + dispatchUrl: string | URL did: string setProgress: (progress: number) => void signal: AbortSignal @@ -35,13 +39,14 @@ export async function uploadVideo({ if (signal.aborted) { throw new AbortError() } - await getVideoUploadLimits(agent, i18n) + await getVideoUploadLimits(client, i18n) if (features.isOn(Features.VideoMultipartUploadEnable)) { try { return await uploadVideoMultipart({ video, - agent, + client, + dispatchUrl, setProgress, signal, onStarted: () => onTransport?.('multipart'), @@ -64,7 +69,8 @@ export async function uploadVideo({ throw new AbortError() } const token = await getServiceAuthToken({ - agent, + client, + dispatchUrl, lxm: 'com.atproto.repo.uploadBlob', exp: Date.now() / 1000 + 60 * 30, // 30 minutes }) @@ -91,7 +97,7 @@ export async function uploadVideo({ throw new Error('No response') } - const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus + const responseBody = JSON.parse(res.body) as app.bsky.video.defs.JobStatus if (!responseBody.jobId) { throw new ServerError( diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts index cfefbd797d..90c10a0684 100644 --- a/src/lib/media/video/upload.web.ts +++ b/src/lib/media/video/upload.web.ts @@ -1,4 +1,4 @@ -import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api' +import {type Client} from '@atproto/lex' import {type I18n} from '@lingui/core' import {msg} from '@lingui/core/macro' import {nanoid} from 'nanoid/non-secure' @@ -10,13 +10,15 @@ import { type VideoUploadTransport, } from '#/lib/media/video/types' import {Features, features} from '#/analytics/features' +import {type app} from '#/lexicons' import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload' import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' import {createVideoEndpointUrl, mimeToExt} from './util' export async function uploadVideo({ video, - agent, + client, + dispatchUrl, did, setProgress, signal, @@ -24,7 +26,9 @@ export async function uploadVideo({ onTransport, }: { video: CompressedVideo - agent: AtpAgent + client: Client + /** The account's PDS/dispatch URL, for the uploadBlob service-auth token. */ + dispatchUrl: string | URL did: string setProgress: (progress: number) => void signal: AbortSignal @@ -34,13 +38,14 @@ export async function uploadVideo({ if (signal.aborted) { throw new AbortError() } - await getVideoUploadLimits(agent, i18n) + await getVideoUploadLimits(client, i18n) if (features.isOn(Features.VideoMultipartUploadEnable)) { try { return await uploadVideoMultipart({ video, - agent, + client, + dispatchUrl, setProgress, signal, onStarted: () => onTransport?.('multipart'), @@ -71,7 +76,8 @@ export async function uploadVideo({ throw new AbortError() } const token = await getServiceAuthToken({ - agent, + client, + dispatchUrl, lxm: 'com.atproto.repo.uploadBlob', exp: Date.now() / 1000 + 60 * 30, // 30 minutes }) @@ -80,7 +86,7 @@ export async function uploadVideo({ throw new AbortError() } const xhr = new XMLHttpRequest() - const res = await new Promise( + const res = await new Promise( (resolve, reject) => { xhr.upload.addEventListener('progress', e => { const progress = e.loaded / e.total @@ -92,7 +98,7 @@ export async function uploadVideo({ } else if (xhr.readyState === 4) { const uploadRes = JSON.parse( xhr.responseText, - ) as AppBskyVideoDefs.JobStatus + ) as app.bsky.video.defs.JobStatus resolve(uploadRes) } else { reject(new ServerError(i18n._(msg`Failed to upload video`))) diff --git a/src/lib/media/video/util.ts b/src/lib/media/video/util.ts index 236f0cff3e..7c9686a7f4 100644 --- a/src/lib/media/video/util.ts +++ b/src/lib/media/video/util.ts @@ -1,6 +1,5 @@ -import {AtpAgent} from '@atproto/api' - import {type SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' +import {createLexClient} from '#/lib/lexClient' export const createVideoEndpointUrl = ( route: string, @@ -16,12 +15,29 @@ export const createVideoEndpointUrl = ( return url.href } -export function createVideoAgent() { - return new AtpAgent({ +/** + * A non-refreshing single-use lex {@link Client} scoped to the video service and + * authenticated by a per-call service-auth token. It has no session, so nothing + * can refresh it: requests go straight to the video service with the token as a + * static `authorization` header, which a raw client - unlike a session-backed + * one - is allowed to preset. Mirrors the scoped client in + * `#/ageAssurance/useBeginAgeAssurance`. + */ +export function createVideoServiceClient(token: string) { + return createLexClient({ service: VIDEO_SERVICE, + headers: {authorization: `Bearer ${token}`}, }) } +/** + * An unauthenticated lex {@link Client} scoped to the video service, for public + * reads like `getJobStatus` polling. + */ +export function createTokenlessVideoServiceClient() { + return createLexClient({service: VIDEO_SERVICE}) +} + export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { switch (mimeType) { case 'video/mp4': diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 6351a8d8e1..5acc6ab06d 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -2,33 +2,47 @@ 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 Client} from '@atproto/lex' import debounce from 'lodash.debounce' import { - BLUESKY_NOTIF_SERVICE_HEADERS, + NOTIF_SERVICE, PUBLIC_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID, } from '#/lib/constants' import {logger as notyLogger} from '#/lib/notifications/util' import {isNetworkError} from '#/lib/strings/errors' -import {type SessionAccount, useAgent, useSession} from '#/state/session' +import {type SessionAccount, usePdsClient, useSession} from '#/state/session' import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' import {useAgeAssurance} from '#/ageAssurance' import {useAnalytics} from '#/analytics' import {IS_DEV, IS_NATIVE} from '#/env' +import {app} from '#/lexicons' + +/** + * A resumed single-use account client paired with the account's service origin + * and handle. Produced by `createTemporaryClientsAndResume` (session util) and + * consumed by {@link unregisterPushToken}, which needs the service host to pick + * the appview DID and the handle for a debug log line without reaching into the + * session internals. + */ +export type TemporaryPushClient = { + client: Client + service: string + handle: string +} /** * @private * Registers the device's push notification token with the Bluesky server. */ async function _registerPushToken({ - agent, + client, currentAccount, token, extra = {}, }: { - agent: AtpAgent + client: Client currentAccount: SessionAccount token: Notifications.DevicePushToken extra?: { @@ -36,7 +50,7 @@ async function _registerPushToken({ } }) { try { - const payload: AppBskyNotificationRegisterPush.InputSchema = { + const payload: app.bsky.notification.registerPush.$InputBody = { serviceDid: currentAccount.service?.includes('staging') ? PUBLIC_STAGING_APPVIEW_DID : PUBLIC_APPVIEW_DID, @@ -48,8 +62,8 @@ async function _registerPushToken({ notyLogger.debug(`registerPushToken: registering`, {...payload}) - await agent.app.bsky.notification.registerPush(payload, { - headers: BLUESKY_NOTIF_SERVICE_HEADERS, + await client.call(app.bsky.notification.registerPush, payload, { + service: NOTIF_SERVICE, }) notyLogger.debug(`registerPushToken: success`) @@ -74,7 +88,7 @@ const _registerPushTokenDebounced = debounce(_registerPushToken, 100) * `_registerPushTokenDebounced` directly. */ export function useRegisterPushToken() { - const agent = useAgent() + const client = usePdsClient() const {currentAccount} = useSession() return useCallback( @@ -87,7 +101,7 @@ export function useRegisterPushToken() { }) => { if (!currentAccount) return return _registerPushTokenDebounced({ - agent, + client, currentAccount, token, extra: { @@ -95,7 +109,7 @@ export function useRegisterPushToken() { }, }) }, - [agent, currentAccount], + [client, currentAccount], ) } @@ -326,16 +340,17 @@ export async function resetBadgeCount() { await setBadgeCountAsync(0) } -export async function unregisterPushToken(agents: AtpAgent[]) { +export async function unregisterPushToken(clients: TemporaryPushClient[]) { if (!IS_NATIVE) return try { const token = await getPushToken() if (token) { - for (const agent of agents) { - await agent.app.bsky.notification.unregisterPush( + for (const {client, service, handle} of clients) { + await client.call( + app.bsky.notification.unregisterPush, { - serviceDid: agent.serviceUrl.hostname.includes('staging') + serviceDid: service.includes('staging') ? PUBLIC_STAGING_APPVIEW_DID : PUBLIC_APPVIEW_DID, platform: Platform.OS, @@ -343,10 +358,10 @@ export async function unregisterPushToken(agents: AtpAgent[]) { appId: 'xyz.blueskyweb.app', }, { - headers: BLUESKY_NOTIF_SERVICE_HEADERS, + service: NOTIF_SERVICE, }, ) - notyLogger.debug(`Push token unregistered for ${agent.session?.handle}`) + notyLogger.debug(`Push token unregistered for ${handle}`) } } else { notyLogger.debug('Tried to unregister push token, but could not find one') diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index 2782c5ef0d..3be42add78 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -7,10 +7,11 @@ import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' +import {isErrorMaybeAppPasswordPermissions} from '#/lib/strings/errors' import {logger} from '#/logger' import { type SessionAccount, - useAgent, + usePdsClient, useSession, useSessionApi, } from '#/state/session' @@ -25,6 +26,7 @@ import * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {IS_WEB} from '#/env' +import {com} from '#/lexicons' const COL_WIDTH = 400 @@ -36,8 +38,8 @@ export function Deactivated() { const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() const {setShowLoggedOut} = useLoggedOutViewControls() const hasOtherAccounts = accounts.length > 1 - const {logoutCurrentAccount} = useSessionApi() - const agent = useAgent() + const {logoutCurrentAccount, refreshSession} = useSessionApi() + const pdsClient = usePdsClient() const [pending, setPending] = useState(false) const [error, setError] = useState() const queryClient = useQueryClient() @@ -70,21 +72,24 @@ export function Deactivated() { const handleActivate = useCallback(async () => { try { setPending(true) - await agent.com.atproto.server.activateAccount() + await pdsClient.call(com.atproto.server.activateAccount) await queryClient.resetQueries() - await agent.resumeSession(agent.session!) + await refreshSession() } catch (e: any) { - switch (e.message) { - case 'Bad token scope': - setError( - _( - msg`You're signed in with an App Password. Please sign in with your main password to continue deactivating your account.`, - ), - ) - break - default: - setError(_(msg`Something went wrong, please try again`)) - break + /* + * `activateAccount` declares no lexicon errors, so the app-password case + * arrives as an undeclared code plus a message. The shared helper matches + * both that and the plain-string form the old exact `e.message` switch + * relied on. + */ + if (isErrorMaybeAppPasswordPermissions(e)) { + setError( + _( + msg`You're signed in with an App Password. Please sign in with your main password to continue deactivating your account.`, + ), + ) + } else { + setError(_(msg`Something went wrong, please try again`)) } logger.error(e, { @@ -93,7 +98,7 @@ export function Deactivated() { } finally { setPending(false) } - }, [_, agent, setPending, setError, queryClient]) + }, [_, pdsClient, refreshSession, setPending, setError, queryClient]) return ( diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx index 1b31a7de2a..a8c9474410 100644 --- a/src/screens/Settings/components/ChangeHandleDialog.tsx +++ b/src/screens/Settings/components/ChangeHandleDialog.tsx @@ -27,7 +27,7 @@ import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {useServiceQuery} from '#/state/queries/service' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' -import {useAgent, useSession} from '#/state/session' +import {useAgent, useSession, useSessionApi} from '#/state/session' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' @@ -152,7 +152,7 @@ function ProvidedHandlePage({ }) { const {_} = useLingui() const [subdomain, setSubdomain] = useState('') - const agent = useAgent() + const {refreshSession} = useSessionApi() const control = Dialog.useDialogContext() const {currentAccount} = useSession() const queryClient = useQueryClient() @@ -173,7 +173,7 @@ function ProvidedHandlePage({ queryKey: RQKEY_PROFILE(currentAccount.did), }) } - agent.resumeSession(agent.session!).then(() => control.close()) + refreshSession().then(() => control.close()) }, }) @@ -311,7 +311,7 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) { const {currentAccount} = useSession() const [dnsPanel, setDNSPanel] = useState(true) const [domain, setDomain] = useState('') - const agent = useAgent() + const {refreshSession} = useSessionApi() const control = Dialog.useDialogContext() const fetchDid = useFetchDid() const queryClient = useQueryClient() @@ -328,7 +328,7 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) { queryKey: RQKEY_PROFILE(currentAccount.did), }) } - agent.resumeSession(agent.session!).then(() => control.close()) + refreshSession().then(() => control.close()) }, }) diff --git a/src/screens/Settings/components/DisableEmail2FADialog.tsx b/src/screens/Settings/components/DisableEmail2FADialog.tsx index 8b774b54f7..c633c82029 100644 --- a/src/screens/Settings/components/DisableEmail2FADialog.tsx +++ b/src/screens/Settings/components/DisableEmail2FADialog.tsx @@ -5,7 +5,8 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {cleanError} from '#/lib/strings/errors' -import {useAgent, useSession} from '#/state/session' +import {matchXrpcError} from '#/lib/xrpc-error' +import {usePdsClient, useSession, useSessionApi} from '#/state/session' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -16,6 +17,7 @@ import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {P, Text} from '#/components/Typography' import {IS_NATIVE} from '#/env' +import {com} from '#/lexicons' enum Stages { Email, @@ -31,7 +33,8 @@ export function DisableEmail2FADialog({ const t = useTheme() const {gtMobile} = useBreakpoints() const {currentAccount} = useSession() - const agent = useAgent() + const pdsClient = usePdsClient() + const {refreshSession} = useSessionApi() const [stage, setStage] = useState(Stages.Email) const [confirmationCode, setConfirmationCode] = useState('') @@ -42,7 +45,7 @@ export function DisableEmail2FADialog({ setError('') setIsProcessing(true) try { - await agent.com.atproto.server.requestEmailUpdate() + await pdsClient.call(com.atproto.server.requestEmailUpdate) setStage(Stages.ConfirmCode) } catch (e) { setError(cleanError(String(e))) @@ -56,21 +59,26 @@ export function DisableEmail2FADialog({ setIsProcessing(true) try { if (currentAccount?.email) { - await agent.com.atproto.server.updateEmail({ + await pdsClient.call(com.atproto.server.updateEmail, { email: currentAccount.email, token: confirmationCode.trim(), emailAuthFactor: false, }) - await agent.resumeSession(agent.session!) + await refreshSession() Toast.show(_(msg({message: 'Email 2FA disabled', context: 'toast'}))) } control.close() } catch (e) { - const errMsg = String(e) - if (errMsg.includes('Token is invalid')) { + /* + * The old check matched the PDS message "Token is invalid"; the lexicon + * declares that case as `InvalidToken`, so match the code instead. + */ + if ( + matchXrpcError(e, com.atproto.server.updateEmail) === 'InvalidToken' + ) { setError(_(msg`Invalid 2FA confirmation code.`)) } else { - setError(cleanError(errMsg)) + setError(cleanError(e)) } } finally { setIsProcessing(false) diff --git a/src/screens/Settings/components/ExportCarDialog.tsx b/src/screens/Settings/components/ExportCarDialog.tsx index 13b60dfdaa..6f28aa340f 100644 --- a/src/screens/Settings/components/ExportCarDialog.tsx +++ b/src/screens/Settings/components/ExportCarDialog.tsx @@ -1,11 +1,11 @@ import {useCallback, useState} from 'react' import {View} from 'react-native' +import {type DidString} from '@atproto/syntax' import {Trans, useLingui} from '@lingui/react/macro' -import {DM_SERVICE_HEADERS} from '#/lib/constants' import {saveBytesToDisk} from '#/lib/media/manip' import {logger} from '#/logger' -import {useAgent} from '#/state/session' +import {useChatClient, usePdsClient, 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' @@ -14,6 +14,7 @@ import {InlineLinkText} from '#/components/Link' import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' +import {chat, com} from '#/lexicons' export function ExportCarDialog({ control, @@ -22,21 +23,29 @@ export function ExportCarDialog({ }) { const {t: l} = useLingui() const t = useTheme() - const agent = useAgent() + const {currentAccount} = useSession() + const pdsClient = usePdsClient() + const chatClient = useChatClient() const [loading, setLoading] = useState<'repo' | 'chat' | false>(false) const download = useCallback(async () => { - if (!agent.session) { + if (!currentAccount) { return // shouldn't ever happen } try { setLoading('repo') - const did = agent.session.did - const downloadRes = await agent.com.atproto.sync.getRepo({did}) + const did = currentAccount.did as DidString + const data = await pdsClient.call(com.atproto.sync.getRepo, {did}) + /* + * getRepo declares `application/vnd.ipld.car`, so lex-client hands back + * the raw bytes unparsed and does not surface the response content-type. + * The old code already fell back to this same constant when the header was + * absent, and the endpoint always returns CAR. + */ const saveRes = await saveBytesToDisk( 'repo.car', - downloadRes.data, - downloadRes.headers['content-type'] || 'application/vnd.ipld.car', + data, + 'application/vnd.ipld.car', ) if (saveRes) { @@ -48,28 +57,26 @@ export function ExportCarDialog({ } finally { setLoading(false) } - }, [l, agent]) + }, [l, currentAccount, pdsClient]) const downloadChatData = useCallback(async () => { - if (!agent.session) { + if (!currentAccount) { return } try { setLoading('chat') - // Using raw fetch because the XRPC client incorrectly tries to JSON-parse - // application/jsonl responses (substring match on application/json). - const res = await agent.sessionManager.fetchHandler( - '/xrpc/chat.bsky.actor.exportAccountData', - {headers: DM_SERVICE_HEADERS}, - ) - if (!res.ok) { - throw new Error(`HTTP ${res.status}`) - } - const data = new Uint8Array(await res.arrayBuffer()) + /* + * lex-client only JSON-parses a response when the declared output encoding + * is `application/json`; this endpoint declares `application/jsonl`, so it + * returns the raw bytes. That removes the reason for the old low-level + * fetchHandler workaround, and the chat client emits the proxy header + * itself, so the per-call DM headers go away too. + */ + const data = await chatClient.call(chat.bsky.actor.exportAccountData) const saveRes = await saveBytesToDisk( 'chat.jsonl', data, - res.headers.get('content-type') || 'application/jsonl', + 'application/jsonl', ) if (saveRes) { @@ -81,7 +88,7 @@ export function ExportCarDialog({ } finally { setLoading(false) } - }, [l, agent]) + }, [l, currentAccount, chatClient]) return ( diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx index b83e627404..e0bf7f2edd 100644 --- a/src/screens/SignupQueued.tsx +++ b/src/screens/SignupQueued.tsx @@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {logger} from '#/logger' -import {isSignupQueued, useAgent, useSessionApi} from '#/state/session' +import {isSignupQueued, usePdsClient, useSessionApi} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {Logo} from '#/view/icons/Logo' import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' @@ -15,6 +15,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Loader} from '#/components/Loader' import {P, Text} from '#/components/Typography' import {IS_IOS, IS_LIQUID_GLASS, IS_WEB} from '#/env' +import {com} from '#/lexicons' const COL_WIDTH = 400 @@ -24,8 +25,8 @@ export function SignupQueued() { const insets = useSafeAreaInsets() const {gtMobile} = useBreakpoints() const onboardingDispatch = useOnboardingDispatch() - const {logoutCurrentAccount} = useSessionApi() - const agent = useAgent() + const {logoutCurrentAccount, refreshSession} = useSessionApi() + const pdsClient = usePdsClient() const [isProcessing, setProcessing] = useState(false) const [estimatedTime, setEstimatedTime] = useState( @@ -38,18 +39,23 @@ export function SignupQueued() { const checkStatus = useCallback(async () => { setProcessing(true) try { - 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() - if (!isSignupQueued(agent.session?.accessJwt)) { + const res = await pdsClient.call(com.atproto.temp.checkSignupQueue) + if (res.activated) { + /* + * Ready to go, exchange the access token for a usable one and kick off + * onboarding. The refreshed snapshot carries the new scope; reading + * `currentAccount` here would still see the pre-refresh token, since the + * session's update hook dispatches a render away. + */ + const refreshed = await refreshSession() + if (!isSignupQueued(refreshed?.accessJwt)) { onboardingDispatch({type: 'start'}) } } else { // not ready, update UI - setEstimatedTime(msToString(res.data.estimatedTimeMs)) - if (typeof res.data.placeInQueue !== 'undefined') { - setPlaceInQueue(Math.max(res.data.placeInQueue, 1)) + setEstimatedTime(msToString(res.estimatedTimeMs)) + if (typeof res.placeInQueue !== 'undefined') { + setPlaceInQueue(Math.max(res.placeInQueue, 1)) } } } catch (e: any) { @@ -62,7 +68,8 @@ export function SignupQueued() { setEstimatedTime, setPlaceInQueue, onboardingDispatch, - agent, + pdsClient, + refreshSession, ]) useEffect(() => { diff --git a/src/state/session/__tests__/provider-refresh-session-test.tsx b/src/state/session/__tests__/provider-refresh-session-test.tsx new file mode 100644 index 0000000000..6b1e10bdec --- /dev/null +++ b/src/state/session/__tests__/provider-refresh-session-test.tsx @@ -0,0 +1,204 @@ +import {PasswordSession} from '@atproto/lex-password-session' +import {beforeEach, describe, expect, it, jest} from '@jest/globals' +import {act, render} from '@testing-library/react-native' + +import {type SessionAccount} from '../types' + +/* + * The provider pulls the whole app shell in through `#/state/util` and the + * account factories. These mocks cut the tree back to the session lifecycle + * itself, mirroring provider-clients-test.tsx. + */ +jest.mock('#/state/persisted', () => { + const { + defaults, + }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') + return { + defaults, + get: (key: keyof typeof defaults) => defaults[key], + write: () => Promise.resolve(), + readLatest: (key: keyof typeof defaults) => defaults[key], + onUpdate: () => () => {}, + } +}) +jest.mock('#/state/util', () => ({useCloseAllActiveElements: () => () => {}})) +jest.mock('#/components/dialogs/Context', () => ({ + useGlobalDialogsControlContext: () => ({signinDialogControl: {open() {}}}), +})) +jest.mock('#/analytics', () => ({ + AnalyticsContext: ({children}: {children: React.ReactNode}) => children, + useAnalyticsBase: () => ({metric() {}, logger: {debug() {}, error() {}}}), + utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined}, +})) +jest.mock('#/state/shell/onboarding', () => ({ + useOnboardingDispatch: () => () => {}, +})) +jest.mock('#/ageAssurance/data', () => ({ + clearAgeAssuranceServerDataForAll: () => {}, + clearAgeAssuranceServerDataForDid: () => {}, +})) +jest.mock('#/lib/persisted-query-storage', () => ({ + clearPersistedQueryStorage: () => Promise.resolve(), +})) +jest.mock('#/lib/notifications/notifications', () => ({ + unregisterPushToken: () => Promise.resolve(), +})) +jest.mock('jwt-decode', () => ({ + jwtDecode: () => ({scope: 'com.atproto.access'}), +})) +jest.mock('#/state/events', () => ({ + emitSessionDropped: () => {}, + emitNetworkConfirmed: () => {}, + emitNetworkLost: () => {}, +})) + +const mockLogin = jest.fn<(...args: unknown[]) => Promise>() +jest.mock('../session-core', () => ({ + ...jest.requireActual('../session-core'), + createSessionBundleAndLogin: (...args: unknown[]) => mockLogin(...args), +})) +jest.mock('../create-account', () => ({ + createSessionBundleAndCreateAccount: () => new Promise(() => {}), +})) + +import {Provider, useSession, useSessionApi} from '#/state/session' +import {type SessionApiContext} from '#/state/session/types' +import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' +import {type SessionBundle} from '../session-core' +import {sessionAccountToSessionData} from '../session-data' +import { + asFetch, + DID, + HANDLE, + json, + makeAccount, + makeMockFetch, + type MockFetch, +} from './mock-fetch' + +/** + * Build a bundle whose session is a real `PasswordSession` over the stubbed + * network, since `refreshSession` drives the session's own refresh machinery. + */ +function makeBundle( + account: SessionAccount, + fetchMock: MockFetch, +): SessionBundle { + const session = new PasswordSession(sessionAccountToSessionData(account), { + fetch: asFetch(fetchMock), + }) + const manager = new PasswordSessionManager(session, { + service: account.service, + }) + manager.setFetch(asFetch(fetchMock)) + return { + session, + agent: new BskyAppAgent(manager), + service: new URL(account.service), + } +} + +type Harness = { + api: SessionApiContext + currentAccount: () => SessionAccount | undefined +} + +function renderProvider(): Harness { + let api!: SessionApiContext + let currentAccount: SessionAccount | undefined + function Probe() { + api = useSessionApi() + currentAccount = useSession().currentAccount + return null + } + render( + + + , + ) + return {api, currentAccount: () => currentAccount} +} + +/** Render the provider and log `account` in through the stubbed login factory. */ +async function renderLoggedIn( + account: SessionAccount, + fetchMock: MockFetch, +): Promise { + const bundle = makeBundle(account, fetchMock) + const harness = renderProvider() + mockLogin.mockResolvedValueOnce({bundle, account}) + await act(async () => { + await harness.api.login({} as never, 'LoginForm') + }) + return harness +} + +beforeEach(() => { + mockLogin.mockReset() +}) + +describe('refreshSession', () => { + it('resolves with the rotated account snapshot', async () => { + const fetchMock = makeMockFetch() + const {api} = await renderLoggedIn(makeAccount(), fetchMock) + + let refreshed: SessionAccount | undefined + await act(async () => { + refreshed = await api.refreshSession() + }) + + /* the mock's refresh response rotates both tokens */ + expect(refreshed?.accessJwt).toBe('access-jwt-2') + expect(refreshed?.refreshJwt).toBe('refresh-jwt-2') + expect(refreshed?.did).toBe(DID) + expect(refreshed?.handle).toBe(HANDLE) + }) + + it('exposes the fresh tokens before the store has caught up', async () => { + const fetchMock = makeMockFetch() + const {api, currentAccount} = await renderLoggedIn(makeAccount(), fetchMock) + + /* + * The point of the return value: `SignupQueued` branches on the fresh + * accessJwt synchronously, without waiting for `onUpdated` -> dispatch -> + * re-render. + */ + let refreshed: SessionAccount | undefined + const before = currentAccount()?.accessJwt + await act(async () => { + refreshed = await api.refreshSession() + }) + expect(before).toBe('access-jwt') + expect(refreshed?.accessJwt).toBe('access-jwt-2') + }) + + it('resolves with undefined when logged out', async () => { + const {api} = renderProvider() + + let refreshed: SessionAccount | undefined = makeAccount() + await act(async () => { + refreshed = await api.refreshSession() + }) + + expect(refreshed).toBeUndefined() + }) + + it('rejects when the refresh rotated nothing', async () => { + /* + * A transient failure: `PasswordSession.refresh()` reports through + * `onUpdateFailure` and resolves with the SAME data object. Callers read + * resolution as "tokens rotated", so this must reject. + */ + const fetchMock = makeMockFetch({ + 'com.atproto.server.refreshSession': () => + json({error: 'InternalServerError'}, 500), + }) + const {api} = await renderLoggedIn(makeAccount(), fetchMock) + + await expect( + act(async () => { + await api.refreshSession() + }), + ).rejects.toThrow('Failed to refresh session') + }) +}) diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 9cb2db6a59..578b4d26e7 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -1,4 +1,3 @@ -import {type AtpAgent} from '@atproto/api' import {type SessionData} from '@atproto/lex-password-session' import {describe, expect, it, jest} from '@jest/globals' @@ -18,21 +17,21 @@ jest.mock('../../../ageAssurance/state', () => ({ unsafeGetAndComputeAgeAssurance: () => ({state: {}}), })) jest.mock('#/lib/notifications/notifications', () => ({ - unregisterPushToken(_agents: AtpAgent[]) { + unregisterPushToken(_clients: unknown[]) { return Promise.resolve() }, })) /* * The logout and account-removal reducer cases fire a push-token side effect - * whose first step, `createTemporaryAgentsAndResume`, builds real `AtpAgent`s - * and resumes them over the real network. Under jest that request outlives the + * whose first step, `createTemporaryClientsAndResume`, resumes real + * `PasswordSession`s over the real network. Under jest that request outlives the * suite: it rejects after teardown, and the resulting `logger.error` reaches * for `nanoid` in an environment that no longer has it, failing whichever suite * happens to be running at that moment. Stubbing the module keeps the side * effect synchronous and offline. */ jest.mock('../util', () => ({ - createTemporaryAgentsAndResume: () => Promise.resolve([]), + createTemporaryClientsAndResume: () => Promise.resolve([]), })) // Reuse a bundle within each test: session events are scoped by bundle identity. diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 01bca93c36..730cdd9808 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -82,6 +82,7 @@ const ApiContext = createContext({ resumeSession: async () => {}, removeAccount: () => {}, partialRefreshSession: async () => {}, + refreshSession: () => Promise.resolve(undefined), }) ApiContext.displayName = 'SessionApiContext' @@ -473,6 +474,50 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) }, [store, cancelPendingTask]) + /** + * Rotate the session's tokens and hand back the resulting account snapshot. + * + * Rejects when the rotation was a no-op, restoring the contract the + * `agent.resumeSession(agent.session!)` call sites were written against (the + * bridge agent's `refreshSession` override does the same, for the same + * reason). `PasswordSession.refresh()` resolves with the + * unchanged `SessionData` on a transient failure - a 500 or a network error + * reported through `onUpdateFailure` - and reserves rejection for a + * definitively dead session. Callers here all read resolution as "tokens + * rotated": the verification dialogs close, `Deactivated` clears its error + * state, and `SignupQueued` re-checks the token scope, so a resolved no-op + * would report success or loop silently. Identity, not a field comparison, is + * the signal: `PasswordSession` allocates a new object per successful + * rotation and returns the existing one untouched otherwise. Capturing the + * data immediately before the call also handles concurrent refreshes, since a + * rotation another caller's queued refresh performed still differs from what + * we captured. + * + * Like {@link partialRefreshSession}, the bundle comes from + * `store.getState()` rather than the render's `state`: a dispatch landing + * before the next render would otherwise leave this holding a disposed + * bundle, and reading live also keeps the callback's identity stable across + * unrelated state updates. + */ + const refreshSession = useCallback< + SessionApiContext['refreshSession'] + >(async () => { + const bundle = store.getState().currentBundleState.bundle as unknown as + | SessionBundle + | PublicSessionBundle + if (!bundle.session) return undefined // logged out: nothing to refresh + const before = bundle.session.session + const after = await bundle.session.refresh() + if (after === before) { + throw new Error('Failed to refresh session') + } + /* + * The session's `onUpdated` hook dispatches the new tokens into the store, + * but that lands a render away; this snapshot exposes them immediately. + */ + return sessionDataToSessionAccount(after, after.service) + }, [store]) + const removeAccount = useCallback( account => { addSessionDebugLog({ @@ -607,6 +652,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { resumeSession, removeAccount, partialRefreshSession, + refreshSession, }), [ createAccount, @@ -616,6 +662,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { resumeSession, removeAccount, partialRefreshSession, + refreshSession, ], ) diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index 8571539f87..06ceb8ece6 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -3,7 +3,7 @@ import {logger} from '#/lib/notifications/util' import {wrapSessionReducerForLogging} from './logging' import {createPublicSessionBundle} from './session-core' import {type AtpSessionEvent, type SessionAccount} from './types' -import {createTemporaryAgentsAndResume} from './util' +import {createTemporaryClientsAndResume} from './util' // Keep session internals outside the reducer's static view of a bundle. type OpaqueSessionBundle = { @@ -155,8 +155,8 @@ let reducer = (state: State, action: Action): State => { // side effect const account = state.accounts.find(a => a.did === accountDid) if (account) { - createTemporaryAgentsAndResume([account]) - .then(agents => unregisterPushToken(agents)) + createTemporaryClientsAndResume([account]) + .then(clients => unregisterPushToken(clients)) .then(() => logger.debug('Push token unregistered', {did: accountDid}), ) @@ -183,8 +183,8 @@ let reducer = (state: State, action: Action): State => { // side effect const account = state.accounts.find(a => a.did === accountDid) if (account && accountDid) { - createTemporaryAgentsAndResume([account]) - .then(agents => unregisterPushToken(agents)) + createTemporaryClientsAndResume([account]) + .then(clients => unregisterPushToken(clients)) .then(() => logger.debug('Push token unregistered', {did: accountDid}), ) @@ -211,8 +211,8 @@ let reducer = (state: State, action: Action): State => { } } case 'logged-out-every-account': { - createTemporaryAgentsAndResume(state.accounts) - .then(agents => unregisterPushToken(agents)) + createTemporaryClientsAndResume(state.accounts) + .then(clients => unregisterPushToken(clients)) .then(() => logger.debug('Push token unregistered')) .catch(err => { logger.error('Failed to unregister push token', { diff --git a/src/state/session/types.ts b/src/state/session/types.ts index ca1bca62f1..c02e9cca7d 100644 --- a/src/state/session/types.ts +++ b/src/state/session/types.ts @@ -52,4 +52,17 @@ export type SessionApiContext = { * so it produces no session-change side effects. */ partialRefreshSession: () => Promise + /** + * Rotates the session's tokens and resolves with the resulting account + * snapshot, or `undefined` when logged out. + * + * Rejects when nothing was rotated, so a resolved promise means "tokens + * rotated". Every caller relies on that: the verification dialogs close on + * resolution, and `SignupQueued` re-checks the token scope. + * + * The snapshot is returned rather than read off `currentAccount`, because the + * session's `onUpdated` hook -> `store.dispatch` path is a render cycle away + * and `SignupQueued` branches synchronously on the fresh `accessJwt`. + */ + refreshSession: () => Promise } diff --git a/src/state/session/util.ts b/src/state/session/util.ts index af1c2846c7..5c3dcfb01c 100644 --- a/src/state/session/util.ts +++ b/src/state/session/util.ts @@ -1,7 +1,10 @@ -import AtpAgent from '@atproto/api' +import {PasswordSession} from '@atproto/lex-password-session' +import {createLexClient} from '#/lib/lexClient' +import {type TemporaryPushClient} from '#/lib/notifications/notifications' import * as persisted from '#/state/persisted' -import {sessionAccountToSession} from './session-data' +import {networkAwareFetch} from './network' +import {sessionAccountToSessionData} from './session-data' import {type SessionAccount} from './types' export {isSessionExpired, isSignupQueued} from './session-data' @@ -12,30 +15,41 @@ export function readLastActiveAccount() { } /** - * Creates and attempted to resumeSession for every stored session. - * Intended to be used to send push token revokations just before logout. + * Resume a single-use session per stored account, for the push-token revocation + * sent just before logout. + * + * The sessions carry no lifecycle hooks - no `onUpdated`, no `onDeleted` - so a + * rotation one of them performs can neither persist over nor race the live + * session's tokens. That isolation is load-bearing: each exists only long enough + * to authenticate one `unregisterPush` call. + * + * PDS routing is left to the session rather than pinned from the stored + * `pdsUrl`, because `resume` refreshes (and fills in a missing didDoc from + * `getSession`) before the client issues anything, so the request already goes + * to the didDoc PDS. + * + * `resume` rejects only when a session is definitively dead; a transient network + * failure resolves with the stored tokens, which are the same ones the old agent + * path would have sent. Definitively dead sessions drop out of the settled list. */ -export async function createTemporaryAgentsAndResume( +export async function createTemporaryClientsAndResume( accounts: SessionAccount[], -) { - const agents = await Promise.allSettled( +): Promise { + 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 { + client: createLexClient(session), + service: session.session.service, + handle: session.session.handle, + } satisfies TemporaryPushClient }), ) - return agents + return settled .filter(x => x.status === 'fulfilled') .map(promise => promise.value) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 1388ce0c45..3f44040444 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -285,6 +285,12 @@ export const ComposePost = ({ const pdsClient = usePdsClient() const queryClient = useQueryClient() const currentDid = currentAccount!.did + /* + * The host the video service-auth token is minted for. This is the same value + * that seeds the session's PDS routing, so the audience always matches the host + * the upload actually reaches; a mismatch would 401 the upload. + */ + const currentDispatchUrl = currentAccount!.pdsUrl ?? currentAccount!.service const {closeComposer} = useComposerControls() const {t: l, i18n} = useLingui() const requireAltTextEnabled = useRequireAltTextEnabled() @@ -479,7 +485,8 @@ export const ComposePost = ({ }, }) }, - agent, + pdsClient, + currentDispatchUrl, currentDid, abortController.signal, i18n, @@ -489,7 +496,8 @@ export const ComposePost = ({ [ l, i18n, - agent, + pdsClient, + currentDispatchUrl, currentDid, composerDispatch, ax.metric, @@ -661,7 +669,8 @@ export const ComposePost = ({ }, }) }, - agent, + pdsClient, + currentDispatchUrl, currentDid, abortController.signal, i18n, @@ -677,7 +686,8 @@ export const ComposePost = ({ [ l, i18n, - agent, + pdsClient, + currentDispatchUrl, currentDid, composerDispatch, ax.metric, diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index eeca7dca47..4ae825a7f1 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -1,5 +1,5 @@ import {type ImagePickerAsset} from 'expo-image-picker' -import {type AppBskyVideoDefs, type AtpAgent, type BlobRef} from '@atproto/api' +import {type BlobRef, type Client} from '@atproto/lex' import {type I18n} from '@lingui/core' import {msg} from '@lingui/core/macro' @@ -14,9 +14,10 @@ import { import {type VideoTelemetry} from '#/lib/media/video/telemetry' import {type CompressedVideo} from '#/lib/media/video/types' import {uploadVideo} from '#/lib/media/video/upload' -import {createVideoAgent} from '#/lib/media/video/util' +import {createTokenlessVideoServiceClient} from '#/lib/media/video/util' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' +import {app} from '#/lexicons' import { advanceVideoProgress, didSkipVideoCompression, @@ -56,7 +57,7 @@ export type VideoAction = } | { type: 'update_job_status' - jobStatus: AppBskyVideoDefs.JobStatus + jobStatus: app.bsky.video.defs.JobStatus signal: AbortSignal } @@ -126,7 +127,7 @@ type ProcessingState = { asset: ImagePickerAsset video: CompressedVideo jobId: string - jobStatus: AppBskyVideoDefs.JobStatus | null + jobStatus: app.bsky.video.defs.JobStatus | null pendingPublish?: undefined telemetry: VideoTelemetry altText: string @@ -295,7 +296,8 @@ function trunc2dp(num: number) { export async function processVideo( asset: ImagePickerAsset, dispatch: (action: VideoAction) => void, - agent: AtpAgent, + client: Client, + dispatchUrl: string | URL, did: string, signal: AbortSignal, i18n: I18n, @@ -339,12 +341,13 @@ export async function processVideo( signal, }) - let uploadResponse: AppBskyVideoDefs.JobStatus | undefined + let uploadResponse: app.bsky.video.defs.JobStatus | undefined try { telemetry.uploadStarted(video.size) uploadResponse = await uploadVideo({ video, - agent, + client, + dispatchUrl, did, signal, i18n, @@ -381,12 +384,14 @@ export async function processVideo( return // Exit async loop } - const videoAgent = createVideoAgent() - let status: AppBskyVideoDefs.JobStatus | undefined + const videoClient = createTokenlessVideoServiceClient() + let status: app.bsky.video.defs.JobStatus | undefined let blob: BlobRef | undefined try { - const response = await videoAgent.app.bsky.video.getJobStatus({jobId}) - status = response.data.jobStatus + const response = await videoClient.call(app.bsky.video.getJobStatus, { + jobId, + }) + status = response.jobStatus pollFailures = 0 if (status.state === 'JOB_STATE_COMPLETED') {