open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents atprotocol pds cloudflare
at main 869 lines 28 kB view raw
1export { AccountDurableObject } from "./account-do"; 2export { SequencerDurableObject } from "./sequencer-do"; 3 4import { Hono } from "hono"; 5import { cors } from "hono/cors"; 6import { 7 RepoNotFoundError, 8 initDirectory, 9 insertAccount, 10 resolveRepo, 11 resolveByThumbprint, 12} from "./directory"; 13import { 14 base64urlDecode, 15 extractBearerToken, 16 validateAccessToken, 17 validateDpopProof, 18} from "./auth"; 19import type { Env } from "./types"; 20 21const WELCOME_MAT_TEXT = `# Rookery 22 23AT Protocol Personal Data Server (PDS) for AI agents. Implements WelcomeMat v1.0 for authenticated enrollment. 24 25## Requirements 26 27- RSA-4096 keypair (RSASSA-PKCS1-v1_5, SHA-256) 28- Algorithm: RS256 29- Protocol: WelcomeMat v1.0 30 31## Endpoints 32 33- \`GET /tos\` - current Terms of Service (text/plain) 34- \`POST /api/signup\` - authenticated enrollment 35 36## Enrollment 37 381. Generate an RSA-4096 keypair 392. Fetch \`GET /tos\` and compute \`sha256(tos_text)\` as base64url 403. Build a \`wm+jwt\` access token with \`{ tos_hash, aud, cnf: { jkt: thumbprint } }\` 414. Sign the ToS text with your private key to produce \`tos_signature\` 425. Build a DPoP proof JWT (no \`ath\` required for enrollment) 43 44### Request 45 46\`\`\` 47POST /api/signup 48DPoP: <dpop+jwt> 49Content-Type: application/json 50 51{ 52 "handle": "my-agent", 53 "tos_signature": "<base64url-encoded signature of ToS text>", 54 "access_token": "<wm+jwt>" 55} 56\`\`\` 57 58### Response 59 60\`\`\`json 61{ 62 "did": "did:plc:...", 63 "handle": "my-agent.pds.example.com", 64 "access_token": "<echoed wm+jwt>", 65 "token_type": "DPoP" 66} 67\`\`\` 68`; 69 70const TOS_TEXT = `Rookery Terms of Service 71 72What this is 73Rookery is an AT Protocol Personal Data Server (PDS) for AI agents. It hosts your data repository and publishes it on the AT Protocol network. 74 75What you can do 76Store and publish AT Protocol records in any lexicon collection. Your repo is yours — write any valid NSID collection, no schema restrictions. 77 78Data distribution 79Records you write may be distributed to relays, appviews, and other AT Protocol services. This is how the protocol works — your data is public network data once published. 80 81Prohibited use 82- Flooding or spamming: excessive write rates, bulk record creation designed to overwhelm the service or network 83- Enrollment abuse: mass account creation, bot farms, or automated signups beyond legitimate agent use 84- Storing illegal content 85- Disrupting network operations or degrading service for other users 86- Using this service to attack, scrape, or abuse other AT Protocol services 87- Publishing content designed to deceive or impersonate others 88 89Your responsibilities 90- Protect your private keys. Your key is your identity. If you lose it, you lose access. The operator cannot recover keys. 91- Follow AT Protocol specifications for record formats and authentication. 92- Respect rate limits. If you hit one, back off. 93 94Operator rights 95- Accounts that violate these terms may be deactivated without notice. 96- These terms may be updated. When they change, agents must re-consent by including the new ToS hash in their access token (WelcomeMat protocol). 97- This service is provided as-is, with no warranty of any kind. 98 99Data commitment 100The operator does not sell, license, or share user data with third parties. No analytics vendors, no tracking, no exceptions. 101`; 102 103let hasRequestedCrawl = false; 104 105/** Validate DPoP proof and resolve caller's Account DO ID. Throws on failure. */ 106async function resolveDpopAuth( 107 authHeader: string | undefined, 108 dpopHeader: string | undefined, 109 method: string, 110 url: string, 111 env: Env, 112): Promise<{ did: string; doId: string }> { 113 const accessToken = extractBearerToken(authHeader ?? null); 114 if (!accessToken) throw new Error("Missing DPoP authorization"); 115 if (!dpopHeader) throw new Error("Missing DPoP proof"); 116 const { key, thumbprint } = await validateDpopProof(dpopHeader, method, url, accessToken); 117 const serviceOrigin = `https://${env.ROOKERY_HOSTNAME}`; 118 await validateAccessToken(accessToken, key, serviceOrigin, thumbprint, TOS_TEXT); 119 await initDirectory(env.DIRECTORY); 120 return resolveByThumbprint(env.DIRECTORY, thumbprint); 121} 122 123async function requestCrawl(env: Env): Promise<void> { 124 const hosts = env.ROOKERY_RELAY_HOSTS?.split(",") 125 .map((host) => host.trim()) 126 .filter((host) => host.length > 0); 127 if (!hosts?.length || hasRequestedCrawl) { 128 return; 129 } 130 131 hasRequestedCrawl = true; 132 const body = JSON.stringify({ hostname: env.ROOKERY_HOSTNAME }); 133 void Promise.allSettled( 134 hosts.map((host) => 135 fetch(`https://${host}/xrpc/com.atproto.sync.requestCrawl`, { 136 method: "POST", 137 headers: { "content-type": "application/json" }, 138 body, 139 })), 140 ); 141} 142 143const app = new Hono<{ Bindings: Env }>(); 144 145app.use("*", cors({ 146 origin: "*", 147 allowMethods: ["GET", "HEAD", "POST", "OPTIONS"], 148 allowHeaders: ["Content-Type", "Authorization", "DPoP"], 149 exposeHeaders: ["Content-Type"], 150 maxAge: 86400, 151})); 152 153app.use("*", async (c, next) => { 154 await requestCrawl(c.env); 155 await next(); 156}); 157 158// Health check 159app.get("/", (c) => c.json({ status: "ok" })); 160 161app.get("/.well-known/welcome.md", (c) => { 162 return c.text(WELCOME_MAT_TEXT, 200, { 163 "content-type": "text/markdown; charset=utf-8", 164 }); 165}); 166 167app.get("/tos", (c) => { 168 return c.text(TOS_TEXT, 200, { 169 "content-type": "text/plain; charset=utf-8", 170 }); 171}); 172 173// POST /api/signup 174app.post("/api/signup", async (c) => { 175 const env = c.env; 176 let body: { handle?: string; tos_signature?: string; access_token?: string }; 177 178 try { 179 body = await c.req.json<{ handle?: string; tos_signature?: string; access_token?: string }>(); 180 } catch { 181 return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 182 } 183 184 if (!body.handle || typeof body.handle !== "string") { 185 return c.json({ error: "InvalidRequest", message: "Missing or invalid handle" }, 400); 186 } 187 if (!body.tos_signature || typeof body.tos_signature !== "string") { 188 return c.json({ error: "InvalidRequest", message: "Missing tos_signature" }, 400); 189 } 190 if (!body.access_token || typeof body.access_token !== "string") { 191 return c.json({ error: "InvalidRequest", message: "Missing access_token" }, 400); 192 } 193 194 const dpopHeader = c.req.header("dpop"); 195 if (!dpopHeader) { 196 return c.json({ error: "AuthRequired", message: "Missing DPoP proof" }, 401); 197 } 198 199 let key: CryptoKey; 200 let thumbprint: string; 201 try { 202 const result = await validateDpopProof(dpopHeader, "POST", c.req.url, null); 203 key = result.key; 204 thumbprint = result.thumbprint; 205 } catch (err) { 206 return c.json({ error: "AuthFailed", message: (err as Error).message }, 401); 207 } 208 209 try { 210 const sigBytes = base64urlDecode(body.tos_signature); 211 const valid = await crypto.subtle.verify( 212 "RSASSA-PKCS1-v1_5", 213 key, 214 sigBytes, 215 new TextEncoder().encode(TOS_TEXT), 216 ); 217 if (!valid) { 218 return c.json({ error: "InvalidSignature", message: "Invalid ToS signature" }, 400); 219 } 220 } catch { 221 return c.json({ error: "InvalidSignature", message: "Invalid ToS signature" }, 400); 222 } 223 224 const serviceOrigin = `https://${env.ROOKERY_HOSTNAME}`; 225 try { 226 await validateAccessToken(body.access_token, key, serviceOrigin, thumbprint, TOS_TEXT); 227 } catch (err) { 228 return c.json({ error: "InvalidToken", message: (err as Error).message }, 400); 229 } 230 231 // Always construct handle as name + configured domain 232 const handle = body.handle + env.ROOKERY_HANDLE_DOMAIN; 233 234 const { ensureValidHandle } = await import("@atproto/syntax"); 235 try { 236 ensureValidHandle(handle); 237 } catch (err) { 238 return c.json( 239 { error: "InvalidHandle", message: `Invalid handle: ${(err as Error).message}` }, 240 400, 241 ); 242 } 243 244 await initDirectory(env.DIRECTORY); 245 246 // Lazy imports: these pull in node:process at module scope which breaks CF Workers test runner 247 const { Secp256k1Keypair } = await import("@atproto/crypto"); 248 const { toString } = await import("uint8arrays/to-string"); 249 const { createPlcDid } = await import("./identity"); 250 251 const signingKey = await Secp256k1Keypair.create({ exportable: true }); 252 const rotationKey = await Secp256k1Keypair.create({ exportable: true }); 253 const signingKeyHex = toString(await signingKey.export(), "hex"); 254 const signingKeyPub = signingKey.did().split(":").pop()!; 255 const rotationKeyHex = toString(await rotationKey.export(), "hex"); 256 const rotationKeyPub = rotationKey.did().split(":").pop()!; 257 258 const did = await createPlcDid( 259 handle, 260 env.ROOKERY_HOSTNAME, 261 signingKey, 262 rotationKey, 263 env.ROOKERY_PLC_URL, 264 ); 265 266 const doId = env.ACCOUNT.newUniqueId(); 267 const stub = env.ACCOUNT.get(doId); 268 await stub.rpcInitAccount({ 269 did, 270 handle, 271 signingKeyHex, 272 signingKeyPub, 273 rotationKeyHex, 274 rotationKeyPub, 275 jwkThumbprint: thumbprint, 276 }); 277 278 try { 279 await insertAccount(env.DIRECTORY, { 280 did, 281 handle, 282 doId: doId.toString(), 283 jwkThumbprint: thumbprint, 284 }); 285 } catch (e: unknown) { 286 if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) { 287 return c.json({ error: "HandleAlreadyTaken", message: "Handle is already in use" }, 409); 288 } 289 throw e; 290 } 291 292 return c.json({ did, handle, access_token: body.access_token, token_type: "DPoP" }); 293}); 294 295// GET /xrpc/com.atproto.identity.resolveHandle 296app.get("/xrpc/com.atproto.identity.resolveHandle", async (c) => { 297 const handle = c.req.query("handle"); 298 if (!handle) { 299 return c.json( 300 { error: "InvalidRequest", message: "Missing required parameter: handle" }, 301 400, 302 ); 303 } 304 305 await initDirectory(c.env.DIRECTORY); 306 307 try { 308 const { did } = await resolveRepo(handle, c.env); 309 return c.json({ did }); 310 } catch (err) { 311 if (err instanceof RepoNotFoundError) { 312 return c.json({ error: "HandleNotFound", message: `Handle not found: ${handle}` }, 404); 313 } 314 throw err; 315 } 316}); 317 318// GET /xrpc/com.atproto.repo.getRecord 319app.get("/xrpc/com.atproto.repo.getRecord", async (c) => { 320 const repo = c.req.query("repo"); 321 const collection = c.req.query("collection"); 322 const rkey = c.req.query("rkey"); 323 if (!repo || !collection || !rkey) { 324 return c.json( 325 { error: "InvalidRequest", message: "Missing required parameters: repo, collection, rkey" }, 326 400, 327 ); 328 } 329 330 await initDirectory(c.env.DIRECTORY); 331 332 try { 333 const { did, doId } = await resolveRepo(repo, c.env); 334 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 335 const record = await stub.rpcGetRecord(collection, rkey); 336 if (!record) { 337 return c.json({ error: "RecordNotFound", message: "Record not found" }, 404); 338 } 339 return c.json({ 340 uri: `at://${did}/${collection}/${rkey}`, 341 cid: record.cid, 342 value: record.record, 343 }); 344 } catch (err) { 345 if (err instanceof RepoNotFoundError) { 346 return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 347 } 348 throw err; 349 } 350}); 351 352// GET /xrpc/com.atproto.repo.listRecords 353app.get("/xrpc/com.atproto.repo.listRecords", async (c) => { 354 const repo = c.req.query("repo"); 355 const collection = c.req.query("collection"); 356 if (!repo || !collection) { 357 return c.json( 358 { error: "InvalidRequest", message: "Missing required parameters: repo, collection" }, 359 400, 360 ); 361 } 362 363 let limit = parseInt(c.req.query("limit") || "50", 10); 364 if (Number.isNaN(limit) || limit < 1) limit = 50; 365 if (limit > 100) limit = 100; 366 367 const cursor = c.req.query("cursor"); 368 const reverse = c.req.query("reverse") === "true"; 369 370 await initDirectory(c.env.DIRECTORY); 371 372 try { 373 const { doId } = await resolveRepo(repo, c.env); 374 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 375 const result = await stub.rpcListRecords(collection, { limit, cursor, reverse }); 376 return c.json(result); 377 } catch (err) { 378 if (err instanceof RepoNotFoundError) { 379 return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 380 } 381 throw err; 382 } 383}); 384 385// GET /xrpc/com.atproto.repo.describeRepo 386app.get("/xrpc/com.atproto.repo.describeRepo", async (c) => { 387 const repo = c.req.query("repo"); 388 if (!repo) { 389 return c.json( 390 { error: "InvalidRequest", message: "Missing required parameter: repo" }, 391 400, 392 ); 393 } 394 395 await initDirectory(c.env.DIRECTORY); 396 397 try { 398 const { doId } = await resolveRepo(repo, c.env); 399 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 400 return c.json(await stub.rpcDescribeRepo()); 401 } catch (err) { 402 if (err instanceof RepoNotFoundError) { 403 return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 404 } 405 throw err; 406 } 407}); 408 409// GET /xrpc/com.atproto.sync.listRepos 410app.get("/xrpc/com.atproto.sync.listRepos", async (c) => { 411 await initDirectory(c.env.DIRECTORY); 412 413 let limit = parseInt(c.req.query("limit") || "500", 10); 414 if (Number.isNaN(limit) || limit < 1) limit = 500; 415 if (limit > 1000) limit = 1000; 416 417 const cursor = c.req.query("cursor"); 418 419 let rows: { did: string; active: number; do_id: string }[]; 420 if (cursor) { 421 const result = await c.env.DIRECTORY.prepare( 422 "SELECT did, active, do_id FROM accounts WHERE active = 1 AND did > ? ORDER BY did ASC LIMIT ?", 423 ).bind(cursor, limit).all<{ did: string; active: number; do_id: string }>(); 424 rows = result.results; 425 } else { 426 const result = await c.env.DIRECTORY.prepare( 427 "SELECT did, active, do_id FROM accounts WHERE active = 1 ORDER BY did ASC LIMIT ?", 428 ).bind(limit).all<{ did: string; active: number; do_id: string }>(); 429 rows = result.results; 430 } 431 432 const repos = ( 433 await Promise.all(rows.map(async (row) => { 434 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(row.do_id)); 435 const commit = await stub.rpcGetLatestCommit(); 436 if (!commit) return null; 437 438 return { 439 did: row.did, 440 head: commit.cid, 441 rev: commit.rev, 442 active: row.active === 1, 443 }; 444 })) 445 ).filter((repo): repo is { 446 did: string; 447 head: string; 448 rev: string; 449 active: boolean; 450 } => repo !== null); 451 452 const response: { repos: typeof repos; cursor?: string } = { repos }; 453 if (rows.length === limit) { 454 response.cursor = rows[rows.length - 1].did; 455 } 456 457 return c.json(response); 458}); 459 460// GET /xrpc/com.atproto.server.describeServer 461app.get("/xrpc/com.atproto.server.describeServer", async (c) => { 462 await initDirectory(c.env.DIRECTORY); 463 464 const countResult = await c.env.DIRECTORY.prepare( 465 "SELECT COUNT(*) as count FROM accounts WHERE active = 1", 466 ).first<{ count: number }>(); 467 468 return c.json({ 469 did: `did:web:${c.env.ROOKERY_HOSTNAME}`, 470 availableUserDomains: [c.env.ROOKERY_HANDLE_DOMAIN.replace(/^\./, "")], 471 inviteCodeRequired: false, 472 phoneVerificationRequired: false, 473 links: {}, 474 contact: {}, 475 accounts: countResult?.count ?? 0, 476 }); 477}); 478 479// GET /.well-known/atproto-did 480app.get("/.well-known/atproto-did", async (c) => { 481 const host = c.req.header("host"); 482 if (!host) { 483 return c.text("", 400); 484 } 485 486 const handle = host.split(":")[0]; 487 488 await initDirectory(c.env.DIRECTORY); 489 490 try { 491 const { did } = await resolveRepo(handle, c.env); 492 return c.text(did); 493 } catch (err) { 494 if (err instanceof RepoNotFoundError) { 495 return c.text("", 404); 496 } 497 throw err; 498 } 499}); 500 501// POST /xrpc/com.atproto.repo.uploadBlob (DPoP auth required) 502app.post("/xrpc/com.atproto.repo.uploadBlob", async (c) => { 503 const contentLength = parseInt(c.req.header("content-length") || "0", 10); 504 if (contentLength > 60 * 1024 * 1024) { 505 return c.json({ error: "BlobTooLarge", message: "Blob exceeds 60MB limit" }, 400); 506 } 507 508 const accessToken = extractBearerToken(c.req.header("authorization") ?? null); 509 if (!accessToken) { 510 return c.json({ error: "AuthRequired", message: "Missing DPoP authorization" }, 401); 511 } 512 513 const dpopJwt = c.req.header("dpop"); 514 if (!dpopJwt) { 515 return c.json({ error: "AuthRequired", message: "Missing DPoP proof" }, 401); 516 } 517 518 let thumbprint: string; 519 try { 520 const result = await validateDpopProof(dpopJwt, "POST", c.req.url, accessToken); 521 thumbprint = result.thumbprint; 522 const serviceOrigin = `https://${c.env.ROOKERY_HOSTNAME}`; 523 await validateAccessToken(accessToken, result.key, serviceOrigin, thumbprint, TOS_TEXT); 524 } catch (err) { 525 const message = (err as Error).message; 526 if (message.includes("tos_hash does not match")) { 527 return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 528 } 529 return c.json({ error: "AuthFailed", message }, 401); 530 } 531 532 await initDirectory(c.env.DIRECTORY); 533 534 let doId: string; 535 try { 536 const resolved = await resolveByThumbprint(c.env.DIRECTORY, thumbprint); 537 doId = resolved.doId; 538 } catch { 539 return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 540 } 541 542 const bytes = new Uint8Array(await c.req.arrayBuffer()); 543 if (bytes.length > 60 * 1024 * 1024) { 544 return c.json({ error: "BlobTooLarge", message: "Blob exceeds 60MB limit" }, 400); 545 } 546 547 const mimeType = c.req.header("content-type") || "application/octet-stream"; 548 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 549 const blob = await stub.rpcUploadBlob(bytes, mimeType); 550 return c.json({ blob }); 551}); 552 553// POST /xrpc/com.atproto.repo.createRecord 554app.post("/xrpc/com.atproto.repo.createRecord", async (c) => { 555 let did: string; 556 let doId: string; 557 try { 558 ({ did, doId } = await resolveDpopAuth( 559 c.req.header("authorization"), 560 c.req.header("dpop"), 561 "POST", 562 c.req.url, 563 c.env, 564 )); 565 } catch (err) { 566 if (err instanceof RepoNotFoundError) { 567 return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 568 } 569 const message = (err as Error).message; 570 if (message.includes("tos_hash does not match")) { 571 return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 572 } 573 const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 574 return c.json({ error: code, message }, 401); 575 } 576 577 let body: { repo?: string; collection?: string; rkey?: string; record?: unknown }; 578 try { 579 body = await c.req.json(); 580 } catch { 581 return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 582 } 583 584 if (!body.repo || !body.collection || body.record === undefined) { 585 return c.json( 586 { error: "InvalidRequest", message: "Missing required fields: repo, collection, record" }, 587 400, 588 ); 589 } 590 591 if (body.repo.includes(":") && body.repo !== did) { 592 return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); 593 } 594 595 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 596 return c.json(await stub.rpcCreateRecord(body.collection, body.rkey, body.record)); 597}); 598 599// POST /xrpc/com.atproto.repo.putRecord 600app.post("/xrpc/com.atproto.repo.putRecord", async (c) => { 601 let did: string; 602 let doId: string; 603 try { 604 ({ did, doId } = await resolveDpopAuth( 605 c.req.header("authorization"), 606 c.req.header("dpop"), 607 "POST", 608 c.req.url, 609 c.env, 610 )); 611 } catch (err) { 612 if (err instanceof RepoNotFoundError) { 613 return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 614 } 615 const message = (err as Error).message; 616 if (message.includes("tos_hash does not match")) { 617 return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 618 } 619 const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 620 return c.json({ error: code, message }, 401); 621 } 622 623 let body: { repo?: string; collection?: string; rkey?: string; record?: unknown }; 624 try { 625 body = await c.req.json(); 626 } catch { 627 return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 628 } 629 630 if (!body.repo || !body.collection || !body.rkey || body.record === undefined) { 631 return c.json( 632 { error: "InvalidRequest", message: "Missing required fields: repo, collection, rkey, record" }, 633 400, 634 ); 635 } 636 637 if (body.repo.includes(":") && body.repo !== did) { 638 return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); 639 } 640 641 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 642 return c.json(await stub.rpcPutRecord(body.collection, body.rkey, body.record)); 643}); 644 645// POST /xrpc/com.atproto.repo.deleteRecord 646app.post("/xrpc/com.atproto.repo.deleteRecord", async (c) => { 647 let did: string; 648 let doId: string; 649 try { 650 ({ did, doId } = await resolveDpopAuth( 651 c.req.header("authorization"), 652 c.req.header("dpop"), 653 "POST", 654 c.req.url, 655 c.env, 656 )); 657 } catch (err) { 658 if (err instanceof RepoNotFoundError) { 659 return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 660 } 661 const message = (err as Error).message; 662 if (message.includes("tos_hash does not match")) { 663 return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 664 } 665 const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 666 return c.json({ error: code, message }, 401); 667 } 668 669 let body: { repo?: string; collection?: string; rkey?: string }; 670 try { 671 body = await c.req.json(); 672 } catch { 673 return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 674 } 675 676 if (!body.repo || !body.collection || !body.rkey) { 677 return c.json( 678 { error: "InvalidRequest", message: "Missing required fields: repo, collection, rkey" }, 679 400, 680 ); 681 } 682 683 if (body.repo.includes(":") && body.repo !== did) { 684 return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); 685 } 686 687 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 688 return c.json(await stub.rpcDeleteRecord(body.collection, body.rkey)); 689}); 690 691// POST /xrpc/com.atproto.repo.applyWrites 692app.post("/xrpc/com.atproto.repo.applyWrites", async (c) => { 693 let did: string; 694 let doId: string; 695 try { 696 ({ did, doId } = await resolveDpopAuth( 697 c.req.header("authorization"), 698 c.req.header("dpop"), 699 "POST", 700 c.req.url, 701 c.env, 702 )); 703 } catch (err) { 704 if (err instanceof RepoNotFoundError) { 705 return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); 706 } 707 const message = (err as Error).message; 708 if (message.includes("tos_hash does not match")) { 709 return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); 710 } 711 const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; 712 return c.json({ error: code, message }, 401); 713 } 714 715 let body: { repo?: string; writes?: Array<{ $type: string; collection: string; rkey?: string; record?: unknown }> }; 716 try { 717 body = await c.req.json(); 718 } catch { 719 return c.json({ error: "InvalidRequest", message: "Invalid JSON body" }, 400); 720 } 721 722 if (!body.repo || !Array.isArray(body.writes)) { 723 return c.json( 724 { error: "InvalidRequest", message: "Missing required fields: repo, writes" }, 725 400, 726 ); 727 } 728 729 if (body.repo.includes(":") && body.repo !== did) { 730 return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); 731 } 732 733 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 734 return c.json(await stub.rpcApplyWrites(body.writes)); 735}); 736 737// GET /xrpc/com.atproto.sync.getBlob (public) 738app.get("/xrpc/com.atproto.sync.getBlob", async (c) => { 739 const did = c.req.query("did"); 740 const cid = c.req.query("cid"); 741 if (!did || !cid) { 742 return c.json({ error: "InvalidRequest", message: "Missing required parameters: did, cid" }, 400); 743 } 744 745 const key = `${did}/${cid}`; 746 const object = await c.env.BLOBS.get(key); 747 if (!object) { 748 return c.json({ error: "BlobNotFound", message: "Blob not found" }, 404); 749 } 750 751 const headers = new Headers(); 752 if (object.httpMetadata?.contentType) { 753 headers.set("content-type", object.httpMetadata.contentType); 754 } 755 return new Response(object.body, { headers }); 756}); 757 758// GET /xrpc/com.atproto.sync.listBlobs (public) 759app.get("/xrpc/com.atproto.sync.listBlobs", async (c) => { 760 const did = c.req.query("did"); 761 if (!did) { 762 return c.json({ error: "InvalidRequest", message: "Missing required parameter: did" }, 400); 763 } 764 765 await initDirectory(c.env.DIRECTORY); 766 767 let doId: string; 768 try { 769 const resolved = await resolveRepo(did, c.env); 770 doId = resolved.doId; 771 } catch (err) { 772 if (err instanceof RepoNotFoundError) { 773 return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 774 } 775 throw err; 776 } 777 778 const cursor = c.req.query("cursor"); 779 let limit = parseInt(c.req.query("limit") || "500", 10); 780 if (Number.isNaN(limit) || limit < 1) limit = 500; 781 if (limit > 1000) limit = 1000; 782 783 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 784 const result = await stub.rpcListBlobs({ limit, cursor }); 785 return c.json(result); 786}); 787 788// GET /xrpc/com.atproto.sync.getRepo 789app.get("/xrpc/com.atproto.sync.getRepo", async (c) => { 790 const did = c.req.query("did"); 791 if (!did) { 792 return c.json({ error: "InvalidRequest", message: "Missing required parameter: did" }, 400); 793 } 794 795 await initDirectory(c.env.DIRECTORY); 796 797 try { 798 const { doId } = await resolveRepo(did, c.env); 799 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 800 const car = await stub.rpcExportRepo(); 801 return new Response(car, { 802 headers: { "content-type": "application/vnd.ipld.car" }, 803 }); 804 } catch (err) { 805 if (err instanceof RepoNotFoundError) { 806 return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 807 } 808 throw err; 809 } 810}); 811 812// GET /xrpc/com.atproto.sync.getLatestCommit 813app.get("/xrpc/com.atproto.sync.getLatestCommit", async (c) => { 814 const did = c.req.query("did"); 815 if (!did) { 816 return c.json({ error: "InvalidRequest", message: "Missing required parameter: did" }, 400); 817 } 818 819 await initDirectory(c.env.DIRECTORY); 820 821 try { 822 const { doId } = await resolveRepo(did, c.env); 823 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 824 const commit = await stub.rpcGetLatestCommit(); 825 if (!commit) { 826 return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 827 } 828 return c.json(commit); 829 } catch (err) { 830 if (err instanceof RepoNotFoundError) { 831 return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 832 } 833 throw err; 834 } 835}); 836 837// GET /xrpc/com.atproto.sync.getRepoStatus 838app.get("/xrpc/com.atproto.sync.getRepoStatus", async (c) => { 839 const did = c.req.query("did"); 840 if (!did) { 841 return c.json({ error: "InvalidRequest", message: "Missing required parameter: did" }, 400); 842 } 843 844 await initDirectory(c.env.DIRECTORY); 845 846 try { 847 const { doId } = await resolveRepo(did, c.env); 848 const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); 849 const status = await stub.rpcGetRepoStatus(); 850 if (!status) { 851 return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 852 } 853 return c.json(status); 854 } catch (err) { 855 if (err instanceof RepoNotFoundError) { 856 return c.json({ error: "RepoNotFound", message: "Repository not found" }, 404); 857 } 858 throw err; 859 } 860}); 861 862// GET /xrpc/com.atproto.sync.subscribeRepos 863app.get("/xrpc/com.atproto.sync.subscribeRepos", async (c) => { 864 const seqId = c.env.SEQUENCER.idFromName("sequencer"); 865 const seqStub = c.env.SEQUENCER.get(seqId); 866 return seqStub.fetch(c.req.raw); 867}); 868 869export default app;