From 121a8df0e65714c2072c8dd44975db423f0ffcc0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 May 2026 12:37:46 +0100 Subject: [PATCH] feat(pds): proxy com.atproto.moderation.createReport to labeler 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. --- .changeset/create-report-proxy.md | 5 + packages/pds/src/index.ts | 12 +- packages/pds/src/xrpc-proxy.ts | 81 ++++++--- packages/pds/test/proxy.test.ts | 275 ++++++++++++++++++++++++++++++ 4 files changed, 352 insertions(+), 21 deletions(-) create mode 100644 .changeset/create-report-proxy.md diff --git a/.changeset/create-report-proxy.md b/.changeset/create-report-proxy.md new file mode 100644 index 0000000..2144014 --- /dev/null +++ b/.changeset/create-report-proxy.md @@ -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. diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index fc0b4ac..bc794af 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -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"; @@ -543,6 +547,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)); diff --git a/packages/pds/src/xrpc-proxy.ts b/packages/pds/src/xrpc-proxy.ts index 9aee67e..2978931 100644 --- a/packages/pds/src/xrpc-proxy.ts +++ b/packages/pds/src/xrpc-proxy.ts @@ -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" @@ -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 @@ -55,6 +67,7 @@ export async function handleXrpcProxy( didResolver: DidResolver, getKeypair: () => Promise, serviceAuthOverride?: ServiceAuthOverride, + defaultRoute?: DefaultRoute, ): Promise { // Extract XRPC method name from path (e.g., "app.bsky.feed.getTimeline") const url = new URL(c.req.url); @@ -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}`, }); @@ -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( @@ -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, +): Promise { + 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", + }); +} diff --git a/packages/pds/test/proxy.test.ts b/packages/pds/test/proxy.test.ts index 512bdfd..c4790cd 100644 --- a/packages/pds/test/proxy.test.ts +++ b/packages/pds/test/proxy.test.ts @@ -983,4 +983,279 @@ describe("XRPC Service Proxying", () => { expect(body.error).toBe("InsufficientScope"); }); }); + + describe("createReport proxy", () => { + const BLUESKY_MOD_SERVICE_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; + + function decodeJwtPayload(authHeader: string | null): any { + expect(authHeader).toMatch(/^Bearer /); + const payloadB64 = authHeader!.slice(7).split(".")[1]!; + return JSON.parse(Buffer.from(payloadB64, "base64url").toString()); + } + + const blueskyModDidDoc = { + "@context": ["https://www.w3.org/ns/did/v1"], + id: BLUESKY_MOD_SERVICE_DID, + service: [ + { + id: "#atproto_labeler", + type: "AtprotoLabeler", + serviceEndpoint: "https://mod.bsky.app", + }, + ], + }; + + const otherLabelerDidDoc = { + "@context": ["https://www.w3.org/ns/did/v1"], + id: "did:web:labeler.example.com", + service: [ + { + id: "#atproto_labeler", + type: "AtprotoLabeler", + serviceEndpoint: "https://labeler.example.com", + }, + ], + }; + + const reportBody = JSON.stringify({ + reasonType: "com.atproto.moderation.defs#reasonSpam", + subject: { + $type: "com.atproto.admin.defs#repoRef", + did: "did:plc:target", + }, + }); + + it("routes to Bluesky's moderation service by default and addresses the JWT to it", async () => { + let capturedUrl: string | null = null; + let capturedAuth: string | null = null; + + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL, init?: RequestInit) => { + const u = url.toString(); + // PLC DIDs resolve via plc.directory + if ( + u.includes("plc.directory") && + u.includes(BLUESKY_MOD_SERVICE_DID) + ) { + return new Response(JSON.stringify(blueskyModDidDoc), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if ( + u.startsWith( + "https://mod.bsky.app/xrpc/com.atproto.moderation.createReport", + ) + ) { + capturedUrl = u; + capturedAuth = new Headers(init?.headers).get("Authorization"); + return new Response( + JSON.stringify({ id: 1, reportedBy: env.DID }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } + return originalFetch(url, init); + }), + ); + + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.moderation.createReport", + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + body: reportBody, + }, + ), + env, + ); + + expect(response.status).toBe(200); + expect(capturedUrl).not.toBeNull(); + expect(new URL(capturedUrl!).host).toBe("mod.bsky.app"); + const payload = decodeJwtPayload(capturedAuth); + expect(payload.aud).toBe(BLUESKY_MOD_SERVICE_DID); + expect(payload.lxm).toBe("com.atproto.moderation.createReport"); + }); + + it("honors the atproto-proxy header to override the labeler", async () => { + let capturedUrl: string | null = null; + let capturedAuth: string | null = null; + + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL, init?: RequestInit) => { + const u = url.toString(); + if (u === "https://labeler.example.com/.well-known/did.json") { + return new Response(JSON.stringify(otherLabelerDidDoc), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if ( + u.startsWith( + "https://labeler.example.com/xrpc/com.atproto.moderation.createReport", + ) + ) { + capturedUrl = u; + capturedAuth = new Headers(init?.headers).get("Authorization"); + return new Response(JSON.stringify({ id: 7 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return originalFetch(url, init); + }), + ); + + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.moderation.createReport", + { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "atproto-proxy": "did:web:labeler.example.com#atproto_labeler", + }, + body: reportBody, + }, + ), + env, + ); + + expect(response.status).toBe(200); + expect(capturedUrl).not.toBeNull(); + expect(new URL(capturedUrl!).host).toBe("labeler.example.com"); + const payload = decodeJwtPayload(capturedAuth); + expect(payload.aud).toBe("did:web:labeler.example.com"); + expect(payload.lxm).toBe("com.atproto.moderation.createReport"); + }); + + it("rejects a DPoP token without rpc scope for createReport at the labeler", async () => { + async function generateEs256() { + const kp = (await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + const publicJwk = (await crypto.subtle.exportKey( + "jwk", + kp.publicKey, + )) as JsonWebKey; + delete publicJwk.key_ops; + delete publicJwk.ext; + return { privateKey: kp.privateKey, publicJwk }; + } + + async function makeDpopProof( + privateKey: CryptoKey, + publicJwk: JsonWebKey, + accessToken: string, + requestUrl: string, + method: string, + ): Promise { + const u = new URL(requestUrl); + const ath = base64url.encode( + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken), + ), + ), + ); + return new SignJWT({ + htm: method, + htu: u.origin + u.pathname, + ath, + }) + .setProtectedHeader({ + typ: "dpop+jwt", + alg: "ES256", + jwk: publicJwk as Record, + }) + .setIssuedAt() + .setJti(base64url.encode(crypto.getRandomValues(new Uint8Array(16)))) + .sign(privateKey); + } + + const { privateKey, publicJwk } = await generateEs256(); + const dpopJkt = await calculateJwkThumbprint( + publicJwk as Parameters[0], + "sha256", + ); + const accessToken = "tok-createreport-wrong-scope"; + + const stub = env.ACCOUNT.get(env.ACCOUNT.idFromName("account")); + await runInDurableObject(stub, async (instance: AccountDurableObject) => { + await instance.rpcSaveTokens({ + accessToken, + refreshToken: `refresh-${accessToken}`, + clientId: "did:web:client.example.com", + sub: env.DID, + scope: `atproto rpc:com.atproto.moderation.createReport?aud=did:web:other.example.com#atproto_labeler`, + dpopJkt, + issuedAt: Date.now(), + accessExpiresAt: Date.now() + 3600_000, + refreshExpiresAt: Date.now() + 90 * 24 * 3600_000, + }); + }); + + let labelerCalled = false; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL, init?: RequestInit) => { + const u = url.toString(); + if ( + u.includes("plc.directory") && + u.includes(BLUESKY_MOD_SERVICE_DID) + ) { + return new Response(JSON.stringify(blueskyModDidDoc), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (u.startsWith("https://mod.bsky.app/")) { + labelerCalled = true; + } + return originalFetch(url, init); + }), + ); + + const requestUrl = + "http://pds.test/xrpc/com.atproto.moderation.createReport"; + const dpop = await makeDpopProof( + privateKey, + publicJwk, + accessToken, + requestUrl, + "POST", + ); + + const response = await worker.fetch( + new Request(requestUrl, { + method: "POST", + headers: { + Authorization: `DPoP ${accessToken}`, + DPoP: dpop, + "Content-Type": "application/json", + }, + body: reportBody, + }), + env, + ); + + expect(response.status).toBe(403); + expect(labelerCalled).toBe(false); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("InsufficientScope"); + }); + }); });