forward labelers to chat

This commit is contained in:
Samuel Newman
2026-08-26 12:54:08 +01:00
parent 5b88335bb1
commit 55c5f9dc9c
6 changed files with 89 additions and 25 deletions
+6 -5
View File
@@ -38,7 +38,7 @@ import {
type UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types'
import {createQueryKey} from '#/state/queries/util'
import {useAppviewClient, usePdsClient} from '#/state/session'
import {useAppviewClient, useChatClient, usePdsClient} from '#/state/session'
import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation'
import {useAgeAssurance} from '#/ageAssurance'
import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util'
@@ -58,6 +58,7 @@ export const preferencesQueryKey = createQueryKey(
export function usePreferencesQuery() {
const client = usePdsClient()
const appviewClient = useAppviewClient()
const chatClient = useChatClient()
const aa = useAgeAssurance()
const query = useQuery({
@@ -85,12 +86,12 @@ export function usePreferencesQuery() {
* from a labeler would not affect server-attached labels until the
* session bundle was rebuilt.
*
* The subscriptions go on the appview client, which is what stamps
* `atproto-accept-labelers` on its own requests. The Bluesky moderation
* DID is dropped so the globally redacted authority is not also listed
* unredacted.
* The subscriptions go on both services that hydrate moderated content.
* The Bluesky moderation DID is dropped so the globally redacted
* authority is not also listed unredacted.
*/
applyLabelersToClient(appviewClient, labelerDids)
applyLabelersToClient(chatClient, labelerDids)
/*
* `BskyPreferences` is now the sdk's own type, so the assembled
+26 -10
View File
@@ -251,19 +251,35 @@ describe('buildChatClient', () => {
expect(headers.get('authorization')).toBe('Bearer access-jwt')
})
it('emits no labeler header', async () => {
/* the global authorities do not apply: a chat call is not an appview read */
it('emits a global app labeler once, redacted', async () => {
const client = buildChatClient(makeSession(fetchMock))
configureGlobalAppLabelers(['did:plc:global-labeler'])
await buildChatClient(makeSession(fetchMock))
.call(chat.bsky.convo.listConvos, {})
.catch(() => {})
await client.call(chat.bsky.convo.listConvos, {}).catch(() => {})
expect(
headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
'atproto-accept-labelers',
),
).toBeNull()
const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
'atproto-accept-labelers',
)
const entries = labelers!
.split(',')
.map(l => l.trim())
.filter(l => l.includes('did:plc:global-labeler'))
expect(entries).toEqual(['did:plc:global-labeler;redact'])
})
it('emits an account subscription exactly once', async () => {
const client = buildChatClient(makeSession(fetchMock))
client.setLabelers(['did:plc:labeler'])
await client.call(chat.bsky.convo.listConvos, {}).catch(() => {})
const labelers = headersFor(fetchMock, 'chat.bsky.convo.listConvos').get(
'atproto-accept-labelers',
)
const entries = labelers!
.split(',')
.filter(l => l.includes('did:plc:labeler'))
expect(entries).toHaveLength(1)
})
})
@@ -0,0 +1,45 @@
import {type Client} from '@atproto/lex'
import {api} from '@bsky/sdk'
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
jest.mock('#/storage', () => ({
account: {
get: jest.fn(),
set: jest.fn(),
},
device: {
get: jest.fn(),
},
}))
import {account} from '#/storage'
import {configureGlobalAppLabelers} from '../additional-moderation-authorities'
import {configureModerationForAccount} from '../moderation'
import {makeAccount} from './mock-fetch'
describe('configureModerationForAccount', () => {
beforeEach(() => {
jest.clearAllMocks()
configureGlobalAppLabelers([])
})
it('applies cached account labelers to appview and chat', () => {
const appviewClient = {setLabelers: jest.fn()} as unknown as Client
const chatClient = {setLabelers: jest.fn()} as unknown as Client
jest
.mocked(account.get)
.mockReturnValue(['did:plc:account-labeler', api.moderation.did])
configureModerationForAccount(
{appviewClient, chatClient},
makeAccount({handle: 'alice.example.com'}),
)
expect(appviewClient.setLabelers).toHaveBeenCalledWith([
'did:plc:account-labeler',
])
expect(chatClient.setLabelers).toHaveBeenCalledWith([
'did:plc:account-labeler',
])
})
})
@@ -98,10 +98,10 @@ export function configureAdditionalModerationAuthorities() {
* reads, so a request carries the same `;redact` authorities whether or not
* there is a session behind it.
*
* It is a single global producer by design. The PDS and chat clients opt out
* with `appLabelers: null` (see `clients.ts`) because those services take no
* moderation authorities, leaving exactly one producer on an appview request and
* none elsewhere.
* It is a single global producer by design. The PDS client opts out with
* `appLabelers: null` (see `clients.ts`) because that service takes no
* moderation authorities. Appview and chat requests each carry one copy from
* their respective clients.
*/
export function configureGlobalAppLabelers(dids: string[]) {
Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
+4 -3
View File
@@ -54,12 +54,13 @@ export function buildPdsClient(agent: Agent): Client {
* env-configurable `CHAT_PROXY_DID` rather than a hard-coded constant, so it can
* be retargeted per environment.
*
* `appLabelers: null` for the same reason as the PDS client: the chat service
* takes no moderation authorities.
* Unlike the PDS client, chat carries moderation authorities. The service uses
* them to hydrate labels on profiles embedded in conversation responses, so
* this client reads the global `Client.appLabelers` and receives the account's
* subscriptions through `configureModerationForAccount`.
*/
export function buildChatClient(agent: Agent): Client {
return createLexClient(agent, {
appLabelers: null,
service: CHAT_PROXY_SERVICE,
})
}
+4 -3
View File
@@ -12,7 +12,7 @@ import {
import {type SessionAccount} from './types'
/** The moderation surface of a session bundle. */
type ModerationSession = {appviewClient: Client}
type ModerationSession = {appviewClient: Client; chatClient: Client}
/**
* Cache an account's subscribed labeler DIDs. Called on every preferences
@@ -45,8 +45,8 @@ export function readLabelers(did: string): string[] | undefined {
* lex collects the two lists into a `Set` keyed on the suffixed string, so
* neither dedupes against the other.
*
* Only the appview client takes subscriptions - the PDS and chat clients suppress
* labelers entirely (see clients.ts).
* Appview and chat both take subscriptions. The PDS suppresses labelers because
* repo and identity requests do not hydrate moderated content (see clients.ts).
*/
export function applyLabelersToClient(
client: Client,
@@ -85,6 +85,7 @@ export function configureModerationForAccount(
const labelerDids = readLabelers(account.did)
if (labelerDids) {
applyLabelersToClient(bundle.appviewClient, labelerDids)
applyLabelersToClient(bundle.chatClient, labelerDids)
} else {
// If there are no headers in the storage, we'll not send them on the initial requests.
// If we wanted to fix this, we could block on the preferences query here.