···2233is an [atproto PDS](https://atproto.com/guides/glossary#pds-personal-data-server), along with an assortment of atproto-relevant libraries, written in OCaml.
4455+## table of contents
66+77+- [Running It](#running-it)
88+- [Environment](#environment)
99+ - [SMTP](#smtp)
1010+ - [S3](#s3)
1111+- [Development](#development)
1212+- [Libraries](#libraries)
1313+ - [ipld](#ipld) - IPLD implementation (CIDs, CAR, DAG-CBOR)
1414+ - [kleidos](#kleidos) - Cryptographic key management
1515+ - [mist](#mist) - Merkle Search Tree implementation
1616+ - [hermes](#hermes) - XRPC client
1717+ - [frontend](#frontend) - Web interface
1818+ - [pegasus](#pegasus-library) - PDS implementation
1919+520## running it
621722After cloning this repo, start by running
823924```
2525+docker compose pull
2626+```
2727+2828+to pull the latest image, or
2929+3030+```
1031docker compose build
1132```
3333+3434+to build from source.
12351336Next, run
1437···5073- `PDS_S3_ENDPOINT`, `PDS_S3_REGION`, `PDS_S3_BUCKET`, `PDS_S3_ACCESS_KEY`, `PDS_S3_SECRET_KEY` — S3 configuration.
5174- `PDS_S3_CDN_URL` — You may optionally set this to redirect `getBlob` requests to `{PDS_S3_CDN_URL}/blobs/{did}/{cid}`. When unset, blobs will be fetched either from local storage or from S3, depending on `PDS_S3_BLOBS_ENABLED`.
52755353-## development
7676+## libraries
54775555-This repo contains several libraries in addition to the `pegasus` PDS:
7878+This repo contains several libraries in addition to the `pegasus` PDS. Each library has its own README with detailed documentation.
56795757-| library | description |
5858-| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
5959-| frontend | The PDS frontend, containing the admin dashboard and account page. |
6060-| ipld | A mostly [DASL-compliant](https://dasl.ing/) implementation of [CIDs](https://dasl.ing/cid.html), [CAR](https://dasl.ing/car.html), and [DAG-CBOR](https://dasl.ing/drisl.html). |
6161-| kleidos | An atproto-valid interface for secp256k1 and secp256r1 key management, signing/verifying, and encoding/decoding. |
6262-| mist | A [Merkle Search Tree](https://atproto.com/specs/repository#mst-structure) implementation for data repository purposes. |
6363-| hermes | An XRPC client for atproto. |
6464-| hermes_ppx | A preprocessor for hermes, making API calls more ergonomic. |
6565-| hermes-cli | A CLI to generate OCaml types from atproto lexicons. |
6666-| pegasus | The PDS implementation. |
8080+### <a id="ipld"></a>[ipld](ipld/README.md)
8181+8282+A mostly [DASL-compliant](https://dasl.ing/) implementation of [CIDs](https://dasl.ing/cid.html), [CAR](https://dasl.ing/car.html), and [DAG-CBOR](https://dasl.ing/drisl.html).
8383+8484+Provides content addressing primitives for IPLD: Content Identifiers (CIDs), Content Addressable aRchives (CAR), and deterministic CBOR encoding.
8585+8686+### <a id="kleidos"></a>[kleidos](kleidos/README.md)
8787+8888+An atproto-valid interface for secp256k1 and secp256r1 key management, signing/verifying, and encoding/decoding.
8989+9090+Handles cryptographic operations for both K-256 and P-256 elliptic curves with multikey encoding and did:ket generation.
9191+9292+### <a id="mist"></a>[mist](mist/README.md)
9393+9494+A [Merkle Search Tree](https://atproto.com/specs/repository#mst-structure) implementation for data repository purposes with a swappable storage backend.
9595+9696+### <a id="hermes"></a>[hermes](hermes/README.md)
9797+9898+An XRPC client for atproto with three components:
9999+100100+- **hermes** - Core XRPC client library
101101+- **hermes_ppx** - PPX extension for ergonomic API calls
102102+- **hermes-cli** - CLI to generate OCaml types from atproto lexicons
103103+104104+### <a id="frontend"></a>[frontend](frontend/README.md)
105105+106106+The PDS frontend, containing the admin dashboard and account page.
107107+108108+### <a id="pegasus-library"></a>[pegasus](pegasus/README.md)
109109+110110+The PDS implementation.
111111+112112+## development
6711368114To start developing, you'll need:
69115···108154109155to download the formatter and LSP services. You can run `dune fmt` to format the project.
110156111111-The [frontend](frontend) is written in [MLX](https://github.com/ocaml-mlx/mlx), a JSX-ish OCaml dialect. To format it, you'll need to `opam install ocamlformat-mlx`, then `ocamlformat-mlx -i frontend/**/*.mlx`. You'll see a few errors on formatting files containing `[%browser_only]`; I'm waiting on the next release of `mlx` to fix those.
157157+The [frontend](frontend/) is written in [MLX](https://github.com/ocaml-mlx/mlx), a JSX-ish OCaml dialect. To format it, you'll need to `opam install ocamlformat-mlx`, then `ocamlformat-mlx -i frontend/**/*.mlx`. You'll see a few errors on formatting files containing `[%browser_only]`; I'm waiting on the next release of `mlx` to fix those.
+51
frontend/README.md
···11+# frontend
22+33+is the web interface for pegasus, containing the admin dashboard and account management pages.
44+55+Built with [MLX](https://github.com/ocaml-mlx/mlx) (a JSX-like OCaml dialect), [server-reason-react](https://github.com/ml-in-barcelona/server-reason-react) (React SSR in OCaml) and [melange](https://melange.re) (OCaml to JavaScript compiler).
66+77+## pages
88+99+### admin
1010+1111+- **Login** (`/admin`) - Admin authentication
1212+- **Users** (`/admin/users`) - View and manage PDS users
1313+- **Invites** (`/admin/invites`) - Create and manage invite codes
1414+- **Blobs** (`/admin/blobs`) - Monitor blob storage usage
1515+1616+### account
1717+1818+- **Account page** (`/account`) - User profile and email settings
1919+- **Identity** (`/account/identity`) - Handle and DID management
2020+- **Permissions** (`/account/permissions`) - OAuth app permissions and sessions
2121+- **Login** (`/login`) - User authentication
2222+- **Signup** (`/signup`) - New account creation
2323+2424+### oauth
2525+2626+- **Authorize** (`/oauth/authorize`) - OAuth authorization flow
2727+2828+## development
2929+3030+The frontend is built as part of the main pegasus project. When developing:
3131+3232+```bash
3333+# Build the frontend
3434+dune build
3535+3636+# The compiled JavaScript will be in _build/default/public/
3737+```
3838+3939+### formatting
4040+4141+The frontend uses MLX syntax, which can't be formatted using ocamlformat:
4242+4343+```bash
4444+# Install formatter
4545+opam install ocamlformat-mlx
4646+4747+# Format MLX files
4848+ocamlformat-mlx -i frontend/src/**/*.mlx
4949+```
5050+5151+Note: You may see errors formatting files containing `[%browser_only]`. This is a known issue pending the next MLX release.
+116
ipld/README.md
···11+# ipld
22+33+is a mostly [DASL-compliant](https://dasl.ing/) implementation of [CIDs](https://dasl.ing/cid.html), [CAR](https://dasl.ing/car.html), and [DAG-CBOR](https://dasl.ing/drisl.html) for OCaml.
44+55+This library implements the core IPLD primitives needed for atproto.
66+77+## components
88+99+- **CID** - Content Identifiers (CIDv1) with SHA-256 digests
1010+- **CAR** - Content Addressable aRchives for storing and transferring IPLD data
1111+- **DAG-CBOR** - Deterministic CBOR encoding for content-addressed data
1212+1313+## installation
1414+1515+Add to your `dune-project`:
1616+1717+```lisp
1818+(depends
1919+ ipld)
2020+```
2121+2222+## usage
2323+2424+### working with CIDs
2525+2626+```ocaml
2727+open Ipld
2828+2929+(* Create a CID from data *)
3030+let cid = Cid.create Cid.Dcbor data_bytes
3131+3232+(* Encode CID to base32 string *)
3333+let cid_string = Cid.to_string cid
3434+(* => "bafyreihffx5a2e7k5uwrmmgofbvzujc5cmw5h4espouwuxt3liqoflx3ee" *)
3535+3636+(* Decode CID from string *)
3737+match Cid.of_string cid_string with
3838+| Ok cid -> (* use cid *)
3939+| Error msg -> failwith msg
4040+4141+(* Convert to/from bytes *)
4242+let bytes = Cid.to_bytes cid
4343+match Cid.of_bytes bytes with
4444+| Ok cid -> (* use cid *)
4545+| Error msg -> failwith msg
4646+```
4747+4848+### DAG-CBOR encoding
4949+5050+```ocaml
5151+open Dag_cbor
5252+5353+(* Create CBOR values *)
5454+let value = `Map (String_map.of_list [
5555+ ("name", `String "Alice");
5656+ ("age", `Integer 30L);
5757+ ("verified", `Boolean true);
5858+ ("profile_cid", `Link some_cid);
5959+])
6060+6161+(* Encode to bytes *)
6262+let encoded = Dag_cbor.encode value
6363+6464+(* Decode from bytes *)
6565+let decoded = Dag_cbor.decode encoded
6666+```
6767+6868+### CAR files
6969+7070+CAR (Content Addressable aRchive) files store multiple IPLD blocks with their CIDs.
7171+7272+```ocaml
7373+open Car
7474+7575+(* Write blocks to a CAR file *)
7676+let blocks = [
7777+ (cid1, data1);
7878+ (cid2, data2);
7979+ (cid3, data3);
8080+] in
8181+Car.write ~roots:[root_cid] blocks output_channel
8282+8383+(* Read blocks from a CAR file *)
8484+let (roots, blocks) = Car.read input_channel
8585+```
8686+8787+## types
8888+8989+### CID
9090+9191+```ocaml
9292+type Cid.t = {
9393+ version: int; (* Always 1 for CIDv1 *)
9494+ codec: codec; (* Raw or Dcbor *)
9595+ digest: digest; (* SHA-256 hash *)
9696+ bytes: bytes; (* Serialized CID *)
9797+}
9898+9999+type codec = Raw | Dcbor
100100+```
101101+102102+### DAG-CBOR
103103+104104+```ocaml
105105+type Dag_cbor.value =
106106+ | `Null
107107+ | `Boolean of bool
108108+ | `Integer of int64
109109+ | `Float of float
110110+ | `String of string
111111+ | `Bytes of bytes
112112+ | `Array of value array
113113+ | `Map of value String_map.t
114114+ | `Link of Cid.t
115115+ | `Tag of int * value
116116+```
+77
kleidos/README.md
···11+# kleidos
22+33+is an atproto-valid interface for secp256k1 and secp256r1 (P-256) key management, signing, verification, and encoding.
44+55+The library provides a unified interface for working with both elliptic curves used in atproto, with support for multikey encoding and did:key generation.
66+77+## installation
88+99+Add to your `dune-project`:
1010+1111+```lisp
1212+(depends
1313+ kleidos)
1414+```
1515+1616+## usage
1717+1818+Both K-256 and P-256 share the same interface through the `CURVE` module type.
1919+2020+### generating keys
2121+2222+```ocaml
2323+open Kleidos
2424+2525+(* Generate a K-256 keypair *)
2626+let (privkey, pubkey) = K256.generate_keypair ()
2727+2828+(* Generate a P-256 keypair *)
2929+let (privkey, pubkey) = P256.generate_keypair ()
3030+```
3131+3232+### signing and verifying
3333+3434+```ocaml
3535+(* Sign a message *)
3636+let msg = Bytes.of_string "Hello, atproto!" in
3737+let signature = K256.sign ~privkey ~msg
3838+3939+(* Verify a signature *)
4040+let is_valid = K256.verify ~pubkey ~msg ~signature
4141+(* => true *)
4242+```
4343+4444+### key encoding
4545+4646+```ocaml
4747+(* Convert keys to multikey format *)
4848+let privkey_multikey = K256.privkey_to_multikey privkey
4949+(* => "z2MkApQ..." *)
5050+5151+let pubkey_multikey = K256.pubkey_to_multikey pubkey
5252+(* => "zQ3sh..." *)
5353+5454+(* Generate a DID key *)
5555+let did_key = K256.pubkey_to_did_key pubkey
5656+(* => "did:key:zQ3sh..." *)
5757+```
5858+5959+### deriving public keys
6060+6161+```ocaml
6262+(* Derive public key from private key *)
6363+let pubkey = K256.derive_pubkey ~privkey
6464+```
6565+6666+### key validation
6767+6868+```ocaml
6969+(* Check if a private key is valid *)
7070+let is_valid = K256.is_valid_privkey privkey
7171+```
7272+7373+## implementation details
7474+7575+- Implements [RFC 6979](https://datatracker.ietf.org/doc/html/rfc6979) for deterministic signature generation
7676+- Implements low-S normalization to prevent signature malleability
7777+- All signatures are deterministic given the same private key and message
+92
mist/README.md
···11+# mist
22+33+is a [Merkle Search Tree](https://atproto.com/specs/repository#mst-structure) implementation for atproto data repositories.
44+55+## installation
66+77+Add to your `dune-project`:
88+99+```lisp
1010+(depends
1111+ mist
1212+ ipld) ; required dependency
1313+```
1414+1515+## usage
1616+1717+### working with TIDs
1818+1919+TIDs are 13-character base32-encoded identifiers that combine a microsecond timestamp with a clock ID for ordering and uniqueness.
2020+2121+```ocaml
2222+open Mist
2323+2424+(* Generate a TID from current timestamp *)
2525+let tid = Tid.now ()
2626+(* => "3jzfcijpj2z23" *)
2727+2828+(* Create TID from timestamp *)
2929+let tid = Tid.of_timestamp_ms 1609459200000L
3030+ ~clockid:123
3131+3232+(* Parse TID from string *)
3333+let tid = Tid.of_string "3jzfcijpj2z23"
3434+3535+(* Extract timestamp *)
3636+let (timestamp_us, clockid) = Tid.to_timestamp_us tid
3737+3838+(* TIDs are comparable for ordering *)
3939+let is_later = Tid.compare tid1 tid2 > 0
4040+```
4141+4242+### working with MSTs
4343+4444+```ocaml
4545+open Lwt.Syntax
4646+4747+(* Create a new MST with a blockstore and an empty root *)
4848+let blockstore = Mist.Storage.Memory_blockstore.create () in
4949+let* mst = Mst.create blockstore (Cid.of_string "")
5050+5151+(* Add an entry *)
5252+let key = "app.bsky.feed.post/3jzfcijpj2z23" in
5353+let cid = Cid.of_string "bafy2bzaceb3z2z23" in
5454+let* mst = Mst.add mst key cid blockstore
5555+5656+(* Get an entry *)
5757+let* value_opt = Mst.retrieve_node mst cid in
5858+match value_opt with
5959+| Some node -> (* found *)
6060+| None -> (* not found *)
6161+6262+(* Delete an entry *)
6363+let* mst = Mst.delete mst key
6464+6565+(* Get the root CID *)
6666+let root_cid = Cid.to_string mst.root
6767+```
6868+6969+### inductive proof
7070+7171+```ocaml
7272+(* Generate a map of all blocks needed to prove a given key *)
7373+let* proof = Mst.proof_for_key mst cid key in
7474+```
7575+7676+### working with blob references
7777+7878+```ocaml
7979+(* Parse blob reference from JSON *)
8080+let blob = Blob_ref.of_yojson json
8181+8282+(* Access blob properties *)
8383+let cid = blob.ref in
8484+let mime_type = blob.mime_type in
8585+let size = blob.size
8686+8787+(* Convert to IPLD representation *)
8888+let ipld = Blob_ref.to_ipld blob
8989+9090+(* Convert back to JSON *)
9191+let json = Blob_ref.to_yojson blob
9292+```
+2-75
mist/lib/mst.ml
···96969797type node_or_entry = Node of node | Entry of entry
98989999-type diff_add = {key: string; cid: Cid.t}
100100-101101-type diff_update = {key: string; prev: Cid.t; cid: Cid.t}
102102-103103-type diff_delete = {key: string; cid: Cid.t}
104104-105105-type data_diff =
106106- { adds: diff_add list
107107- ; updates: diff_update list
108108- ; deletes: diff_delete list
109109- ; new_mst_blocks: (Cid.t * bytes) list
110110- ; new_leaf_cids: Cid.Set.t
111111- ; removed_cids: Cid.Set.t }
112112-113113-let ( let*? ) lazy_opt_lwt f =
114114- let%lwt result = Lazy.force lazy_opt_lwt in
115115- f result
116116-11799let ( >>? ) lazy_opt_lwt f =
118100 let%lwt result = Lazy.force lazy_opt_lwt in
119101 f result
···345327 let traverse t fn : unit Lwt.t =
346328 let rec traverse node =
347329 let%lwt () =
348348- let*? left = node.left in
330330+ let%lwt left = Lazy.force node.left in
349331 match left with Some l -> traverse l | None -> Lwt.return_unit
350332 in
351333 let%lwt () =
352334 Lwt_list.iter_s
353335 (fun (entry : entry) ->
354336 fn entry.key entry.value ;
355355- let*? right = entry.right in
337337+ let%lwt right = Lazy.force entry.right in
356338 match right with Some r -> traverse r | None -> Lwt.return_unit )
357339 node.entries
358340 in
···16341616 | _ ->
16351617 Lwt.return false
16361618end
16371637-16381638-module Inductive (M : Intf) = struct
16391639- module Cache_bs = Cache_blockstore (Memory_blockstore)
16401640- module Mem_mst = Make (Cache_bs)
16411641-16421642- type diff =
16431643- | Add of {key: string; cid: Cid.t}
16441644- | Update of {key: string; prev: Cid.t option; cid: Cid.t}
16451645- | Delete of {key: string; prev: Cid.t}
16461646-16471647- (* given an mst diff, returns all new blocks as well as inductive proof blocks *)
16481648- let generate_proof (map : Cid.t String_map.t) (diff : diff list)
16491649- ~(new_root : Cid.t) ~(prev_root : Cid.t) : (Block_map.t, exn) Lwt_result.t
16501650- =
16511651- try%lwt
16521652- let%lwt mem_mst =
16531653- Mem_mst.of_assoc
16541654- (Cache_bs.create (Memory_blockstore.create ()))
16551655- (String_map.bindings map)
16561656- in
16571657- (* save this now so we can read blocks from it later *)
16581658- let blockstore = mem_mst.blockstore in
16591659- (* apply inverse of operations in reverse order,
16601660- check that mst root matches prev_root *)
16611661- let%lwt inverted_mst, added_cids =
16621662- Lwt_list.fold_right_s
16631663- (fun (diff : diff) (mst, added_cids) ->
16641664- match diff with
16651665- | Delete {key; prev} | Update {key; prev= Some prev; _} ->
16661666- let%lwt mst = Mem_mst.add mst key prev in
16671667- Lwt.return (mst, Cid.Set.remove prev added_cids)
16681668- | Add {key; cid} | Update {key; prev= None; cid} ->
16691669- let%lwt mst = Mem_mst.delete mst key in
16701670- Lwt.return (mst, Cid.Set.add cid added_cids) )
16711671- diff (mem_mst, Cid.Set.empty)
16721672- in
16731673- if not (Cid.equal inverted_mst.root prev_root) then
16741674- failwith
16751675- (Printf.sprintf
16761676- "inductive proof produced invalid previous cid: expected %s, got \
16771677- %s"
16781678- (Cid.to_string prev_root)
16791679- (Cid.to_string inverted_mst.root) ) ;
16801680- let proof_cids =
16811681- Cid.Set.union added_cids (Cache_bs.get_reads blockstore)
16821682- |> Cid.Set.remove prev_root |> Cid.Set.add new_root
16831683- in
16841684- let {blocks= proof_bm; _} : Block_map.with_missing =
16851685- Block_map.get_many
16861686- (Cid.Set.elements proof_cids)
16871687- (Cache_bs.get_cache blockstore)
16881688- in
16891689- Lwt.return_ok proof_bm
16901690- with e -> Lwt.return_error e
16911691-end
+21-24
mist/test/test_mst.ml
···44module Mem_mst = Mst.Make (Storage.Memory_blockstore)
55module String_map = Dag_cbor.String_map
6677+type diff_add = {key: string; cid: Cid.t}
88+99+type diff_update = {key: string; prev: Cid.t; cid: Cid.t}
1010+1111+type diff_delete = {key: string; cid: Cid.t}
1212+1313+type data_diff =
1414+ { adds: diff_add list
1515+ ; updates: diff_update list
1616+ ; deletes: diff_delete list
1717+ ; new_mst_blocks: (Cid.t * bytes) list
1818+ ; new_leaf_cids: Cid.Set.t }
1919+720module Differ (Prev : Mst.Intf) (Curr : Mst.Intf) = struct
88- let diff ~(t_curr : Curr.t) ~(t_prev : Prev.t) : Mst.data_diff Lwt.t =
99- let%lwt curr_nodes, curr_node_set, curr_leaf_set =
2121+ let diff ~(t_curr : Curr.t) ~(t_prev : Prev.t) : data_diff Lwt.t =
2222+ let%lwt curr_nodes, _, curr_leaf_set =
1023 Curr.collect_nodes_and_leaves t_curr
1124 in
1225 let%lwt _, prev_node_set, prev_leaf_set =
1326 Prev.collect_nodes_and_leaves t_prev
1427 in
1528 let in_prev_nodes cid = Cid.Set.mem cid prev_node_set in
1616- let in_curr_nodes cid = Cid.Set.mem cid curr_node_set in
1729 let in_prev_leaves cid = Cid.Set.mem cid prev_leaf_set in
1818- let in_curr_leaves cid = Cid.Set.mem cid curr_leaf_set in
1930 let new_mst_blocks =
2031 List.filter (fun (cid, _) -> not (in_prev_nodes cid)) curr_nodes
2132 in
2222- let removed_node_cids =
2323- Cid.Set.fold
2424- (fun cid acc ->
2525- if not (in_curr_nodes cid) then Cid.Set.add cid acc else acc )
2626- prev_node_set Cid.Set.empty
2727- in
2828- let removed_leaf_cids =
2929- Cid.Set.fold
3030- (fun cid acc ->
3131- if not (in_curr_leaves cid) then Cid.Set.add cid acc else acc )
3232- prev_leaf_set Cid.Set.empty
3333- in
3434- let removed_cids = Cid.Set.union removed_node_cids removed_leaf_cids in
3533 let new_leaf_cids =
3634 Cid.Set.fold
3735 (fun cid acc ->
···4139 let%lwt curr_leaves = Curr.leaves_of_root t_curr in
4240 let%lwt prev_leaves = Prev.leaves_of_root t_prev in
4341 let rec merge (pl : (string * Cid.t) list) (cl : (string * Cid.t) list)
4444- (adds : Mst.diff_add list) (updates : Mst.diff_update list)
4545- (deletes : Mst.diff_delete list) =
4242+ (adds : diff_add list) (updates : diff_update list)
4343+ (deletes : diff_delete list) =
4644 match (pl, cl) with
4745 | [], [] ->
4846 (List.rev adds, List.rev updates, List.rev deletes)
···6462 updates deletes
6563 in
6664 let adds, updates, deletes = merge prev_leaves curr_leaves [] [] [] in
6767- Lwt.return
6868- {Mst.adds; updates; deletes; new_mst_blocks; new_leaf_cids; removed_cids}
6565+ Lwt.return {adds; updates; deletes; new_mst_blocks; new_leaf_cids}
6966end
70677168module Mem_diff = Differ (Mem_mst) (Mem_mst)
···487484 (* contents: convert to maps to compare *)
488485 let adds_map =
489486 List.fold_left
490490- (fun m (a : Mst.diff_add) -> String_map.add a.key a.cid m)
487487+ (fun m (a : diff_add) -> String_map.add a.key a.cid m)
491488 String_map.empty diff.adds
492489 in
493490 let updates_map =
494491 List.fold_left
495495- (fun m (u : Mst.diff_update) -> String_map.add u.key (u.prev, u.cid) m)
492492+ (fun m (u : diff_update) -> String_map.add u.key (u.prev, u.cid) m)
496493 String_map.empty diff.updates
497494 in
498495 let deletes_map =
499496 List.fold_left
500500- (fun m (d : Mst.diff_delete) -> String_map.add d.key d.cid m)
497497+ (fun m (d : diff_delete) -> String_map.add d.key d.cid m)
501498 String_map.empty diff.deletes
502499 in
503500 (* compare adds *)
+80
pegasus/README.md
···11+# pegasus
22+33+is the core library implementing the PDS functionality.
44+55+## architecture
66+77+```
88+pegasus/lib/
99+├── api/ # XRPC API endpoints
1010+│ ├── account_/ # Account management UI
1111+│ ├── admin/ # com.atproto.admin.* XRPC endpoints
1212+│ ├── admin_/ # Admin UI
1313+│ ├── identity/ # com.atproto.identity.* XRPC endpoints
1414+│ ├── oauth_/ # OAuth flows
1515+│ ├── repo/ # com.atproto.repo.* XRPC endpoints
1616+│ ├── server/ # com.atproto.server.* XRPC endpoints
1717+│ └── sync/ # com.atproto.sync.* XRPC endpoints
1818+├── oauth/ # OAuth implementation
1919+├── lexicons/ # Generated atproto types
2020+├── migrations/ # Database schema migrations
2121+├── s3/ # S3 blob storage backend
2222+├── auth.ml # Authentication logic
2323+├── blob_store.ml # Blob storage interface
2424+├── data_store.ml # Database interface
2525+├── env.ml # Environment configuration
2626+├── id_resolver.ml # Identity resolution
2727+├── jwt.ml # JWT token handling
2828+├── plc.ml # PLC directory client
2929+├── rate_limiter.ml # Rate limiting
3030+├── repository.ml # Repository operations
3131+├── sequencer.ml # Event sequencing
3232+├── session.ml # Session management
3333+└── user_store.ml # User data access
3434+```
3535+3636+## storage
3737+3838+### database
3939+4040+Currently only SQLite is supported. Open to pull requests for other databases!
4141+4242+### blob storage
4343+4444+Supports two backends:
4545+4646+- **Local filesystem** - Stores blobs in `{PDS_DATA_DIR}/blobs/{did}/{cid}`
4747+- **S3-compatible** - Stores blobs in S3 bucket
4848+4949+Configurable via environment variables (see main README).
5050+5151+## email notifications
5252+5353+Optional email support for:
5454+5555+- Email address verification
5656+- Password reset
5757+- Identity update confirmation
5858+- Account deletion confirmation
5959+6060+Falls back to stdout logging if SMTP not configured.
6161+6262+## contributing
6363+6464+To add new endpoints:
6565+6666+1. Add handler in `./lib/api/`
6767+2. Register route in [`bin/main.ml`](bin/main.ml)
6868+3. If frontend, also add to [`frontend/client/Router.mlx`](frontend/client/Router.mlx)
6969+7070+## testing
7171+7272+Tests are in `pegasus/test/`:
7373+7474+```bash
7575+dune test
7676+```
7777+7878+## environment
7979+8080+Configuration is loaded from environment variables. See main README for configuration options.