feat(pds): proxy com.atproto.moderation.createReport to labeler#196
Conversation
Reports default to Bluesky's moderation service (did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler) with a service-auth JWT addressed to that labeler. Clients can override the target labeler via the atproto-proxy header. Mirrors the established getFeed pattern: special-cased ahead of the generic AppView proxy so the outbound JWT is addressed correctly.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
pdscheck | 535cf84 | May 31 2026, 01:56 PM |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
cirrusdocs | 535cf84 | Commit Preview URL Branch Preview URL |
May 31 2026, 01:56 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
atproto-pds | 535cf84 | May 31 2026, 01:56 PM |
commit: |
There was a problem hiding this comment.
Pull request overview
This PR fixes Bluesky app report submissions by special-casing com.atproto.moderation.createReport so PDS instances proxy reports to a moderation labeler (defaulting to Bluesky’s moderation service) instead of falling through to the generic XRPC proxy behavior that routes most non-chat traffic to the AppView (where reports are rejected).
Changes:
- Add a dedicated
/xrpc/com.atproto.moderation.createReportPOST handler that routes to a moderation labeler (default PLC DID) unless overridden viaatproto-proxy. - Extend
handleXrpcProxyto accept an optional DID-doc-resolveddefaultRoute, enabling “header-like” routing without changing existing catch-all behavior. - Add unit tests covering default routing,
atproto-proxyoverride routing, and DPoP scope enforcement.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
packages/pds/src/xrpc-proxy.ts |
Adds default-route support to handleXrpcProxy and introduces a createReport-specific proxy handler targeting a labeler by default. |
packages/pds/src/index.ts |
Wires a first-class createReport POST route ahead of the /xrpc/* catch-all. |
packages/pds/test/proxy.test.ts |
Adds coverage validating routing destination + service-auth JWT claims for createReport, including DPoP scope rejection. |
.changeset/create-report-proxy.md |
Documents the new proxy behavior and ships it as a minor bump for @getcirrus/pds. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Summary
The Bluesky app's "Report" button calls
com.atproto.moderation.createReportagainst the user's PDS. Today that request falls through to the generic XRPC proxy, which routes anything non-chat toapi.bsky.app— the AppView, not a labeler — so reports are silently rejected.This adds a first-class handler that proxies
com.atproto.moderation.createReportto a moderation labeler:did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler) with a service-auth JWT addressed to it (aud= the labeler DID,lxm=com.atproto.moderation.createReport).atproto-proxy: did:...#atproto_labelerheader, routing follows that header and the JWT is addressed there instead. This lets users pick a different labeler from the app.The pattern mirrors the existing
app.bsky.feed.getFeedspecial-case inxrpc-proxy.ts: special-cased ahead of the catch-all so the outbound JWT lands at the right service.Internally,
handleXrpcProxynow accepts an optionaldefaultRoute({ proxyDid, serviceId }) so the createReport handler can synthesize the same DID-doc-driven routing theatproto-proxyheader path uses, without touching the catch-all's existing AppView/chat behavior. The catch-all paths and existing scope checks are unchanged.The labeler DID is a static constant rather than an env var — opinionated default that matches Bluesky's reference PDS, and clients can override per-request. We can add config later if anyone needs a different default.
Test plan
atproto-proxyheader → request goes tomod.bsky.app(Bluesky's mod service endpoint, resolved via PLC) and the service-auth JWT carriesaud = did:plc:ar7c4by46qjdydhdevvrndacandlxm = com.atproto.moderation.createReport.atproto-proxy: did:web:labeler.example.com#atproto_labeler→ request goes to that labeler's resolved endpoint, JWT addressed there.createReportat a different audience returns 403InsufficientScopeand never reaches the labeler.pnpm test:unitinpackages/pds— 310 tests pass (20 in proxy.test.ts, up from 17).pnpm checkfrom repo root — green.