annot.at is a service for syncing static websites, such as blogs, to atproto and standard.site annot.at
elixir atproto standardsite
3

Configure Feed

Select the types of activity you want to include in your feed.

Fetch profile info too

Also started sketching out the XRPC client

Johanna Larsson (Jun 16, 2026, 6:41 AM +0100) 59e616a6 ec889b62

+302 -3
+36
lib/annot_at/atproto/http.ex
··· 64 64 end 65 65 end 66 66 67 + @doc """ 68 + Performs an HTTP request with the given method, headers, and optional JSON 69 + body, returning the raw status, body, and response headers. 70 + """ 71 + @spec request(String.t(), String.t(), [{String.t(), String.t()}], map() | nil) :: 72 + {:ok, 73 + %{status: pos_integer(), body: binary(), headers: %{optional(binary()) => [binary()]}}} 74 + | {:error, {:transport, term()}} 75 + def request(method, url, headers, json_body \\ nil) do 76 + options = [ 77 + method: method_atom(method), 78 + url: url, 79 + headers: headers, 80 + decode_body: false, 81 + receive_timeout: @receive_timeout 82 + ] 83 + 84 + options = 85 + if json_body do 86 + Keyword.put(options, :json, json_body) 87 + else 88 + options 89 + end 90 + 91 + case Req.request(options) do 92 + {:ok, %Req.Response{status: status, body: body, headers: resp_headers}} -> 93 + {:ok, %{status: status, body: body, headers: resp_headers}} 94 + 95 + {:error, reason} -> 96 + {:error, {:transport, reason}} 97 + end 98 + end 99 + 100 + defp method_atom("GET"), do: :get 101 + defp method_atom("POST"), do: :post 102 + 67 103 defp get_body(url) when is_binary(url) do 68 104 case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do 69 105 {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body}
+17 -1
lib/annot_at/atproto/oauth/login.ex
··· 16 16 alias AnnotAt.Atproto.OAuth.DPoP 17 17 alias AnnotAt.Atproto.OAuth.Flow 18 18 alias AnnotAt.Atproto.OAuth.PKCE 19 + alias AnnotAt.Atproto.Profile 19 20 20 21 require Logger 21 22 ··· 151 152 end 152 153 153 154 defp persist(request, session) do 155 + profile = fetch_profile(request.handle) 156 + 154 157 Accounts.upsert_login( 155 158 %{ 156 159 did: session.did, 157 160 handle: request.handle, 158 161 pds_host: session.pds_endpoint, 159 - handle_verified_at: DateTime.utc_now(:second) 162 + handle_verified_at: DateTime.utc_now(:second), 163 + display_name: profile.display_name, 164 + avatar_url: profile.avatar_url 160 165 }, 161 166 %{ 162 167 auth_server_issuer: session.issuer, ··· 191 196 192 197 defp callback_error(:invalid_state), do: :invalid_state 193 198 defp callback_error(_reason), do: :login_failed 199 + 200 + defp fetch_profile(handle) do 201 + case Profile.fetch(handle) do 202 + {:ok, profile} -> 203 + profile 204 + 205 + {:error, reason} -> 206 + Logger.warning("failed to fetch profile for #{handle}: #{inspect(reason)}") 207 + %{display_name: nil, avatar: nil} 208 + end 209 + end 194 210 end
+25
lib/annot_at/atproto/profile.ex
··· 1 + defmodule AnnotAt.Atproto.Profile do 2 + @moduledoc """ 3 + Fetches public Bluesky profile data (display name, avatar) from the AppView. 4 + """ 5 + 6 + alias AnnotAt.Atproto.HTTP 7 + 8 + @appview "https://public.api.bsky.app" 9 + 10 + @doc """ 11 + Fetches a public profile by DID or handle. 12 + """ 13 + @spec fetch(String.t()) :: 14 + {:ok, %{display_name: String.t() | nil, avatar: String.t() | nil}} 15 + | {:error, {:http_status, pos_integer()} | {:transport, term()} | :invalid_json} 16 + 17 + def fetch(actor) do 18 + url = "#{@appview}/xrpc/app.bsky.actor.getProfile?#{URI.encode_query(actor: actor)}" 19 + 20 + with {:ok, profile} <- HTTP.get_json(url) do 21 + {:ok, 22 + %{display_name: Map.get(profile, "displayName"), avatar_url: Map.get(profile, "avatar")}} 23 + end 24 + end 25 + end
+90
lib/annot_at/atproto/xrpc.ex
··· 1 + defmodule AnnotAt.Atproto.XRPC do 2 + @moduledoc """ 3 + DPoP-authenticated XRPC calls to a session's PDS. 4 + 5 + Each request carriers `Authorization: DPoP <token` and a DPoP proof bound to 6 + the acess token `ath`. The PDS issues its own DPoP nonces, so a request is 7 + attempted once and retried with the nonce the PDS returns. Token refresh and 8 + persistence are the caller's concerns, not this module's. 9 + """ 10 + 11 + alias AnnotAt.Atproto.HTTP 12 + alias AnnotAt.Atproto.OAuth.DPoP 13 + alias AnnotAt.Atproto.OAuth.Session 14 + 15 + @type error :: 16 + {:transport, term()} 17 + | :invalid_json 18 + | :missing_dpop_nonce 19 + | {:xrpc_error, pos_integer(), map()} 20 + 21 + @doc """ 22 + Performs and authenticated XRPC query against the session's PDS. 23 + """ 24 + @spec query(Session.t(), String.t(), keyword()) :: {:ok, map()} | {:error, error()} 25 + def query(%Session{} = session, method, params \\ []) do 26 + request( 27 + session, 28 + "GET", 29 + session.pds_endpoint <> "/xrpc/" <> method <> query_string(params), 30 + nil 31 + ) 32 + end 33 + 34 + defp request(%Session{} = session, http_method, url, body, nonce \\ nil) do 35 + proof = 36 + DPoP.proof(session.dpop_key, http_method, url, 37 + nonce: nonce, 38 + access_token: session.access_token 39 + ) 40 + 41 + headers = [{"authorization", "DPoP #{session.access_token}"}, {"dpop", proof}] 42 + 43 + with {:ok, %{status: status, body: raw, headers: resp_headers}} <- 44 + HTTP.request(http_method, url, headers, body), 45 + {:ok, decoded} <- decode_json(raw) do 46 + cond do 47 + status in 200..299 -> 48 + {:ok, decoded} 49 + 50 + needs_nonce?(status, resp_headers) -> 51 + retry_with_nonce(session, http_method, url, body, resp_headers) 52 + 53 + true -> 54 + {:error, {:xrpc_error, status, decoded}} 55 + end 56 + end 57 + end 58 + 59 + defp retry_with_nonce(session, http_method, url, body, headers) do 60 + case nonce_header(headers) do 61 + nil -> {:error, :missing_dpop_nonce} 62 + nonce -> request(session, http_method, url, body, nonce) 63 + end 64 + end 65 + 66 + defp needs_nonce?(401, headers) do 67 + headers 68 + |> Map.get("www-authenticate") 69 + |> Enum.any?(&String.contains?(&1, "use_dpop_nonce")) 70 + end 71 + 72 + defp needs_nonce?(_status, _headers), do: false 73 + 74 + defp nonce_header(headers) do 75 + case Map.get(headers, "dpop-nonce") do 76 + [nonce | _] -> nonce 77 + _ -> nil 78 + end 79 + end 80 + 81 + defp decode_json(raw) do 82 + case Jason.decode(raw) do 83 + {:ok, json} -> {:ok, json} 84 + _ -> {:error, :invalid_json} 85 + end 86 + end 87 + 88 + defp query_string([]), do: "" 89 + defp query_string(params), do: "?" <> URI.encode_query(params) 90 + end
+8 -2
lib/annot_at_web/controllers/page_html/home.html.heex
··· 3 3 <h1 class="text-3xl font-bold">annot.at</h1> 4 4 5 5 <%= if @current_scope do %> 6 - <p>Signed in as <span class="font-semibold">{@current_scope.user.handle}</span></p> 7 - <p class="text-sm opacity-70">{@current_scope.user.did}</p> 6 + <%= if @current_scope.user.avatar_url do %> 7 + <img src={@current_scope.user.avatar_url} alt="" class="mx-auto h-20 w-20 rounded-full" /> 8 + <% end %> 9 + <p class="text-lg font-semibold"> 10 + {@current_scope.user.display_name || @current_scope.user.handle} 11 + </p> 12 + <p class="opacity-80">{@current_scope.user.handle}</p> 13 + <p class="text-sm opacity-60">{@current_scope.user.did}</p> 8 14 <.link href={~p"/logout"} method="delete" class="underline">Log out</.link> 9 15 <% else %> 10 16 <.link navigate={~p"/login"} class="underline">Sign in with Bluesky</.link>
+7
test/annot_at/atproto/oauth/login_test.exs
··· 10 10 alias AnnotAt.Atproto.OAuth.Login 11 11 alias AnnotAt.Atproto.OAuth.ServerMetadata 12 12 alias AnnotAt.Atproto.OAuth.Session 13 + alias AnnotAt.Atproto.Profile 13 14 14 15 @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 15 16 @pds "https://enoki.us-east.host.bsky.network" ··· 82 83 expect(Discovery, :discover, fn @pds -> {:ok, server} end) 83 84 expect(Flow, :exchange_code, fn ^server, _opts -> {:ok, session} end) 84 85 86 + expect(Profile, :fetch, fn "jola.dev" -> 87 + {:ok, %{display_name: "Johanna", avatar_url: "https://cdn/av.jpg"}} 88 + end) 89 + 85 90 params = %{"code" => "code-1", "state" => "state-1", "iss" => @issuer} 86 91 assert {:ok, user} = Login.complete_login(params) 87 92 88 93 assert @did == user.did 94 + assert "Johanna" == user.display_name 95 + assert "https://cdn/av.jpg" == user.avatar_url 89 96 assert "access-1" == user.atproto_session.access_token 90 97 refute Accounts.take_login_request("state-1") 91 98 end
+24
test/annot_at/atproto/profile_test.exs
··· 1 + defmodule AnnotAt.Atproto.ProfileTest do 2 + use ExUnit.Case, async: true 3 + use Mimic 4 + 5 + alias AnnotAt.Atproto.HTTP 6 + alias AnnotAt.Atproto.Profile 7 + 8 + test "fetch/1 returns the display name and avatar from the AppView" do 9 + expect(HTTP, :get_json, fn url -> 10 + assert "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=did%3Aplc%3Aabc" == 11 + url 12 + 13 + {:ok, %{"did" => "did:plc:abc", "displayName" => "Jola", "avatar" => "https://cdn/av.jpg"}} 14 + end) 15 + 16 + assert {:ok, %{display_name: "Jola", avatar_url: "https://cdn/av.jpg"}} = 17 + Profile.fetch("did:plc:abc") 18 + end 19 + 20 + test "fetch/1 propagates an HTTP error" do 21 + expect(HTTP, :get_json, fn _ -> {:error, {:http_status, 400}} end) 22 + assert {:error, {:http_status, 400}} = Profile.fetch("did:plc:abc") 23 + end 24 + end
+94
test/annot_at/atproto/xrpc_test.exs
··· 1 + defmodule AnnotAt.Atproto.XRPCTest do 2 + use ExUnit.Case, async: true 3 + use Mimic 4 + 5 + alias AnnotAt.Atproto.HTTP 6 + alias AnnotAt.Atproto.OAuth.Session 7 + alias AnnotAt.Atproto.XRPC 8 + 9 + @pds "https://shaggymane.us-west.host.bsky.network" 10 + 11 + setup do 12 + jwk = 13 + "../../support/fixtures/atproto/es256_jwk.json" 14 + |> Path.expand(__DIR__) 15 + |> File.read!() 16 + |> Jason.decode!() 17 + |> JOSE.JWK.from() 18 + 19 + session = %Session{ 20 + did: "did:plc:abc", 21 + access_token: "access-1", 22 + refresh_token: "refresh-1", 23 + dpop_key: jwk, 24 + scope: "atproto", 25 + issuer: "https://bsky.social", 26 + pds_endpoint: @pds, 27 + expires_at: ~U[2026-01-01 00:00:00Z] 28 + } 29 + 30 + %{session: session} 31 + end 32 + 33 + test "query/3 sends a DPoP-authenticated GET with ath and returns the body", %{session: session} do 34 + expect(HTTP, :request, fn "GET", url, headers, nil -> 35 + assert "#{@pds}/xrpc/app.bsky.actor.getProfile?actor=did%3Aplc%3Aabc" == url 36 + assert {"authorization", "DPoP access-1"} in headers 37 + 38 + {"dpop", proof} = List.keyfind(headers, "dpop", 0) 39 + %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(proof) 40 + assert claims["ath"] 41 + 42 + {:ok, %{status: 200, body: ~s({"displayName":"Jola"}), headers: %{}}} 43 + end) 44 + 45 + assert {:ok, %{"displayName" => "Jola"}} = 46 + XRPC.query(session, "app.bsky.actor.getProfile", actor: "did:plc:abc") 47 + end 48 + 49 + test "query/3 retries with the PDS nonce", %{session: session} do 50 + expect(HTTP, :request, fn "GET", _url, _headers, nil -> 51 + {:ok, 52 + %{ 53 + status: 401, 54 + body: ~s({}), 55 + headers: %{ 56 + "www-authenticate" => ["DPoP error=\"use_dpop_nonce\""], 57 + "dpop-nonce" => ["nonce-1"] 58 + } 59 + }} 60 + end) 61 + 62 + expect(HTTP, :request, fn "GET", _url, headers, nil -> 63 + {"dpop", proof} = List.keyfind(headers, "dpop", 0) 64 + %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(proof) 65 + assert "nonce-1" == claims["nonce"] 66 + 67 + {:ok, %{status: 200, body: ~s({"ok":true}), headers: %{}}} 68 + end) 69 + 70 + assert {:ok, %{"ok" => true}} = XRPC.query(session, "app.bsky.actor.getProfile") 71 + end 72 + 73 + test "query/3 surfaces an XRPC error", %{session: session} do 74 + expect(HTTP, :request, fn "GET", _url, _headers, nil -> 75 + {:ok, %{status: 400, body: ~s({"error":"InvalidRequest"}), headers: %{}}} 76 + end) 77 + 78 + assert {:error, {:xrpc_error, 400, %{"error" => "InvalidRequest"}}} = 79 + XRPC.query(session, "app.bsky.actor.getProfile") 80 + end 81 + 82 + test "query/3 returns :missing_dpop_nonce when the 401 has no nonce header", %{session: session} do 83 + expect(HTTP, :request, fn "GET", _url, _headers, nil -> 84 + {:ok, 85 + %{ 86 + status: 401, 87 + body: ~s({}), 88 + headers: %{"www-authenticate" => ["DPoP error=\"use_dpop_nonce\""]} 89 + }} 90 + end) 91 + 92 + assert {:error, :missing_dpop_nonce} = XRPC.query(session, "app.bsky.actor.getProfile") 93 + end 94 + end
+1
test/test_helper.exs
··· 1 1 Mimic.copy(AnnotAt.Atproto.DNS) 2 2 Mimic.copy(AnnotAt.Atproto.HTTP) 3 3 Mimic.copy(AnnotAt.Atproto.Identity) 4 + Mimic.copy(AnnotAt.Atproto.Profile) 4 5 Mimic.copy(AnnotAt.Atproto.OAuth.Discovery) 5 6 Mimic.copy(AnnotAt.Atproto.OAuth.Flow) 6 7 Mimic.copy(AnnotAt.Atproto.OAuth.Login)