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 L15 complete Worker routing for all v0.1 XRPC endpoints

Wire 12 missing HTTP route handlers into src/worker.ts:
- Static: GET /.well-known/welcome.md, GET /tos
- Public repo reads: getRecord, listRecords, describeRepo
- Authenticated writes: createRecord, putRecord, deleteRecord, applyWrites
- Public sync reads: getRepo (CAR), getLatestCommit, getRepoStatus

Add rpcPutRecord (create-or-update) and applyWrites#update support
to src/account-do.ts. Add ROOKERY_RELAY_HOSTS to Env in src/types.ts.
Add fire-and-forget requestCrawl relay announcement on first request.

+829
+87
src/account-do.ts
··· 7 7 blocksToCarFile, 8 8 type RecordCreateOp, 9 9 type RecordDeleteOp, 10 + type RecordUpdateOp, 10 11 type RecordWriteOp, 11 12 } from "@atproto/repo"; 12 13 type RepoRecord = Record<string, unknown>; ··· 311 312 }; 312 313 } 313 314 315 + /** RPC: Create or update a record (putRecord semantics) */ 316 + async rpcPutRecord( 317 + collection: string, 318 + rkey: string, 319 + record: unknown, 320 + ): Promise<{ 321 + uri: string; 322 + cid: string; 323 + commit: { cid: string; rev: string }; 324 + }> { 325 + const repo = await this.getRepo(); 326 + await this.ensureRepoInitialized(); 327 + const keypair = this.keypair!; 328 + const prevDataCid = this.storage!.getState()?.prev_data_cid ?? null; 329 + 330 + // Check if record already exists to decide create vs update 331 + const dataKey = `${collection}/${rkey}`; 332 + const existingCid = await repo.data.get(dataKey); 333 + 334 + const op: RecordWriteOp = existingCid 335 + ? { 336 + action: WriteOpAction.Update, 337 + collection, 338 + rkey, 339 + record: jsonToLex(record) as RepoRecord, 340 + } 341 + : { 342 + action: WriteOpAction.Create, 343 + collection, 344 + rkey, 345 + record: jsonToLex(record) as RepoRecord, 346 + }; 347 + 348 + const updatedRepo = await repo.applyWrites([op], keypair); 349 + this.repo = updatedRepo; 350 + 351 + const recordCid = await this.repo.data.get(dataKey); 352 + if (!recordCid) { 353 + throw new Error(`Failed to put record: ${collection}/${rkey}`); 354 + } 355 + 356 + this.storage!.addCollection(collection); 357 + 358 + await this.emitCommitEvent(prevDataCid, [ 359 + { 360 + action: existingCid ? "update" : "create", 361 + path: `${collection}/${rkey}`, 362 + cid: recordCid.toString(), 363 + }, 364 + ]); 365 + 366 + return { 367 + uri: `at://${this.repo.did}/${collection}/${rkey}`, 368 + cid: recordCid.toString(), 369 + commit: { 370 + cid: this.repo.cid.toString(), 371 + rev: this.repo.commit.rev, 372 + }, 373 + }; 374 + } 375 + 314 376 /** RPC: Delete a record */ 315 377 async rpcDeleteRecord( 316 378 collection: string, ··· 393 455 results.push({ 394 456 $type: "com.atproto.repo.applyWrites#deleteResult", 395 457 }); 458 + } else if (write.$type === "com.atproto.repo.applyWrites#update") { 459 + if (!write.rkey) throw new Error("Update requires rkey"); 460 + const updateOp: RecordUpdateOp = { 461 + action: WriteOpAction.Update, 462 + collection: write.collection, 463 + rkey: write.rkey, 464 + record: jsonToLex(write.record) as RepoRecord, 465 + }; 466 + ops.push(updateOp); 467 + this.storage!.addCollection(write.collection); 468 + results.push({ 469 + $type: "com.atproto.repo.applyWrites#updateResult", 470 + uri: `at://${repo.did}/${write.collection}/${write.rkey}`, 471 + cid: "", 472 + }); 396 473 } 397 474 } 398 475 ··· 422 499 action: "delete", 423 500 path: `${deleteOp.collection}/${deleteOp.rkey}`, 424 501 cid: null, 502 + }); 503 + } else if (op.action === WriteOpAction.Update) { 504 + const updateOp = op as RecordUpdateOp; 505 + const recordCid = await this.repo.data.get( 506 + `${updateOp.collection}/${updateOp.rkey}`, 507 + ); 508 + firehoseOps.push({ 509 + action: "update", 510 + path: `${updateOp.collection}/${updateOp.rkey}`, 511 + cid: recordCid?.toString() ?? null, 425 512 }); 426 513 } 427 514 }
+2
src/types.ts
··· 20 20 ROOKERY_HANDLE_DOMAIN: string; 21 21 /** PLC directory URL */ 22 22 ROOKERY_PLC_URL: string; 23 + /** Comma-separated relay hostnames for requestCrawl fanout */ 24 + ROOKERY_RELAY_HOSTS?: string; 23 25 }
+406
src/worker.ts
··· 15 15 } from "./auth"; 16 16 import type { Env } from "./types"; 17 17 18 + const WELCOME_MAT_TEXT = `# Welcome to rookery 19 + 20 + This service supports WelcomeMat enrollment for AT Protocol agents. 21 + 22 + Use DPoP for authenticated write requests. 23 + Read /tos before requesting access tokens or signing up. 24 + `; 25 + 26 + const TOS_TEXT = `Rookery Terms of Service 27 + 28 + By using this service, you accept that repository content you write may be distributed to relays and other AT Protocol services. 29 + Do not use the service for unlawful activity, abuse, or attempts to disrupt network operations. 30 + `; 31 + 32 + let hasRequestedCrawl = false; 33 + 34 + /** Validate DPoP proof and resolve caller's Account DO ID. Throws on failure. */ 35 + async function resolveDpopAuth( 36 + authHeader: string | undefined, 37 + dpopHeader: string | undefined, 38 + method: string, 39 + url: string, 40 + env: Env, 41 + ): Promise<{ did: string; doId: string }> { 42 + const accessToken = extractBearerToken(authHeader ?? null); 43 + if (!accessToken) throw new Error("Missing DPoP authorization"); 44 + if (!dpopHeader) throw new Error("Missing DPoP proof"); 45 + const { thumbprint } = await validateDpopProof(dpopHeader, method, url, accessToken); 46 + await initDirectory(env.DIRECTORY); 47 + return resolveByThumbprint(env.DIRECTORY, thumbprint); 48 + } 49 + 50 + async function requestCrawl(env: Env): Promise<void> { 51 + const hosts = env.ROOKERY_RELAY_HOSTS?.split(",") 52 + .map((host) => host.trim()) 53 + .filter((host) => host.length > 0); 54 + if (!hosts?.length || hasRequestedCrawl) { 55 + return; 56 + } 57 + 58 + hasRequestedCrawl = true; 59 + const body = JSON.stringify({ hostname: env.ROOKERY_HOSTNAME }); 60 + void Promise.allSettled( 61 + hosts.map((host) => 62 + fetch(`https://${host}/xrpc/com.atproto.sync.requestCrawl`, { 63 + method: "POST", 64 + headers: { "content-type": "application/json" }, 65 + body, 66 + })), 67 + ); 68 + } 69 + 18 70 const app = new Hono<{ Bindings: Env }>(); 19 71 72 + app.use("*", async (c, next) => { 73 + await requestCrawl(c.env); 74 + await next(); 75 + }); 76 + 20 77 // Health check 21 78 app.get("/", (c) => c.json({ status: "ok" })); 79 + 80 + app.get("/.well-known/welcome.md", (c) => { 81 + return c.text(WELCOME_MAT_TEXT, 200, { 82 + "content-type": "text/markdown; charset=utf-8", 83 + }); 84 + }); 85 + 86 + app.get("/tos", (c) => { 87 + return c.text(TOS_TEXT, 200, { 88 + "content-type": "text/plain; charset=utf-8", 89 + }); 90 + }); 22 91 23 92 // POST /api/signup 24 93 app.post("/api/signup", async (c) => { ··· 118 187 } 119 188 }); 120 189 190 + // GET /xrpc/com.atproto.repo.getRecord 191 + app.get("/xrpc/com.atproto.repo.getRecord", async (c) => { 192 + const repo = c.req.query("repo"); 193 + const collection = c.req.query("collection"); 194 + const rkey = c.req.query("rkey"); 195 + if (!repo || !collection || !rkey) { 196 + return c.json( 197 + { error: "InvalidRequest", message: "Missing required parameters: repo, collection, rkey" }, 198 + 400, 199 + ); 200 + } 201 + 202 + await initDirectory(c.env.DIRECTORY); 203 + 204 + try { 205 + const { did, doId } = await resolveRepo(repo, c.env); 206 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 207 + const record = await stub.rpcGetRecord(collection, rkey); 208 + if (!record) { 209 + return c.json({ error: "RecordNotFound", message: "Record not found" }, 404); 210 + } 211 + return c.json({ 212 + uri: `at://${did}/${collection}/${rkey}`, 213 + cid: record.cid, 214 + value: record.record, 215 + }); 216 + } catch (err) { 217 + if (err instanceof RepoNotFoundError) { 218 + return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 219 + } 220 + throw err; 221 + } 222 + }); 223 + 224 + // GET /xrpc/com.atproto.repo.listRecords 225 + app.get("/xrpc/com.atproto.repo.listRecords", async (c) => { 226 + const repo = c.req.query("repo"); 227 + const collection = c.req.query("collection"); 228 + if (!repo || !collection) { 229 + return c.json( 230 + { error: "InvalidRequest", message: "Missing required parameters: repo, collection" }, 231 + 400, 232 + ); 233 + } 234 + 235 + let limit = parseInt(c.req.query("limit") || "50", 10); 236 + if (Number.isNaN(limit) || limit < 1) limit = 50; 237 + if (limit > 100) limit = 100; 238 + 239 + const cursor = c.req.query("cursor"); 240 + const reverse = c.req.query("reverse") === "true"; 241 + 242 + await initDirectory(c.env.DIRECTORY); 243 + 244 + try { 245 + const { doId } = await resolveRepo(repo, c.env); 246 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 247 + const result = await stub.rpcListRecords(collection, { limit, cursor, reverse }); 248 + return c.json(result); 249 + } catch (err) { 250 + if (err instanceof RepoNotFoundError) { 251 + return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 252 + } 253 + throw err; 254 + } 255 + }); 256 + 257 + // GET /xrpc/com.atproto.repo.describeRepo 258 + app.get("/xrpc/com.atproto.repo.describeRepo", async (c) => { 259 + const repo = c.req.query("repo"); 260 + if (!repo) { 261 + return c.json( 262 + { error: "InvalidRequest", message: "Missing required parameter: repo" }, 263 + 400, 264 + ); 265 + } 266 + 267 + await initDirectory(c.env.DIRECTORY); 268 + 269 + try { 270 + const { doId } = await resolveRepo(repo, c.env); 271 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 272 + return c.json(await stub.rpcDescribeRepo()); 273 + } catch (err) { 274 + if (err instanceof RepoNotFoundError) { 275 + return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 276 + } 277 + throw err; 278 + } 279 + }); 280 + 121 281 // GET /xrpc/com.atproto.sync.listRepos 122 282 app.get("/xrpc/com.atproto.sync.listRepos", async (c) => { 123 283 await initDirectory(c.env.DIRECTORY); ··· 241 401 return c.json({ blob }); 242 402 }); 243 403 404 + // POST /xrpc/com.atproto.repo.createRecord 405 + app.post("/xrpc/com.atproto.repo.createRecord", async (c) => { 406 + let did: string; 407 + let doId: string; 408 + try { 409 + ({ did, doId } = await resolveDpopAuth( 410 + c.req.header("authorization"), 411 + c.req.header("dpop"), 412 + "POST", 413 + c.req.url, 414 + c.env, 415 + )); 416 + } catch (err) { 417 + if (err instanceof RepoNotFoundError) { 418 + return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 419 + } 420 + const message = (err as Error).message; 421 + const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 422 + return c.json({ error: code, message }, 401); 423 + } 424 + 425 + let body: { repo?: string; collection?: string; rkey?: string; record?: unknown }; 426 + try { 427 + body = await c.req.json(); 428 + } catch { 429 + return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 430 + } 431 + 432 + if (!body.repo || !body.collection || body.record === undefined) { 433 + return c.json( 434 + { error: "InvalidRequest", message: "Missing required fields: repo, collection, record" }, 435 + 400, 436 + ); 437 + } 438 + 439 + if (body.repo.includes(":") && body.repo !== did) { 440 + return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); 441 + } 442 + 443 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 444 + return c.json(await stub.rpcCreateRecord(body.collection, body.rkey, body.record)); 445 + }); 446 + 447 + // POST /xrpc/com.atproto.repo.putRecord 448 + app.post("/xrpc/com.atproto.repo.putRecord", async (c) => { 449 + let did: string; 450 + let doId: string; 451 + try { 452 + ({ did, doId } = await resolveDpopAuth( 453 + c.req.header("authorization"), 454 + c.req.header("dpop"), 455 + "POST", 456 + c.req.url, 457 + c.env, 458 + )); 459 + } catch (err) { 460 + if (err instanceof RepoNotFoundError) { 461 + return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 462 + } 463 + const message = (err as Error).message; 464 + const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 465 + return c.json({ error: code, message }, 401); 466 + } 467 + 468 + let body: { repo?: string; collection?: string; rkey?: string; record?: unknown }; 469 + try { 470 + body = await c.req.json(); 471 + } catch { 472 + return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 473 + } 474 + 475 + if (!body.repo || !body.collection || !body.rkey || body.record === undefined) { 476 + return c.json( 477 + { error: "InvalidRequest", message: "Missing required fields: repo, collection, rkey, record" }, 478 + 400, 479 + ); 480 + } 481 + 482 + if (body.repo.includes(":") && body.repo !== did) { 483 + return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); 484 + } 485 + 486 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 487 + return c.json(await stub.rpcPutRecord(body.collection, body.rkey, body.record)); 488 + }); 489 + 490 + // POST /xrpc/com.atproto.repo.deleteRecord 491 + app.post("/xrpc/com.atproto.repo.deleteRecord", async (c) => { 492 + let did: string; 493 + let doId: string; 494 + try { 495 + ({ did, doId } = await resolveDpopAuth( 496 + c.req.header("authorization"), 497 + c.req.header("dpop"), 498 + "POST", 499 + c.req.url, 500 + c.env, 501 + )); 502 + } catch (err) { 503 + if (err instanceof RepoNotFoundError) { 504 + return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 505 + } 506 + const message = (err as Error).message; 507 + const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 508 + return c.json({ error: code, message }, 401); 509 + } 510 + 511 + let body: { repo?: string; collection?: string; rkey?: string }; 512 + try { 513 + body = await c.req.json(); 514 + } catch { 515 + return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 516 + } 517 + 518 + if (!body.repo || !body.collection || !body.rkey) { 519 + return c.json( 520 + { error: "InvalidRequest", message: "Missing required fields: repo, collection, rkey" }, 521 + 400, 522 + ); 523 + } 524 + 525 + if (body.repo.includes(":") && body.repo !== did) { 526 + return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); 527 + } 528 + 529 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 530 + return c.json(await stub.rpcDeleteRecord(body.collection, body.rkey)); 531 + }); 532 + 533 + // POST /xrpc/com.atproto.repo.applyWrites 534 + app.post("/xrpc/com.atproto.repo.applyWrites", async (c) => { 535 + let did: string; 536 + let doId: string; 537 + try { 538 + ({ did, doId } = await resolveDpopAuth( 539 + c.req.header("authorization"), 540 + c.req.header("dpop"), 541 + "POST", 542 + c.req.url, 543 + c.env, 544 + )); 545 + } catch (err) { 546 + if (err instanceof RepoNotFoundError) { 547 + return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 548 + } 549 + const message = (err as Error).message; 550 + const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 551 + return c.json({ error: code, message }, 401); 552 + } 553 + 554 + let body: { repo?: string; writes?: Array<{ $type: string; collection: string; rkey?: string; record?: unknown }> }; 555 + try { 556 + body = await c.req.json(); 557 + } catch { 558 + return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 559 + } 560 + 561 + if (!body.repo || !Array.isArray(body.writes)) { 562 + return c.json( 563 + { error: "InvalidRequest", message: "Missing required fields: repo, writes" }, 564 + 400, 565 + ); 566 + } 567 + 568 + if (body.repo.includes(":") && body.repo !== did) { 569 + return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); 570 + } 571 + 572 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 573 + return c.json(await stub.rpcApplyWrites(body.writes)); 574 + }); 575 + 244 576 // GET /xrpc/com.atproto.sync.getBlob (public) 245 577 app.get("/xrpc/com.atproto.sync.getBlob", async (c) => { 246 578 const did = c.req.query("did"); ··· 290 622 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 291 623 const result = await stub.rpcListBlobs({ limit, cursor }); 292 624 return c.json(result); 625 + }); 626 + 627 + // GET /xrpc/com.atproto.sync.getRepo 628 + app.get("/xrpc/com.atproto.sync.getRepo", async (c) => { 629 + const did = c.req.query("did"); 630 + if (!did) { 631 + return c.json({ error: "InvalidRequest", message: "Missing required parameter: did" }, 400); 632 + } 633 + 634 + await initDirectory(c.env.DIRECTORY); 635 + 636 + try { 637 + const { doId } = await resolveRepo(did, c.env); 638 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 639 + const car = await stub.rpcExportRepo(); 640 + return new Response(car, { 641 + headers: { "content-type": "application/vnd.ipld.car" }, 642 + }); 643 + } catch (err) { 644 + if (err instanceof RepoNotFoundError) { 645 + return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 646 + } 647 + throw err; 648 + } 649 + }); 650 + 651 + // GET /xrpc/com.atproto.sync.getLatestCommit 652 + app.get("/xrpc/com.atproto.sync.getLatestCommit", async (c) => { 653 + const did = c.req.query("did"); 654 + if (!did) { 655 + return c.json({ error: "InvalidRequest", message: "Missing required parameter: did" }, 400); 656 + } 657 + 658 + await initDirectory(c.env.DIRECTORY); 659 + 660 + try { 661 + const { doId } = await resolveRepo(did, c.env); 662 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 663 + const commit = await stub.rpcGetLatestCommit(); 664 + if (!commit) { 665 + return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 666 + } 667 + return c.json(commit); 668 + } catch (err) { 669 + if (err instanceof RepoNotFoundError) { 670 + return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 671 + } 672 + throw err; 673 + } 674 + }); 675 + 676 + // GET /xrpc/com.atproto.sync.getRepoStatus 677 + app.get("/xrpc/com.atproto.sync.getRepoStatus", async (c) => { 678 + const did = c.req.query("did"); 679 + if (!did) { 680 + return c.json({ error: "InvalidRequest", message: "Missing required parameter: did" }, 400); 681 + } 682 + 683 + await initDirectory(c.env.DIRECTORY); 684 + 685 + try { 686 + const { doId } = await resolveRepo(did, c.env); 687 + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 688 + const status = await stub.rpcGetRepoStatus(); 689 + if (!status) { 690 + return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 691 + } 692 + return c.json(status); 693 + } catch (err) { 694 + if (err instanceof RepoNotFoundError) { 695 + return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 696 + } 697 + throw err; 698 + } 293 699 }); 294 700 295 701 // GET /xrpc/com.atproto.sync.subscribeRepos
+61
test/account-do.test.ts
··· 154 154 }); 155 155 }); 156 156 157 + it("rpcPutRecord creates and updates a record", async () => { 158 + const id = env.ACCOUNT.newUniqueId(); 159 + const stub = env.ACCOUNT.get(id); 160 + 161 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 162 + await provisionAccount(instance); 163 + 164 + const created = await instance.rpcPutRecord("app.bsky.feed.post", "put-rkey", { 165 + text: "first", 166 + createdAt: new Date().toISOString(), 167 + }); 168 + expect(created.uri).toBe(`at://${TEST_DID}/app.bsky.feed.post/put-rkey`); 169 + 170 + const updated = await instance.rpcPutRecord("app.bsky.feed.post", "put-rkey", { 171 + text: "second", 172 + createdAt: new Date().toISOString(), 173 + }); 174 + 175 + expect(updated.uri).toBe(created.uri); 176 + expect(updated.cid).not.toBe(created.cid); 177 + 178 + const record = await instance.rpcGetRecord("app.bsky.feed.post", "put-rkey"); 179 + expect(record).not.toBeNull(); 180 + expect(record!.record).toMatchObject({ text: "second" }); 181 + }); 182 + }); 183 + 157 184 it("rpcApplyWrites applies multiple writes", async () => { 158 185 const id = env.ACCOUNT.newUniqueId(); 159 186 const stub = env.ACCOUNT.get(id); ··· 176 203 177 204 expect(result.commit.cid).toBeTruthy(); 178 205 expect(result.results).toHaveLength(2); 206 + }); 207 + }); 208 + 209 + it("rpcApplyWrites supports update operations", async () => { 210 + const id = env.ACCOUNT.newUniqueId(); 211 + const stub = env.ACCOUNT.get(id); 212 + 213 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 214 + await provisionAccount(instance); 215 + 216 + await instance.rpcCreateRecord("app.bsky.feed.post", "update-rkey", { 217 + text: "before", 218 + createdAt: new Date().toISOString(), 219 + }); 220 + 221 + const result = await instance.rpcApplyWrites([ 222 + { 223 + $type: "com.atproto.repo.applyWrites#update", 224 + collection: "app.bsky.feed.post", 225 + rkey: "update-rkey", 226 + record: { text: "after", createdAt: new Date().toISOString() }, 227 + }, 228 + ]); 229 + 230 + expect(result.commit.cid).toBeTruthy(); 231 + expect(result.results).toHaveLength(1); 232 + expect(result.results[0]).toMatchObject({ 233 + $type: "com.atproto.repo.applyWrites#updateResult", 234 + uri: `at://${TEST_DID}/app.bsky.feed.post/update-rkey`, 235 + }); 236 + 237 + const record = await instance.rpcGetRecord("app.bsky.feed.post", "update-rkey"); 238 + expect(record).not.toBeNull(); 239 + expect(record!.record).toMatchObject({ text: "after" }); 179 240 }); 180 241 }); 181 242 });
+273
test/worker-routes.test.ts
··· 1 + import { describe, it, expect, beforeAll } from "vitest"; 2 + import { env, runInDurableObject, worker } from "./helpers"; 3 + import { AccountDurableObject } from "../src/account-do"; 4 + import { initDirectory, insertAccount } from "../src/directory"; 5 + import { Secp256k1Keypair } from "@atproto/crypto"; 6 + import { toString } from "uint8arrays/to-string"; 7 + import { base64urlEncode, jwkThumbprint, sha256Base64url } from "../src/auth"; 8 + 9 + async function signJwt( 10 + header: Record<string, unknown>, 11 + payload: Record<string, unknown>, 12 + privateKey: CryptoKey, 13 + ): Promise<string> { 14 + const encode = (obj: Record<string, unknown>) => 15 + base64urlEncode(new TextEncoder().encode(JSON.stringify(obj))); 16 + const headerStr = encode(header); 17 + const payloadStr = encode(payload); 18 + const signingInput = `${headerStr}.${payloadStr}`; 19 + const signature = await crypto.subtle.sign( 20 + "RSASSA-PKCS1-v1_5", 21 + privateKey, 22 + new TextEncoder().encode(signingInput), 23 + ); 24 + return `${signingInput}.${base64urlEncode(signature)}`; 25 + } 26 + 27 + async function setupRouteTestAccount() { 28 + const authKeys = await crypto.subtle.generateKey( 29 + { 30 + name: "RSASSA-PKCS1-v1_5", 31 + modulusLength: 4096, 32 + publicExponent: new Uint8Array([1, 0, 1]), 33 + hash: "SHA-256", 34 + }, 35 + true, 36 + ["sign", "verify"], 37 + ); 38 + const publicJwk = await crypto.subtle.exportKey("jwk", authKeys.publicKey); 39 + const thumbprint = await jwkThumbprint(publicJwk as { kty: string; n: string; e: string }); 40 + 41 + const signing = await Secp256k1Keypair.create({ exportable: true }); 42 + const rotation = await Secp256k1Keypair.create({ exportable: true }); 43 + 44 + const did = `did:plc:routes${Date.now().toString(36)}`; 45 + const handle = `routes-${Date.now().toString(36)}.rookery.test`; 46 + const doId = env.ACCOUNT.newUniqueId(); 47 + const stub = env.ACCOUNT.get(doId); 48 + 49 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 50 + await instance.rpcInitAccount({ 51 + did, 52 + handle, 53 + signingKeyHex: toString(await signing.export(), "hex"), 54 + signingKeyPub: signing.did().split(":").pop()!, 55 + rotationKeyHex: toString(await rotation.export(), "hex"), 56 + rotationKeyPub: rotation.did().split(":").pop()!, 57 + jwkThumbprint: thumbprint, 58 + }); 59 + }); 60 + 61 + await insertAccount(env.DIRECTORY, { 62 + did, 63 + handle, 64 + doId: doId.toString(), 65 + jwkThumbprint: thumbprint, 66 + }); 67 + 68 + return { did, handle, stub, authKeys, publicJwk }; 69 + } 70 + 71 + async function createDpopJwt( 72 + authKeys: CryptoKeyPair, 73 + publicJwk: JsonWebKey, 74 + htu: string, 75 + accessToken: string, 76 + ): Promise<string> { 77 + return signJwt( 78 + { 79 + typ: "dpop+jwt", 80 + alg: "RS256", 81 + jwk: publicJwk, 82 + }, 83 + { 84 + jti: `jti-${Date.now().toString(36)}`, 85 + htm: "POST", 86 + htu, 87 + iat: Math.floor(Date.now() / 1000), 88 + ath: await sha256Base64url(accessToken), 89 + }, 90 + authKeys.privateKey, 91 + ); 92 + } 93 + 94 + describe("Worker routes", () => { 95 + beforeAll(async () => { 96 + await initDirectory(env.DIRECTORY); 97 + }); 98 + 99 + it("serves welcome and terms documents", async () => { 100 + const welcome = await worker.fetch("http://localhost/.well-known/welcome.md"); 101 + expect(welcome.status).toBe(200); 102 + expect(await welcome.text()).toContain("WelcomeMat"); 103 + 104 + const tos = await worker.fetch("http://localhost/tos"); 105 + expect(tos.status).toBe(200); 106 + expect(await tos.text()).toContain("Terms of Service"); 107 + }); 108 + 109 + it("serves repo read and sync routes", async () => { 110 + const { did, handle, stub } = await setupRouteTestAccount(); 111 + 112 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 113 + await instance.rpcCreateRecord("app.bsky.feed.post", "route-rkey", { 114 + text: "route post", 115 + createdAt: new Date().toISOString(), 116 + }); 117 + }); 118 + 119 + const getRecord = await worker.fetch( 120 + `http://localhost/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(handle)}&collection=app.bsky.feed.post&rkey=route-rkey`, 121 + ); 122 + expect(getRecord.status).toBe(200); 123 + expect(await getRecord.json()).toMatchObject({ 124 + uri: `at://${did}/app.bsky.feed.post/route-rkey`, 125 + value: { text: "route post" }, 126 + }); 127 + 128 + const listRecords = await worker.fetch( 129 + `http://localhost/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(handle)}&collection=app.bsky.feed.post`, 130 + ); 131 + expect(listRecords.status).toBe(200); 132 + const listBody = await listRecords.json() as { 133 + records: Array<{ uri: string }>; 134 + }; 135 + expect(listBody.records.some((record) => record.uri.endsWith("/route-rkey"))).toBe(true); 136 + 137 + const describeRepo = await worker.fetch( 138 + `http://localhost/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(handle)}`, 139 + ); 140 + expect(describeRepo.status).toBe(200); 141 + expect(await describeRepo.json()).toMatchObject({ 142 + did, 143 + collections: ["app.bsky.feed.post"], 144 + }); 145 + 146 + const latestCommit = await worker.fetch( 147 + `http://localhost/xrpc/com.atproto.sync.getLatestCommit?did=${encodeURIComponent(did)}`, 148 + ); 149 + expect(latestCommit.status).toBe(200); 150 + expect(await latestCommit.json()).toMatchObject({ 151 + cid: expect.any(String), 152 + rev: expect.any(String), 153 + }); 154 + 155 + const repoStatus = await worker.fetch( 156 + `http://localhost/xrpc/com.atproto.sync.getRepoStatus?did=${encodeURIComponent(did)}`, 157 + ); 158 + expect(repoStatus.status).toBe(200); 159 + expect(await repoStatus.json()).toMatchObject({ 160 + did, 161 + active: true, 162 + }); 163 + 164 + const repoExport = await worker.fetch( 165 + `http://localhost/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`, 166 + ); 167 + expect(repoExport.status).toBe(200); 168 + expect(repoExport.headers.get("content-type")).toBe("application/vnd.ipld.car"); 169 + expect((await repoExport.arrayBuffer()).byteLength).toBeGreaterThan(0); 170 + }); 171 + 172 + it("handles authenticated repo write routes", async () => { 173 + const { did, stub, authKeys, publicJwk } = await setupRouteTestAccount(); 174 + const accessToken = "worker-route-token"; 175 + 176 + const createUrl = "http://localhost/xrpc/com.atproto.repo.createRecord"; 177 + const createDpop = await createDpopJwt(authKeys, publicJwk, createUrl, accessToken); 178 + const createResponse = await worker.fetch( 179 + new Request(createUrl, { 180 + method: "POST", 181 + headers: { 182 + authorization: `DPoP ${accessToken}`, 183 + dpop: createDpop, 184 + "content-type": "application/json", 185 + }, 186 + body: JSON.stringify({ 187 + repo: did, 188 + collection: "app.bsky.feed.post", 189 + rkey: "write-rkey", 190 + record: { text: "created", createdAt: new Date().toISOString() }, 191 + }), 192 + }), 193 + ); 194 + expect(createResponse.status).toBe(200); 195 + 196 + const putUrl = "http://localhost/xrpc/com.atproto.repo.putRecord"; 197 + const putDpop = await createDpopJwt(authKeys, publicJwk, putUrl, accessToken); 198 + const putResponse = await worker.fetch( 199 + new Request(putUrl, { 200 + method: "POST", 201 + headers: { 202 + authorization: `DPoP ${accessToken}`, 203 + dpop: putDpop, 204 + "content-type": "application/json", 205 + }, 206 + body: JSON.stringify({ 207 + repo: did, 208 + collection: "app.bsky.feed.post", 209 + rkey: "write-rkey", 210 + record: { text: "updated", createdAt: new Date().toISOString() }, 211 + }), 212 + }), 213 + ); 214 + expect(putResponse.status).toBe(200); 215 + 216 + const applyWritesUrl = "http://localhost/xrpc/com.atproto.repo.applyWrites"; 217 + const applyWritesDpop = await createDpopJwt(authKeys, publicJwk, applyWritesUrl, accessToken); 218 + const applyWritesResponse = await worker.fetch( 219 + new Request(applyWritesUrl, { 220 + method: "POST", 221 + headers: { 222 + authorization: `DPoP ${accessToken}`, 223 + dpop: applyWritesDpop, 224 + "content-type": "application/json", 225 + }, 226 + body: JSON.stringify({ 227 + repo: did, 228 + writes: [ 229 + { 230 + $type: "com.atproto.repo.applyWrites#update", 231 + collection: "app.bsky.feed.post", 232 + rkey: "write-rkey", 233 + record: { text: "batch-updated", createdAt: new Date().toISOString() }, 234 + }, 235 + ], 236 + }), 237 + }), 238 + ); 239 + expect(applyWritesResponse.status).toBe(200); 240 + expect(await applyWritesResponse.json()).toMatchObject({ 241 + results: [ 242 + { 243 + $type: "com.atproto.repo.applyWrites#updateResult", 244 + uri: `at://${did}/app.bsky.feed.post/write-rkey`, 245 + }, 246 + ], 247 + }); 248 + 249 + const deleteUrl = "http://localhost/xrpc/com.atproto.repo.deleteRecord"; 250 + const deleteDpop = await createDpopJwt(authKeys, publicJwk, deleteUrl, accessToken); 251 + const deleteResponse = await worker.fetch( 252 + new Request(deleteUrl, { 253 + method: "POST", 254 + headers: { 255 + authorization: `DPoP ${accessToken}`, 256 + dpop: deleteDpop, 257 + "content-type": "application/json", 258 + }, 259 + body: JSON.stringify({ 260 + repo: did, 261 + collection: "app.bsky.feed.post", 262 + rkey: "write-rkey", 263 + }), 264 + }), 265 + ); 266 + expect(deleteResponse.status).toBe(200); 267 + 268 + await runInDurableObject(stub, async (instance: AccountDurableObject) => { 269 + const record = await instance.rpcGetRecord("app.bsky.feed.post", "write-rkey"); 270 + expect(record).toBeNull(); 271 + }); 272 + }); 273 + });