···54545555This repo contains several libraries in addition to the `pegasus` PDS:
56565757-| 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-| pegasus | The PDS implementation. |
5757+| 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. |
64676568To start developing, you'll need:
6669
···11+# hermes
22+33+is a type-safe XRPC client for atproto.
44+55+Hermes provides three components:
66+77+- **hermes** - Core library for making XRPC calls
88+- **hermes-cli** - Code generator for atproto lexicons
99+- **hermes_ppx** - PPX extension for ergonomic API calls
1010+1111+- [Quick Start](#quick-start)
1212+- [Complete Example](#complete-example)
1313+- [Installation](#installation)
1414+- [hermes](#hermes-lib)
1515+ - [Session Management](#session-management)
1616+ - [Making XRPC Calls](#making-xrpc-calls)
1717+ - [Error Handling](#error-handling)
1818+- [hermes_ppx](#hermes-ppx)
1919+ - [Setup](#setup)
2020+ - [Usage](#ppx-usage)
2121+- [hermes-cli](#hermes-cli)
2222+ - [Usage](#usage)
2323+ - [Options](#options)
2424+ - [Generated Code Structure](#generated-code-structure)
2525+ - [Type Mappings](#type-mappings)
2626+ - [Bytes Encoding](#bytes-encoding)
2727+ - [Union Types](#union-types)
2828+2929+## quick start
3030+3131+```ocaml
3232+open Hermes_lexicons (* generate lexicons using hermes-cli! *)
3333+open Lwt.Syntax
3434+3535+let () = Lwt_main.run begin
3636+ (* Create an unauthenticated client *)
3737+ let client = Hermes.make_client ~service:"https://public.api.bsky.app" () in
3838+3939+ (* Make a query using the generated module *)
4040+ let* profile = App_bsky_actor_getProfile.call ~actor:"bsky.app" client in
4141+ print_endline profile.display_name;
4242+ Lwt.return_unit
4343+end
4444+```
4545+4646+## complete example
4747+4848+```ocaml
4949+open Hermes_lexicons (* generate lexicons using hermes-cli! *)
5050+open Lwt.Syntax
5151+5252+let main () =
5353+ (* Set up credential manager with persistence *)
5454+ let manager = Hermes.make_credential_manager ~service:"https://pegasus.example" () in
5555+5656+ Hermes.on_session_update manager (fun session ->
5757+ let json = Hermes.session_to_yojson session in
5858+ Yojson.Safe.to_file "session.json" json;
5959+ Lwt.return_unit
6060+ );
6161+6262+ (* Log in or resume session *)
6363+ let* client =
6464+ if Sys.file_exists "session.json" then
6565+ let json = Yojson.Safe.from_file "session.json" in
6666+ match Hermes.session_of_yojson json with
6767+ | Ok session -> Hermes.resume manager ~session ()
6868+ | Error _ -> failwith "Invalid session file"
6969+ else
7070+ Hermes.login manager
7171+ ~identifier:"you.bsky.social"
7272+ ~password:"your-app-password"
7373+ ()
7474+ in
7575+7676+ (* Fetch your profile *)
7777+ let session = Hermes.get_session client |> Option.get in
7878+ let* profile =
7979+ [%xrpc get "app.bsky.actor.getProfile"]
8080+ ~actor:session.did
8181+ client
8282+ in
8383+ Printf.printf "Logged in as %s\n" profile.handle;
8484+8585+ (* Create a post *)
8686+ let* _ =
8787+ [%xrpc post "com.atproto.repo.createRecord"]
8888+ ~repo:session.did
8989+ ~collection:"app.bsky.feed.post"
9090+ ~record:(`Assoc [
9191+ ("$type", `String "app.bsky.feed.post");
9292+ ("text", `String "Hello from Hermes!");
9393+ ("createdAt", `String (Ptime.to_rfc3339 (Ptime_clock.now ())));
9494+ ])
9595+ client
9696+ in
9797+ print_endline "Post created!";
9898+ Lwt.return_unit
9999+100100+let () = Lwt_main.run (main ())
101101+```
102102+103103+## installation
104104+105105+Add to your `dune-project`:
106106+107107+```lisp
108108+(depends
109109+ hermes
110110+ hermes_ppx)
111111+```
112112+113113+<h2 id="hermes-lib">hermes</h2>
114114+115115+### session management
116116+117117+```ocaml
118118+(* Unauthenticated client for public endpoints *)
119119+let client = Hermes.make_client ~service:"https://public.api.bsky.app" ()
120120+121121+(* Authenticated client with credential manager *)
122122+let manager = Hermes.make_credential_manager ~service:"https://bsky.social" ()
123123+124124+let%lwt client = Hermes.login manager
125125+ ~identifier:"user.bsky.social"
126126+ ~password:"app-password-here"
127127+ ()
128128+129129+(* Get current session for persistence *)
130130+let session = Hermes.get_session client
131131+132132+(* Save session to JSON *)
133133+let json = Hermes.session_to_yojson session
134134+135135+(* Resume from saved session *)
136136+let%lwt client = Hermes.resume manager ~session ()
137137+138138+(* Auto-save session to disk *)
139139+let () = Hermes.on_session_update manager (fun session ->
140140+ save_to_disk (Hermes.session_to_yojson session);
141141+ Lwt.return_unit
142142+)
143143+144144+(* Listen for session expiration *)
145145+let () = Hermes.on_session_expired manager (fun () ->
146146+ print_endline "session expired, log in again!";
147147+ Lwt.return_unit
148148+)
149149+```
150150+151151+### making XRPC calls
152152+153153+```ocaml
154154+(* GET request *)
155155+let%lwt result = Hermes.query client
156156+ "app.bsky.actor.getProfile"
157157+ (`Assoc [("actor", `String "bsky.app")])
158158+ decode_profile
159159+160160+(* GET request returning raw bytes *)
161161+let%lwt (data, content_type) = Hermes.query_bytes client
162162+ "com.atproto.sync.getBlob"
163163+ (`Assoc [("did", `String did); ("cid", `String cid)])
164164+165165+(* POST request *)
166166+let%lwt result = Hermes.procedure client
167167+ "com.atproto.repo.createRecord"
168168+ (`Assoc []) (* query params *)
169169+ (Some input_json)
170170+ decode_response
171171+172172+(* POST request with raw bytes as input *)
173173+let%lwt response = Hermes.procedure_bytes client
174174+ "com.atproto.repo.importRepo"
175175+ (`Assoc [])
176176+ (Some car_data)
177177+ ~content_type:"application/vnd.ipld.car"
178178+179179+(* upload bytes, get a blob back *)
180180+let%lwt blob = Hermes.procedure_blob client
181181+ "com.atproto.repo.uploadBlob"
182182+ (`Assoc [])
183183+ image_bytes
184184+ ~content_type:"image/jpeg"
185185+ decode_blob
186186+```
187187+188188+### error handling
189189+190190+```ocaml
191191+try%lwt
192192+ let%lwt _ = some_xrpc_call client in
193193+ Lwt.return_unit
194194+with Hermes.Xrpc_error { status; error; message } ->
195195+ Printf.printf "Error %d: %s (%s)\n"
196196+ status error (Option.value message ~default:"no message");
197197+ Lwt.return_unit
198198+```
199199+200200+<h2 id="hermes-cli">hermes-cli (codegen)</h2>
201201+202202+generates type-safe OCaml modules from atproto lexicon files.
203203+204204+### usage
205205+206206+```bash
207207+# Generate from lexicons directory
208208+hermes-cli generate --input ./lexicons --output ./lib/generated
209209+210210+# With custom root module name
211211+hermes-cli generate -i ./lexicons -o ./lib/generated --module-name Bsky_api
212212+```
213213+214214+### options
215215+216216+| Option | Short | Description |
217217+| --------------- | ----- | --------------------------------------- |
218218+| `--input` | `-i` | Directory containing lexicon JSON files |
219219+| `--output` | `-o` | Output directory for generated OCaml |
220220+| `--module-name` | `-m` | Root module name (default: Lexicons) |
221221+222222+### generated code structure
223223+224224+For a lexicon like `app.bsky.actor.getProfile`, the generator creates:
225225+226226+```
227227+lib/generated/
228228+├── dune
229229+├── lexicons.ml # Re-exports all modules
230230+└── app/
231231+ └── bsky/
232232+ └── actor/
233233+ └── getProfile.ml
234234+```
235235+236236+Each endpoint module contains:
237237+238238+```ocaml
239239+module GetProfile = struct
240240+ type params = {
241241+ actor: string;
242242+ } [@@deriving yojson]
243243+244244+ type output = {
245245+ did: string;
246246+ handle: string;
247247+ display_name: string option;
248248+ (* ... *)
249249+ } [@@deriving yojson]
250250+251251+ let nsid = "app.bsky.actor.getProfile"
252252+253253+ let call ~actor (client : Hermes.client) : output Lwt.t =
254254+ let params = { actor } in
255255+ Hermes.query client nsid (params_to_yojson params) output_of_yojson
256256+end
257257+```
258258+259259+### type mappings
260260+261261+| Lexicon Type | OCaml Type |
262262+| ------------ | --------------- |
263263+| `boolean` | `bool` |
264264+| `integer` | `int` |
265265+| `string` | `string` |
266266+| `bytes` | `string` |
267267+| `blob` | `Hermes.blob` |
268268+| `cid-link` | `Cid.t` |
269269+| `array` | `list` |
270270+| `object` | record type |
271271+| `union` | variant type |
272272+| `unknown` | `Yojson.Safe.t` |
273273+274274+### bytes encoding
275275+276276+Endpoints with non-JSON encoding are automatically detected and handled:
277277+278278+- **Queries with bytes output** (e.g., `com.atproto.sync.getBlob` with `encoding: "*/*"`):
279279+ - Output type is `string * string` (data, content_type)
280280+ - Generated code uses `Hermes.query_bytes`
281281+282282+- **Procedures with bytes input**:
283283+ - Input is `?input:string` (optional raw bytes)
284284+ - Generated code uses `Hermes.procedure_bytes`
285285+286286+### union types
287287+288288+Unions generate variant types with a discriminator:
289289+290290+```ocaml
291291+type relationship_union =
292292+ | Relationship of relationship
293293+ | NotFoundActor of not_found_actor
294294+ | Unknown of Yojson.Safe.t (* for open unions *)
295295+```
296296+297297+<h2 id="hermes-ppx">hermes_ppx (PPX extension)</h2>
298298+299299+Transforms `[%xrpc ...]` into generated module calls.
300300+301301+### setup
302302+303303+```lisp
304304+(library
305305+ (name my_app)
306306+ (libraries hermes hermes_ppx lwt)
307307+ (preprocess (pps hermes_ppx)))
308308+```
309309+310310+<h3 id="ppx-usage">usage</h3>
311311+312312+```ocaml
313313+let get_followers ~actor ~limit client =
314314+ [%xrpc get "app.bsky.graph.getFollowers"]
315315+ ~actor
316316+ ?limit
317317+ client
318318+319319+let create_post ~text client =
320320+ let session = Hermes.get_session client |> Option.get in
321321+ [%xrpc post "com.atproto.repo.createRecord"]
322322+ ~repo:session.did
323323+ ~collection:"app.bsky.feed.post"
324324+ ~record:(`Assoc [
325325+ ("$type", `String "app.bsky.feed.post");
326326+ ("text", `String text);
327327+ ("createdAt", `String (Ptime.to_rfc3339 (Ptime_clock.now ())));
328328+ ])
329329+ client
330330+```
+375
hermes/lib/client.ml
···11+open Lwt.Syntax
22+33+type t =
44+ { service: Uri.t
55+ ; mutable headers: (string * string) list
66+ ; mutable session: Types.session option
77+ ; on_request: (t -> unit Lwt.t) option
88+ (* called before each request for token refresh *) }
99+1010+module type S = sig
1111+ val make : service:string -> unit -> t
1212+1313+ val make_with_interceptor :
1414+ service:string -> on_request:(t -> unit Lwt.t) -> unit -> t
1515+1616+ val set_session : t -> Types.session -> unit
1717+1818+ val clear_session : t -> unit
1919+2020+ val get_session : t -> Types.session option
2121+2222+ val get_service : t -> Uri.t
2323+2424+ val query :
2525+ t
2626+ -> string
2727+ -> Yojson.Safe.t
2828+ -> (Yojson.Safe.t -> ('a, string) result)
2929+ -> 'a Lwt.t
3030+3131+ val procedure :
3232+ t
3333+ -> string
3434+ -> Yojson.Safe.t
3535+ -> Yojson.Safe.t option
3636+ -> (Yojson.Safe.t -> ('a, string) result)
3737+ -> 'a Lwt.t
3838+3939+ val query_bytes : t -> string -> Yojson.Safe.t -> (string * string) Lwt.t
4040+4141+ val procedure_bytes :
4242+ t
4343+ -> string
4444+ -> Yojson.Safe.t
4545+ -> string option
4646+ -> content_type:string
4747+ -> (string * string) option Lwt.t
4848+4949+ val procedure_blob :
5050+ t
5151+ -> string
5252+ -> Yojson.Safe.t
5353+ -> bytes
5454+ -> content_type:string
5555+ -> (Yojson.Safe.t -> ('a, string) result)
5656+ -> 'a Lwt.t
5757+end
5858+5959+module Make (Http : Http_backend.S) : S = struct
6060+ let make ~service () =
6161+ let service = Uri.of_string service in
6262+ {service; headers= []; session= None; on_request= None}
6363+6464+ let make_with_interceptor ~service ~on_request () =
6565+ let service = Uri.of_string service in
6666+ {service; headers= []; session= None; on_request= Some on_request}
6767+6868+ let set_session t session =
6969+ t.session <- Some session ;
7070+ t.headers <-
7171+ List.filter (fun (k, _) -> k <> "Authorization") t.headers
7272+ @ [("Authorization", "Bearer " ^ session.Types.access_jwt)]
7373+7474+ let clear_session t =
7575+ t.session <- None ;
7676+ t.headers <- List.filter (fun (k, _) -> k <> "Authorization") t.headers
7777+7878+ let get_session t = t.session
7979+8080+ let get_service t = t.service
8181+8282+ (* build query string from json params *)
8383+ let params_to_query (params : Yojson.Safe.t) : (string * string list) list =
8484+ match params with
8585+ | `Assoc pairs ->
8686+ List.filter_map
8787+ (fun (k, v) ->
8888+ match v with
8989+ | `Null ->
9090+ None
9191+ | `Bool b ->
9292+ Some (k, [string_of_bool b])
9393+ | `Int i ->
9494+ Some (k, [string_of_int i])
9595+ | `Float f ->
9696+ Some (k, [string_of_float f])
9797+ | `String s ->
9898+ Some (k, [s])
9999+ | `List items ->
100100+ let strs =
101101+ List.filter_map
102102+ (function
103103+ | `String s ->
104104+ Some s
105105+ | `Int i ->
106106+ Some (string_of_int i)
107107+ | `Bool b ->
108108+ Some (string_of_bool b)
109109+ | _ ->
110110+ None )
111111+ items
112112+ in
113113+ if strs = [] then None else Some (k, strs)
114114+ | _ ->
115115+ None )
116116+ pairs
117117+ | _ ->
118118+ []
119119+120120+ let make_headers ?(extra = []) ?(accept = "application/json") t =
121121+ Cohttp.Header.of_list
122122+ ([("User-Agent", "hermes/1.0"); ("Accept", accept)] @ t.headers @ extra)
123123+124124+ let query (t : t) (nsid : string) (params : Yojson.Safe.t)
125125+ (of_yojson : Yojson.Safe.t -> ('a, string) result) : 'a Lwt.t =
126126+ (* call interceptor if present for token refresh *)
127127+ let* () =
128128+ match t.on_request with Some f -> f t | None -> Lwt.return_unit
129129+ in
130130+ let query = params_to_query params in
131131+ let uri =
132132+ Uri.with_path t.service ("/xrpc/" ^ nsid)
133133+ |> fun u -> Uri.with_query u query
134134+ in
135135+ let headers = make_headers t in
136136+ let* resp, body =
137137+ Lwt.catch
138138+ (fun () -> Lwt_unix.with_timeout 30.0 (fun () -> Http.get ~headers uri))
139139+ (fun exn ->
140140+ Types.raise_xrpc_error_raw ~status:0 ~error:"NetworkError"
141141+ ~message:(Printexc.to_string exn) () )
142142+ in
143143+ let status = Cohttp.Response.status resp |> Cohttp.Code.code_of_status in
144144+ let* body_str = Cohttp_lwt.Body.to_string body in
145145+ if status >= 200 && status < 300 then
146146+ if String.length body_str = 0 then
147147+ (* empty response, try parsing empty object *)
148148+ match of_yojson (`Assoc []) with
149149+ | Ok v ->
150150+ Lwt.return v
151151+ | Error e ->
152152+ Types.raise_xrpc_error_raw ~status ~error:"ParseError" ~message:e ()
153153+ else
154154+ let json = Yojson.Safe.from_string body_str in
155155+ match of_yojson json with
156156+ | Ok v ->
157157+ Lwt.return v
158158+ | Error e ->
159159+ Types.raise_xrpc_error_raw ~status ~error:"ParseError" ~message:e ()
160160+ else
161161+ let payload =
162162+ try
163163+ let json = Yojson.Safe.from_string body_str in
164164+ match Types.xrpc_error_payload_of_yojson json with
165165+ | Ok p ->
166166+ p
167167+ | Error _ ->
168168+ {error= "UnknownError"; message= Some body_str}
169169+ with _ -> {error= "UnknownError"; message= Some body_str}
170170+ in
171171+ Types.raise_xrpc_error ~status payload
172172+173173+ let procedure (t : t) (nsid : string) (params : Yojson.Safe.t)
174174+ (input : Yojson.Safe.t option)
175175+ (of_yojson : Yojson.Safe.t -> ('a, string) result) : 'a Lwt.t =
176176+ (* call interceptor if present for token refresh *)
177177+ let* () =
178178+ match t.on_request with Some f -> f t | None -> Lwt.return_unit
179179+ in
180180+ let query = params_to_query params in
181181+ let uri =
182182+ Uri.with_path t.service ("/xrpc/" ^ nsid)
183183+ |> fun u -> Uri.with_query u query
184184+ in
185185+ let body, content_type =
186186+ match input with
187187+ | Some json ->
188188+ ( Cohttp_lwt.Body.of_string (Yojson.Safe.to_string json)
189189+ , "application/json" )
190190+ | None ->
191191+ (Cohttp_lwt.Body.empty, "application/json")
192192+ in
193193+ let headers = make_headers ~extra:[("Content-Type", content_type)] t in
194194+ let* resp, resp_body =
195195+ Lwt.catch
196196+ (fun () ->
197197+ Lwt_unix.with_timeout 30.0 (fun () -> Http.post ~headers ~body uri) )
198198+ (fun exn ->
199199+ Types.raise_xrpc_error_raw ~status:0 ~error:"NetworkError"
200200+ ~message:(Printexc.to_string exn) () )
201201+ in
202202+ let status = Cohttp.Response.status resp |> Cohttp.Code.code_of_status in
203203+ let* body_str = Cohttp_lwt.Body.to_string resp_body in
204204+ if status >= 200 && status < 300 then
205205+ if String.length body_str = 0 then
206206+ match of_yojson (`Assoc []) with
207207+ | Ok v ->
208208+ Lwt.return v
209209+ | Error e ->
210210+ Types.raise_xrpc_error_raw ~status ~error:"ParseError" ~message:e ()
211211+ else
212212+ let json = Yojson.Safe.from_string body_str in
213213+ match of_yojson json with
214214+ | Ok v ->
215215+ Lwt.return v
216216+ | Error e ->
217217+ Types.raise_xrpc_error_raw ~status ~error:"ParseError" ~message:e ()
218218+ else
219219+ let payload =
220220+ try
221221+ let json = Yojson.Safe.from_string body_str in
222222+ match Types.xrpc_error_payload_of_yojson json with
223223+ | Ok p ->
224224+ p
225225+ | Error _ ->
226226+ {error= "UnknownError"; message= Some body_str}
227227+ with _ -> {error= "UnknownError"; message= Some body_str}
228228+ in
229229+ Types.raise_xrpc_error ~status payload
230230+231231+ let query_bytes (t : t) (nsid : string) (params : Yojson.Safe.t) :
232232+ (string * string) Lwt.t =
233233+ (* call interceptor if present for token refresh *)
234234+ let* () =
235235+ match t.on_request with Some f -> f t | None -> Lwt.return_unit
236236+ in
237237+ let query = params_to_query params in
238238+ let uri =
239239+ Uri.with_path t.service ("/xrpc/" ^ nsid)
240240+ |> fun u -> Uri.with_query u query
241241+ in
242242+ let headers = make_headers ~accept:"*/*" t in
243243+ let* resp, body =
244244+ Lwt.catch
245245+ (fun () -> Lwt_unix.with_timeout 120.0 (fun () -> Http.get ~headers uri))
246246+ (fun exn ->
247247+ Types.raise_xrpc_error_raw ~status:0 ~error:"NetworkError"
248248+ ~message:(Printexc.to_string exn) () )
249249+ in
250250+ let status = Cohttp.Response.status resp |> Cohttp.Code.code_of_status in
251251+ let* body_str = Cohttp_lwt.Body.to_string body in
252252+ if status >= 200 && status < 300 then
253253+ let content_type =
254254+ Cohttp.Response.headers resp
255255+ |> fun h ->
256256+ Cohttp.Header.get h "content-type"
257257+ |> Option.value ~default:"application/octet-stream"
258258+ in
259259+ Lwt.return (body_str, content_type)
260260+ else
261261+ let payload =
262262+ try
263263+ let json = Yojson.Safe.from_string body_str in
264264+ match Types.xrpc_error_payload_of_yojson json with
265265+ | Ok p ->
266266+ p
267267+ | Error _ ->
268268+ {error= "UnknownError"; message= Some body_str}
269269+ with _ -> {error= "UnknownError"; message= Some body_str}
270270+ in
271271+ Types.raise_xrpc_error ~status payload
272272+273273+ (* execute procedure with raw bytes input, returns raw bytes or none if no output *)
274274+ let procedure_bytes (t : t) (nsid : string) (params : Yojson.Safe.t)
275275+ (input : string option) ~(content_type : string) :
276276+ (string * string) option Lwt.t =
277277+ (* call interceptor if present for token refresh *)
278278+ let* () =
279279+ match t.on_request with Some f -> f t | None -> Lwt.return_unit
280280+ in
281281+ let query = params_to_query params in
282282+ let uri =
283283+ Uri.with_path t.service ("/xrpc/" ^ nsid)
284284+ |> fun u -> Uri.with_query u query
285285+ in
286286+ let body =
287287+ match input with
288288+ | Some data ->
289289+ Cohttp_lwt.Body.of_string data
290290+ | None ->
291291+ Cohttp_lwt.Body.empty
292292+ in
293293+ let headers =
294294+ make_headers ~extra:[("Content-Type", content_type)] ~accept:"*/*" t
295295+ in
296296+ let* resp, resp_body =
297297+ Lwt.catch
298298+ (fun () ->
299299+ Lwt_unix.with_timeout 120.0 (fun () -> Http.post ~headers ~body uri) )
300300+ (fun exn ->
301301+ Types.raise_xrpc_error_raw ~status:0 ~error:"NetworkError"
302302+ ~message:(Printexc.to_string exn) () )
303303+ in
304304+ let status = Cohttp.Response.status resp |> Cohttp.Code.code_of_status in
305305+ let* body_str = Cohttp_lwt.Body.to_string resp_body in
306306+ if status >= 200 && status < 300 then
307307+ if String.length body_str = 0 then Lwt.return None
308308+ else
309309+ let resp_content_type =
310310+ Cohttp.Response.headers resp
311311+ |> fun h ->
312312+ Cohttp.Header.get h "content-type"
313313+ |> Option.value ~default:"application/octet-stream"
314314+ in
315315+ Lwt.return (Some (body_str, resp_content_type))
316316+ else
317317+ let payload =
318318+ try
319319+ let json = Yojson.Safe.from_string body_str in
320320+ match Types.xrpc_error_payload_of_yojson json with
321321+ | Ok p ->
322322+ p
323323+ | Error _ ->
324324+ {error= "UnknownError"; message= Some body_str}
325325+ with _ -> {error= "UnknownError"; message= Some body_str}
326326+ in
327327+ Types.raise_xrpc_error ~status payload
328328+329329+ let procedure_blob (t : t) (nsid : string) (params : Yojson.Safe.t)
330330+ (blob_data : bytes) ~(content_type : string)
331331+ (of_yojson : Yojson.Safe.t -> ('a, string) result) : 'a Lwt.t =
332332+ (* call interceptor if present for token refresh *)
333333+ let* () =
334334+ match t.on_request with Some f -> f t | None -> Lwt.return_unit
335335+ in
336336+ let query = params_to_query params in
337337+ let uri =
338338+ Uri.with_path t.service ("/xrpc/" ^ nsid)
339339+ |> fun u -> Uri.with_query u query
340340+ in
341341+ let body = Cohttp_lwt.Body.of_string (Bytes.to_string blob_data) in
342342+ let headers = make_headers ~extra:[("Content-Type", content_type)] t in
343343+ let* resp, resp_body =
344344+ Lwt.catch
345345+ (fun () ->
346346+ Lwt_unix.with_timeout 120.0 (fun () -> Http.post ~headers ~body uri) )
347347+ (fun exn ->
348348+ Types.raise_xrpc_error_raw ~status:0 ~error:"NetworkError"
349349+ ~message:(Printexc.to_string exn) () )
350350+ in
351351+ let status = Cohttp.Response.status resp |> Cohttp.Code.code_of_status in
352352+ let* body_str = Cohttp_lwt.Body.to_string resp_body in
353353+ if status >= 200 && status < 300 then
354354+ let json = Yojson.Safe.from_string body_str in
355355+ match of_yojson json with
356356+ | Ok v ->
357357+ Lwt.return v
358358+ | Error e ->
359359+ Types.raise_xrpc_error_raw ~status ~error:"ParseError" ~message:e ()
360360+ else
361361+ let payload =
362362+ try
363363+ let json = Yojson.Safe.from_string body_str in
364364+ match Types.xrpc_error_payload_of_yojson json with
365365+ | Ok p ->
366366+ p
367367+ | Error _ ->
368368+ {error= "UnknownError"; message= Some body_str}
369369+ with _ -> {error= "UnknownError"; message= Some body_str}
370370+ in
371371+ Types.raise_xrpc_error ~status payload
372372+end
373373+374374+(* default client using real http backend *)
375375+include Make (Http_backend.Default)
+180
hermes/lib/credential_manager.ml
···11+open Lwt.Syntax
22+33+type t =
44+ { service: Uri.t
55+ ; mutable session: Types.session option
66+ ; mutable on_session_update: (Types.session -> unit Lwt.t) option
77+ ; mutable on_session_expired: (unit -> unit Lwt.t) option
88+ ; refresh_mutex: Lwt_mutex.t
99+ ; mutable refresh_promise: unit Lwt.t option }
1010+1111+module type S = sig
1212+ val make : service:string -> unit -> t
1313+1414+ val on_session_update : t -> (Types.session -> unit Lwt.t) -> unit
1515+1616+ val on_session_expired : t -> (unit -> unit Lwt.t) -> unit
1717+1818+ val get_session : t -> Types.session option
1919+2020+ val login :
2121+ t
2222+ -> identifier:string
2323+ -> password:string
2424+ -> ?auth_factor_token:string
2525+ -> unit
2626+ -> Client.t Lwt.t
2727+2828+ val resume : t -> session:Types.session -> unit -> Client.t Lwt.t
2929+3030+ val logout : t -> unit Lwt.t
3131+end
3232+3333+module Make (C : Client.S) : S = struct
3434+ let make ~service () =
3535+ { service= Uri.of_string service
3636+ ; session= None
3737+ ; on_session_update= None
3838+ ; on_session_expired= None
3939+ ; refresh_mutex= Lwt_mutex.create ()
4040+ ; refresh_promise= None }
4141+4242+ let on_session_update t callback = t.on_session_update <- Some callback
4343+4444+ let on_session_expired t callback = t.on_session_expired <- Some callback
4545+4646+ let get_session t = t.session
4747+4848+ (* update session and notify *)
4949+ let update_session t session =
5050+ t.session <- Some session ;
5151+ match t.on_session_update with
5252+ | Some callback ->
5353+ callback session
5454+ | None ->
5555+ Lwt.return_unit
5656+5757+ (* clear session and notify *)
5858+ let clear_session t =
5959+ t.session <- None ;
6060+ match t.on_session_expired with
6161+ | Some callback ->
6262+ callback ()
6363+ | None ->
6464+ Lwt.return_unit
6565+6666+ (* create raw client for auth operations *)
6767+ let make_raw_client t = C.make ~service:(Uri.to_string t.service) ()
6868+6969+ let rec login t ~identifier ~password ?auth_factor_token () =
7070+ let client = make_raw_client t in
7171+ let input =
7272+ Types.login_request_to_yojson
7373+ {Types.identifier; password; auth_factor_token}
7474+ in
7575+ let* session =
7676+ C.procedure client "com.atproto.server.createSession" (`Assoc [])
7777+ (Some input) Types.session_of_yojson
7878+ in
7979+ let* () = update_session t session in
8080+ (* create client with request interceptor for auto-refresh *)
8181+ let authed_client =
8282+ C.make_with_interceptor ~service:(Uri.to_string t.service)
8383+ ~on_request:(fun c -> check_and_refresh t c)
8484+ ()
8585+ in
8686+ C.set_session authed_client session ;
8787+ Lwt.return authed_client
8888+8989+ and resume t ~session () =
9090+ let* () = update_session t session in
9191+ let authed_client =
9292+ C.make_with_interceptor ~service:(Uri.to_string t.service)
9393+ ~on_request:(fun c -> check_and_refresh t c)
9494+ ()
9595+ in
9696+ C.set_session authed_client session ;
9797+ Lwt.return authed_client
9898+9999+ (* refresh the session *)
100100+ and refresh_session t =
101101+ match t.session with
102102+ | None ->
103103+ Types.raise_xrpc_error_raw ~status:401 ~error:"AuthRequired"
104104+ ~message:"No session to refresh" ()
105105+ | Some session ->
106106+ let client = make_raw_client t in
107107+ (* use refresh token for auth *)
108108+ C.set_session client {session with access_jwt= session.refresh_jwt} ;
109109+ Lwt.catch
110110+ (fun () ->
111111+ let* new_session =
112112+ C.procedure client "com.atproto.server.refreshSession" (`Assoc [])
113113+ None Types.session_of_yojson
114114+ in
115115+ let* () = update_session t new_session in
116116+ Lwt.return (Some new_session) )
117117+ (fun exn ->
118118+ match exn with
119119+ | Types.Xrpc_error {error= "ExpiredToken"; _}
120120+ | Types.Xrpc_error {error= "InvalidToken"; _} ->
121121+ let* () = clear_session t in
122122+ Lwt.return None
123123+ | _ ->
124124+ Lwt.reraise exn )
125125+126126+ (* check token expiry and refresh if needed *)
127127+ and check_and_refresh t client =
128128+ match t.session with
129129+ | None ->
130130+ Lwt.return_unit
131131+ | Some session ->
132132+ if Jwt.is_expired ~buffer_seconds:300 session.access_jwt then
133133+ (* token expired or about to expire, need to refresh *)
134134+ Lwt_mutex.with_lock t.refresh_mutex (fun () ->
135135+ (* check again in case another request already refreshed *)
136136+ match t.session with
137137+ | None ->
138138+ Lwt.return_unit
139139+ | Some current_session ->
140140+ if
141141+ Jwt.is_expired ~buffer_seconds:300
142142+ current_session.access_jwt
143143+ then (
144144+ let* new_session = refresh_session t in
145145+ match new_session with
146146+ | Some s ->
147147+ C.set_session client s ; Lwt.return_unit
148148+ | None ->
149149+ C.clear_session client ;
150150+ Types.raise_xrpc_error_raw ~status:401
151151+ ~error:"SessionExpired"
152152+ ~message:"Failed to refresh session" () )
153153+ else (
154154+ (* another request already refreshed, just update our client *)
155155+ C.set_session client current_session ;
156156+ Lwt.return_unit ) )
157157+ else Lwt.return_unit
158158+159159+ let logout t =
160160+ match t.session with
161161+ | None ->
162162+ Lwt.return_unit
163163+ | Some session ->
164164+ let client = make_raw_client t in
165165+ C.set_session client session ;
166166+ Lwt.catch
167167+ (fun () ->
168168+ let* (_ : Yojson.Safe.t) =
169169+ C.procedure client "com.atproto.server.deleteSession" (`Assoc [])
170170+ None (fun j -> Ok j )
171171+ in
172172+ let* () = clear_session t in
173173+ Lwt.return_unit )
174174+ (fun _ ->
175175+ (* even if server fails, clear local session *)
176176+ let* () = clear_session t in
177177+ Lwt.return_unit )
178178+end
179179+180180+include Make (Client)
···11+type blob = {ref_: Cid.t; mime_type: string; size: int64}
22+33+exception Xrpc_error of {status: int; error: string; message: string option}
44+55+type session =
66+ { access_jwt: string
77+ ; refresh_jwt: string
88+ ; did: string
99+ ; handle: string
1010+ ; pds_uri: string option
1111+ ; email: string option
1212+ ; email_confirmed: bool option
1313+ ; email_auth_factor: bool option
1414+ ; active: bool option
1515+ ; status: string option }
1616+1717+type client
1818+1919+type credential_manager
2020+2121+val make_client : service:string -> unit -> client
2222+2323+val make_credential_manager : service:string -> unit -> credential_manager
2424+2525+val login :
2626+ credential_manager
2727+ -> identifier:string
2828+ -> password:string
2929+ -> ?auth_factor_token:string
3030+ -> unit
3131+ -> client Lwt.t
3232+3333+val resume : credential_manager -> session:session -> unit -> client Lwt.t
3434+3535+val logout : credential_manager -> unit Lwt.t
3636+3737+val get_manager_session : credential_manager -> session option
3838+3939+val on_session_update : credential_manager -> (session -> unit Lwt.t) -> unit
4040+4141+val on_session_expired : credential_manager -> (unit -> unit Lwt.t) -> unit
4242+4343+val get_session : client -> session option
4444+4545+val get_service : client -> Uri.t
4646+4747+val query :
4848+ client
4949+ -> string
5050+ -> Yojson.Safe.t
5151+ -> (Yojson.Safe.t -> ('a, string) result)
5252+ -> 'a Lwt.t
5353+5454+val procedure :
5555+ client
5656+ -> string
5757+ -> Yojson.Safe.t
5858+ -> Yojson.Safe.t option
5959+ -> (Yojson.Safe.t -> ('a, string) result)
6060+ -> 'a Lwt.t
6161+6262+val procedure_blob :
6363+ client
6464+ -> string
6565+ -> Yojson.Safe.t
6666+ -> bytes
6767+ -> content_type:string
6868+ -> (Yojson.Safe.t -> ('a, string) result)
6969+ -> 'a Lwt.t
7070+7171+val query_bytes : client -> string -> Yojson.Safe.t -> (string * string) Lwt.t
7272+7373+val procedure_bytes :
7474+ client
7575+ -> string
7676+ -> Yojson.Safe.t
7777+ -> string option
7878+ -> content_type:string
7979+ -> (string * string) option Lwt.t
8080+8181+val session_to_yojson : session -> Yojson.Safe.t
8282+8383+val session_of_yojson : Yojson.Safe.t -> (session, string) result
8484+8585+val blob_to_yojson : blob -> Yojson.Safe.t
8686+8787+val blob_of_yojson : Yojson.Safe.t -> (blob, string) result
8888+8989+module Jwt : sig
9090+ type payload =
9191+ { exp: int option
9292+ ; iat: int option
9393+ ; sub: string option
9494+ ; aud: string option
9595+ ; iss: string option }
9696+9797+ val decode_payload : string -> (payload, string) result
9898+9999+ val is_expired : ?buffer_seconds:int -> string -> bool
100100+101101+ val get_expiration : string -> int option
102102+end
103103+104104+module Http_backend : sig
105105+ type response = Cohttp.Response.t * Cohttp_lwt.Body.t
106106+107107+ module type S = sig
108108+ val get : headers:Cohttp.Header.t -> Uri.t -> response Lwt.t
109109+110110+ val post :
111111+ headers:Cohttp.Header.t
112112+ -> body:Cohttp_lwt.Body.t
113113+ -> Uri.t
114114+ -> response Lwt.t
115115+ end
116116+117117+ module Default : S
118118+end
119119+120120+module Client : sig
121121+ type t = client
122122+123123+ module type S = sig
124124+ val make : service:string -> unit -> t
125125+126126+ val make_with_interceptor :
127127+ service:string -> on_request:(t -> unit Lwt.t) -> unit -> t
128128+129129+ val set_session : t -> session -> unit
130130+131131+ val clear_session : t -> unit
132132+133133+ val get_session : t -> session option
134134+135135+ val get_service : t -> Uri.t
136136+137137+ val query :
138138+ t
139139+ -> string
140140+ -> Yojson.Safe.t
141141+ -> (Yojson.Safe.t -> ('a, string) result)
142142+ -> 'a Lwt.t
143143+144144+ val procedure :
145145+ t
146146+ -> string
147147+ -> Yojson.Safe.t
148148+ -> Yojson.Safe.t option
149149+ -> (Yojson.Safe.t -> ('a, string) result)
150150+ -> 'a Lwt.t
151151+152152+ val query_bytes : t -> string -> Yojson.Safe.t -> (string * string) Lwt.t
153153+154154+ val procedure_bytes :
155155+ t
156156+ -> string
157157+ -> Yojson.Safe.t
158158+ -> string option
159159+ -> content_type:string
160160+ -> (string * string) option Lwt.t
161161+162162+ val procedure_blob :
163163+ t
164164+ -> string
165165+ -> Yojson.Safe.t
166166+ -> bytes
167167+ -> content_type:string
168168+ -> (Yojson.Safe.t -> ('a, string) result)
169169+ -> 'a Lwt.t
170170+ end
171171+172172+ module Make (_ : Http_backend.S) : S
173173+end
174174+175175+module Credential_manager : sig
176176+ type t = credential_manager
177177+178178+ module type S = sig
179179+ val make : service:string -> unit -> t
180180+181181+ val on_session_update : t -> (session -> unit Lwt.t) -> unit
182182+183183+ val on_session_expired : t -> (unit -> unit Lwt.t) -> unit
184184+185185+ val get_session : t -> session option
186186+187187+ val login :
188188+ t
189189+ -> identifier:string
190190+ -> password:string
191191+ -> ?auth_factor_token:string
192192+ -> unit
193193+ -> Client.t Lwt.t
194194+195195+ val resume : t -> session:session -> unit -> Client.t Lwt.t
196196+197197+ val logout : t -> unit Lwt.t
198198+ end
199199+200200+ module Make (_ : Client.S) : S
201201+end
+17
hermes/lib/http_backend.ml
···11+(* abstract http backend for dependency injection *)
22+33+type response = Cohttp.Response.t * Cohttp_lwt.Body.t
44+55+module type S = sig
66+ val get : headers:Cohttp.Header.t -> Uri.t -> response Lwt.t
77+88+ val post :
99+ headers:Cohttp.Header.t -> body:Cohttp_lwt.Body.t -> Uri.t -> response Lwt.t
1010+end
1111+1212+(* default implementation using cohttp-lwt-unix *)
1313+module Default : S = struct
1414+ let get ~headers uri = Cohttp_lwt_unix.Client.get ~headers uri
1515+1616+ let post ~headers ~body uri = Cohttp_lwt_unix.Client.post ~headers ~body uri
1717+end
+45
hermes/lib/jwt.ml
···11+type payload =
22+ { exp: int option [@default None]
33+ ; iat: int option [@default None]
44+ ; sub: string option [@default None]
55+ ; aud: string option [@default None]
66+ ; iss: string option [@default None] }
77+[@@deriving yojson {strict= false}]
88+99+(* decode jwt payload without signature verification *)
1010+let decode_payload (jwt : string) : (payload, string) result =
1111+ try
1212+ match String.split_on_char '.' jwt with
1313+ | [_header; payload_str; _signature] -> (
1414+ match
1515+ Base64.decode ~pad:false ~alphabet:Base64.uri_safe_alphabet payload_str
1616+ with
1717+ | Ok decoded -> (
1818+ let json = Yojson.Safe.from_string decoded in
1919+ match payload_of_yojson json with Ok p -> Ok p | Error e -> Error e )
2020+ | Error (`Msg e) ->
2121+ Error ("invalid base64 in JWT: " ^ e) )
2222+ | _ ->
2323+ Error "invalid JWT format"
2424+ with
2525+ | Yojson.Json_error e ->
2626+ Error ("invalid JSON in JWT payload: " ^ e)
2727+ | e ->
2828+ Error (Printexc.to_string e)
2929+3030+(* check if jwt is expired with buffer in seconds *)
3131+let is_expired ?(buffer_seconds = 60) (jwt : string) : bool =
3232+ match decode_payload jwt with
3333+ | Ok {exp= Some exp; _} ->
3434+ let now = int_of_float (Unix.time ()) in
3535+ exp - buffer_seconds <= now
3636+ | Ok {exp= None; _} ->
3737+ (* no expiration, assume not expired *)
3838+ false
3939+ | Error _ ->
4040+ (* can't decode, assume expired to be safe *)
4141+ true
4242+4343+(* get expiration time from jwt *)
4444+let get_expiration (jwt : string) : int option =
4545+ match decode_payload jwt with Ok {exp; _} -> exp | Error _ -> None
···11+(** test utilities *)
22+33+open Lwt.Syntax
44+55+(* run a test with a mock HTTP client using queued responses *)
66+let with_mock_responses responses f =
77+ let queue = Mock_http.Queue.create responses in
88+ let handler_ref = ref (Mock_http.Queue.handler queue) in
99+ let module MockHttp = Mock_http.Make (struct
1010+ let handler = handler_ref
1111+ end) in
1212+ let module MockClient = Hermes.Client.Make (MockHttp) in
1313+ let client = MockClient.make ~service:"https://test.example.com" () in
1414+ let* result = f (module MockClient : Hermes.Client.S) client in
1515+ Lwt.return (result, Mock_http.Queue.get_requests queue)
1616+1717+(* run a test with a mock HTTP client using pattern matching *)
1818+let with_mock_patterns ?default rules f =
1919+ let pattern = Mock_http.Pattern.create ?default rules in
2020+ let handler_ref = ref (Mock_http.Pattern.handler pattern) in
2121+ let module MockHttp = Mock_http.Make (struct
2222+ let handler = handler_ref
2323+ end) in
2424+ let module MockClient = Hermes.Client.Make (MockHttp) in
2525+ let client = MockClient.make ~service:"https://test.example.com" () in
2626+ let* result = f (module MockClient : Hermes.Client.S) client in
2727+ Lwt.return (result, Mock_http.Pattern.get_requests pattern)
2828+2929+(* create a valid JWT for testing *)
3030+let make_test_jwt ?(exp_offset = 3600) ?(sub = "did:plc:test") () =
3131+ let now = int_of_float (Unix.time ()) in
3232+ let exp = now + exp_offset in
3333+ let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" in
3434+ let payload_json =
3535+ Printf.sprintf {|{"sub":"%s","iat":%d,"exp":%d}|} sub now exp
3636+ in
3737+ let payload =
3838+ Base64.encode_string ~alphabet:Base64.uri_safe_alphabet ~pad:false
3939+ payload_json
4040+ in
4141+ header ^ "." ^ payload ^ ".fake_signature"
4242+4343+(* create a test session *)
4444+let make_test_session ?(exp_offset = 3600) () : Hermes.session =
4545+ let jwt = make_test_jwt ~exp_offset () in
4646+ { access_jwt= jwt
4747+ ; refresh_jwt= make_test_jwt ~exp_offset:86400 ()
4848+ ; did= "did:plc:testuser123"
4949+ ; handle= "test.bsky.social"
5050+ ; pds_uri= Some "https://pds.example.com"
5151+ ; email= Some "test@example.com"
5252+ ; email_confirmed= Some true
5353+ ; email_auth_factor= Some false
5454+ ; active= Some true
5555+ ; status= None }
5656+5757+(* create a session response JSON *)
5858+let session_response_json ?(exp_offset = 3600) () =
5959+ let session = make_test_session ~exp_offset () in
6060+ `Assoc
6161+ [ ("accessJwt", `String session.access_jwt)
6262+ ; ("refreshJwt", `String session.refresh_jwt)
6363+ ; ("did", `String session.did)
6464+ ; ("handle", `String session.handle) ]
6565+6666+(** assert helpers for requests *)
6767+6868+let assert_request_path expected_path (req : Mock_http.request) =
6969+ let actual = Uri.path req.uri in
7070+ if actual <> expected_path then
7171+ failwith (Printf.sprintf "expected path %s but got %s" expected_path actual)
7272+7373+let assert_request_method expected_meth (req : Mock_http.request) =
7474+ if req.meth <> expected_meth then
7575+ let expected_str =
7676+ match expected_meth with `GET -> "GET" | `POST -> "POST"
7777+ in
7878+ let actual_str = match req.meth with `GET -> "GET" | `POST -> "POST" in
7979+ failwith
8080+ (Printf.sprintf "expected method %s but got %s" expected_str actual_str)
8181+8282+let assert_request_has_header name value (req : Mock_http.request) =
8383+ match Cohttp.Header.get req.headers name with
8484+ | Some v when v = value ->
8585+ ()
8686+ | Some v ->
8787+ failwith (Printf.sprintf "header %s: expected %s but got %s" name value v)
8888+ | None ->
8989+ failwith (Printf.sprintf "header %s not found" name)
9090+9191+let assert_request_has_auth_header (req : Mock_http.request) =
9292+ match Cohttp.Header.get req.headers "authorization" with
9393+ | Some v when String.length v > 7 && String.sub v 0 7 = "Bearer " ->
9494+ ()
9595+ | Some v ->
9696+ failwith (Printf.sprintf "invalid auth header: %s" v)
9797+ | None ->
9898+ failwith "authorization header not found"
9999+100100+let assert_request_query_param name expected_value (req : Mock_http.request) =
101101+ let query = Uri.query req.uri in
102102+ match List.assoc_opt name query with
103103+ | Some [v] when v = expected_value ->
104104+ ()
105105+ | Some [v] ->
106106+ failwith
107107+ (Printf.sprintf "query param %s: expected %s but got %s" name
108108+ expected_value v )
109109+ | Some vs ->
110110+ failwith
111111+ (Printf.sprintf "query param %s has multiple values: %s" name
112112+ (String.concat ", " vs) )
113113+ | None ->
114114+ failwith (Printf.sprintf "query param %s not found" name)
115115+116116+let assert_request_body_contains substring (req : Mock_http.request) =
117117+ match req.body with
118118+ | Some body when String.length body > 0 ->
119119+ if
120120+ not
121121+ ( String.length substring <= String.length body
122122+ &&
123123+ let rec check i =
124124+ if i > String.length body - String.length substring then false
125125+ else if String.sub body i (String.length substring) = substring then
126126+ true
127127+ else check (i + 1)
128128+ in
129129+ check 0 )
130130+ then failwith (Printf.sprintf "body does not contain '%s'" substring)
131131+ | _ ->
132132+ failwith "expected request body but none found"
133133+134134+(* run a test with a mock credential manager *)
135135+let with_mock_credential_manager responses f =
136136+ let queue = Mock_http.Queue.create responses in
137137+ let handler_ref = ref (Mock_http.Queue.handler queue) in
138138+ let module MockHttp = Mock_http.Make (struct
139139+ let handler = handler_ref
140140+ end) in
141141+ let module MockClient = Hermes.Client.Make (MockHttp) in
142142+ let module MockCredManager = Hermes.Credential_manager.Make (MockClient) in
143143+ let manager = MockCredManager.make ~service:"https://test.example.com" () in
144144+ let* result =
145145+ f
146146+ (module MockCredManager : Hermes.Credential_manager.S)
147147+ (module MockClient : Hermes.Client.S)
148148+ manager
149149+ in
150150+ Lwt.return (result, Mock_http.Queue.get_requests queue)