Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/create-report-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@getcirrus/pds": minor
---

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.
12 changes: 11 additions & 1 deletion packages/pds/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { isDid, isHandle } from "@atcute/lexicons/syntax";
import { requireAuth } from "./middleware/auth";
import { DidResolver } from "./did-resolver";
import { WorkersDidCache } from "./did-cache";
import { handleXrpcProxy, handleGetFeedProxy } from "./xrpc-proxy";
import {
handleXrpcProxy,
handleGetFeedProxy,
handleCreateReportProxy,
} from "./xrpc-proxy";
import { createOAuthApp } from "./oauth";
import * as sync from "./xrpc/sync";
import * as repo from "./xrpc/repo";
Expand Down Expand Up @@ -548,6 +552,12 @@ app.get("/xrpc/app.bsky.feed.getFeed", (c) =>
handleGetFeedProxy(c, didResolver, getKeypair),
);

// createReport routes to a moderation labeler, not the AppView. Clients can
// override the labeler with the atproto-proxy header.
app.post("/xrpc/com.atproto.moderation.createReport", (c) =>
handleCreateReportProxy(c, didResolver, getKeypair),
);

// Proxy unhandled XRPC requests to services specified via atproto-proxy header
// or fall back to Bluesky services for backward compatibility
app.all("/xrpc/*", (c) => handleXrpcProxy(c, didResolver, getKeypair));
Expand Down
81 changes: 61 additions & 20 deletions packages/pds/src/xrpc-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { getProvider } from "./oauth";
import type { PDSEnv } from "./types";
import type { Secp256k1Keypair } from "@atproto/crypto";

const BLUESKY_MOD_SERVICE_DID = "did:plc:ar7c4by46qjdydhdevvrndac";

/**
* Parse atproto-proxy header value
* Format: "did:web:example.com#service_id"
Expand Down Expand Up @@ -46,6 +48,16 @@ export interface ServiceAuthOverride {
lxm: string;
}

/**
* Override the default fallback route when no atproto-proxy header is present.
* Resolved via the DID document like a header-driven route. Used for
* createReport, which targets a moderation labeler rather than the AppView.
*/
export interface DefaultRoute {
proxyDid: string;
serviceId: string;
}

/**
* Handle XRPC proxy requests
* Routes requests to external services based on atproto-proxy header or lexicon namespace
Expand All @@ -55,6 +67,7 @@ export async function handleXrpcProxy(
didResolver: DidResolver,
getKeypair: () => Promise<Secp256k1Keypair>,
serviceAuthOverride?: ServiceAuthOverride,
defaultRoute?: DefaultRoute,
): Promise<Response> {
// Extract XRPC method name from path (e.g., "app.bsky.feed.getTimeline")
const url = new URL(c.req.url);
Expand All @@ -80,36 +93,40 @@ export async function handleXrpcProxy(
let scopeAud: string;
let targetUrl: URL;

if (proxyHeader) {
// Parse proxy header: "did:web:example.com#service_id"
const parsed = parseProxyHeader(proxyHeader);
if (!parsed) {
return c.json(
{
error: "InvalidRequest",
message: `Invalid atproto-proxy header format: ${proxyHeader}`,
},
400,
);
}
const resolvedRoute = proxyHeader
? parseProxyHeader(proxyHeader)
: defaultRoute
? { did: defaultRoute.proxyDid, serviceId: defaultRoute.serviceId }
: null;

if (proxyHeader && !resolvedRoute) {
return c.json(
{
error: "InvalidRequest",
message: `Invalid atproto-proxy header format: ${proxyHeader}`,
},
400,
);
}

if (resolvedRoute) {
try {
// Resolve DID document to get service endpoint (with caching)
const didDoc = await didResolver.resolve(parsed.did);
const didDoc = await didResolver.resolve(resolvedRoute.did);
if (!didDoc) {
return c.json(
{
error: "InvalidRequest",
message: `DID not found: ${parsed.did}`,
message: `DID not found: ${resolvedRoute.did}`,
},
400,
);
}

// getServiceEndpoint expects the ID to start with #
const serviceId = parsed.serviceId.startsWith("#")
? parsed.serviceId
: `#${parsed.serviceId}`;
const serviceId = resolvedRoute.serviceId.startsWith("#")
? resolvedRoute.serviceId
: `#${resolvedRoute.serviceId}`;
const endpoint = getAtprotoServiceEndpoint(didDoc, {
id: serviceId as `#${string}`,
});
Expand All @@ -118,15 +135,15 @@ export async function handleXrpcProxy(
return c.json(
{
error: "InvalidRequest",
message: `Service not found in DID document: ${parsed.serviceId}`,
message: `Service not found in DID document: ${resolvedRoute.serviceId}`,
},
400,
);
}

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

return handleXrpcProxy(c, didResolver, getKeypair, override);
}

/**
* Proxy com.atproto.moderation.createReport.
*
* Reports are routed to a moderation labeler rather than the AppView. If the
* client sets an atproto-proxy header (e.g. to pick a different labeler),
* routing follows the header. Otherwise reports go to Bluesky's default
* moderation service. The outbound service-auth JWT's aud matches the chosen
* labeler so it can authorize the user.
*/
export async function handleCreateReportProxy(
c: Context<{ Bindings: PDSEnv }>,
didResolver: DidResolver,
getKeypair: () => Promise<Secp256k1Keypair>,
): Promise<Response> {
if (c.req.header("atproto-proxy")) {
return handleXrpcProxy(c, didResolver, getKeypair);
}

return handleXrpcProxy(c, didResolver, getKeypair, undefined, {
proxyDid: BLUESKY_MOD_SERVICE_DID,
serviceId: "atproto_labeler",
});
}
Loading
Loading