Skip to content

Commit c560f2e

Browse files
authored
feat(pds): proxy com.atproto.moderation.createReport to labeler (#196)
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.
1 parent 3008f88 commit c560f2e

4 files changed

Lines changed: 352 additions & 21 deletions

File tree

.changeset/create-report-proxy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@getcirrus/pds": minor
3+
---
4+
5+
Proxy `com.atproto.moderation.createReport` so the Bluesky app's "Report" button works. 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 by setting the `atproto-proxy` header to a different labeler's `did#service_id`, in which case the request is routed to the resolved endpoint and the JWT is addressed there instead. Previously these reports fell through to the generic AppView proxy and were silently rejected.

packages/pds/src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import { isDid, isHandle } from "@atcute/lexicons/syntax";
1010
import { requireAuth } from "./middleware/auth";
1111
import { DidResolver } from "./did-resolver";
1212
import { WorkersDidCache } from "./did-cache";
13-
import { handleXrpcProxy, handleGetFeedProxy } from "./xrpc-proxy";
13+
import {
14+
handleXrpcProxy,
15+
handleGetFeedProxy,
16+
handleCreateReportProxy,
17+
} from "./xrpc-proxy";
1418
import { createOAuthApp } from "./oauth";
1519
import * as sync from "./xrpc/sync";
1620
import * as repo from "./xrpc/repo";
@@ -548,6 +552,12 @@ app.get("/xrpc/app.bsky.feed.getFeed", (c) =>
548552
handleGetFeedProxy(c, didResolver, getKeypair),
549553
);
550554

555+
// createReport routes to a moderation labeler, not the AppView. Clients can
556+
// override the labeler with the atproto-proxy header.
557+
app.post("/xrpc/com.atproto.moderation.createReport", (c) =>
558+
handleCreateReportProxy(c, didResolver, getKeypair),
559+
);
560+
551561
// Proxy unhandled XRPC requests to services specified via atproto-proxy header
552562
// or fall back to Bluesky services for backward compatibility
553563
app.all("/xrpc/*", (c) => handleXrpcProxy(c, didResolver, getKeypair));

packages/pds/src/xrpc-proxy.ts

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { getProvider } from "./oauth";
1414
import type { PDSEnv } from "./types";
1515
import type { Secp256k1Keypair } from "@atproto/crypto";
1616

17+
const BLUESKY_MOD_SERVICE_DID = "did:plc:ar7c4by46qjdydhdevvrndac";
18+
1719
/**
1820
* Parse atproto-proxy header value
1921
* Format: "did:web:example.com#service_id"
@@ -46,6 +48,16 @@ export interface ServiceAuthOverride {
4648
lxm: string;
4749
}
4850

51+
/**
52+
* Override the default fallback route when no atproto-proxy header is present.
53+
* Resolved via the DID document like a header-driven route. Used for
54+
* createReport, which targets a moderation labeler rather than the AppView.
55+
*/
56+
export interface DefaultRoute {
57+
proxyDid: string;
58+
serviceId: string;
59+
}
60+
4961
/**
5062
* Handle XRPC proxy requests
5163
* Routes requests to external services based on atproto-proxy header or lexicon namespace
@@ -55,6 +67,7 @@ export async function handleXrpcProxy(
5567
didResolver: DidResolver,
5668
getKeypair: () => Promise<Secp256k1Keypair>,
5769
serviceAuthOverride?: ServiceAuthOverride,
70+
defaultRoute?: DefaultRoute,
5871
): Promise<Response> {
5972
// Extract XRPC method name from path (e.g., "app.bsky.feed.getTimeline")
6073
const url = new URL(c.req.url);
@@ -80,36 +93,40 @@ export async function handleXrpcProxy(
8093
let scopeAud: string;
8194
let targetUrl: URL;
8295

83-
if (proxyHeader) {
84-
// Parse proxy header: "did:web:example.com#service_id"
85-
const parsed = parseProxyHeader(proxyHeader);
86-
if (!parsed) {
87-
return c.json(
88-
{
89-
error: "InvalidRequest",
90-
message: `Invalid atproto-proxy header format: ${proxyHeader}`,
91-
},
92-
400,
93-
);
94-
}
96+
const resolvedRoute = proxyHeader
97+
? parseProxyHeader(proxyHeader)
98+
: defaultRoute
99+
? { did: defaultRoute.proxyDid, serviceId: defaultRoute.serviceId }
100+
: null;
101+
102+
if (proxyHeader && !resolvedRoute) {
103+
return c.json(
104+
{
105+
error: "InvalidRequest",
106+
message: `Invalid atproto-proxy header format: ${proxyHeader}`,
107+
},
108+
400,
109+
);
110+
}
95111

112+
if (resolvedRoute) {
96113
try {
97114
// Resolve DID document to get service endpoint (with caching)
98-
const didDoc = await didResolver.resolve(parsed.did);
115+
const didDoc = await didResolver.resolve(resolvedRoute.did);
99116
if (!didDoc) {
100117
return c.json(
101118
{
102119
error: "InvalidRequest",
103-
message: `DID not found: ${parsed.did}`,
120+
message: `DID not found: ${resolvedRoute.did}`,
104121
},
105122
400,
106123
);
107124
}
108125

109126
// getServiceEndpoint expects the ID to start with #
110-
const serviceId = parsed.serviceId.startsWith("#")
111-
? parsed.serviceId
112-
: `#${parsed.serviceId}`;
127+
const serviceId = resolvedRoute.serviceId.startsWith("#")
128+
? resolvedRoute.serviceId
129+
: `#${resolvedRoute.serviceId}`;
113130
const endpoint = getAtprotoServiceEndpoint(didDoc, {
114131
id: serviceId as `#${string}`,
115132
});
@@ -118,15 +135,15 @@ export async function handleXrpcProxy(
118135
return c.json(
119136
{
120137
error: "InvalidRequest",
121-
message: `Service not found in DID document: ${parsed.serviceId}`,
138+
message: `Service not found in DID document: ${resolvedRoute.serviceId}`,
122139
},
123140
400,
124141
);
125142
}
126143

127144
// Use the resolved service endpoint
128-
audienceDid = parsed.did;
129-
scopeAud = proxyHeader;
145+
audienceDid = resolvedRoute.did;
146+
scopeAud = `${resolvedRoute.did}#${resolvedRoute.serviceId.replace(/^#/, "")}`;
130147
targetUrl = new URL(endpoint);
131148
if (targetUrl.protocol !== "https:") {
132149
return c.json(
@@ -379,3 +396,27 @@ export async function handleGetFeedProxy(
379396

380397
return handleXrpcProxy(c, didResolver, getKeypair, override);
381398
}
399+
400+
/**
401+
* Proxy com.atproto.moderation.createReport.
402+
*
403+
* Reports are routed to a moderation labeler rather than the AppView. If the
404+
* client sets an atproto-proxy header (e.g. to pick a different labeler),
405+
* routing follows the header. Otherwise reports go to Bluesky's default
406+
* moderation service. The outbound service-auth JWT's aud matches the chosen
407+
* labeler so it can authorize the user.
408+
*/
409+
export async function handleCreateReportProxy(
410+
c: Context<{ Bindings: PDSEnv }>,
411+
didResolver: DidResolver,
412+
getKeypair: () => Promise<Secp256k1Keypair>,
413+
): Promise<Response> {
414+
if (c.req.header("atproto-proxy")) {
415+
return handleXrpcProxy(c, didResolver, getKeypair);
416+
}
417+
418+
return handleXrpcProxy(c, didResolver, getKeypair, undefined, {
419+
proxyDid: BLUESKY_MOD_SERVICE_DID,
420+
serviceId: "atproto_labeler",
421+
});
422+
}

0 commit comments

Comments
 (0)