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.

Add public repo read and server description endpoints (L5)

+681
+2
src/index.ts
··· 2 2 import { createApp } from "./app.js"; 3 3 import { loadConfig } from "./config.js"; 4 4 import { initDatabase } from "./db.js"; 5 + import { createRepoRoutes } from "./repo.js"; 5 6 import { createSyncRoutes } from "./sync.js"; 6 7 7 8 const config = loadConfig(); 8 9 const db = initDatabase(config.dbPath); 9 10 const app = createApp(config, db); 10 11 app.route("/", createSyncRoutes(db)); 12 + app.route("/", createRepoRoutes(db, config)); 11 13 12 14 serve({ fetch: app.fetch, port: config.port }, (info) => { 13 15 console.log(`rookery listening on port ${info.port}`);
+198
src/repo.ts
··· 1 + import { Hono, type Context } from "hono"; 2 + import { CID } from "@atproto/lex-data"; 3 + import { ReadableRepo } from "@atproto/repo/dist/readable-repo.js"; 4 + import type Database from "better-sqlite3"; 5 + import type { Config } from "./config.js"; 6 + import { SqliteRepoStorage } from "./storage.js"; 7 + 8 + type AccountRow = { 9 + id: number; 10 + did: string; 11 + handle: string | null; 12 + root_cid: string | null; 13 + rev: string | null; 14 + active: number; 15 + }; 16 + 17 + function xrpcError( 18 + c: Context, 19 + status: 400 | 404, 20 + error: string, 21 + message: string, 22 + ) { 23 + return c.json({ error, message }, status); 24 + } 25 + 26 + function resolveRepo( 27 + db: Database.Database, 28 + repo: string, 29 + ): AccountRow | undefined { 30 + if (repo.startsWith("did:")) { 31 + return db 32 + .prepare("SELECT id, did, handle, root_cid, rev, active FROM accounts WHERE did = ?") 33 + .get(repo) as AccountRow | undefined; 34 + } 35 + return db 36 + .prepare("SELECT id, did, handle, root_cid, rev, active FROM accounts WHERE handle = ?") 37 + .get(repo) as AccountRow | undefined; 38 + } 39 + 40 + export function createRepoRoutes( 41 + db: Database.Database, 42 + config: Config, 43 + ): Hono { 44 + const app = new Hono(); 45 + 46 + app.get("/xrpc/com.atproto.repo.getRecord", async (c) => { 47 + const repo = c.req.query("repo"); 48 + if (!repo) { 49 + return xrpcError(c, 400, "InvalidRequest", "missing repo parameter"); 50 + } 51 + 52 + const collection = c.req.query("collection"); 53 + if (!collection) { 54 + return xrpcError(c, 400, "InvalidRequest", "missing collection parameter"); 55 + } 56 + 57 + const rkey = c.req.query("rkey"); 58 + if (!rkey) { 59 + return xrpcError(c, 400, "InvalidRequest", "missing rkey parameter"); 60 + } 61 + 62 + const account = resolveRepo(db, repo); 63 + if (!account) { 64 + return xrpcError(c, 400, "RepoNotFound", "repo not found"); 65 + } 66 + if (!account.root_cid) { 67 + return xrpcError(c, 400, "RepoNotFound", "repo not initialized"); 68 + } 69 + 70 + const storage = new SqliteRepoStorage(db, account.id); 71 + const rootCid = CID.parse(account.root_cid); 72 + const readableRepo = await ReadableRepo.load(storage, rootCid); 73 + const cid = await readableRepo.data.get(`${collection}/${rkey}`); 74 + if (!cid) { 75 + return xrpcError(c, 404, "RecordNotFound", "record not found"); 76 + } 77 + 78 + const record = await storage.readRecord(cid); 79 + return c.json({ 80 + uri: `at://${account.did}/${collection}/${rkey}`, 81 + cid: cid.toString(), 82 + value: record, 83 + }); 84 + }); 85 + 86 + app.get("/xrpc/com.atproto.repo.listRecords", async (c) => { 87 + const repo = c.req.query("repo"); 88 + if (!repo) { 89 + return xrpcError(c, 400, "InvalidRequest", "missing repo parameter"); 90 + } 91 + 92 + const collection = c.req.query("collection"); 93 + if (!collection) { 94 + return xrpcError(c, 400, "InvalidRequest", "missing collection parameter"); 95 + } 96 + 97 + const limit = Math.min( 98 + Math.max(Number.parseInt(c.req.query("limit") ?? "50", 10) || 50, 1), 99 + 100, 100 + ); 101 + const cursor = c.req.query("cursor"); 102 + const reverse = c.req.query("reverse") === "true"; 103 + 104 + const account = resolveRepo(db, repo); 105 + if (!account) { 106 + return xrpcError(c, 400, "RepoNotFound", "repo not found"); 107 + } 108 + if (!account.root_cid) { 109 + return c.json({ records: [] }); 110 + } 111 + 112 + const storage = new SqliteRepoStorage(db, account.id); 113 + const rootCid = CID.parse(account.root_cid); 114 + const readableRepo = await ReadableRepo.load(storage, rootCid); 115 + 116 + let leaves; 117 + if (reverse) { 118 + const allLeaves = await readableRepo.data.listWithPrefix(`${collection}/`); 119 + allLeaves.reverse(); 120 + let startIdx = 0; 121 + if (cursor) { 122 + const cursorKey = `${collection}/${cursor}`; 123 + startIdx = allLeaves.findIndex((leaf) => leaf.key < cursorKey); 124 + if (startIdx === -1) { 125 + startIdx = allLeaves.length; 126 + } 127 + } 128 + leaves = allLeaves.slice(startIdx, startIdx + limit + 1); 129 + } else { 130 + const after = cursor ? `${collection}/${cursor}` : `${collection}/`; 131 + const before = `${collection}0`; 132 + leaves = await readableRepo.data.list(limit + 1, after, before); 133 + } 134 + 135 + let nextCursor: string | undefined; 136 + if (leaves.length > limit) { 137 + leaves.pop(); 138 + const lastLeaf = leaves[leaves.length - 1]; 139 + if (lastLeaf) { 140 + nextCursor = lastLeaf.key.slice(collection.length + 1); 141 + } 142 + } 143 + 144 + const records = await Promise.all( 145 + leaves.map(async (leaf) => { 146 + const record = await storage.readRecord(leaf.value); 147 + const rkey = leaf.key.slice(collection.length + 1); 148 + return { 149 + uri: `at://${account.did}/${collection}/${rkey}`, 150 + cid: leaf.value.toString(), 151 + value: record, 152 + }; 153 + }), 154 + ); 155 + 156 + return c.json(nextCursor ? { cursor: nextCursor, records } : { records }); 157 + }); 158 + 159 + app.get("/xrpc/com.atproto.repo.describeRepo", async (c) => { 160 + const repo = c.req.query("repo"); 161 + if (!repo) { 162 + return xrpcError(c, 400, "InvalidRequest", "missing repo parameter"); 163 + } 164 + 165 + const account = resolveRepo(db, repo); 166 + if (!account) { 167 + return xrpcError(c, 400, "RepoNotFound", "repo not found"); 168 + } 169 + 170 + const storage = new SqliteRepoStorage(db, account.id); 171 + const collections = storage.getCollections(); 172 + 173 + let didDoc: unknown = {}; 174 + try { 175 + const res = await fetch(`${config.plcUrl}/${account.did}`); 176 + if (res.ok) { 177 + didDoc = await res.json(); 178 + } 179 + } catch {} 180 + 181 + return c.json({ 182 + handle: account.handle ?? "", 183 + did: account.did, 184 + didDoc, 185 + collections, 186 + handleIsCorrect: account.handle !== null, 187 + }); 188 + }); 189 + 190 + app.get("/xrpc/com.atproto.server.describeServer", (c) => { 191 + return c.json({ 192 + did: `did:web:${config.hostname}`, 193 + availableUserDomains: [config.handleDomain], 194 + }); 195 + }); 196 + 197 + return app; 198 + }
+481
test/repo.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 + import Database from "better-sqlite3"; 3 + import { Hono } from "hono"; 4 + import { Repo, WriteOpAction } from "@atproto/repo"; 5 + import { Secp256k1Keypair } from "@atproto/crypto"; 6 + import { initDatabase } from "../src/db.js"; 7 + import { SqliteRepoStorage } from "../src/storage.js"; 8 + import { createRepoRoutes } from "../src/repo.js"; 9 + import type { Config } from "../src/config.js"; 10 + 11 + const testConfig: Config = { 12 + hostname: "pds.test.example", 13 + handleDomain: "test.example", 14 + plcUrl: "https://plc.test", 15 + dbPath: ":memory:", 16 + port: 3000, 17 + tosText: "test tos", 18 + }; 19 + 20 + function createAccount( 21 + db: Database.Database, 22 + did: string, 23 + ): { id: number; storage: SqliteRepoStorage } { 24 + const info = db.prepare("INSERT INTO accounts (did) VALUES (?)").run(did); 25 + const id = Number(info.lastInsertRowid); 26 + return { id, storage: new SqliteRepoStorage(db, id) }; 27 + } 28 + 29 + async function createRepoWithPost(db: Database.Database, did: string) { 30 + const { id, storage } = createAccount(db, did); 31 + const keypair = await Secp256k1Keypair.create(); 32 + const created = await Repo.create(storage, did, keypair); 33 + const repo = await created.applyWrites( 34 + { 35 + action: WriteOpAction.Create, 36 + collection: "app.bsky.feed.post", 37 + rkey: "test1", 38 + record: { 39 + text: "hello world", 40 + createdAt: new Date().toISOString(), 41 + $type: "app.bsky.feed.post", 42 + }, 43 + }, 44 + keypair, 45 + ); 46 + 47 + return { id, storage, repo, keypair }; 48 + } 49 + 50 + async function createRepoWithPosts( 51 + db: Database.Database, 52 + did: string, 53 + rkeys: string[], 54 + ) { 55 + const { id, storage } = createAccount(db, did); 56 + const keypair = await Secp256k1Keypair.create(); 57 + let repo = await Repo.create(storage, did, keypair); 58 + for (const rkey of rkeys) { 59 + repo = await repo.applyWrites( 60 + { 61 + action: WriteOpAction.Create, 62 + collection: "app.bsky.feed.post", 63 + rkey, 64 + record: { 65 + text: `post ${rkey}`, 66 + createdAt: new Date().toISOString(), 67 + $type: "app.bsky.feed.post", 68 + }, 69 + }, 70 + keypair, 71 + ); 72 + } 73 + return { id, storage, repo, keypair }; 74 + } 75 + 76 + function setHandle(db: Database.Database, did: string, handle: string) { 77 + db.prepare("UPDATE accounts SET handle = ? WHERE did = ?").run(handle, did); 78 + } 79 + 80 + describe("createRepoRoutes", () => { 81 + let db: Database.Database; 82 + let app: Hono; 83 + 84 + beforeEach(() => { 85 + db = initDatabase(":memory:"); 86 + app = createRepoRoutes(db, testConfig); 87 + }); 88 + 89 + afterEach(() => { 90 + vi.restoreAllMocks(); 91 + }); 92 + 93 + describe("getRecord", () => { 94 + it("returns uri, cid, and value for an existing record", async () => { 95 + const did = "did:plc:alice"; 96 + await createRepoWithPost(db, did); 97 + 98 + const res = await app.request( 99 + `/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.feed.post&rkey=test1`, 100 + ); 101 + const body = await res.json(); 102 + 103 + expect(res.status).toBe(200); 104 + expect(body.uri).toBe(`at://${did}/app.bsky.feed.post/test1`); 105 + expect(typeof body.cid).toBe("string"); 106 + expect(body.value.text).toBe("hello world"); 107 + }); 108 + 109 + it("returns 404 for a missing record in an existing repo", async () => { 110 + const did = "did:plc:alice"; 111 + await createRepoWithPost(db, did); 112 + 113 + const res = await app.request( 114 + `/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.feed.post&rkey=nonexistent`, 115 + ); 116 + const body = await res.json(); 117 + 118 + expect(res.status).toBe(404); 119 + expect(body.error).toBe("RecordNotFound"); 120 + }); 121 + 122 + it("returns 400 when the repo does not exist", async () => { 123 + const res = await app.request( 124 + "/xrpc/com.atproto.repo.getRecord?repo=did:plc:missing&collection=app.bsky.feed.post&rkey=test1", 125 + ); 126 + const body = await res.json(); 127 + 128 + expect(res.status).toBe(400); 129 + expect(body.error).toBe("RepoNotFound"); 130 + }); 131 + 132 + it("works with a handle as the repo param", async () => { 133 + const did = "did:plc:alice"; 134 + await createRepoWithPost(db, did); 135 + setHandle(db, did, "alice.test.example"); 136 + 137 + const res = await app.request( 138 + "/xrpc/com.atproto.repo.getRecord?repo=alice.test.example&collection=app.bsky.feed.post&rkey=test1", 139 + ); 140 + 141 + expect(res.status).toBe(200); 142 + }); 143 + 144 + it("returns 400 when repo is missing", async () => { 145 + const res = await app.request( 146 + "/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&rkey=test1", 147 + ); 148 + const body = await res.json(); 149 + 150 + expect(res.status).toBe(400); 151 + expect(body).toEqual({ 152 + error: "InvalidRequest", 153 + message: "missing repo parameter", 154 + }); 155 + }); 156 + 157 + it("returns 400 when collection is missing", async () => { 158 + const res = await app.request( 159 + "/xrpc/com.atproto.repo.getRecord?repo=did:plc:alice&rkey=test1", 160 + ); 161 + const body = await res.json(); 162 + 163 + expect(res.status).toBe(400); 164 + expect(body).toEqual({ 165 + error: "InvalidRequest", 166 + message: "missing collection parameter", 167 + }); 168 + }); 169 + 170 + it("returns 400 when rkey is missing", async () => { 171 + const res = await app.request( 172 + "/xrpc/com.atproto.repo.getRecord?repo=did:plc:alice&collection=app.bsky.feed.post", 173 + ); 174 + const body = await res.json(); 175 + 176 + expect(res.status).toBe(400); 177 + expect(body).toEqual({ 178 + error: "InvalidRequest", 179 + message: "missing rkey parameter", 180 + }); 181 + }); 182 + }); 183 + 184 + describe("listRecords", () => { 185 + it("returns records with the expected structure", async () => { 186 + const did = "did:plc:alice"; 187 + await createRepoWithPost(db, did); 188 + 189 + const res = await app.request( 190 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post`, 191 + ); 192 + const body = await res.json(); 193 + 194 + expect(res.status).toBe(200); 195 + expect(body.records).toHaveLength(1); 196 + expect(body.records[0].uri).toBe(`at://${did}/app.bsky.feed.post/test1`); 197 + expect(typeof body.records[0].cid).toBe("string"); 198 + expect(body.records[0].value.text).toBe("hello world"); 199 + }); 200 + 201 + it("returns empty records for a collection with no records", async () => { 202 + const did = "did:plc:alice"; 203 + await createRepoWithPost(db, did); 204 + 205 + const res = await app.request( 206 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.graph.follow`, 207 + ); 208 + const body = await res.json(); 209 + 210 + expect(res.status).toBe(200); 211 + expect(body).toEqual({ records: [] }); 212 + }); 213 + 214 + it("respects limit and returns a cursor", async () => { 215 + const did = "did:plc:alice"; 216 + await createRepoWithPosts(db, did, ["test1", "test2"]); 217 + 218 + const res = await app.request( 219 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1`, 220 + ); 221 + const body = await res.json(); 222 + 223 + expect(res.status).toBe(200); 224 + expect(body.records).toHaveLength(1); 225 + expect(body.cursor).toBe("test1"); 226 + }); 227 + 228 + it("uses the default limit when limit is omitted", async () => { 229 + const did = "did:plc:alice"; 230 + await createRepoWithPosts(db, did, ["test1", "test2"]); 231 + 232 + const res = await app.request( 233 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post`, 234 + ); 235 + const body = await res.json(); 236 + 237 + expect(res.status).toBe(200); 238 + expect(body.records).toHaveLength(2); 239 + }); 240 + 241 + it("caps limit at 100", async () => { 242 + const did = "did:plc:alice"; 243 + await createRepoWithPosts(db, did, ["test1", "test2"]); 244 + 245 + const res = await app.request( 246 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=999`, 247 + ); 248 + const body = await res.json(); 249 + 250 + expect(res.status).toBe(200); 251 + expect(body.records).toHaveLength(2); 252 + expect(body.cursor).toBeUndefined(); 253 + }); 254 + 255 + it("paginates correctly with cursor chaining", async () => { 256 + const did = "did:plc:alice"; 257 + await createRepoWithPosts(db, did, ["test1", "test2", "test3"]); 258 + 259 + const firstRes = await app.request( 260 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1`, 261 + ); 262 + const firstBody = await firstRes.json(); 263 + 264 + const secondRes = await app.request( 265 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&cursor=${firstBody.cursor}`, 266 + ); 267 + const secondBody = await secondRes.json(); 268 + 269 + const thirdRes = await app.request( 270 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&cursor=${secondBody.cursor}`, 271 + ); 272 + const thirdBody = await thirdRes.json(); 273 + 274 + expect(firstBody.records[0].value.text).toBe("post test1"); 275 + expect(secondBody.records[0].value.text).toBe("post test2"); 276 + expect(thirdBody.records[0].value.text).toBe("post test3"); 277 + expect(thirdBody.cursor).toBeUndefined(); 278 + }); 279 + 280 + it("paginates correctly with cursor and reverse", async () => { 281 + const did = "did:plc:alice"; 282 + await createRepoWithPosts(db, did, ["test1", "test2", "test3"]); 283 + 284 + const firstRes = await app.request( 285 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&reverse=true`, 286 + ); 287 + const firstBody = await firstRes.json(); 288 + 289 + expect(firstBody.records).toHaveLength(1); 290 + expect(firstBody.records[0].uri).toContain("test3"); 291 + expect(firstBody.cursor).toBeDefined(); 292 + 293 + const secondRes = await app.request( 294 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&reverse=true&cursor=${firstBody.cursor}`, 295 + ); 296 + const secondBody = await secondRes.json(); 297 + 298 + expect(secondBody.records).toHaveLength(1); 299 + expect(secondBody.records[0].uri).toContain("test2"); 300 + 301 + const thirdRes = await app.request( 302 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=1&reverse=true&cursor=${secondBody.cursor}`, 303 + ); 304 + const thirdBody = await thirdRes.json(); 305 + 306 + expect(thirdBody.records).toHaveLength(1); 307 + expect(thirdBody.records[0].uri).toContain("test1"); 308 + expect(thirdBody.cursor).toBeUndefined(); 309 + }); 310 + 311 + it("returns records in reverse rkey order", async () => { 312 + const did = "did:plc:alice"; 313 + await createRepoWithPosts(db, did, ["test1", "test2", "test3"]); 314 + 315 + const res = await app.request( 316 + `/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&reverse=true`, 317 + ); 318 + const body = await res.json(); 319 + 320 + expect(body.records.map((record: { uri: string }) => record.uri)).toEqual([ 321 + `at://${did}/app.bsky.feed.post/test3`, 322 + `at://${did}/app.bsky.feed.post/test2`, 323 + `at://${did}/app.bsky.feed.post/test1`, 324 + ]); 325 + }); 326 + 327 + it("returns 400 when repo is missing", async () => { 328 + const res = await app.request( 329 + "/xrpc/com.atproto.repo.listRecords?collection=app.bsky.feed.post", 330 + ); 331 + const body = await res.json(); 332 + 333 + expect(res.status).toBe(400); 334 + expect(body).toEqual({ 335 + error: "InvalidRequest", 336 + message: "missing repo parameter", 337 + }); 338 + }); 339 + 340 + it("returns 400 when collection is missing", async () => { 341 + const res = await app.request("/xrpc/com.atproto.repo.listRecords?repo=did:plc:alice"); 342 + const body = await res.json(); 343 + 344 + expect(res.status).toBe(400); 345 + expect(body).toEqual({ 346 + error: "InvalidRequest", 347 + message: "missing collection parameter", 348 + }); 349 + }); 350 + 351 + it("works with a handle as the repo param", async () => { 352 + const did = "did:plc:alice"; 353 + await createRepoWithPosts(db, did, ["test1"]); 354 + setHandle(db, did, "alice.test.example"); 355 + 356 + const res = await app.request( 357 + "/xrpc/com.atproto.repo.listRecords?repo=alice.test.example&collection=app.bsky.feed.post", 358 + ); 359 + const body = await res.json(); 360 + 361 + expect(res.status).toBe(200); 362 + expect(body.records).toHaveLength(1); 363 + }); 364 + }); 365 + 366 + describe("describeRepo", () => { 367 + it("returns the expected shape", async () => { 368 + const did = "did:plc:alice"; 369 + const fakeDidDoc = { id: did, verificationMethod: [] }; 370 + const { storage } = await createRepoWithPost(db, did); 371 + setHandle(db, did, "alice.test.example"); 372 + storage.addCollection("app.bsky.feed.post"); 373 + 374 + vi.stubGlobal("fetch", async (url: string) => { 375 + if (url === `${testConfig.plcUrl}/${did}`) { 376 + return new Response(JSON.stringify(fakeDidDoc), { status: 200 }); 377 + } 378 + return new Response("not found", { status: 404 }); 379 + }); 380 + 381 + const res = await app.request(`/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 382 + const body = await res.json(); 383 + 384 + expect(res.status).toBe(200); 385 + expect(body).toEqual({ 386 + handle: "alice.test.example", 387 + did, 388 + didDoc: fakeDidDoc, 389 + collections: ["app.bsky.feed.post"], 390 + handleIsCorrect: true, 391 + }); 392 + }); 393 + 394 + it("returns handleIsCorrect true when a handle is set", async () => { 395 + const did = "did:plc:alice"; 396 + await createRepoWithPost(db, did); 397 + setHandle(db, did, "alice.test.example"); 398 + vi.stubGlobal("fetch", async () => new Response("{}", { status: 200 })); 399 + 400 + const res = await app.request(`/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 401 + const body = await res.json(); 402 + 403 + expect(body.handleIsCorrect).toBe(true); 404 + }); 405 + 406 + it("returns handleIsCorrect false when the handle is null", async () => { 407 + const did = "did:plc:alice"; 408 + await createRepoWithPost(db, did); 409 + vi.stubGlobal("fetch", async () => new Response("{}", { status: 200 })); 410 + 411 + const res = await app.request(`/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 412 + const body = await res.json(); 413 + 414 + expect(body.handle).toBe(""); 415 + expect(body.handleIsCorrect).toBe(false); 416 + }); 417 + 418 + it("returns collections from storage", async () => { 419 + const did = "did:plc:alice"; 420 + const { storage } = await createRepoWithPost(db, did); 421 + storage.addCollection("app.bsky.graph.follow"); 422 + storage.addCollection("app.bsky.feed.post"); 423 + vi.stubGlobal("fetch", async () => new Response("{}", { status: 200 })); 424 + 425 + const res = await app.request(`/xrpc/com.atproto.repo.describeRepo?repo=${did}`); 426 + const body = await res.json(); 427 + 428 + expect(body.collections).toEqual([ 429 + "app.bsky.feed.post", 430 + "app.bsky.graph.follow", 431 + ]); 432 + }); 433 + 434 + it("returns 400 when repo is missing", async () => { 435 + const res = await app.request("/xrpc/com.atproto.repo.describeRepo"); 436 + const body = await res.json(); 437 + 438 + expect(res.status).toBe(400); 439 + expect(body).toEqual({ 440 + error: "InvalidRequest", 441 + message: "missing repo parameter", 442 + }); 443 + }); 444 + 445 + it("returns 400 when the repo is unknown", async () => { 446 + const res = await app.request( 447 + "/xrpc/com.atproto.repo.describeRepo?repo=did:plc:missing", 448 + ); 449 + const body = await res.json(); 450 + 451 + expect(res.status).toBe(400); 452 + expect(body.error).toBe("RepoNotFound"); 453 + }); 454 + 455 + it("works with a handle as the repo param", async () => { 456 + const did = "did:plc:alice"; 457 + await createRepoWithPost(db, did); 458 + setHandle(db, did, "alice.test.example"); 459 + vi.stubGlobal("fetch", async () => new Response("{}", { status: 200 })); 460 + 461 + const res = await app.request( 462 + "/xrpc/com.atproto.repo.describeRepo?repo=alice.test.example", 463 + ); 464 + 465 + expect(res.status).toBe(200); 466 + }); 467 + }); 468 + 469 + describe("describeServer", () => { 470 + it("returns the expected server description", async () => { 471 + const res = await app.request("/xrpc/com.atproto.server.describeServer"); 472 + const body = await res.json(); 473 + 474 + expect(res.status).toBe(200); 475 + expect(body).toEqual({ 476 + did: "did:web:pds.test.example", 477 + availableUserDomains: ["test.example"], 478 + }); 479 + }); 480 + }); 481 + });