open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents
atprotocol
pds
cloudflare
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;