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/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/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/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/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/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) }