From 9f8adeef6ebd009dcdbc76f88da81bfaa7142037 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 24 May 2026 20:55:33 +0100 Subject: [PATCH] feat(pds): implement PDS-side identity endpoints (#179) Adds three com.atproto.identity.* endpoints that previously fell through to the AppView proxy and returned 501: - resolveDid: returns the local DID document for our DID - resolveIdentity: returns {did, handle, didDoc} for our DID or handle - getRecommendedDidCredentials: returns rotation keys, alsoKnownAs, verification methods, and PDS service entry for inbound migration Foreign DIDs and handles still fall through to the AppView proxy. --- .changeset/pds-identity-endpoints.md | 11 ++ packages/pds/src/index.ts | 42 +++---- packages/pds/src/xrpc/identity.ts | 119 ++++++++++++++++++- packages/pds/test/identity.test.ts | 165 +++++++++++++++++++++++++++ 4 files changed, 311 insertions(+), 26 deletions(-) create mode 100644 .changeset/pds-identity-endpoints.md create mode 100644 packages/pds/test/identity.test.ts diff --git a/.changeset/pds-identity-endpoints.md b/.changeset/pds-identity-endpoints.md new file mode 100644 index 0000000..fa4711a --- /dev/null +++ b/.changeset/pds-identity-endpoints.md @@ -0,0 +1,11 @@ +--- +"@getcirrus/pds": minor +--- + +Implement three PDS-side identity endpoints that previously fell through to the AppView proxy and returned 501: + +- `com.atproto.identity.resolveDid` returns the DID document for the local account. +- `com.atproto.identity.resolveIdentity` returns `{did, handle, didDoc}` for the local handle or DID. +- `com.atproto.identity.getRecommendedDidCredentials` (authenticated) returns the rotation keys, `alsoKnownAs`, verification methods, and PDS service entry that a migrating account should advertise. + +Requests for foreign DIDs or handles continue to fall through to the AppView proxy unchanged. diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 4ef2a86..4c104ca 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -107,31 +107,7 @@ function getAccountDO(env: PDSEnv) { // DID document for did:web resolution app.get("/.well-known/did.json", (c) => { - const didDocument = { - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/multikey/v1", - "https://w3id.org/security/suites/secp256k1-2019/v1", - ], - id: c.env.DID, - alsoKnownAs: [`at://${c.env.HANDLE}`], - verificationMethod: [ - { - id: `${c.env.DID}#atproto`, - type: "Multikey", - controller: c.env.DID, - publicKeyMultibase: c.env.SIGNING_KEY_PUBLIC, - }, - ], - service: [ - { - id: "#atproto_pds", - type: "AtprotoPersonalDataServer", - serviceEndpoint: `https://${c.env.PDS_HOSTNAME}`, - }, - ], - }; - return c.json(didDocument); + return c.json(identity.buildDidDocument(c.env)); }); // Handle verification for AT Protocol @@ -309,6 +285,22 @@ app.use("/xrpc/com.atproto.identity.resolveHandle", async (c, next) => { await next(); }); +// DID resolution - serve our DID doc for our DID, others fall through to proxy +app.use("/xrpc/com.atproto.identity.resolveDid", identity.resolveDid); + +// Identity resolution - serve our identity for our DID/handle, others fall through +app.use( + "/xrpc/com.atproto.identity.resolveIdentity", + identity.resolveIdentity, +); + +// Recommended PLC credentials for inbound migration +app.get( + "/xrpc/com.atproto.identity.getRecommendedDidCredentials", + requireAuth, + identity.getRecommendedDidCredentials, +); + // Identity management for outbound migration // These endpoints allow migrating FROM Cirrus to another PDS app.post( diff --git a/packages/pds/src/xrpc/identity.ts b/packages/pds/src/xrpc/identity.ts index bd54f6d..e552de6 100644 --- a/packages/pds/src/xrpc/identity.ts +++ b/packages/pds/src/xrpc/identity.ts @@ -16,7 +16,7 @@ import type { Context } from "hono"; import { Secp256k1Keypair } from "@atproto/crypto"; import { encode } from "@atcute/cbor"; import { base64url } from "jose"; -import type { AuthedAppEnv } from "../types"; +import type { AppEnv, AuthedAppEnv, PDSEnv } from "../types"; import { createMigrationToken, validateMigrationToken, @@ -24,6 +24,123 @@ import { const PLC_DIRECTORY = "https://plc.directory"; +/** + * Build the DID document for the local account. + * + * Shared between /.well-known/did.json and the + * com.atproto.identity.resolveDid / resolveIdentity endpoints. + */ +export function buildDidDocument(env: PDSEnv) { + return { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1", + "https://w3id.org/security/suites/secp256k1-2019/v1", + ], + id: env.DID, + alsoKnownAs: [`at://${env.HANDLE}`], + verificationMethod: [ + { + id: `${env.DID}#atproto`, + type: "Multikey", + controller: env.DID, + publicKeyMultibase: env.SIGNING_KEY_PUBLIC, + }, + ], + service: [ + { + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: `https://${env.PDS_HOSTNAME}`, + }, + ], + }; +} + +/** + * Resolve a DID to its DID document. + * + * For our local DID we serve the document directly. Any other DID is + * passed through to the next handler (the AppView proxy). + * + * Endpoint: GET com.atproto.identity.resolveDid + */ +export async function resolveDid( + c: Context, + next: () => Promise, +): Promise { + const did = c.req.query("did"); + if (!did) { + return c.json( + { error: "InvalidRequest", message: "Missing required parameter: did" }, + 400, + ); + } + if (did === c.env.DID) { + return c.json({ didDoc: buildDidDocument(c.env) }); + } + await next(); +} + +/** + * Resolve an identifier (handle or DID) to its full identity. + * + * For our local handle or DID we serve the response directly. Anything + * else falls through to the AppView proxy. + * + * Endpoint: GET com.atproto.identity.resolveIdentity + */ +export async function resolveIdentity( + c: Context, + next: () => Promise, +): Promise { + const identifier = c.req.query("identifier"); + if (!identifier) { + return c.json( + { + error: "InvalidRequest", + message: "Missing required parameter: identifier", + }, + 400, + ); + } + if (identifier === c.env.DID || identifier === c.env.HANDLE) { + return c.json({ + did: c.env.DID, + handle: c.env.HANDLE, + didDoc: buildDidDocument(c.env), + }); + } + await next(); +} + +/** + * Return recommended PLC credentials for the current account. + * + * Used by other PDSes during an inbound migration to discover the + * keys / services they should attach to a new PLC operation. + * + * Endpoint: GET com.atproto.identity.getRecommendedDidCredentials + */ +export async function getRecommendedDidCredentials( + c: Context, +): Promise { + const keypair = await Secp256k1Keypair.import(c.env.SIGNING_KEY); + const signingKey = keypair.did(); + + return c.json({ + rotationKeys: [signingKey], + alsoKnownAs: [`at://${c.env.HANDLE}`], + verificationMethods: { atproto: signingKey }, + services: { + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: `https://${c.env.PDS_HOSTNAME}`, + }, + }, + }); +} + /** * PLC operation structure */ diff --git a/packages/pds/test/identity.test.ts b/packages/pds/test/identity.test.ts new file mode 100644 index 0000000..5be11b3 --- /dev/null +++ b/packages/pds/test/identity.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { Secp256k1Keypair } from "@atproto/crypto"; +import { env, worker } from "./helpers"; + +describe("Identity Endpoints", () => { + describe("com.atproto.identity.resolveDid", () => { + it("returns the DID document for our DID", async () => { + const response = await worker.fetch( + new Request( + `http://pds.test/xrpc/com.atproto.identity.resolveDid?did=${env.DID}`, + ), + env, + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { didDoc: Record }; + expect(data.didDoc).toBeDefined(); + expect(data.didDoc.id).toBe(env.DID); + expect(data.didDoc.alsoKnownAs).toEqual([`at://${env.HANDLE}`]); + expect(Array.isArray(data.didDoc.verificationMethod)).toBe(true); + expect(Array.isArray(data.didDoc.service)).toBe(true); + const services = data.didDoc.service as Array<{ + id: string; + type: string; + serviceEndpoint: string; + }>; + expect(services[0]).toMatchObject({ + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: `https://${env.PDS_HOSTNAME}`, + }); + }); + + it("returns 400 when did param is missing", async () => { + const response = await worker.fetch( + new Request("http://pds.test/xrpc/com.atproto.identity.resolveDid"), + env, + ); + expect(response.status).toBe(400); + const data = (await response.json()) as { error: string }; + expect(data.error).toBe("InvalidRequest"); + }); + + it("falls through to the AppView proxy for foreign DIDs", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.resolveDid?did=did:web:other.example", + ), + env, + ); + expect(response.status).not.toBe(404); + }); + }); + + describe("com.atproto.identity.resolveIdentity", () => { + it("returns identity info for our DID", async () => { + const response = await worker.fetch( + new Request( + `http://pds.test/xrpc/com.atproto.identity.resolveIdentity?identifier=${env.DID}`, + ), + env, + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + did: string; + handle: string; + didDoc: { id: string }; + }; + expect(data.did).toBe(env.DID); + expect(data.handle).toBe(env.HANDLE); + expect(data.didDoc.id).toBe(env.DID); + }); + + it("returns identity info for our handle", async () => { + const response = await worker.fetch( + new Request( + `http://pds.test/xrpc/com.atproto.identity.resolveIdentity?identifier=${env.HANDLE}`, + ), + env, + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + did: string; + handle: string; + didDoc: { id: string }; + }; + expect(data.did).toBe(env.DID); + expect(data.handle).toBe(env.HANDLE); + expect(data.didDoc.id).toBe(env.DID); + }); + + it("returns 400 when identifier param is missing", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.resolveIdentity", + ), + env, + ); + expect(response.status).toBe(400); + const data = (await response.json()) as { error: string }; + expect(data.error).toBe("InvalidRequest"); + }); + + it("falls through to the AppView proxy for foreign identifiers", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.resolveIdentity?identifier=bob.test", + ), + env, + ); + expect(response.status).not.toBe(404); + }); + }); + + describe("com.atproto.identity.getRecommendedDidCredentials", () => { + it("requires authentication", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.getRecommendedDidCredentials", + ), + env, + ); + expect(response.status).toBe(401); + }); + + it("returns recommended credentials for the current account", async () => { + const response = await worker.fetch( + new Request( + "http://pds.test/xrpc/com.atproto.identity.getRecommendedDidCredentials", + { + headers: { Authorization: `Bearer ${env.AUTH_TOKEN}` }, + }, + ), + env, + ); + expect(response.status).toBe(200); + + const data = (await response.json()) as { + rotationKeys: string[]; + alsoKnownAs: string[]; + verificationMethods: { atproto: string }; + services: { + atproto_pds: { type: string; endpoint: string }; + }; + }; + + const expectedSigningKey = ( + await Secp256k1Keypair.import(env.SIGNING_KEY) + ).did(); + + expect(data.rotationKeys).toEqual([expectedSigningKey]); + expect(data.alsoKnownAs).toEqual([`at://${env.HANDLE}`]); + expect(data.verificationMethods).toEqual({ atproto: expectedSigningKey }); + expect(data.services).toEqual({ + atproto_pds: { + type: "AtprotoPersonalDataServer", + endpoint: `https://${env.PDS_HOSTNAME}`, + }, + }); + expect(expectedSigningKey.startsWith("did:key:")).toBe(true); + }); + }); +}); -- knotmirror 0.0.1