open-source, lexicon-agnostic PDS for AI agents. welcome-mat enrollment, AT Proto federation.
agents
atprotocol
pds
cloudflare
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 sol pbc
3
4import { DurableObject } from "cloudflare:workers";
5import { create as createCid, format as formatCid } from "@atcute/cid";
6import {
7 Repo,
8 WriteOpAction,
9 BlockMap,
10 blocksToCarFile,
11 type RecordCreateOp,
12 type RecordDeleteOp,
13 type RecordUpdateOp,
14 type RecordWriteOp,
15} from "@atproto/repo";
16import { Secp256k1Keypair } from "@atproto/crypto";
17import { CID } from "@atproto/lex-data";
18import { jsonToLex, lexToJson } from "@atproto/lex-json";
19import { base64urlEncode } from "./auth";
20import { SqliteRepoStorage } from "./storage";
21import type { Env } from "./types";
22
23const CODEC_RAW = 0x55;
24type RepoCollection = `${string}.${string}.${string}`;
25type RepoJsonValue = Parameters<typeof jsonToLex>[0];
26type RepoLexRecord = RecordCreateOp["record"];
27
28let generatedRkeyCounter = 0;
29
30function nextRkey(): string {
31 return `${Date.now().toString(36)}${(generatedRkeyCounter++).toString(36)}`;
32}
33
34function asRepoCollection(collection: string): RepoCollection {
35 return collection as RepoCollection;
36}
37
38function asRepoRecord(record: unknown): RepoLexRecord {
39 return jsonToLex(record as RepoJsonValue) as RepoLexRecord;
40}
41
42function asRepoJson(record: unknown): unknown {
43 return lexToJson(record as Parameters<typeof lexToJson>[0]);
44}
45
46export interface BlobRef {
47 $type: "blob";
48 ref: { $link: string };
49 mimeType: string;
50 size: number;
51}
52
53export interface TakedownResult {
54 recordsDeleted: number;
55 blobsDeleted: number;
56 collections: string[];
57}
58
59export class AccountDurableObject extends DurableObject<Env> {
60 private storage: SqliteRepoStorage | null = null;
61 private repo: Repo | null = null;
62 private keypair: Secp256k1Keypair | null = null;
63 private storageInitialized = false;
64 private repoInitialized = false;
65
66 constructor(ctx: DurableObjectState, env: Env) {
67 super(ctx, env);
68 }
69
70 private async ensureStorageInitialized(): Promise<void> {
71 if (!this.storageInitialized) {
72 await this.ctx.blockConcurrencyWhile(async () => {
73 if (this.storageInitialized) return;
74 this.storage = new SqliteRepoStorage(this.ctx.storage.sql);
75 this.storage.initSchema();
76 this.storageInitialized = true;
77 });
78 }
79 }
80
81 private async ensureRepoInitialized(): Promise<void> {
82 await this.ensureStorageInitialized();
83 if (!this.repoInitialized) {
84 await this.ctx.blockConcurrencyWhile(async () => {
85 if (this.repoInitialized) return;
86
87 const state = this.storage!.getState();
88 if (!state || !state.signing_key_hex) {
89 return;
90 }
91
92 this.keypair = await Secp256k1Keypair.import(state.signing_key_hex);
93
94 const root = await this.storage!.getRoot();
95 if (root) {
96 this.repo = await Repo.load(this.storage!, root);
97 } else {
98 this.repo = await Repo.create(this.storage!, state.did, this.keypair);
99 }
100
101 this.repoInitialized = true;
102 });
103 }
104 }
105
106 /** Expose storage for tests */
107 async getStorage(): Promise<SqliteRepoStorage> {
108 await this.ensureStorageInitialized();
109 return this.storage!;
110 }
111
112 /** Expose repo for tests */
113 async getRepo(): Promise<Repo> {
114 await this.ensureRepoInitialized();
115 if (!this.repo) {
116 throw new Error("Repo not initialized - account may not be provisioned");
117 }
118 return this.repo;
119 }
120
121 async rpcSignServiceAuth(opts: {
122 aud: string;
123 lxm: string;
124 exp: number;
125 }): Promise<{ token: string }> {
126 await this.ensureRepoInitialized();
127 if (!this.repo || !this.keypair) {
128 throw new Error("Repo not initialized - account may not be provisioned");
129 }
130
131 const now = Math.floor(Date.now() / 1000);
132 const header = { typ: "JWT", alg: "ES256K", kid: "#atproto" };
133 const payload = {
134 iss: this.repo.did,
135 aud: opts.aud,
136 exp: opts.exp,
137 iat: now,
138 jti: crypto.randomUUID(),
139 lxm: opts.lxm,
140 };
141 const signingInput = [
142 base64urlEncode(new TextEncoder().encode(JSON.stringify(header))),
143 base64urlEncode(new TextEncoder().encode(JSON.stringify(payload))),
144 ].join(".");
145 const signature = await this.keypair.sign(new TextEncoder().encode(signingInput));
146 return { token: `${signingInput}.${base64urlEncode(signature)}` };
147 }
148
149 private async emitCommitEvent(
150 prevDataCid: string | null,
151 ops: Array<{
152 action: "create" | "update" | "delete";
153 path: string;
154 cid: string | null;
155 }>,
156 ): Promise<void> {
157 const commit = this.storage!.lastCommit!;
158 const carBytes = await blocksToCarFile(commit.cid, commit.newBlocks);
159 const seqId = this.env.SEQUENCER.idFromName("sequencer");
160 const seqStub = this.env.SEQUENCER.get(seqId);
161 await seqStub.sequenceCommit({
162 did: this.repo!.did,
163 commit: commit.cid.toString(),
164 rev: commit.rev,
165 since: commit.since,
166 prevData: prevDataCid,
167 blocks: carBytes,
168 ops,
169 });
170 }
171
172 /**
173 * RPC: Provision a new account in this DO.
174 * Must be called before any repo operations.
175 * Rookery is multi-tenant: keys live in DO SQL, not env.
176 */
177 async rpcInitAccount(opts: {
178 did: string;
179 handle: string;
180 signingKeyHex: string;
181 signingKeyPub: string;
182 rotationKeyHex: string;
183 rotationKeyPub: string;
184 jwkThumbprint?: string;
185 }): Promise<{ did: string; handle: string }> {
186 await this.ensureStorageInitialized();
187
188 this.storage!.initAccountState({
189 did: opts.did,
190 handle: opts.handle,
191 signing_key_hex: opts.signingKeyHex,
192 signing_key_pub: opts.signingKeyPub,
193 rotation_key_hex: opts.rotationKeyHex,
194 rotation_key_pub: opts.rotationKeyPub,
195 jwk_thumbprint: opts.jwkThumbprint ?? null,
196 });
197
198 this.repoInitialized = false;
199 this.repo = null;
200 this.keypair = null;
201
202 await this.ensureRepoInitialized();
203
204 // Emit identity and account events for firehose
205 const seqId = this.env.SEQUENCER.idFromName("sequencer");
206 const seqStub = this.env.SEQUENCER.get(seqId);
207 await seqStub.sequenceIdentity(opts.did, opts.handle);
208 await seqStub.sequenceAccount(opts.did, true, null);
209
210 return { did: opts.did, handle: opts.handle };
211 }
212
213 /** RPC: Get account state */
214 async rpcGetState(): Promise<{
215 did: string;
216 handle: string;
217 root_cid: string | null;
218 rev: string | null;
219 active: boolean;
220 } | null> {
221 await this.ensureStorageInitialized();
222 const state = this.storage!.getState();
223 if (!state || !state.did) return null;
224 return {
225 did: state.did,
226 handle: state.handle,
227 root_cid: state.root_cid,
228 rev: state.rev,
229 active: state.active === 1,
230 };
231 }
232
233 /** RPC: Get latest commit info */
234 async rpcGetLatestCommit(): Promise<{ cid: string; rev: string } | null> {
235 await this.ensureRepoInitialized();
236 if (!this.repo) return null;
237 const root = await this.storage!.getRoot();
238 const rev = await this.storage!.getRev();
239 if (!root || !rev) return null;
240 return { cid: root.toString(), rev };
241 }
242
243 /** RPC: Describe repo metadata */
244 async rpcDescribeRepo(): Promise<{
245 did: string;
246 collections: string[];
247 cid: string;
248 handle: string;
249 handleIsCorrect: boolean;
250 }> {
251 const repo = await this.getRepo();
252 const storage = await this.getStorage();
253 const state = this.storage!.getState();
254 return {
255 did: repo.did,
256 collections: storage.getCollections(),
257 cid: repo.cid.toString(),
258 handle: state!.handle,
259 handleIsCorrect: true,
260 };
261 }
262
263 /** RPC: Has this account ever published a collection? */
264 async rpcHasPublished(): Promise<boolean> {
265 await this.ensureStorageInitialized();
266 return this.storage!.getCollections().length > 0;
267 }
268
269 /** RPC: Get a single record */
270 async rpcGetRecord(
271 collection: string,
272 rkey: string,
273 ): Promise<{ cid: string; record: unknown } | null> {
274 const repo = await this.getRepo();
275 const dataKey = `${collection}/${rkey}`;
276 const recordCid = await repo.data.get(dataKey);
277 if (!recordCid) return null;
278
279 const record = await repo.getRecord(collection, rkey);
280 if (!record) return null;
281
282 return {
283 cid: recordCid.toString(),
284 record: asRepoJson(record),
285 };
286 }
287
288 /** RPC: List records in a collection */
289 async rpcListRecords(
290 collection: string,
291 opts: { limit: number; cursor?: string; reverse?: boolean },
292 ): Promise<{
293 records: Array<{ uri: string; cid: string; value: unknown }>;
294 cursor?: string;
295 }> {
296 const repo = await this.getRepo();
297 const records: Array<{ uri: string; cid: string; value: unknown }> = [];
298 const startFrom = opts.cursor || `${collection}/`;
299
300 for await (const record of repo.walkRecords(startFrom)) {
301 if (record.collection !== collection) {
302 if (records.length > 0) break;
303 continue;
304 }
305
306 records.push({
307 uri: `at://${repo.did}/${record.collection}/${record.rkey}`,
308 cid: record.cid.toString(),
309 value: asRepoJson(record.record),
310 });
311
312 if (records.length >= opts.limit + 1) break;
313 }
314
315 if (opts.reverse) records.reverse();
316
317 const hasMore = records.length > opts.limit;
318 const results = hasMore ? records.slice(0, opts.limit) : records;
319 const cursor = hasMore
320 ? `${collection}/${results[results.length - 1]?.uri.split("/").pop() ?? ""}`
321 : undefined;
322
323 return { records: results, cursor };
324 }
325
326 /** RPC: Create a record */
327 async rpcCreateRecord(
328 collection: string,
329 rkey: string | undefined,
330 record: unknown,
331 ): Promise<{
332 uri: string;
333 cid: string;
334 commit: { cid: string; rev: string };
335 }> {
336 const repo = await this.getRepo();
337 await this.ensureRepoInitialized();
338 const keypair = this.keypair!;
339 const prevDataCid = this.storage!.getState()?.prev_data_cid ?? null;
340
341 const actualRkey = rkey || nextRkey();
342 const createOp: RecordCreateOp = {
343 action: WriteOpAction.Create,
344 collection: asRepoCollection(collection),
345 rkey: actualRkey,
346 record: asRepoRecord(record),
347 };
348
349 const updatedRepo = await repo.applyWrites([createOp], keypair);
350 this.repo = updatedRepo;
351
352 const dataKey = `${collection}/${actualRkey}`;
353 const recordCid = await this.repo.data.get(dataKey);
354 if (!recordCid) {
355 throw new Error(`Failed to create record: ${collection}/${actualRkey}`);
356 }
357
358 this.storage!.addCollection(collection);
359
360 await this.emitCommitEvent(prevDataCid, [
361 {
362 action: "create",
363 path: `${collection}/${actualRkey}`,
364 cid: recordCid.toString(),
365 },
366 ]);
367
368 return {
369 uri: `at://${this.repo.did}/${collection}/${actualRkey}`,
370 cid: recordCid.toString(),
371 commit: {
372 cid: this.repo.cid.toString(),
373 rev: this.repo.commit.rev,
374 },
375 };
376 }
377
378 /** RPC: Create or update a record (putRecord semantics) */
379 async rpcPutRecord(
380 collection: string,
381 rkey: string,
382 record: unknown,
383 ): Promise<{
384 uri: string;
385 cid: string;
386 commit: { cid: string; rev: string };
387 }> {
388 const repo = await this.getRepo();
389 await this.ensureRepoInitialized();
390 const keypair = this.keypair!;
391 const prevDataCid = this.storage!.getState()?.prev_data_cid ?? null;
392
393 // Check if record already exists to decide create vs update
394 const dataKey = `${collection}/${rkey}`;
395 const existingCid = await repo.data.get(dataKey);
396
397 const op: RecordWriteOp = existingCid
398 ? {
399 action: WriteOpAction.Update,
400 collection: asRepoCollection(collection),
401 rkey,
402 record: asRepoRecord(record),
403 }
404 : {
405 action: WriteOpAction.Create,
406 collection: asRepoCollection(collection),
407 rkey,
408 record: asRepoRecord(record),
409 };
410
411 const updatedRepo = await repo.applyWrites([op], keypair);
412 this.repo = updatedRepo;
413
414 const recordCid = await this.repo.data.get(dataKey);
415 if (!recordCid) {
416 throw new Error(`Failed to put record: ${collection}/${rkey}`);
417 }
418
419 this.storage!.addCollection(collection);
420
421 await this.emitCommitEvent(prevDataCid, [
422 {
423 action: existingCid ? "update" : "create",
424 path: `${collection}/${rkey}`,
425 cid: recordCid.toString(),
426 },
427 ]);
428
429 return {
430 uri: `at://${this.repo.did}/${collection}/${rkey}`,
431 cid: recordCid.toString(),
432 commit: {
433 cid: this.repo.cid.toString(),
434 rev: this.repo.commit.rev,
435 },
436 };
437 }
438
439 /** RPC: Delete a record */
440 async rpcDeleteRecord(
441 collection: string,
442 rkey: string,
443 ): Promise<{ commit: { cid: string; rev: string } }> {
444 const repo = await this.getRepo();
445 await this.ensureRepoInitialized();
446 const keypair = this.keypair!;
447 const prevDataCid = this.storage!.getState()?.prev_data_cid ?? null;
448
449 const deleteOp: RecordDeleteOp = {
450 action: WriteOpAction.Delete,
451 collection: asRepoCollection(collection),
452 rkey,
453 };
454
455 const updatedRepo = await repo.applyWrites([deleteOp], keypair);
456 this.repo = updatedRepo;
457
458 await this.emitCommitEvent(prevDataCid, [
459 {
460 action: "delete",
461 path: `${collection}/${rkey}`,
462 cid: null,
463 },
464 ]);
465
466 return {
467 commit: {
468 cid: this.repo.cid.toString(),
469 rev: this.repo.commit.rev,
470 },
471 };
472 }
473
474 /** RPC: Apply multiple writes atomically */
475 async rpcApplyWrites(
476 writes: Array<{
477 $type: string;
478 collection: string;
479 rkey?: string;
480 record?: unknown;
481 }>,
482 ): Promise<{
483 commit: { cid: string; rev: string };
484 results: unknown[];
485 }> {
486 const repo = await this.getRepo();
487 await this.ensureRepoInitialized();
488 const keypair = this.keypair!;
489 const prevDataCid = this.storage!.getState()?.prev_data_cid ?? null;
490
491 const ops: RecordWriteOp[] = [];
492 const results: unknown[] = [];
493
494 for (const write of writes) {
495 if (write.$type === "com.atproto.repo.applyWrites#create") {
496 const rkey = write.rkey || nextRkey();
497 const createOp: RecordCreateOp = {
498 action: WriteOpAction.Create,
499 collection: asRepoCollection(write.collection),
500 rkey,
501 record: asRepoRecord(write.record),
502 };
503 ops.push(createOp);
504 this.storage!.addCollection(write.collection);
505 results.push({
506 $type: "com.atproto.repo.applyWrites#createResult",
507 uri: `at://${repo.did}/${write.collection}/${rkey}`,
508 cid: "",
509 });
510 } else if (write.$type === "com.atproto.repo.applyWrites#delete") {
511 if (!write.rkey) throw new Error("Delete requires rkey");
512 const deleteOp: RecordDeleteOp = {
513 action: WriteOpAction.Delete,
514 collection: asRepoCollection(write.collection),
515 rkey: write.rkey,
516 };
517 ops.push(deleteOp);
518 results.push({
519 $type: "com.atproto.repo.applyWrites#deleteResult",
520 });
521 } else if (write.$type === "com.atproto.repo.applyWrites#update") {
522 if (!write.rkey) throw new Error("Update requires rkey");
523 const updateOp: RecordUpdateOp = {
524 action: WriteOpAction.Update,
525 collection: asRepoCollection(write.collection),
526 rkey: write.rkey,
527 record: asRepoRecord(write.record),
528 };
529 ops.push(updateOp);
530 this.storage!.addCollection(write.collection);
531 results.push({
532 $type: "com.atproto.repo.applyWrites#updateResult",
533 uri: `at://${repo.did}/${write.collection}/${write.rkey}`,
534 cid: "",
535 });
536 }
537 }
538
539 const updatedRepo = await repo.applyWrites(ops, keypair);
540 this.repo = updatedRepo;
541
542 // Build firehose ops from the already-constructed ops array
543 const firehoseOps: Array<{
544 action: "create" | "update" | "delete";
545 path: string;
546 cid: string | null;
547 }> = [];
548 for (const op of ops) {
549 if (op.action === WriteOpAction.Create) {
550 const createOp = op as RecordCreateOp;
551 const recordCid = await this.repo.data.get(
552 `${createOp.collection}/${createOp.rkey}`,
553 );
554 firehoseOps.push({
555 action: "create",
556 path: `${createOp.collection}/${createOp.rkey}`,
557 cid: recordCid?.toString() ?? null,
558 });
559 } else if (op.action === WriteOpAction.Delete) {
560 const deleteOp = op as RecordDeleteOp;
561 firehoseOps.push({
562 action: "delete",
563 path: `${deleteOp.collection}/${deleteOp.rkey}`,
564 cid: null,
565 });
566 } else if (op.action === WriteOpAction.Update) {
567 const updateOp = op as RecordUpdateOp;
568 const recordCid = await this.repo.data.get(
569 `${updateOp.collection}/${updateOp.rkey}`,
570 );
571 firehoseOps.push({
572 action: "update",
573 path: `${updateOp.collection}/${updateOp.rkey}`,
574 cid: recordCid?.toString() ?? null,
575 });
576 }
577 }
578 await this.emitCommitEvent(prevDataCid, firehoseOps);
579
580 return {
581 commit: {
582 cid: this.repo.cid.toString(),
583 rev: this.repo.commit.rev,
584 },
585 results,
586 };
587 }
588
589 /** RPC: Permanently remove an account's repo, blobs, and DO storage. */
590 async rpcTakedown(did: string): Promise<TakedownResult> {
591 await this.ensureRepoInitialized();
592
593 const records: Array<{ collection: string; rkey: string }> = [];
594 const collectionSet = new Set<string>();
595 if (this.repo) {
596 for await (const record of this.repo.walkRecords()) {
597 records.push({ collection: record.collection, rkey: record.rkey });
598 collectionSet.add(record.collection);
599 }
600 }
601
602 for (let offset = 0; offset < records.length; offset += 200) {
603 const chunk = records.slice(offset, offset + 200);
604 const prevDataCid = this.storage!.getState()?.prev_data_cid ?? null;
605 const deleteOps: RecordDeleteOp[] = chunk.map((record) => ({
606 action: WriteOpAction.Delete,
607 collection: asRepoCollection(record.collection),
608 rkey: record.rkey,
609 }));
610
611 this.repo = await this.repo!.applyWrites(deleteOps, this.keypair!);
612 await this.emitCommitEvent(
613 prevDataCid,
614 chunk.map((record) => ({
615 action: "delete" as const,
616 path: `${record.collection}/${record.rkey}`,
617 cid: null,
618 })),
619 );
620 }
621
622 const seqId = this.env.SEQUENCER.idFromName("sequencer");
623 const seqStub = this.env.SEQUENCER.get(seqId);
624 await seqStub.sequenceAccount(did, false, "deleted");
625
626 const prefix = `${did}/`;
627 let blobsDeleted = 0;
628 let cursor: string | undefined;
629 do {
630 const listed = await this.env.BLOBS.list({ prefix, cursor });
631 const keys = listed.objects.map((object) => object.key);
632 if (keys.length > 0) {
633 await this.env.BLOBS.delete(keys);
634 blobsDeleted += keys.length;
635 }
636 if (listed.truncated && !listed.cursor) {
637 throw new Error("R2 blob listing was truncated without a cursor");
638 }
639 cursor = listed.truncated ? listed.cursor : undefined;
640 } while (cursor);
641
642 await this.ctx.storage.deleteAll();
643 this.storage = null;
644 this.repo = null;
645 this.keypair = null;
646 this.storageInitialized = false;
647 this.repoInitialized = false;
648
649 return {
650 recordsDeleted: records.length,
651 blobsDeleted,
652 collections: Array.from(collectionSet).sort(),
653 };
654 }
655
656 /** RPC: Get repo status */
657 async rpcGetRepoStatus(): Promise<{
658 did: string;
659 active: boolean;
660 status: string;
661 rev: string | null;
662 } | null> {
663 await this.ensureStorageInitialized();
664 const state = this.storage!.getState();
665 if (!state || !state.did) return null;
666 return {
667 did: state.did,
668 active: state.active === 1,
669 status: state.active === 1 ? "active" : "deactivated",
670 rev: state.rev,
671 };
672 }
673
674 /** RPC: Export repo as CAR bytes */
675 async rpcExportRepo(): Promise<Uint8Array> {
676 await this.ensureRepoInitialized();
677 const root = await this.storage!.getRoot();
678 if (!root) throw new Error("Repo has no root");
679
680 const allBlocks = new BlockMap();
681 const rows = this.storage!.getAllBlocks();
682 for (const row of rows) {
683 allBlocks.set(CID.parse(row.cid), new Uint8Array(row.bytes as ArrayBuffer));
684 }
685
686 return blocksToCarFile(root, allBlocks);
687 }
688
689 /** RPC: Upload a blob to R2 and track in metadata */
690 async rpcUploadBlob(bytes: Uint8Array, mimeType: string): Promise<BlobRef> {
691 await this.ensureStorageInitialized();
692 const state = this.storage!.getState();
693 if (!state?.did) throw new Error("Account not provisioned");
694
695 const cidObj = await createCid(CODEC_RAW, bytes);
696 const cidStr = formatCid(cidObj);
697
698 const key = `${state.did}/${cidStr}`;
699 await this.env.BLOBS.put(key, bytes, {
700 httpMetadata: { contentType: mimeType },
701 });
702
703 this.storage!.insertBlob(cidStr, mimeType, bytes.length);
704
705 return {
706 $type: "blob",
707 ref: { $link: cidStr },
708 mimeType,
709 size: bytes.length,
710 };
711 }
712
713 /** RPC: List blob CIDs for this account */
714 async rpcListBlobs(
715 opts?: { limit?: number; cursor?: string },
716 ): Promise<{ cids: string[]; cursor?: string }> {
717 await this.ensureStorageInitialized();
718 return this.storage!.listBlobs(opts);
719 }
720}