open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents atprotocol pds cloudflare
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-2bcsahry-l2-identity'

# Conflicts:
# src/index.ts

+350 -22
+31 -6
package-lock.json
··· 19 19 "@hono/node-server": "^1", 20 20 "@hono/node-ws": "^1", 21 21 "better-sqlite3": "^11", 22 - "hono": "^4" 22 + "hono": "^4", 23 + "uint8arrays": "^5.1.0" 23 24 }, 24 25 "devDependencies": { 25 26 "@types/better-sqlite3": "^7", ··· 147 148 "node": ">=18.7.0" 148 149 } 149 150 }, 151 + "node_modules/@atproto/crypto/node_modules/uint8arrays": { 152 + "version": "3.0.0", 153 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 154 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 155 + "license": "MIT", 156 + "dependencies": { 157 + "multiformats": "^9.4.2" 158 + } 159 + }, 150 160 "node_modules/@atproto/lex-cbor": { 151 161 "version": "0.0.15", 152 162 "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.0.15.tgz", ··· 167 177 "tslib": "^2.8.1", 168 178 "uint8arrays": "3.0.0", 169 179 "unicode-segmenter": "^0.14.0" 180 + } 181 + }, 182 + "node_modules/@atproto/lex-data/node_modules/uint8arrays": { 183 + "version": "3.0.0", 184 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 185 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 186 + "license": "MIT", 187 + "dependencies": { 188 + "multiformats": "^9.4.2" 170 189 } 171 190 }, 172 191 "node_modules/@atproto/lex-json": { ··· 2407 2426 } 2408 2427 }, 2409 2428 "node_modules/uint8arrays": { 2410 - "version": "3.0.0", 2411 - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 2412 - "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 2413 - "license": "MIT", 2429 + "version": "5.1.0", 2430 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", 2431 + "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", 2432 + "license": "Apache-2.0 OR MIT", 2414 2433 "dependencies": { 2415 - "multiformats": "^9.4.2" 2434 + "multiformats": "^13.0.0" 2416 2435 } 2436 + }, 2437 + "node_modules/uint8arrays/node_modules/multiformats": { 2438 + "version": "13.4.2", 2439 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", 2440 + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", 2441 + "license": "Apache-2.0 OR MIT" 2417 2442 }, 2418 2443 "node_modules/undici-types": { 2419 2444 "version": "7.18.2",
+12 -11
package.json
··· 11 11 }, 12 12 "license": "MIT", 13 13 "dependencies": { 14 - "hono": "^4", 14 + "@atcute/cbor": "^1", 15 + "@atcute/cid": "^1", 16 + "@atcute/lexicons": "^1", 17 + "@atcute/tid": "^1", 18 + "@atproto/crypto": "^0.4", 19 + "@atproto/lex-data": "^0.0.14", 20 + "@atproto/repo": "^0.9", 15 21 "@hono/node-server": "^1", 16 22 "@hono/node-ws": "^1", 17 23 "better-sqlite3": "^11", 18 - "@atproto/repo": "^0.9", 19 - "@atproto/crypto": "^0.4", 20 - "@atproto/lex-data": "^0.0.14", 21 - "@atcute/cbor": "^1", 22 - "@atcute/cid": "^1", 23 - "@atcute/tid": "^1", 24 - "@atcute/lexicons": "^1" 24 + "hono": "^4", 25 + "uint8arrays": "^5.1.0" 25 26 }, 26 27 "devDependencies": { 28 + "@types/better-sqlite3": "^7", 29 + "tsx": "^4", 27 30 "typescript": "^5", 28 - "vitest": "^3", 29 - "@types/better-sqlite3": "^7", 30 - "tsx": "^4" 31 + "vitest": "^3" 31 32 } 32 33 }
+50
src/app.ts
··· 1 + import type Database from "better-sqlite3"; 2 + import { Hono } from "hono"; 3 + import type { Config } from "./config.js"; 4 + 5 + export function createApp(db: Database.Database, _config: Config) { 6 + const app = new Hono(); 7 + 8 + app.get("/", (c) => c.json({ status: "ok" })); 9 + 10 + app.get("/xrpc/com.atproto.identity.resolveHandle", (c) => { 11 + const handle = c.req.query("handle"); 12 + if (!handle) { 13 + return c.json( 14 + { error: "InvalidRequest", message: "handle parameter is required" }, 15 + 400, 16 + ); 17 + } 18 + 19 + const row = db 20 + .prepare("SELECT did FROM accounts WHERE handle = ?") 21 + .get(handle) as { did: string } | undefined; 22 + if (!row) { 23 + return c.json( 24 + { error: "InvalidRequest", message: "Unable to resolve handle" }, 25 + 400, 26 + ); 27 + } 28 + 29 + return c.json({ did: row.did }); 30 + }); 31 + 32 + app.get("/.well-known/atproto-did", (c) => { 33 + const host = c.req.header("host"); 34 + if (!host) { 35 + return c.text("", 400); 36 + } 37 + 38 + const handle = host.replace(/:\d+$/, ""); 39 + const row = db 40 + .prepare("SELECT did FROM accounts WHERE handle = ?") 41 + .get(handle) as { did: string } | undefined; 42 + if (!row) { 43 + return c.text("", 404); 44 + } 45 + 46 + return c.text(row.did); 47 + }); 48 + 49 + return app; 50 + }
+1
src/db.ts
··· 55 55 created_at TEXT DEFAULT (datetime('now')) 56 56 ); 57 57 CREATE INDEX IF NOT EXISTS idx_firehose_did ON firehose_events(did); 58 + CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_handle ON accounts(handle); 58 59 `); 59 60 60 61 return db;
+83
src/identity.ts
··· 1 + import { sha256, type Keypair } from "@atproto/crypto"; 2 + import { encode } from "@atcute/cbor"; 3 + import { toString } from "uint8arrays/to-string"; 4 + 5 + export type GenesisOperation = { 6 + type: "plc_operation"; 7 + rotationKeys: string[]; 8 + verificationMethods: { atproto: string }; 9 + alsoKnownAs: [string]; 10 + services: { 11 + atproto_pds: { 12 + type: "AtprotoPersonalDataServer"; 13 + endpoint: string; 14 + }; 15 + }; 16 + prev: null; 17 + }; 18 + 19 + export type SignedGenesisOperation = GenesisOperation & { sig: string }; 20 + 21 + export function buildUnsignedGenesisOp( 22 + handle: string, 23 + hostname: string, 24 + signingKeypair: Keypair, 25 + rotationKeypair: Keypair, 26 + ): GenesisOperation { 27 + return { 28 + type: "plc_operation", 29 + rotationKeys: [rotationKeypair.did()], 30 + verificationMethods: { atproto: signingKeypair.did() }, 31 + alsoKnownAs: [`at://${handle}`], 32 + services: { 33 + atproto_pds: { 34 + type: "AtprotoPersonalDataServer", 35 + endpoint: `https://${hostname}`, 36 + }, 37 + }, 38 + prev: null, 39 + }; 40 + } 41 + 42 + export async function signGenesisOp( 43 + unsignedOp: GenesisOperation, 44 + rotationKeypair: Keypair, 45 + ): Promise<{ signedOp: SignedGenesisOperation; did: string }> { 46 + const cborBytes = encode(unsignedOp); 47 + const sigBytes = await rotationKeypair.sign(cborBytes); 48 + const sig = toString(sigBytes, "base64url"); 49 + const signedOp = { ...unsignedOp, sig }; 50 + const signedCbor = encode(signedOp); 51 + const hash = await sha256(signedCbor); 52 + const did = `did:plc:${toString(hash, "base32").slice(0, 24)}`; 53 + 54 + return { signedOp, did }; 55 + } 56 + 57 + export async function createPlcDid( 58 + handle: string, 59 + hostname: string, 60 + signingKeypair: Keypair, 61 + rotationKeypair: Keypair, 62 + plcUrl: string, 63 + ): Promise<string> { 64 + const unsignedOp = buildUnsignedGenesisOp( 65 + handle, 66 + hostname, 67 + signingKeypair, 68 + rotationKeypair, 69 + ); 70 + const { signedOp, did } = await signGenesisOp(unsignedOp, rotationKeypair); 71 + const res = await fetch(`${plcUrl}/${did}`, { 72 + method: "POST", 73 + headers: { "Content-Type": "application/json" }, 74 + body: JSON.stringify(signedOp), 75 + }); 76 + 77 + if (!res.ok) { 78 + const body = await res.text(); 79 + throw new Error(`PLC directory rejected operation (${res.status}): ${body}`); 80 + } 81 + 82 + return did; 83 + }
+2 -5
src/index.ts
··· 1 - import { Hono } from "hono"; 2 1 import { serve } from "@hono/node-server"; 2 + import { createApp } from "./app.js"; 3 3 import { loadConfig } from "./config.js"; 4 4 import { initDatabase } from "./db.js"; 5 5 import { createSyncRoutes } from "./sync.js"; 6 6 7 7 const config = loadConfig(); 8 8 const db = initDatabase(config.dbPath); 9 - 10 - const app = new Hono(); 11 - 12 - app.get("/", (c) => c.json({ status: "ok" })); 9 + const app = createApp(db, config); 13 10 app.route("/", createSyncRoutes(db)); 14 11 15 12 serve({ fetch: app.fetch, port: config.port }, (info) => {
+171
test/identity.test.ts
··· 1 + import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; 2 + import Database from "better-sqlite3"; 3 + import { Secp256k1Keypair } from "@atproto/crypto"; 4 + import { fromString } from "uint8arrays/from-string"; 5 + import { toString } from "uint8arrays/to-string"; 6 + import { createApp } from "../src/app.js"; 7 + import type { Config } from "../src/config.js"; 8 + import { initDatabase } from "../src/db.js"; 9 + import { 10 + buildUnsignedGenesisOp, 11 + createPlcDid, 12 + signGenesisOp, 13 + } from "../src/identity.js"; 14 + 15 + const testConfig: Config = { 16 + hostname: "pds.test.example", 17 + handleDomain: "test.example", 18 + plcUrl: "https://plc.test", 19 + dbPath: ":memory:", 20 + port: 3000, 21 + }; 22 + 23 + describe("identity", () => { 24 + let db: Database.Database; 25 + 26 + beforeEach(() => { 27 + db = initDatabase(":memory:"); 28 + }); 29 + 30 + afterEach(() => { 31 + vi.unstubAllGlobals(); 32 + }); 33 + 34 + it("round-trips a secp256k1 keypair through export/import", async () => { 35 + const keypair = await Secp256k1Keypair.create({ exportable: true }); 36 + const exported = await keypair.export(); 37 + const hex = toString(exported, "hex"); 38 + const imported = await Secp256k1Keypair.import(fromString(hex, "hex"), { 39 + exportable: true, 40 + }); 41 + 42 + expect(imported.did()).toBe(keypair.did()); 43 + }); 44 + 45 + it("builds the expected unsigned genesis operation", async () => { 46 + const signingKeypair = await Secp256k1Keypair.create(); 47 + const rotationKeypair = await Secp256k1Keypair.create(); 48 + 49 + expect( 50 + buildUnsignedGenesisOp( 51 + "agent.test.example", 52 + "pds.test.example", 53 + signingKeypair, 54 + rotationKeypair, 55 + ), 56 + ).toEqual({ 57 + type: "plc_operation", 58 + rotationKeys: [rotationKeypair.did()], 59 + verificationMethods: { atproto: signingKeypair.did() }, 60 + alsoKnownAs: ["at://agent.test.example"], 61 + services: { 62 + atproto_pds: { 63 + type: "AtprotoPersonalDataServer", 64 + endpoint: "https://pds.test.example", 65 + }, 66 + }, 67 + prev: null, 68 + }); 69 + }); 70 + 71 + it("signs a genesis op and derives a did:plc identifier", async () => { 72 + const signingKeypair = await Secp256k1Keypair.create(); 73 + const rotationKeypair = await Secp256k1Keypair.create(); 74 + const unsignedOp = buildUnsignedGenesisOp( 75 + "agent.test.example", 76 + "pds.test.example", 77 + signingKeypair, 78 + rotationKeypair, 79 + ); 80 + 81 + const { signedOp, did } = await signGenesisOp(unsignedOp, rotationKeypair); 82 + 83 + expect(did.startsWith("did:plc:")).toBe(true); 84 + expect(did).toHaveLength(32); 85 + expect(did.slice("did:plc:".length)).toMatch(/^[a-z2-7]{24}$/); 86 + expect(signedOp).toMatchObject(unsignedOp); 87 + expect(signedOp.sig).toMatch(/^[A-Za-z0-9_-]+$/); 88 + 89 + // Deterministic: same inputs produce same DID (RFC 6979) 90 + const { did: did2 } = await signGenesisOp(unsignedOp, rotationKeypair); 91 + expect(did2).toBe(did); 92 + }); 93 + 94 + it("posts the signed genesis operation to the PLC directory", async () => { 95 + const signingKeypair = await Secp256k1Keypair.create(); 96 + const rotationKeypair = await Secp256k1Keypair.create(); 97 + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { 98 + expect(init?.method).toBe("POST"); 99 + expect(init?.headers).toEqual({ "Content-Type": "application/json" }); 100 + expect(typeof init?.body).toBe("string"); 101 + 102 + return new Response("", { status: 200 }); 103 + }); 104 + vi.stubGlobal("fetch", fetchMock); 105 + 106 + const did = await createPlcDid( 107 + "agent.test.example", 108 + "pds.test.example", 109 + signingKeypair, 110 + rotationKeypair, 111 + "https://plc.test", 112 + ); 113 + 114 + expect(did.startsWith("did:plc:")).toBe(true); 115 + expect(fetchMock).toHaveBeenCalledTimes(1); 116 + const [url] = fetchMock.mock.calls[0] as [string, RequestInit | undefined]; 117 + expect(url).toMatch(/^https:\/\/plc\.test\/did:plc:[a-z2-7]{24}$/); 118 + }); 119 + 120 + it("resolves handles through the xrpc route", async () => { 121 + db.prepare("INSERT INTO accounts (did, handle) VALUES (?, ?)") 122 + .run("did:plc:agenttestexample1234", "agent.test.example"); 123 + const app = createApp(db, testConfig); 124 + 125 + const okRes = await app.request( 126 + "/xrpc/com.atproto.identity.resolveHandle?handle=agent.test.example", 127 + ); 128 + expect(okRes.status).toBe(200); 129 + await expect(okRes.json()).resolves.toEqual({ 130 + did: "did:plc:agenttestexample1234", 131 + }); 132 + 133 + const unknownRes = await app.request( 134 + "/xrpc/com.atproto.identity.resolveHandle?handle=missing.test.example", 135 + ); 136 + expect(unknownRes.status).toBe(400); 137 + await expect(unknownRes.json()).resolves.toEqual({ 138 + error: "InvalidRequest", 139 + message: "Unable to resolve handle", 140 + }); 141 + 142 + const missingRes = await app.request("/xrpc/com.atproto.identity.resolveHandle"); 143 + expect(missingRes.status).toBe(400); 144 + await expect(missingRes.json()).resolves.toEqual({ 145 + error: "InvalidRequest", 146 + message: "handle parameter is required", 147 + }); 148 + }); 149 + 150 + it("serves dids from the well-known endpoint by host header", async () => { 151 + db.prepare("INSERT INTO accounts (did, handle) VALUES (?, ?)") 152 + .run("did:plc:agenttestexample1234", "agent.test.example"); 153 + const app = createApp(db, testConfig); 154 + 155 + const okRes = await app.request("http://localhost/.well-known/atproto-did", { 156 + headers: { host: "agent.test.example" }, 157 + }); 158 + expect(okRes.status).toBe(200); 159 + await expect(okRes.text()).resolves.toBe("did:plc:agenttestexample1234"); 160 + 161 + const unknownRes = await app.request("http://localhost/.well-known/atproto-did", { 162 + headers: { host: "missing.test.example" }, 163 + }); 164 + expect(unknownRes.status).toBe(404); 165 + await expect(unknownRes.text()).resolves.toBe(""); 166 + 167 + const missingRes = await app.request("/.well-known/atproto-did"); 168 + expect(missingRes.status).toBe(400); 169 + await expect(missingRes.text()).resolves.toBe(""); 170 + }); 171 + });