From 8c0ad354446ccbc0d70a772b17500dcf4f488d8e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 31 May 2026 12:35:16 +0100 Subject: [PATCH] feat(pds): implement com.atproto.identity.submitPlcOperation Forwards an already-signed PLC operation to plc.directory on the user's behalf so migration clients don't have to talk to the directory themselves. Pairs with the existing signPlcOperation endpoint to match the reference PDS migration flow. --- .changeset/submit-plc-operation.md | 5 ++ packages/pds/src/index.ts | 5 ++ packages/pds/src/xrpc/identity.ts | 49 +++++++++++ packages/pds/test/identity.test.ts | 130 ++++++++++++++++++++++++++++- 4 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 .changeset/submit-plc-operation.md diff --git a/.changeset/submit-plc-operation.md b/.changeset/submit-plc-operation.md new file mode 100644 index 00000000..d48a688b --- /dev/null +++ b/.changeset/submit-plc-operation.md @@ -0,0 +1,5 @@ +--- +"@getcirrus/pds": minor +--- + +Implement `com.atproto.identity.submitPlcOperation`. The endpoint forwards an already-signed PLC operation to `plc.directory` on the user's behalf, so migration clients can complete an outbound move without talking to the PLC directory themselves. Pairs with the existing `com.atproto.identity.signPlcOperation` to match the reference PDS migration flow. diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index fc0b4acf..2b870135 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -306,6 +306,11 @@ app.post( requireAuth, identity.signPlcOperation, ); +app.post( + "/xrpc/com.atproto.identity.submitPlcOperation", + requireAuth, + identity.submitPlcOperation, +); app.get( "/xrpc/gg.mk.experimental.getMigrationToken", requireAuth, diff --git a/packages/pds/src/xrpc/identity.ts b/packages/pds/src/xrpc/identity.ts index db2fd1f0..c8ebfaf5 100644 --- a/packages/pds/src/xrpc/identity.ts +++ b/packages/pds/src/xrpc/identity.ts @@ -248,6 +248,55 @@ async function signOperation( }; } +/** + * Submit a signed PLC operation to the PLC directory. + * + * Forwards an already-signed operation (e.g. one produced by + * signPlcOperation) to plc.directory on the user's behalf, so + * migration clients don't have to talk to the directory themselves. + * + * Endpoint: POST com.atproto.identity.submitPlcOperation + */ +export async function submitPlcOperation( + c: Context, +): Promise { + const body = await c.req.json<{ operation?: SignedPlcOperation }>(); + + const { operation } = body; + + if (!operation) { + return c.json( + { + error: "InvalidRequest", + message: "Missing required parameter: operation", + }, + 400, + ); + } + + const res = await fetch(`${PLC_DIRECTORY}/${c.env.DID}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(operation), + }); + + if (!res.ok) { + const message = await res.text(); + return new Response( + JSON.stringify({ + error: "PlcDirectoryError", + message: message || `PLC directory responded with ${res.status}`, + }), + { + status: res.status, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + return new Response(null, { status: 200 }); +} + /** * Generate a migration token for the CLI. * diff --git a/packages/pds/test/identity.test.ts b/packages/pds/test/identity.test.ts index 90aab255..10ab4a31 100644 --- a/packages/pds/test/identity.test.ts +++ b/packages/pds/test/identity.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { Secp256k1Keypair } from "@atproto/crypto"; import { env, worker } from "./helpers"; @@ -51,4 +51,132 @@ describe("Identity Endpoints", () => { expect(expectedSigningKey.startsWith("did:key:")).toBe(true); }); }); + + describe("com.atproto.identity.submitPlcOperation", () => { + let originalFetch: typeof fetch; + + beforeAll(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.unstubAllGlobals(); + }); + + it("requires authentication", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.submitPlcOperation", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ operation: { type: "plc_operation" } }), + }, + ), + env, + ); + expect(response.status).toBe(401); + }); + + it("rejects request without operation", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.submitPlcOperation", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({}), + }, + ), + env, + ); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toBe("InvalidRequest"); + }); + + it("forwards the operation to plc.directory for this DID", async () => { + const operation = { + type: "plc_operation", + prev: "bafyreid", + rotationKeys: ["did:key:zRotation"], + verificationMethods: { atproto: "did:key:zVerify" }, + alsoKnownAs: ["at://example.test"], + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: "https://new.pds.example", + }, + }, + sig: "AAAA", + }; + + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + const href = typeof url === "string" ? url : url.toString(); + expect(href).toBe(`https://plc.directory/${env.DID}`); + expect(init?.method).toBe("POST"); + expect(JSON.parse(init?.body as string)).toEqual(operation); + return new Response(null, { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.submitPlcOperation", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({ operation }), + }, + ), + env, + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(response.status).toBe(200); + }); + + it("surfaces PLC directory errors to the caller", async () => { + const fetchMock = vi.fn( + async () => + new Response("invalid signature", { + status: 400, + headers: { "Content-Type": "text/plain" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.submitPlcOperation", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${env.AUTH_TOKEN}`, + }, + body: JSON.stringify({ + operation: { type: "plc_operation", sig: "bad" }, + }), + }, + ), + env, + ); + + expect(response.status).toBe(400); + const body = (await response.json()) as { + error: string; + message: string; + }; + expect(body.error).toBe("PlcDirectoryError"); + expect(body.message).toContain("invalid signature"); + }); + }); });