phase 4: migrate account flows, trending queries, notifications, and report subjects off the bridge

This commit is contained in:
Samuel Newman
2026-07-17 12:46:12 +03:00
parent 5a099a8db6
commit bab2b621a9
57 changed files with 724 additions and 494 deletions
+14 -4
View File
@@ -1,10 +1,17 @@
import {type Insets, Platform} from 'react-native'
import {type Service} from '@atproto/lex-client'
import {api} from '@bsky.app/sdk'
import {type ProxyHeaderValue} from '#/state/session/agent'
import {BLUESKY_PROXY_DID, IS_DEV} from '#/env'
import {type app} from '#/lexicons'
/**
* The `atproto-proxy` header value: a DID plus a service fragment, e.g.
* `did:web:api.bsky.app#bsky_appview`. Kept local to this module (previously
* lived in the now-removed session `agent.ts` compat layer).
*/
type ProxyHeaderValue = `did:${string}:${string}#${string}`
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev'
@@ -251,9 +258,12 @@ export const BLUESKY_MOD_SERVICE_HEADERS = {
'atproto-proxy': `${api.moderation.did}#atproto_labeler`,
}
export const BLUESKY_NOTIF_SERVICE_HEADERS = {
'atproto-proxy': `${BLUESKY_PROXY_DID}#bsky_notif`,
}
/**
* Service proxy identifier for the notification/entryway service. Passed as the
* per-call `service` option on the account client so lex-client emits the
* `atproto-proxy` header (replaces the old `BLUESKY_NOTIF_SERVICE_HEADERS`).
*/
export const NOTIF_SERVICE = `${BLUESKY_PROXY_DID}#bsky_notif` as Service
export const webLinks = {
tos: `https://bsky.social/about/support/tos`,
@@ -0,0 +1,68 @@
import {Client} from '@atproto/lex-client'
import {describe, expect, it} from '@jest/globals'
import {NOTIF_SERVICE} from '#/lib/constants'
import {app} from '#/lexicons'
/*
* Proxy-emission guard for the push-notification register/unregister calls.
*
* registerPush/unregisterPush move from an explicit `atproto-proxy` header on
* the old bridge agent to a per-call `service` option on the lex account
* client (see notifications.ts). This test proves the notif service DID
* actually reaches the wire as the `atproto-proxy` header when that option is
* used, so a wrong proxy target cannot fail silently (design Risk #2).
*
* The technique mirrors clients-bundle-test.ts: build a Client over a fake
* `fetchHandler` agent (no session/native chain), issue a real `Client.call`
* with the same per-call `service: NOTIF_SERVICE` option the notification
* calls use, and assert the emitted request header. Procedure request bodies
* cannot be encoded under the jest CID interop, so the call uses a query - the
* `service` -> `atproto-proxy` header path is shared by queries and procedures
* alike, so this faithfully exercises what registerPush/unregisterPush emit.
*/
const DID = 'did:plc:example123'
const HANDLE = 'alice.test'
const SERVICE_ORIGIN = 'https://bsky.social'
function makeCapturingClient() {
const seen: {path: string; headers: Headers}[] = []
const client = new Client({
did: DID,
fetchHandler: (path, init) => {
seen.push({path, headers: new Headers(init.headers)})
return Promise.resolve(
new Response(JSON.stringify({did: DID, handle: HANDLE}), {
status: 200,
headers: {'content-type': 'application/json'},
}),
)
},
})
return {seen, client}
}
describe('notifications proxy emission', () => {
it('NOTIF_SERVICE targets the notif service fragment', () => {
/* the constant is the single source of the proxy DID reaching the wire */
expect(NOTIF_SERVICE).toMatch(/#bsky_notif$/)
})
it('emits atproto-proxy: <NOTIF_SERVICE> when the per-call service option is set', async () => {
const {seen, client} = makeCapturingClient()
await client
.call(
app.bsky.actor.getProfile,
{actor: HANDLE},
{service: NOTIF_SERVICE},
)
.catch(() => {})
expect(seen.length).toBe(1)
expect(seen[0].headers.get('atproto-proxy')).toBe(NOTIF_SERVICE)
/* the account origin is never the proxy target */
expect(seen[0].headers.get('atproto-proxy')).not.toContain(SERVICE_ORIGIN)
})
})
+31 -17
View File
@@ -2,34 +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 Client} from '@atproto/lex-client'
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 SessionAgent} from '#/state/session/session-core'
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 {type app} from '#/lexicons'
import {app} from '#/lexicons'
/**
* A resumed throwaway 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 correct 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: SessionAgent
client: Client
currentAccount: SessionAccount
token: Notifications.DevicePushToken
extra?: {
@@ -49,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`)
@@ -75,7 +88,7 @@ const _registerPushTokenDebounced = debounce(_registerPushToken, 100)
* `_registerPushTokenDebounced` directly.
*/
export function useRegisterPushToken() {
const agent = useAgent()
const client = usePdsClient()
const {currentAccount} = useSession()
return useCallback(
@@ -88,7 +101,7 @@ export function useRegisterPushToken() {
}) => {
if (!currentAccount) return
return _registerPushTokenDebounced({
agent,
client,
currentAccount,
token,
extra: {
@@ -96,7 +109,7 @@ export function useRegisterPushToken() {
},
})
},
[agent, currentAccount],
[client, currentAccount],
)
}
@@ -327,16 +340,17 @@ export async function resetBadgeCount() {
await setBadgeCountAsync(0)
}
export async function unregisterPushToken(agents: SessionAgent[]) {
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,
@@ -344,10 +358,10 @@ export async function unregisterPushToken(agents: SessionAgent[]) {
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')