drop the agent url reads in link meta and push registration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-04 01:10:28 +03:00
parent 4f669812b9
commit 88bc368991
8 changed files with 92 additions and 66 deletions
+2 -3
View File
@@ -23,7 +23,7 @@ import {
useMaybeProfileShadow, useMaybeProfileShadow,
} from '#/state/cache/profile-shadow' } from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' 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 {useTickEveryMinute} from '#/state/shell'
import {useDialogContext} from '#/components/Dialog' import {useDialogContext} from '#/components/Dialog'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
@@ -194,7 +194,6 @@ export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig() const liveNowConfig = useLiveNowConfig()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent()
return useQuery({ return useQuery({
enabled: !!url, enabled: !!url,
queryKey: ['link-meta', url], queryKey: ['link-meta', url],
@@ -212,7 +211,7 @@ export function useLiveLinkMetaQuery(url: string | null) {
) )
} }
return await getLinkMeta(agent, url) return await getLinkMeta(url)
}, },
}) })
} }
+3 -6
View File
@@ -184,7 +184,7 @@ export async function resolveLink(
view: res.data.starterPack, view: res.data.starterPack,
} }
} }
return resolveExternal(agent, uri) return resolveExternal(uri)
// Forked from useGetPost. TODO: move into RQ. // Forked from useGetPost. TODO: move into RQ.
async function getPost({uri}: {uri: string}) { 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 return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined
} }
async function resolveExternal( async function resolveExternal(uri: string): Promise<ResolvedExternalLink> {
agent: AtpAgent, const result = await getLinkMeta(uri)
uri: string,
): Promise<ResolvedExternalLink> {
const result = await getLinkMeta(agent, uri)
return { return {
type: 'external', type: 'external',
uri: result.url, uri: result.url,
+8 -3
View File
@@ -279,9 +279,14 @@ export const DM_SERVICE_HEADERS = {
'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`, '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:
* <this value>` 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 = { export const webLinks = {
tos: `https://bsky.social/about/support/tos`, tos: `https://bsky.social/about/support/tos`,
+2 -5
View File
@@ -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 {LINK_META_PROXY} from '#/lib/constants'
import {getGiphyMetaUri} from '#/lib/strings/embed-player' import {getGiphyMetaUri} from '#/lib/strings/embed-player'
@@ -31,7 +31,6 @@ export interface LinkMeta {
} }
export async function getLinkMeta( export async function getLinkMeta(
agent: AtpAgent,
url: string, url: string,
timeout = 15e3, timeout = 15e3,
): Promise<LinkMeta> { ): Promise<LinkMeta> {
@@ -80,9 +79,7 @@ export async function getLinkMeta(
try { try {
const response = await fetch( const response = await fetch(
`${LINK_META_PROXY(agent.serviceUrl.toString() || '')}${encodeURIComponent( `${LINK_META_PROXY('')}${encodeURIComponent(url)}`,
url,
)}`,
{signal: controller.signal}, {signal: controller.signal},
) )
+32 -17
View File
@@ -2,33 +2,47 @@ import {useCallback, useEffect} from 'react'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {getBadgeCountAsync, setBadgeCountAsync} 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 debounce from 'lodash.debounce'
import { import {
BLUESKY_NOTIF_SERVICE_HEADERS, NOTIF_SERVICE,
PUBLIC_APPVIEW_DID, PUBLIC_APPVIEW_DID,
PUBLIC_STAGING_APPVIEW_DID, PUBLIC_STAGING_APPVIEW_DID,
} from '#/lib/constants' } from '#/lib/constants'
import {logger as notyLogger} from '#/lib/notifications/util' import {logger as notyLogger} from '#/lib/notifications/util'
import {isNetworkError} from '#/lib/strings/errors' 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 BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler'
import {useAgeAssurance} from '#/ageAssurance' import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_DEV, IS_NATIVE} from '#/env' 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 * @private
* Registers the device's push notification token with the Bluesky server. * Registers the device's push notification token with the Bluesky server.
*/ */
async function _registerPushToken({ async function _registerPushToken({
agent, client,
currentAccount, currentAccount,
token, token,
extra = {}, extra = {},
}: { }: {
agent: AtpAgent client: Client
currentAccount: SessionAccount currentAccount: SessionAccount
token: Notifications.DevicePushToken token: Notifications.DevicePushToken
extra?: { extra?: {
@@ -36,7 +50,7 @@ async function _registerPushToken({
} }
}) { }) {
try { try {
const payload: AppBskyNotificationRegisterPush.InputSchema = { const payload: app.bsky.notification.registerPush.$InputBody = {
serviceDid: currentAccount.service?.includes('staging') serviceDid: currentAccount.service?.includes('staging')
? PUBLIC_STAGING_APPVIEW_DID ? PUBLIC_STAGING_APPVIEW_DID
: PUBLIC_APPVIEW_DID, : PUBLIC_APPVIEW_DID,
@@ -48,8 +62,8 @@ async function _registerPushToken({
notyLogger.debug(`registerPushToken: registering`, {...payload}) notyLogger.debug(`registerPushToken: registering`, {...payload})
await agent.app.bsky.notification.registerPush(payload, { await client.call(app.bsky.notification.registerPush, payload, {
headers: BLUESKY_NOTIF_SERVICE_HEADERS, service: NOTIF_SERVICE,
}) })
notyLogger.debug(`registerPushToken: success`) notyLogger.debug(`registerPushToken: success`)
@@ -74,7 +88,7 @@ const _registerPushTokenDebounced = debounce(_registerPushToken, 100)
* `_registerPushTokenDebounced` directly. * `_registerPushTokenDebounced` directly.
*/ */
export function useRegisterPushToken() { export function useRegisterPushToken() {
const agent = useAgent() const client = usePdsClient()
const {currentAccount} = useSession() const {currentAccount} = useSession()
return useCallback( return useCallback(
@@ -87,7 +101,7 @@ export function useRegisterPushToken() {
}) => { }) => {
if (!currentAccount) return if (!currentAccount) return
return _registerPushTokenDebounced({ return _registerPushTokenDebounced({
agent, client,
currentAccount, currentAccount,
token, token,
extra: { extra: {
@@ -95,7 +109,7 @@ export function useRegisterPushToken() {
}, },
}) })
}, },
[agent, currentAccount], [client, currentAccount],
) )
} }
@@ -326,16 +340,17 @@ export async function resetBadgeCount() {
await setBadgeCountAsync(0) await setBadgeCountAsync(0)
} }
export async function unregisterPushToken(agents: AtpAgent[]) { export async function unregisterPushToken(clients: TemporaryPushClient[]) {
if (!IS_NATIVE) return if (!IS_NATIVE) return
try { try {
const token = await getPushToken() const token = await getPushToken()
if (token) { if (token) {
for (const agent of agents) { for (const {client, service, handle} of clients) {
await agent.app.bsky.notification.unregisterPush( await client.call(
app.bsky.notification.unregisterPush,
{ {
serviceDid: agent.serviceUrl.hostname.includes('staging') serviceDid: service.includes('staging')
? PUBLIC_STAGING_APPVIEW_DID ? PUBLIC_STAGING_APPVIEW_DID
: PUBLIC_APPVIEW_DID, : PUBLIC_APPVIEW_DID,
platform: Platform.OS, platform: Platform.OS,
@@ -343,10 +358,10 @@ export async function unregisterPushToken(agents: AtpAgent[]) {
appId: 'xyz.blueskyweb.app', 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 { } else {
notyLogger.debug('Tried to unregister push token, but could not find one') notyLogger.debug('Tried to unregister push token, but could not find one')
+4 -5
View File
@@ -1,4 +1,3 @@
import {type AtpAgent} from '@atproto/api'
import {type SessionData} from '@atproto/lex-password-session' import {type SessionData} from '@atproto/lex-password-session'
import {describe, expect, it, jest} from '@jest/globals' import {describe, expect, it, jest} from '@jest/globals'
@@ -18,21 +17,21 @@ jest.mock('../../../ageAssurance/state', () => ({
unsafeGetAndComputeAgeAssurance: () => ({state: {}}), unsafeGetAndComputeAgeAssurance: () => ({state: {}}),
})) }))
jest.mock('#/lib/notifications/notifications', () => ({ jest.mock('#/lib/notifications/notifications', () => ({
unregisterPushToken(_agents: AtpAgent[]) { unregisterPushToken(_clients: unknown[]) {
return Promise.resolve() return Promise.resolve()
}, },
})) }))
/* /*
* The logout and account-removal reducer cases fire a push-token side effect * The logout and account-removal reducer cases fire a push-token side effect
* whose first step, `createTemporaryAgentsAndResume`, builds real `AtpAgent`s * whose first step, `createTemporaryClientsAndResume`, resumes real
* and resumes them over the real network. Under jest that request outlives the * `PasswordSession`s over the real network. Under jest that request outlives the
* suite: it rejects after teardown, and the resulting `logger.error` reaches * suite: it rejects after teardown, and the resulting `logger.error` reaches
* for `nanoid` in an environment that no longer has it, failing whichever suite * 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 * happens to be running at that moment. Stubbing the module keeps the side
* effect synchronous and offline. * effect synchronous and offline.
*/ */
jest.mock('../util', () => ({ jest.mock('../util', () => ({
createTemporaryAgentsAndResume: () => Promise.resolve([]), createTemporaryClientsAndResume: () => Promise.resolve([]),
})) }))
// Reuse a bundle within each test: session events are scoped by bundle identity. // Reuse a bundle within each test: session events are scoped by bundle identity.
+7 -7
View File
@@ -3,7 +3,7 @@ import {logger} from '#/lib/notifications/util'
import {wrapSessionReducerForLogging} from './logging' import {wrapSessionReducerForLogging} from './logging'
import {createPublicSessionBundle} from './session-core' import {createPublicSessionBundle} from './session-core'
import {type AtpSessionEvent, type SessionAccount} from './types' 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. // Keep session internals outside the reducer's static view of a bundle.
type OpaqueSessionBundle = { type OpaqueSessionBundle = {
@@ -155,8 +155,8 @@ let reducer = (state: State, action: Action): State => {
// side effect // side effect
const account = state.accounts.find(a => a.did === accountDid) const account = state.accounts.find(a => a.did === accountDid)
if (account) { if (account) {
createTemporaryAgentsAndResume([account]) createTemporaryClientsAndResume([account])
.then(agents => unregisterPushToken(agents)) .then(clients => unregisterPushToken(clients))
.then(() => .then(() =>
logger.debug('Push token unregistered', {did: accountDid}), logger.debug('Push token unregistered', {did: accountDid}),
) )
@@ -183,8 +183,8 @@ let reducer = (state: State, action: Action): State => {
// side effect // side effect
const account = state.accounts.find(a => a.did === accountDid) const account = state.accounts.find(a => a.did === accountDid)
if (account && accountDid) { if (account && accountDid) {
createTemporaryAgentsAndResume([account]) createTemporaryClientsAndResume([account])
.then(agents => unregisterPushToken(agents)) .then(clients => unregisterPushToken(clients))
.then(() => .then(() =>
logger.debug('Push token unregistered', {did: accountDid}), logger.debug('Push token unregistered', {did: accountDid}),
) )
@@ -211,8 +211,8 @@ let reducer = (state: State, action: Action): State => {
} }
} }
case 'logged-out-every-account': { case 'logged-out-every-account': {
createTemporaryAgentsAndResume(state.accounts) createTemporaryClientsAndResume(state.accounts)
.then(agents => unregisterPushToken(agents)) .then(clients => unregisterPushToken(clients))
.then(() => logger.debug('Push token unregistered')) .then(() => logger.debug('Push token unregistered'))
.catch(err => { .catch(err => {
logger.error('Failed to unregister push token', { logger.error('Failed to unregister push token', {
+34 -20
View File
@@ -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 * as persisted from '#/state/persisted'
import {sessionAccountToSession} from './session-data' import {networkAwareFetch} from './network'
import {sessionAccountToSessionData} from './session-data'
import {type SessionAccount} from './types' import {type SessionAccount} from './types'
export {isSessionExpired, isSignupQueued} from './session-data' export {isSessionExpired, isSignupQueued} from './session-data'
@@ -12,30 +15,41 @@ export function readLastActiveAccount() {
} }
/** /**
* Creates and attempted to resumeSession for every stored session. * Resume a single-use session per stored account, for the push-token revocation
* Intended to be used to send push token revokations just before logout. * 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[], accounts: SessionAccount[],
) { ): Promise<TemporaryPushClient[]> {
const agents = await Promise.allSettled( const settled = await Promise.allSettled(
accounts.map(async account => { accounts.map(async account => {
const agent: AtpAgent = new AtpAgent({service: account.service}) const session = await PasswordSession.resume(
if (account.pdsUrl) { sessionAccountToSessionData(account),
agent.sessionManager.pdsUrl = new URL(account.pdsUrl) {fetch: networkAwareFetch},
} )
return {
const session = sessionAccountToSession(account) client: createLexClient(session),
const res = await agent.resumeSession(session) service: session.session.service,
if (!res.success) throw new Error('Failed to resume session') handle: session.session.handle,
} satisfies TemporaryPushClient
agent.assertAuthenticated() // confirm auth success
return agent
}), }),
) )
return agents return settled
.filter(x => x.status === 'fulfilled') .filter(x => x.status === 'fulfilled')
.map(promise => promise.value) .map(promise => promise.value)
} }