From 5f539827dabf62f808746557a6dfca975b917bc7 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 4 Aug 2026 02:48:52 +0300 Subject: [PATCH] set the global app labelers on the lex Client static `configureGlobalAppLabelers` now writes both `AtpAgent.configure` and `Client.configure`, so a client built without a wrapped agent carries the global `;redact` moderation authorities. That is what the logged-out appview client needs: it has no agent underneath to stamp the header, and after the bundle rework no client will. Flipping the static alone double-emits, which two probe runs confirmed. Neither producer dedupes against the other - the agent joins its list with whatever is already on the request while lex collects into a `Set` keyed on the `;redact`-suffixed string - so a DID in both statics produced `did:plc:x;redact, did:plc:x;redact`. Separately, the PDS and chat clients wrap the session manager *below* the agent's header layer and so emitted no labelers at all; the static would have started adding them to non-appview requests. Both are closed by building the three agent-wrapping clients with `appLabelers: null`, which suppresses the class-wide static per instance. That leaves exactly one producer on an appview request and none on a PDS or chat one, while the static stays populated for the clients that read only it. The labeler-header tests cover all three surfaces and fail without the suppression. The per-account subscriptions stay on the agent for now, behind `applyLabelersToClient`, because the agent is still what stamps the header on the wrapping client's requests. The bundle-rework slice repoints that one function body at `appviewClient.setLabelers` and drops the agent statics; the `Client.configure` half of `configureGlobalAppLabelers` and the `appLabelers: null` on the appview client both stay as they are. `configureModerationForAccount` takes the bundle rather than a bare agent, matching the signature the bundle rework needs. --- src/state/queries/preferences/index.ts | 17 ++++-- src/state/session/__tests__/clients-test.ts | 31 ++++++++++ .../session/__tests__/session-core-test.ts | 12 ++-- .../additional-moderation-authorities.ts | 21 ++++++- src/state/session/clients.ts | 60 +++++++++++++------ src/state/session/create-account.ts | 2 +- src/state/session/moderation.ts | 43 ++++++++++--- src/state/session/session-core.ts | 10 ++-- 8 files changed, 150 insertions(+), 46 deletions(-) diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 74edee568f..449458c075 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -40,7 +40,7 @@ import { } from '#/state/queries/preferences/types' import {createQueryKey} from '#/state/queries/util' import {useAgent, usePdsClient} from '#/state/session' -import {saveLabelers} from '#/state/session/moderation' +import {applyLabelersToClient, saveLabelers} from '#/state/session/moderation' import {useAgeAssurance} from '#/ageAssurance' import {makeAgeRestrictedModerationPrefs} from '#/ageAssurance/util' import {useAnalytics} from '#/analytics' @@ -81,12 +81,17 @@ export function usePreferencesQuery() { /* * `BskyAgent.getPreferences` used to call `configureLabelers` itself, * so subscribing to a labeler took effect on the very next appview - * read. The sdk action has no such side effect, and appview reads still - * inherit `atproto-accept-labelers` from the agent's fetch handler, so - * apply the subscriptions there rather than on the wrapping client - - * setting them on both would emit the header twice. + * read. The sdk action has no such side effect, so apply the + * subscriptions here - without this, subscribing to or unsubscribing + * from a labeler would not affect server-attached labels until the + * session bundle was rebuilt. + * + * `applyLabelersToClient` writes to the agent, which is what stamps the + * header on the requests the wrapping appview client issues, and it + * drops the Bluesky moderation DID so the globally redacted authority is + * not also listed unredacted. */ - agent.configureLabelers(labelerDids) + applyLabelersToClient(agent, labelerDids) /* * `BskyPreferences` is now the sdk's own type, so the assembled diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts index 452de11d9d..019281f784 100644 --- a/src/state/session/__tests__/clients-test.ts +++ b/src/state/session/__tests__/clients-test.ts @@ -15,6 +15,7 @@ jest.mock('jwt-decode', () => ({ import {CHAT_PROXY_SERVICE} from '#/lib/constants' import {app, chat, com} from '#/lexicons' +import {configureGlobalAppLabelers} from '../additional-moderation-authorities' import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent' import { agentToAppviewClient, @@ -163,6 +164,32 @@ describe('agentToAppviewClient', () => { expect(entries).toHaveLength(1) }) + it('does not duplicate a global app labeler set on both statics', async () => { + /* + * `configureGlobalAppLabelers` populates the agent AND the lex `Client` + * static, because clients built without a wrapped agent read only the + * latter. On this path both producers are in play for the same request, and + * neither dedupes against the other - the agent joins its list with the + * existing header string while lex collects into a `Set` keyed on the + * `;redact`-suffixed value. The appview client suppresses its `appLabelers` + * so exactly one producer contributes. + */ + configureGlobalAppLabelers(['did:plc:global-labeler']) + const {agent} = setup(fetchMock) + + await agentToAppviewClient(agent).call(app.bsky.actor.getProfile, { + actor: HANDLE, + }) + + const init = initFor(fetchMock, 'app.bsky.actor.getProfile') + const labelers = new Headers(init?.headers).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('sends the session access token', async () => { const {agent} = setup(fetchMock) @@ -238,6 +265,8 @@ describe('agentToPdsClient', () => { const {agent} = setup(fetchMock) agent.configureProxy('did:web:api.bsky.app#bsky_appview') agent.configureLabelersHeader(['did:plc:labeler']) + /* nor the global authorities: a PDS call is not an appview read */ + configureGlobalAppLabelers(['did:plc:global-labeler']) await agentToPdsClient(agent).call(com.atproto.server.getSession, {}) @@ -302,6 +331,8 @@ describe('agentToChatClient', () => { it('does not emit the agent labeler header', async () => { const {agent} = setup(fetchMock) agent.configureLabelersHeader(['did:plc:labeler']) + /* nor the global authorities: a chat call is not an appview read */ + configureGlobalAppLabelers(['did:plc:global-labeler']) await agentToChatClient(agent) .call(chat.bsky.convo.listConvos, {}) diff --git a/src/state/session/__tests__/session-core-test.ts b/src/state/session/__tests__/session-core-test.ts index 9dbc646c9f..52484dcf72 100644 --- a/src/state/session/__tests__/session-core-test.ts +++ b/src/state/session/__tests__/session-core-test.ts @@ -838,8 +838,8 @@ describe('factory account snapshot after preparation', () => { */ let capturedAgent: BskyAppAgent | undefined mockConfigureModerationForAccount.mockImplementationOnce( - (agent: unknown) => { - capturedAgent = agent as BskyAppAgent + (bundle: unknown) => { + capturedAgent = (bundle as {agent: BskyAppAgent}).agent }, ) mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => { @@ -904,8 +904,8 @@ describe('a session destroyed or rejected during preparation', () => { */ let capturedAgent: BskyAppAgent | undefined mockConfigureModerationForAccount.mockImplementationOnce( - (agent: unknown) => { - capturedAgent = agent as BskyAppAgent + (bundle: unknown) => { + capturedAgent = (bundle as {agent: BskyAppAgent}).agent }, ) mockPrefetchAgeAssuranceServerData.mockImplementationOnce(async () => { @@ -934,8 +934,8 @@ describe('a session destroyed or rejected during preparation', () => { it('resume: a prep rejection propagates and disposes the bundle', async () => { let capturedAgent: BskyAppAgent | undefined mockConfigureModerationForAccount.mockImplementationOnce( - (agent: unknown) => { - capturedAgent = agent as BskyAppAgent + (bundle: unknown) => { + capturedAgent = (bundle as {agent: BskyAppAgent}).agent }, ) mockPrefetchAgeAssuranceServerData.mockImplementationOnce(() => diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts index 63ada2cdd8..3f3566c5c6 100644 --- a/src/state/session/additional-moderation-authorities.ts +++ b/src/state/session/additional-moderation-authorities.ts @@ -1,4 +1,5 @@ import {AtpAgent} from '@atproto/api' +import {Client} from '@atproto/lex' import {device} from '#/storage' @@ -86,5 +87,23 @@ export function configureAdditionalModerationAuthorities() { new Set([...AtpAgent.appLabelers, ...additionalLabelers]), ) - AtpAgent.configure({appLabelers}) + configureGlobalAppLabelers(appLabelers) +} + +/** + * Set the global app labelers on BOTH statics, so the agent-backed request path + * and any client built without a wrapped agent emit the same `;redact` + * authorities. + * + * Keeping the two in lockstep is what makes the duplicate-header hazard + * avoidable: the agent joins its own list with whatever the caller already put + * on the request, and lex appends the `Client` static on top of that, so a DID + * present in both would appear twice. The agent-wrapping clients therefore + * suppress their `appLabelers` (see `clients.ts`), which leaves exactly one + * producer per request while both statics stay populated for the paths that + * read only one of them. + */ +export function configureGlobalAppLabelers(dids: string[]) { + AtpAgent.configure({appLabelers: dids}) + Client.configure({appLabelers: dids as `did:${string}:${string}`[]}) } diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts index 4daf5368b3..fab530aba2 100644 --- a/src/state/session/clients.ts +++ b/src/state/session/clients.ts @@ -31,18 +31,30 @@ const chatClients = new WeakMap() * routing. Because the agent already emits both headers, the client is * deliberately built with neither a `service` option nor labelers - setting * either here would emit them a second time. + * + * `appLabelers: null` suppresses the class-wide `Client.appLabelers` for this + * instance specifically. The static is populated (see + * `configureGlobalAppLabelers`) so that clients built without a wrapped agent + * carry the global authorities, but the agent already stamped those same DIDs + * onto the request, and lex would append its own copy on top: the agent joins + * its list with the existing header value while lex collects into a `Set` keyed + * on the suffixed string, so neither dedupes against the other and every global + * authority would appear twice. */ export function agentToAppviewClient(agent: BskyAppAgent): Client { const existing = appviewClients.get(agent) if (existing) { return existing } - const client = createLexClient({ - get did() { - return agent.did + const client = createLexClient( + { + get did() { + return agent.did + }, + fetchHandler: (path, init) => agent.fetchHandler(path, init), }, - fetchHandler: (path, init) => agent.fetchHandler(path, init), - }) + {appLabelers: null}, + ) appviewClients.set(agent, client) return client } @@ -58,7 +70,10 @@ export function agentToAppviewClient(agent: BskyAppAgent): Client { * for `com.atproto.*` repo/server/identity calls. * * No `service` option for the same reason: adding one would reintroduce the - * proxy header this client exists to avoid. + * proxy header this client exists to avoid. `appLabelers: null` is the same + * kind of suppression: a PDS request is not an appview read, so it must carry no + * moderation authorities at all - without this it would start emitting the + * global `Client.appLabelers`. * * The handler is wrapped in a closure rather than passed by reference because * `PasswordSessionManager.fetchHandler` reads `this`. Relative paths are @@ -71,12 +86,16 @@ export function agentToPdsClient(agent: BskyAppAgent): Client { if (existing) { return existing } - const client = createLexClient({ - get did() { - return agent.did + const client = createLexClient( + { + get did() { + return agent.did + }, + fetchHandler: (path, init) => + agent.sessionManager.fetchHandler(path, init), }, - fetchHandler: (path, init) => agent.sessionManager.fetchHandler(path, init), - }) + {appLabelers: null}, + ) pdsClients.set(agent, client) return client } @@ -88,7 +107,8 @@ export function agentToPdsClient(agent: BskyAppAgent): Client { * and PDS routing, no agent-level proxy or labeler headers - but constructed * with {@link CHAT_PROXY_SERVICE} as its `service`, so lex-client emits * `atproto-proxy: ` on every request and `chat.bsky.*` - * calls are proxied to the chat service. + * calls are proxied to the chat service. `appLabelers: null` for the same + * reason as the PDS client: the chat service takes no moderation authorities. */ export function agentToChatClient(agent: BskyAppAgent): Client { const existing = chatClients.get(agent) @@ -103,7 +123,7 @@ export function agentToChatClient(agent: BskyAppAgent): Client { fetchHandler: (path, init) => agent.sessionManager.fetchHandler(path, init), }, - {service: CHAT_PROXY_SERVICE}, + {appLabelers: null, service: CHAT_PROXY_SERVICE}, ) chatClients.set(agent, client) return client @@ -150,12 +170,14 @@ let publicLexClient: Client | undefined * {@link networkAwareFetch} so public reads feed the app's reachability signal * like authenticated ones do. * - * Unlike the public agent it parallels, this client sends neither - * `atproto-proxy` nor `atproto-accept-labelers`. `createPublicAgent` configures - * the app labeler and the proxy header, so a logged-out appview *agent* read - * does carry labelers while the same read through this client does not. A - * consumer that needs moderation labels on public reads must configure labelers - * itself before issuing the request. + * Unlike the agent-wrapping clients, this one does NOT suppress + * `Client.appLabelers`: there is no agent underneath to stamp the header, so the + * class-wide static is the only producer and a logged-out read carries the same + * `;redact` moderation authorities an authenticated one does. + * + * That makes `configureModerationForGuest()` load-bearing rather than + * test-only - it is what populates the static before this client's first + * request. `createPublicSessionBundle` runs it while building the bundle. */ export function getPublicAppviewClient(): Client { return (publicLexClient ??= createLexClient({ diff --git a/src/state/session/create-account.ts b/src/state/session/create-account.ts index 959df45b13..a14f50cff5 100644 --- a/src/state/session/create-account.ts +++ b/src/state/session/create-account.ts @@ -91,7 +91,7 @@ export async function createSessionBundleAndCreateAccount( accountDid = earlyAccount.did const gates = features.refresh({strategy: 'prefer-fresh-gates'}) - configureModerationForAccount(bundle.agent, earlyAccount) + configureModerationForAccount(bundle, earlyAccount) const createdAt = toDatetimeString(new Date()) const birthdate = birthDate.toISOString() diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index 2354fab4fa..b5b3a01af8 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -1,8 +1,12 @@ -import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api' +import {type AtpAgent, BSKY_LABELER_DID} from '@atproto/api' +import {type Client} from '@atproto/lex' import {IS_TEST_USER} from '#/lib/constants' import {account as accountStorage} from '#/storage' -import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities' +import { + configureAdditionalModerationAuthorities, + configureGlobalAppLabelers, +} from './additional-moderation-authorities' import {type SessionAccount} from './types' /** @@ -26,6 +30,29 @@ export function readLabelers(did: string): string[] | undefined { } } +/** + * Apply an account's labeler subscriptions without duplicating the globally + * redacted Bluesky moderation authority. + * + * The Bluesky DID is filtered out because it already flows through the global + * `appLabelers`, which lex and the agent both emit with a `;redact` suffix. + * Listing it per-subscription would add a second, non-redacting entry for the + * same authority. + * + * Writes to the agent rather than the client: the agent-level fetch handler is + * what stamps `atproto-accept-labelers` on the requests the wrapping clients + * issue, so setting them here reaches every appview read. The bundle rework + * moves this to `appviewClient.setLabelers` once the agent is gone. + */ +export function applyLabelersToClient( + agent: AtpAgent, + subscribedDids: string[], +) { + agent.configureLabelersHeader( + subscribedDids.filter(did => did !== BSKY_LABELER_DID), + ) +} + export function configureModerationForGuest() { // This global mutation is *only* OK because this code is only relevant for testing. // Don't add any other global behavior here! @@ -39,7 +66,7 @@ export function configureModerationForGuest() { * in the same tick, before any request goes out. */ export function configureModerationForAccount( - agent: AtpAgent, + bundle: {agent: AtpAgent; appviewClient?: Client}, account: SessionAccount, ) { // This global mutation is *only* OK because this code is only relevant for testing. @@ -47,15 +74,13 @@ export function configureModerationForAccount( switchToBskyAppLabeler() if (IS_TEST_USER(account.handle)) { // Test accounts may briefly use the production authority while this resolves. - void trySwitchToTestAppLabeler(agent) + void trySwitchToTestAppLabeler(bundle.agent) } // The code below is actually relevant to production (and isn't global). const labelerDids = readLabelers(account.did) if (labelerDids) { - agent.configureLabelersHeader( - labelerDids.filter(did => did !== BSKY_LABELER_DID), - ) + applyLabelersToClient(bundle.agent, 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. @@ -65,7 +90,7 @@ export function configureModerationForAccount( } function switchToBskyAppLabeler() { - AtpAgent.configure({appLabelers: [BSKY_LABELER_DID]}) + configureGlobalAppLabelers([BSKY_LABELER_DID]) } /** Resolve and install the test environment's moderation authority. */ @@ -77,6 +102,6 @@ async function trySwitchToTestAppLabeler(agent: AtpAgent) { )?.data.did if (did) { console.warn('USING TEST ENV MODERATION') - AtpAgent.configure({appLabelers: [did]}) + configureGlobalAppLabelers([did]) } } diff --git a/src/state/session/session-core.ts b/src/state/session/session-core.ts index 759e6afcfa..7a9d1a22cc 100644 --- a/src/state/session/session-core.ts +++ b/src/state/session/session-core.ts @@ -191,7 +191,9 @@ export type PublicSessionBundle = { /** * Build the logged-out bundle. `createPublicAgent` installs the guest - * moderation authorities as part of building the agent. + * moderation authorities as part of building the agent, which is what populates + * the global `Client.appLabelers` that {@link getPublicAppviewClient} relies on + * for its labeler header. */ export function createPublicSessionBundle(): PublicSessionBundle { return { @@ -290,7 +292,7 @@ export async function createSessionBundleAndResume( storedAccount.pdsUrl, ) ?? storedAccount - configureModerationForAccount(bundle.agent, earlyAccount) + configureModerationForAccount(bundle, earlyAccount) const aa = prefetchAgeAssuranceServerData({ appviewClient: agentToAppviewClient(bundle.agent), accountClient: agentToPdsClient(bundle.agent), @@ -358,7 +360,7 @@ export async function createSessionBundleAndLogin( accountDid = earlyAccount.did const gates = features.refresh({strategy: 'prefer-fresh-gates'}) - configureModerationForAccount(bundle.agent, earlyAccount) + configureModerationForAccount(bundle, earlyAccount) const aa = prefetchAgeAssuranceServerData({ appviewClient: agentToAppviewClient(bundle.agent), accountClient: agentToPdsClient(bundle.agent), @@ -400,7 +402,7 @@ export function createSessionBundleFromStoredAccount( ) bundle = buildBundle(session, storedAccount.pdsUrl) registerBundleKillSwitch(bundle, hooks.kill) - configureModerationForAccount(bundle.agent, storedAccount) + configureModerationForAccount(bundle, storedAccount) bundle.agent.configureProxy(BLUESKY_PROXY_HEADER.get()) const account = session.destroyed