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-4gtl32tl-d1-directory'

+464
+12
schema/directory.sql
··· 1 + -- D1 account directory schema for rookery 2 + -- Apply with: wrangler d1 execute rookery-directory --file schema/directory.sql 3 + 4 + CREATE TABLE IF NOT EXISTS accounts ( 5 + did TEXT PRIMARY KEY, 6 + handle TEXT NOT NULL UNIQUE, 7 + do_id TEXT NOT NULL, 8 + active INTEGER NOT NULL DEFAULT 1, 9 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 10 + ); 11 + 12 + CREATE INDEX IF NOT EXISTS idx_accounts_handle ON accounts(handle);
+55
src/directory.ts
··· 1 + import type { Env } from "./types"; 2 + 3 + const SCHEMA = ` 4 + CREATE TABLE IF NOT EXISTS accounts ( 5 + did TEXT PRIMARY KEY, 6 + handle TEXT NOT NULL UNIQUE, 7 + do_id TEXT NOT NULL, 8 + active INTEGER NOT NULL DEFAULT 1, 9 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 10 + ); 11 + `; 12 + 13 + const INDEX = "CREATE INDEX IF NOT EXISTS idx_accounts_handle ON accounts(handle);"; 14 + 15 + export async function initDirectory(db: D1Database): Promise<void> { 16 + await db.batch([ 17 + db.prepare(SCHEMA), 18 + db.prepare(INDEX), 19 + ]); 20 + } 21 + 22 + export class RepoNotFoundError extends Error { 23 + constructor(message: string) { 24 + super(message); 25 + this.name = "RepoNotFoundError"; 26 + } 27 + } 28 + 29 + export async function resolveRepo( 30 + repo: string, 31 + env: Env, 32 + ): Promise<{ did: string; doId: string }> { 33 + const row = repo.includes(":") 34 + ? await env.DIRECTORY.prepare( 35 + "SELECT did, do_id FROM accounts WHERE did = ? AND active = 1", 36 + ).bind(repo).first<{ did: string; do_id: string }>() 37 + : await env.DIRECTORY.prepare( 38 + "SELECT did, do_id FROM accounts WHERE handle = ? AND active = 1", 39 + ).bind(repo).first<{ did: string; do_id: string }>(); 40 + 41 + if (!row) { 42 + throw new RepoNotFoundError(`Repository not found: ${repo}`); 43 + } 44 + 45 + return { did: row.did, doId: row.do_id }; 46 + } 47 + 48 + export async function insertAccount( 49 + db: D1Database, 50 + account: { did: string; handle: string; doId: string }, 51 + ): Promise<void> { 52 + await db.prepare( 53 + "INSERT INTO accounts (did, handle, do_id) VALUES (?, ?, ?)", 54 + ).bind(account.did, account.handle, account.doId).run(); 55 + }
+2
src/types.ts
··· 7 7 export interface Env { 8 8 /** Durable Object namespace for account storage */ 9 9 ACCOUNT: DurableObjectNamespace<AccountDurableObject>; 10 + /** D1 account directory for cross-account queries */ 11 + DIRECTORY: D1Database; 10 12 /** Public hostname of the PDS */ 11 13 ROOKERY_HOSTNAME: string; 12 14 /** Handle domain suffix (e.g. ".pds.example.com") */
+177
src/worker.ts
··· 1 1 export { AccountDurableObject } from "./account-do"; 2 2 3 3 import { Hono } from "hono"; 4 + import { 5 + RepoNotFoundError, 6 + initDirectory, 7 + insertAccount, 8 + resolveRepo, 9 + } from "./directory"; 4 10 import type { Env } from "./types"; 5 11 6 12 const app = new Hono<{ Bindings: Env }>(); 7 13 14 + // Health check 8 15 app.get("/", (c) => c.json({ status: "ok" })); 16 + 17 + // POST /api/signup 18 + app.post("/api/signup", async (c) => { 19 + const env = c.env; 20 + let body: { handle?: string }; 21 + 22 + try { 23 + body = await c.req.json<{ handle?: string }>(); 24 + } catch { 25 + return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 26 + } 27 + 28 + if (!body.handle || typeof body.handle !== "string") { 29 + return c.json({ error: "InvalidRequest", message: "Missing or invalid handle" }, 400); 30 + } 31 + 32 + // Always construct handle as name + configured domain 33 + const handle = body.handle + env.ROOKERY_HANDLE_DOMAIN; 34 + 35 + const { ensureValidHandle } = await import("@atproto/syntax"); 36 + try { 37 + ensureValidHandle(handle); 38 + } catch (err) { 39 + return c.json( 40 + { error: "InvalidHandle", message: `Invalid handle: ${(err as Error).message}` }, 41 + 400, 42 + ); 43 + } 44 + 45 + await initDirectory(env.DIRECTORY); 46 + 47 + // Lazy imports: these pull in node:process at module scope which breaks CF Workers test runner 48 + const { Secp256k1Keypair } = await import("@atproto/crypto"); 49 + const { toString } = await import("uint8arrays/to-string"); 50 + const { createPlcDid } = await import("./identity"); 51 + 52 + const signingKey = await Secp256k1Keypair.create({ exportable: true }); 53 + const rotationKey = await Secp256k1Keypair.create({ exportable: true }); 54 + const signingKeyHex = toString(await signingKey.export(), "hex"); 55 + const signingKeyPub = signingKey.did().split(":").pop()!; 56 + const rotationKeyHex = toString(await rotationKey.export(), "hex"); 57 + const rotationKeyPub = rotationKey.did().split(":").pop()!; 58 + 59 + const did = await createPlcDid( 60 + handle, 61 + env.ROOKERY_HOSTNAME, 62 + signingKey, 63 + rotationKey, 64 + env.ROOKERY_PLC_URL, 65 + ); 66 + 67 + const doId = env.ACCOUNT.newUniqueId(); 68 + const stub = env.ACCOUNT.get(doId); 69 + await stub.rpcInitAccount({ 70 + did, 71 + handle, 72 + signingKeyHex, 73 + signingKeyPub, 74 + rotationKeyHex, 75 + rotationKeyPub, 76 + }); 77 + 78 + await insertAccount(env.DIRECTORY, { 79 + did, 80 + handle, 81 + doId: doId.toString(), 82 + }); 83 + 84 + return c.json({ did, handle }); 85 + }); 86 + 87 + // GET /xrpc/com.atproto.identity.resolveHandle 88 + app.get("/xrpc/com.atproto.identity.resolveHandle", async (c) => { 89 + const handle = c.req.query("handle"); 90 + if (!handle) { 91 + return c.json( 92 + { error: "InvalidRequest", message: "Missing required parameter: handle" }, 93 + 400, 94 + ); 95 + } 96 + 97 + await initDirectory(c.env.DIRECTORY); 98 + 99 + try { 100 + const { did } = await resolveRepo(handle, c.env); 101 + return c.json({ did }); 102 + } catch (err) { 103 + if (err instanceof RepoNotFoundError) { 104 + return c.json({ error: "HandleNotFound", message: `Handle not found: ${handle}` }, 404); 105 + } 106 + throw err; 107 + } 108 + }); 109 + 110 + // GET /xrpc/com.atproto.sync.listRepos 111 + app.get("/xrpc/com.atproto.sync.listRepos", async (c) => { 112 + await initDirectory(c.env.DIRECTORY); 113 + 114 + let limit = parseInt(c.req.query("limit") || "500", 10); 115 + if (Number.isNaN(limit) || limit < 1) limit = 500; 116 + if (limit > 1000) limit = 1000; 117 + 118 + const cursor = c.req.query("cursor"); 119 + 120 + let rows: { did: string; active: number }[]; 121 + if (cursor) { 122 + const result = await c.env.DIRECTORY.prepare( 123 + "SELECT did, active FROM accounts WHERE active = 1 AND did > ? ORDER BY did ASC LIMIT ?", 124 + ).bind(cursor, limit).all<{ did: string; active: number }>(); 125 + rows = result.results; 126 + } else { 127 + const result = await c.env.DIRECTORY.prepare( 128 + "SELECT did, active FROM accounts WHERE active = 1 ORDER BY did ASC LIMIT ?", 129 + ).bind(limit).all<{ did: string; active: number }>(); 130 + rows = result.results; 131 + } 132 + 133 + const repos = rows.map((row) => ({ 134 + did: row.did, 135 + active: row.active === 1, 136 + })); 137 + 138 + const response: { repos: typeof repos; cursor?: string } = { repos }; 139 + if (repos.length === limit) { 140 + response.cursor = repos[repos.length - 1].did; 141 + } 142 + 143 + return c.json(response); 144 + }); 145 + 146 + // GET /xrpc/com.atproto.server.describeServer 147 + app.get("/xrpc/com.atproto.server.describeServer", async (c) => { 148 + await initDirectory(c.env.DIRECTORY); 149 + 150 + const countResult = await c.env.DIRECTORY.prepare( 151 + "SELECT COUNT(*) as count FROM accounts WHERE active = 1", 152 + ).first<{ count: number }>(); 153 + 154 + return c.json({ 155 + did: `did:web:${c.env.ROOKERY_HOSTNAME}`, 156 + availableUserDomains: [c.env.ROOKERY_HANDLE_DOMAIN.replace(/^\./, "")], 157 + inviteCodeRequired: false, 158 + phoneVerificationRequired: false, 159 + links: {}, 160 + contact: {}, 161 + accounts: countResult?.count ?? 0, 162 + }); 163 + }); 164 + 165 + // GET /.well-known/atproto-did 166 + app.get("/.well-known/atproto-did", async (c) => { 167 + const host = c.req.header("host"); 168 + if (!host) { 169 + return c.text("", 400); 170 + } 171 + 172 + const handle = host.split(":")[0]; 173 + 174 + await initDirectory(c.env.DIRECTORY); 175 + 176 + try { 177 + const { did } = await resolveRepo(handle, c.env); 178 + return c.text(did); 179 + } catch (err) { 180 + if (err instanceof RepoNotFoundError) { 181 + return c.text("", 404); 182 + } 183 + throw err; 184 + } 185 + }); 9 186 10 187 export default app;
+206
test/directory.test.ts
··· 1 + import { Secp256k1Keypair } from "@atproto/crypto"; 2 + import type { AccountDurableObject } from "../src/account-do"; 3 + import { initDirectory, insertAccount, resolveRepo } from "../src/directory"; 4 + import { env, runInDurableObject, worker } from "./helpers"; 5 + import { describe, it, expect, beforeAll } from "vitest"; 6 + import { toString } from "uint8arrays/to-string"; 7 + 8 + async function generateTestKeys() { 9 + const signing = await Secp256k1Keypair.create({ exportable: true }); 10 + const rotation = await Secp256k1Keypair.create({ exportable: true }); 11 + return { 12 + signingKeyHex: toString(await signing.export(), "hex"), 13 + signingKeyPub: signing.did().split(":").pop()!, 14 + rotationKeyHex: toString(await rotation.export(), "hex"), 15 + rotationKeyPub: rotation.did().split(":").pop()!, 16 + }; 17 + } 18 + 19 + async function setupTestAccount( 20 + testEnv: typeof env, 21 + opts: { did: string; handle: string }, 22 + ) { 23 + const keys = await generateTestKeys(); 24 + const doId = testEnv.ACCOUNT.newUniqueId(); 25 + const stub = testEnv.ACCOUNT.get(doId); 26 + 27 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 28 + await instance.rpcInitAccount({ 29 + did: opts.did, 30 + handle: opts.handle, 31 + ...keys, 32 + }); 33 + }); 34 + 35 + await insertAccount(testEnv.DIRECTORY, { 36 + did: opts.did, 37 + handle: opts.handle, 38 + doId: doId.toString(), 39 + }); 40 + 41 + return { did: opts.did, handle: opts.handle, doId: doId.toString() }; 42 + } 43 + 44 + describe("D1 Directory", () => { 45 + beforeAll(async () => { 46 + await initDirectory(env.DIRECTORY); 47 + }); 48 + 49 + describe("com.atproto.identity.resolveHandle", () => { 50 + it("resolves a known handle to a DID", async () => { 51 + const account = await setupTestAccount(env, { 52 + did: "did:plc:resolve-test-1", 53 + handle: "alice.rookery.test", 54 + }); 55 + const res = await worker.fetch( 56 + "http://localhost/xrpc/com.atproto.identity.resolveHandle?handle=alice.rookery.test", 57 + ); 58 + expect(res.status).toBe(200); 59 + const body = await res.json() as { did: string }; 60 + expect(body.did).toBe(account.did); 61 + }); 62 + 63 + it("returns 400 when handle param is missing", async () => { 64 + const res = await worker.fetch( 65 + "http://localhost/xrpc/com.atproto.identity.resolveHandle", 66 + ); 67 + expect(res.status).toBe(400); 68 + }); 69 + 70 + it("returns 404 for unknown handle", async () => { 71 + const res = await worker.fetch( 72 + "http://localhost/xrpc/com.atproto.identity.resolveHandle?handle=nobody.rookery.test", 73 + ); 74 + expect(res.status).toBe(404); 75 + }); 76 + }); 77 + 78 + describe("DO ID round-trip", () => { 79 + it("resolveRepo returns doId that recovers a valid DO stub", async () => { 80 + const account = await setupTestAccount(env, { 81 + did: "did:plc:roundtrip-test-1", 82 + handle: "roundtrip.rookery.test", 83 + }); 84 + const resolved = await resolveRepo("roundtrip.rookery.test", env); 85 + expect(resolved.did).toBe(account.did); 86 + expect(resolved.doId).toBe(account.doId); 87 + 88 + // Round-trip: recover DO stub from stored doId 89 + const recoveredId = env.ACCOUNT.idFromString(resolved.doId); 90 + const stub = env.ACCOUNT.get(recoveredId); 91 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 92 + const state = await instance.rpcGetState(); 93 + expect(state).not.toBeNull(); 94 + expect(state!.did).toBe(account.did); 95 + expect(state!.handle).toBe(account.handle); 96 + }); 97 + }); 98 + }); 99 + 100 + describe("com.atproto.sync.listRepos", () => { 101 + it("returns repos ordered by DID", async () => { 102 + const res = await worker.fetch( 103 + "http://localhost/xrpc/com.atproto.sync.listRepos", 104 + ); 105 + expect(res.status).toBe(200); 106 + const body = await res.json() as { repos: Array<{ did: string }> }; 107 + expect(Array.isArray(body.repos)).toBe(true); 108 + for (let i = 1; i < body.repos.length; i++) { 109 + expect(body.repos[i].did > body.repos[i - 1].did).toBe(true); 110 + } 111 + }); 112 + 113 + it("paginates with cursor", async () => { 114 + await setupTestAccount(env, { 115 + did: "did:plc:page-a", 116 + handle: "page-a.rookery.test", 117 + }); 118 + await setupTestAccount(env, { 119 + did: "did:plc:page-b", 120 + handle: "page-b.rookery.test", 121 + }); 122 + await setupTestAccount(env, { 123 + did: "did:plc:page-c", 124 + handle: "page-c.rookery.test", 125 + }); 126 + 127 + const res1 = await worker.fetch( 128 + "http://localhost/xrpc/com.atproto.sync.listRepos?limit=1", 129 + ); 130 + expect(res1.status).toBe(200); 131 + const body1 = await res1.json() as { 132 + repos: Array<{ did: string }>; 133 + cursor?: string; 134 + }; 135 + expect(body1.repos.length).toBe(1); 136 + expect(body1.cursor).toBeTruthy(); 137 + 138 + const res2 = await worker.fetch( 139 + `http://localhost/xrpc/com.atproto.sync.listRepos?limit=1&cursor=${body1.cursor}`, 140 + ); 141 + expect(res2.status).toBe(200); 142 + const body2 = await res2.json() as { repos: Array<{ did: string }> }; 143 + expect(body2.repos.length).toBe(1); 144 + expect(body2.repos[0].did).not.toBe(body1.repos[0].did); 145 + expect(body2.repos[0].did > body1.repos[0].did).toBe(true); 146 + }); 147 + 148 + it("omits cursor on final page", async () => { 149 + const res = await worker.fetch( 150 + "http://localhost/xrpc/com.atproto.sync.listRepos?limit=1000", 151 + ); 152 + expect(res.status).toBe(200); 153 + const body = await res.json() as { 154 + repos: Array<{ did: string }>; 155 + cursor?: string; 156 + }; 157 + if (body.repos.length < 1000) { 158 + expect(body.cursor).toBeUndefined(); 159 + } 160 + }); 161 + }); 162 + 163 + describe("com.atproto.server.describeServer", () => { 164 + it("returns server description with account count", async () => { 165 + const res = await worker.fetch( 166 + "http://localhost/xrpc/com.atproto.server.describeServer", 167 + ); 168 + expect(res.status).toBe(200); 169 + const body = await res.json() as { 170 + availableUserDomains: string[]; 171 + inviteCodeRequired: boolean; 172 + accounts: number; 173 + }; 174 + expect(body.availableUserDomains).toContain("rookery.test"); 175 + expect(body.inviteCodeRequired).toBe(false); 176 + expect(typeof body.accounts).toBe("number"); 177 + expect(body.accounts).toBeGreaterThanOrEqual(0); 178 + }); 179 + }); 180 + 181 + describe(".well-known/atproto-did", () => { 182 + it("returns DID for a known handle hostname", async () => { 183 + await setupTestAccount(env, { 184 + did: "did:plc:wellknown-test-1", 185 + handle: "wk-test.rookery.test", 186 + }); 187 + const res = await worker.fetch( 188 + new Request("http://localhost/.well-known/atproto-did", { 189 + headers: { Host: "wk-test.rookery.test" }, 190 + }), 191 + ); 192 + expect(res.status).toBe(200); 193 + const text = await res.text(); 194 + expect(text).toBe("did:plc:wellknown-test-1"); 195 + }); 196 + 197 + it("returns 404 for unknown handle hostname", async () => { 198 + const res = await worker.fetch( 199 + new Request("http://localhost/.well-known/atproto-did", { 200 + headers: { Host: "unknown.rookery.test" }, 201 + }), 202 + ); 203 + expect(res.status).toBe(404); 204 + }); 205 + }); 206 + });
+7
test/fixtures/worker/wrangler.jsonc
··· 17 17 "tag": "v1", 18 18 "new_sqlite_classes": ["AccountDurableObject"] 19 19 } 20 + ], 21 + "d1_databases": [ 22 + { 23 + "binding": "DIRECTORY", 24 + "database_name": "rookery-test-directory", 25 + "database_id": "test-directory-id" 26 + } 20 27 ] 21 28 }
+5
wrangler.toml
··· 11 11 [[migrations]] 12 12 tag = "v1" 13 13 new_sqlite_classes = ["AccountDurableObject"] 14 + 15 + [[d1_databases]] 16 + binding = "DIRECTORY" 17 + database_name = "rookery-directory" 18 + database_id = "placeholder-create-with-wrangler-d1-create"