emit moderation labeler only via global appLabelers

The per-client labelers list also carried api.moderation.did, which
the header builder emits plain alongside the global did;redact entry -
a duplicate that could weaken redaction. Per-client lists now carry
only account subscriptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-19 13:19:29 +03:00
parent ffba34c548
commit 4d25c884c7
3 changed files with 102 additions and 79 deletions
@@ -124,7 +124,7 @@ function makeSession(
} }
describe('buildAppviewClient', () => { describe('buildAppviewClient', () => {
it('sets the appview atproto-proxy header and includes the moderation DID in labelers', async () => { it('sets the appview atproto-proxy header and includes only the per-instance labelers', async () => {
const {seen, fetchMock} = makeCapturingFetch() const {seen, fetchMock} = makeCapturingFetch()
const session = makeSession(fetchMock) const session = makeSession(fetchMock)
const client = buildAppviewClient(session, [CUSTOM_LABELER]) const client = buildAppviewClient(session, [CUSTOM_LABELER])
@@ -134,8 +134,14 @@ describe('buildAppviewClient', () => {
expect(seen.length).toBe(1) expect(seen.length).toBe(1)
expect(seen[0].headers.get('atproto-proxy')).toBe(APPVIEW_PROXY) expect(seen[0].headers.get('atproto-proxy')).toBe(APPVIEW_PROXY)
const labelers = seen[0].headers.get('atproto-accept-labelers') ?? '' const labelers = seen[0].headers.get('atproto-accept-labelers') ?? ''
expect(labelers).toContain(api.moderation.did)
expect(labelers).toContain(CUSTOM_LABELER) expect(labelers).toContain(CUSTOM_LABELER)
/*
* The moderation DID is NOT a per-instance labeler: it flows only through
* the global Client.appLabelers (unset in this test), where lex-client
* merges it into the header with `;redact` on every request. See the
* labeler-header regression guard below for the merged composition.
*/
expect(labelers).not.toContain(api.moderation.did)
}) })
it('routes through the session fetchHandler with the bearer token', async () => { it('routes through the session fetchHandler with the bearer token', async () => {
@@ -147,10 +153,6 @@ describe('buildAppviewClient', () => {
expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledTimes(1)
expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt') expect(seen[0].headers.get('authorization')).toBe('Bearer access-jwt')
/* the moderation DID is always re-asserted even with no custom labelers */
expect(seen[0].headers.get('atproto-accept-labelers')).toContain(
api.moderation.did,
)
}) })
}) })
@@ -288,10 +290,12 @@ describe('getPublicLexClient', () => {
describe('labeler-header regression guard', () => { describe('labeler-header regression guard', () => {
it('appview client emits the global Bluesky labeler redacted and the per-instance labeler plain', async () => { it('appview client emits the global Bluesky labeler redacted and the per-instance labeler plain', async () => {
/* /*
* buildAppviewClient re-asserts api.moderation.did as a base labeler; the * The moderation DID flows only through the global Client.appLabelers,
* global Client.appLabelers carry the `;redact` suffix. Configure the global * which lex-client merges into the header per request with the `;redact`
* appLabelers to the Bluesky moderation DID (matching switchToBskyAppLabeler * suffix; buildAppviewClient no longer lists it as a per-instance labeler.
* in moderation.ts) so the composition matches production. * Configure the global appLabelers to the Bluesky moderation DID (matching
* switchToBskyAppLabeler in moderation.ts) so the composition matches
* production.
*/ */
Client.configure({appLabelers: [api.moderation.did]}) Client.configure({appLabelers: [api.moderation.did]})
@@ -305,6 +309,11 @@ describe('labeler-header regression guard', () => {
/* the global Bluesky moderation labeler is redacted */ /* the global Bluesky moderation labeler is redacted */
expect(header).toContain(`${api.moderation.did};redact`) expect(header).toContain(`${api.moderation.did};redact`)
/* ...and appears exactly once (no duplicate plain entry) */
const occurrences = (header ?? '')
.split(',')
.filter(entry => entry.includes(api.moderation.did))
expect(occurrences).toHaveLength(1)
/* the per-instance custom labeler is present and plain (no redact) */ /* the per-instance custom labeler is present and plain (no redact) */
expect(header).toContain(CUSTOM_LABELER) expect(header).toContain(CUSTOM_LABELER)
expect(header).not.toContain(`${CUSTOM_LABELER};redact`) expect(header).not.toContain(`${CUSTOM_LABELER};redact`)
+31 -43
View File
@@ -1,18 +1,17 @@
import {Client} from '@atproto/lex' import {type Client} from '@atproto/lex'
import {type PasswordSession} from '@atproto/lex-password-session' import {type PasswordSession} from '@atproto/lex-password-session'
import {api} from '@bsky.app/sdk'
import { import {
BLUESKY_PROXY_HEADER, BLUESKY_PROXY_HEADER,
CHAT_PROXY_SERVICE, CHAT_PROXY_SERVICE,
PUBLIC_BSKY_SERVICE, PUBLIC_BSKY_SERVICE,
} from '#/lib/constants' } from '#/lib/constants'
import {createLexClient} from '#/lib/lexClient'
import {networkAwareFetch} from './session-core' import {networkAwareFetch} from './session-core'
/** /**
* Lazily-constructed unauthenticated client pointed at the public appview. It * Lazily-constructed unauthenticated client pointed at the public appview. It
* hits {@link PUBLIC_BSKY_SERVICE} directly, mirroring `createPublicAgent`'s * hits {@link PUBLIC_BSKY_SERVICE} directly, so no proxying is required.
* service URL, so no proxying is required.
*/ */
let publicClient: Client | undefined let publicClient: Client | undefined
@@ -21,7 +20,7 @@ export function getPublicLexClient(): Client {
* Pass networkAwareFetch so the unauthenticated public path feeds the same * Pass networkAwareFetch so the unauthenticated public path feeds the same
* reachability signal as the session-backed clients (see session-core). * reachability signal as the session-backed clients (see session-core).
*/ */
publicClient ??= new Client({ publicClient ??= createLexClient({
service: PUBLIC_BSKY_SERVICE, service: PUBLIC_BSKY_SERVICE,
fetch: networkAwareFetch, fetch: networkAwareFetch,
}) })
@@ -31,15 +30,14 @@ export function getPublicLexClient(): Client {
/** /**
* Build the account (PDS) client over a {@link PasswordSession}. Writes and * Build the account (PDS) client over a {@link PasswordSession}. Writes and
* record mutations go here - no `atproto-proxy` header, so requests hit the * record mutations go here - no `atproto-proxy` header, so requests hit the
* user's PDS directly (the session's `fetchHandler` resolves the PDS origin * user's PDS directly (the session's `fetchHandler` resolves the PDS origin per
* per request from the didDoc, falling back to `service`). * request from the didDoc, falling back to `service`).
* *
* The session already owns its own `fetch` (networkAwareFetch, set at * We intentionally do NOT pass `fetch` here: a client built over a session uses
* construction), so we intentionally do NOT pass `fetch` here: a `Client` * that session's own `fetch` (networkAwareFetch, set at construction).
* built over an existing `Agent`/session uses that agent's fetch.
*/ */
export function buildAccountClient(session: PasswordSession): Client { export function buildAccountClient(session: PasswordSession): Client {
return new Client(session) return createLexClient(session)
} }
/** /**
@@ -47,14 +45,13 @@ export function buildAccountClient(session: PasswordSession): Client {
* *
* {@link CHAT_PROXY_SERVICE} (`${CHAT_PROXY_DID}#bsky_chat`, default * {@link CHAT_PROXY_SERVICE} (`${CHAT_PROXY_DID}#bsky_chat`, default
* `did:web:api.bsky.chat#bsky_chat`) is passed as the client's `service`, so * `did:web:api.bsky.chat#bsky_chat`) is passed as the client's `service`, so
* lex-client sets `atproto-proxy: <that value>` on every request. This is * lex-client sets `atproto-proxy: <that value>` on every request and
* exactly what the old per-call `DM_SERVICE_HEADERS` did, once and centrally, so * `chat.bsky.*` calls are proxied to the chat service. The DID is read from the
* `chat.bsky.*` calls are proxied to the chat service. It is read from the
* env-configurable `CHAT_PROXY_DID` (via `EXPO_PUBLIC_CHAT_PROXY_DID`) rather * env-configurable `CHAT_PROXY_DID` (via `EXPO_PUBLIC_CHAT_PROXY_DID`) rather
* than the hard-coded SDK constant, restoring the old routing override. * than the hard-coded SDK constant, so it can be retargeted per environment.
*/ */
export function buildChatClient(session: PasswordSession): Client { export function buildChatClient(session: PasswordSession): Client {
return new Client(session, {service: CHAT_PROXY_SERVICE}) return createLexClient(session, {service: CHAT_PROXY_SERVICE})
} }
/** Thrown when a write/auth-only client is used with no active session. */ /** Thrown when a write/auth-only client is used with no active session. */
@@ -67,20 +64,18 @@ export class NotAuthenticatedError extends Error {
/** /**
* A stable {@link Client} that throws {@link NotAuthenticatedError} on any * A stable {@link Client} that throws {@link NotAuthenticatedError} on any
* request, before any network I/O. Used as the logged-out value of write/auth * request, before any network I/O. Used as the logged-out value of the
* -only hooks (`usePdsClient`/`useChatClient`) so an unauthenticated write or * write/auth-only hooks (`usePdsClient`/`useChatClient`) so an unauthenticated
* chat call fails immediately and legibly instead of silently hitting the * call fails immediately and legibly instead of silently hitting the public
* public appview (which would 404/405 with an opaque error). * appview (which would 404/405 with an opaque error).
* *
* A lazily-constructed process-wide singleton, so its identity is stable across * A lazily-constructed process-wide singleton, so its identity is stable across
* renders - safe to use in React Query keys and as a hook return value. The * renders - safe to use in React Query keys and as a hook return value.
* `did` is `undefined` (logged out) and the `fetchHandler` throws before
* touching the network.
*/ */
let unauthedClient: Client | undefined let unauthedClient: Client | undefined
export function getUnauthenticatedClient(): Client { export function getUnauthenticatedClient(): Client {
unauthedClient ??= new Client({ unauthedClient ??= createLexClient({
did: undefined, did: undefined,
fetchHandler: () => { fetchHandler: () => {
throw new NotAuthenticatedError() throw new NotAuthenticatedError()
@@ -90,33 +85,26 @@ export function getUnauthenticatedClient(): Client {
} }
/** /**
* Build the authed appview client over a {@link PasswordSession}. * Build the authed appview client over a {@link PasswordSession}. Requests are
* proxied to the Bluesky appview and carry the per-instance labelers.
* *
* Requests are proxied to the Bluesky appview and carry the per-instance * The Bluesky moderation labeler (`api.moderation.did`) is deliberately NOT
* labelers. The Bluesky moderation labeler (`api.moderation.did`) is always * listed here - it must flow only through the global `Client.appLabelers` (see
* included as a base labeler because sending ANY `atproto-accept-labelers` * moderation.ts) so it carries the `;redact` suffix; adding it here would
* header replaces the server-side default - so we must re-assert it to keep it * produce a duplicate, non-redact header entry.
* active.
* *
* The proxy `service` is read from `BLUESKY_PROXY_HEADER.get()` (rather than * The proxy `service` is read from `BLUESKY_PROXY_HEADER.get()`, whose default
* hard-coding `api.app.service`). Its default value equals `api.app.service` * equals `api.app.service`. The getter exists so the e2e `TestCtrls` hack can
* (`did:web:api.bsky.app#bsky_appview`), so production is unchanged; the getter * retarget the appview via `BLUESKY_PROXY_HEADER.set()` before sign-in (the
* exists so the e2e `TestCtrls` hack can retarget the appview by calling * client is built at sign-in, so it picks up the override).
* `BLUESKY_PROXY_HEADER.set()` before sign-in (the client is built at
* sign-in, so it picks up the override).
*/ */
export function buildAppviewClient( export function buildAppviewClient(
session: PasswordSession, session: PasswordSession,
labelerDids: string[], labelerDids: string[],
): Client { ): Client {
return new Client(session, { return createLexClient(session, {
/* BLUESKY_PROXY_HEADER.get() is a `did:...#...` ProxyHeaderValue, assignable to Service */
service: BLUESKY_PROXY_HEADER.get(), service: BLUESKY_PROXY_HEADER.get(),
/* labelerDids are validated DID strings; cast to the DidString template type */ labelers: labelerDids as `did:${string}:${string}`[],
labelers: [
api.moderation.did,
...labelerDids,
] as `did:${string}:${string}`[],
}) })
} }
+52 -26
View File
@@ -3,17 +3,17 @@ import {api} from '@bsky.app/sdk'
import {IS_TEST_USER} from '#/lib/constants' import {IS_TEST_USER} from '#/lib/constants'
import {com} from '#/lexicons' import {com} from '#/lexicons'
import {account as accountStorage} from '#/storage'
import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities' import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities'
import {readLabelers} from './agent-config'
import {type SessionBundle} from './session-core' import {type SessionBundle} from './session-core'
import {type SessionAccount} from './types' import {type SessionAccount} from './types'
/* /*
* The Bluesky moderation labeler DID is `api.moderation.did` (from * The Bluesky moderation labeler (`api.moderation.did`) flows ONLY through the
* '@bsky.app/sdk'), value `did:plc:ar7c4by46qjdydhdevvrndac`. We use it * global `Client.appLabelers` config: lex-client merges the static appLabelers
* everywhere: the global appLabelers config, the per-account filter, and the * with each client's per-instance `labelers` into the `atproto-accept-labelers`
* appview client's base labeler (matching `buildAppviewClient`); all resolve to * header on every request, and appLabelers entries carry the `;redact` suffix
* identical `atproto-accept-labelers` headers. * (redaction authority) while plain per-instance labelers don't.
*/ */
/** /**
@@ -24,25 +24,43 @@ function configureGlobalAppLabelers(dids: string[]) {
Client.configure({appLabelers: dids as `did:${string}:${string}`[]}) Client.configure({appLabelers: dids as `did:${string}:${string}`[]})
} }
/**
* Cache an account's subscribed labeler DIDs. Called on every preferences
* fetch, so the cache is eventually consistent with the server.
*/
export function saveLabelers(did: string, value: string[]) {
accountStorage.set([did, 'labelers'], value)
}
/**
* Read the cached labeler DIDs for an account, or `undefined` if none have
* been cached yet (first session on this device) or the entry is unreadable.
*/
export function readLabelers(did: string): string[] | undefined {
try {
return accountStorage.get([did, 'labelers'])
} catch {
/* a corrupt entry fails JSON.parse inside Storage.get; treat as no cache */
return undefined
}
}
/** /**
* Apply an account's subscribed labeler DIDs to a live appview client. The lex * Apply an account's subscribed labeler DIDs to a live appview client. The lex
* `Client` rebuilds the `atproto-accept-labelers` header per request, so this * `Client` rebuilds the header per request, so this takes effect on the next
* takes effect on the very next request without a client rebuild. * request without a client rebuild.
* *
* The Bluesky moderation labeler is always re-asserted as the base: sending ANY * We filter out the Bluesky moderation labeler: it is already asserted globally
* `atproto-accept-labelers` header replaces the server-side default, and * via `Client.appLabelers` (with `;redact`), and a user "subscribing" to it
* `setLabelers` clears then re-adds, so the moderation DID must be included * must not add a second, plain (non-redact) header entry alongside the redacted
* explicitly to stay active. * one.
*/ */
export function applyLabelersToClient( export function applyLabelersToClient(
client: Client, client: Client,
subscribedDids: string[], subscribedDids: string[],
) { ) {
const perAccount = subscribedDids.filter(did => did !== api.moderation.did) const perAccount = subscribedDids.filter(did => did !== api.moderation.did)
client.setLabelers([ client.setLabelers(perAccount as `did:${string}:${string}`[])
api.moderation.did,
...perAccount,
] as `did:${string}:${string}`[])
} }
export function configureModerationForGuest() { export function configureModerationForGuest() {
@@ -53,12 +71,14 @@ export function configureModerationForGuest() {
} }
/** /**
* Configure moderation labelers for a signed-in account. * Configure moderation labelers for a signed-in account. Fully synchronous:
* the labeler cache is a local MMKV read, so the bundle leaves here with its
* per-account labelers already applied, in the same tick.
* *
* Takes the whole {@link SessionBundle} so it can apply per-account labelers to * Takes the whole {@link SessionBundle} so it can apply per-account labelers to
* the authed appview client (`bundle.appviewClient`, backing `useLexClient()`). * the authed appview client (`bundle.appviewClient`, backing `useLexClient()`).
*/ */
export async function configureModerationForAccount( export function configureModerationForAccount(
bundle: SessionBundle, bundle: SessionBundle,
account: SessionAccount, account: SessionAccount,
) { ) {
@@ -66,17 +86,24 @@ export async function configureModerationForAccount(
// Don't add any other global behavior here! // Don't add any other global behavior here!
switchToBskyAppLabeler() switchToBskyAppLabeler()
if (IS_TEST_USER(account.handle)) { if (IS_TEST_USER(account.handle)) {
await trySwitchToTestAppLabeler(bundle) /*
* Fire-and-forget: this resolves a handle over the network and only runs
* in the test environment. Requests made before it lands use the standard
* Bluesky app labeler; that race is acceptable for tests.
*/
void trySwitchToTestAppLabeler(bundle)
} }
// The code below is actually relevant to production (and isn't global). // The code below is actually relevant to production (and isn't global).
const labelerDids = await readLabelers(account.did).catch(_ => {}) const labelerDids = readLabelers(account.did)
if (labelerDids) { if (labelerDids) {
// Apply the per-account labelers to the appview client.
applyLabelersToClient(bundle.appviewClient, labelerDids) applyLabelersToClient(bundle.appviewClient, labelerDids)
} else { } 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. * No cached labelers yet (first session on this device), so the initial
* requests go out without them. We could block on the preferences query
* here to fix that, but choose not to.
*/
} }
configureAdditionalModerationAuthorities() configureAdditionalModerationAuthorities()
@@ -88,9 +115,8 @@ function switchToBskyAppLabeler() {
/** /**
* In the test environment, swap the global app labeler for the test-env * In the test environment, swap the global app labeler for the test-env
* moderation authority. The handle is resolved via the bundle's authed appview * moderation authority, resolving its handle via the bundle's authed appview
* client; `client.call` returns the response body directly (no `{data}` * client.
* wrapper), so `resolveHandle`'s output is `{did}`.
*/ */
async function trySwitchToTestAppLabeler(bundle: SessionBundle) { async function trySwitchToTestAppLabeler(bundle: SessionBundle) {
const did = ( const did = (