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.

Standard.site client with publications, documents, and blobs

Johanna Larsson (Jun 16, 2026, 10:02 PM +0100) 0b10e4c1 4ecb1e88

+391 -25
+2 -1
README.md
··· 4 4 5 5 ## TODO 6 6 7 - - [ ] Sign up/sign in with Bluesky 7 + - [x] Sign up/sign in with Bluesky 8 + - [ ] Maybe some design? 8 9 - [ ] Add site including verification 9 10 - [ ] RSS poller 10 11 - [ ] standard.site document reader
+16 -13
lib/annot_at/atproto/http.ex
··· 9 9 """ 10 10 11 11 @receive_timeout 10_000 12 + @type body :: nil | {:json, map()} | {:raw, binary(), String.t()} 12 13 13 14 @doc """ 14 15 GETs `url`, required a 2xx response, and decodes the body as a JSON object. ··· 65 66 end 66 67 67 68 @doc """ 68 - Performs an HTTP request with the given method, headers, and optional JSON 69 + Performs an HTTP request with the given method, headers, and optional 69 70 body, returning the raw status, body, and response headers. 70 71 """ 71 - @spec request(String.t(), String.t(), [{String.t(), String.t()}], map() | nil) :: 72 + @spec request(String.t(), String.t(), [{String.t(), String.t()}], body()) :: 72 73 {:ok, 73 74 %{status: pos_integer(), body: binary(), headers: %{optional(binary()) => [binary()]}}} 74 75 | {:error, {:transport, term()}} 75 - def request(method, url, headers, json_body \\ nil) do 76 + def request(method, url, headers, body \\ nil) do 76 77 options = [ 77 78 method: method_atom(method), 78 79 url: url, ··· 81 82 receive_timeout: @receive_timeout 82 83 ] 83 84 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}} 85 + case Req.request(put_body(options, body)) do 86 + {:ok, %Req.Response{status: status, body: resp_body, headers: resp_headers}} -> 87 + {:ok, %{status: status, body: resp_body, headers: resp_headers}} 94 88 95 89 {:error, reason} -> 96 90 {:error, {:transport, reason}} ··· 106 100 {:ok, %Req.Response{status: status}} -> {:error, {:http_status, status}} 107 101 {:error, reason} -> {:error, {:transport, reason}} 108 102 end 103 + end 104 + 105 + defp put_body(options, nil), do: options 106 + defp put_body(options, {:json, json}), do: Keyword.put(options, :json, json) 107 + 108 + defp put_body(options, {:raw, body, content_type}) do 109 + options 110 + |> Keyword.put(:body, body) 111 + |> Keyword.update!(:headers, &[{"content-type", content_type} | &1]) 109 112 end 110 113 end
+26 -9
lib/annot_at/atproto/oauth/client.ex
··· 18 18 19 19 @refresh_buffer_seconds 60 20 20 21 + @type error :: 22 + :no_session 23 + | :refresh_failed 24 + | :missing_dpop_nonce 25 + | :invalid_json 26 + | {:transport, term()} 27 + | {:xrpc_error, pos_integer(), map()} 28 + 21 29 @doc """ 22 30 Performs an authenticated XRPC query for the user, refreshing if needed. 23 31 """ 24 - @spec query(integer(), String.t(), keyword()) :: 25 - {:ok, map()} 26 - | {:error, 27 - :no_session 28 - | :refresh_failed 29 - | :missing_dpop_nonce 30 - | :invalid_json 31 - | {:transport, term()} 32 - | {:xrpc_error, pos_integer(), map()}} 32 + @spec query(integer(), String.t(), keyword()) :: {:ok, map()} | {:error, error()} 33 33 def query(user_id, method, params \\ []) do 34 34 call(user_id, fn session -> XRPC.query(session, method, params) end) 35 + end 36 + 37 + @doc """ 38 + Performs and authenticated XRPC procedure for user, refreshing if needed. 39 + """ 40 + @spec procedure(integer(), String.t(), map()) :: {:ok, map()} | {:error, error()} 41 + def procedure(user_id, method, body) do 42 + call(user_id, fn session -> XRPC.procedure(session, method, body) end) 43 + end 44 + 45 + @doc """ 46 + Uploads a blob for the user, refreshing if needed. Returns the response 47 + containing the blob reference. 48 + """ 49 + @spec upload_blob(integer(), binary(), String.t()) :: {:ok, map()} | {:error, error()} 50 + def upload_blob(user_id, bytes, content_type) do 51 + call(user_id, fn session -> XRPC.upload_blob(session, bytes, content_type) end) 35 52 end 36 53 37 54 defp call(user_id, fun) do
+89
lib/annot_at/atproto/standard_site.ex
··· 1 + defmodule AnnotAt.Atproto.StandardSite do 2 + @moduledoc """ 3 + Writes standard.site records (publication, documents) to a user's repo 4 + via the authenticated `Client`, uploading any images as blobs first. 5 + """ 6 + 7 + alias AnnotAt.Accounts 8 + alias AnnotAt.Atproto.OAuth.Client 9 + alias AnnotAt.Atproto.StandardSite.Document 10 + alias AnnotAt.Atproto.StandardSite.Publication 11 + 12 + @publication "site.standard.publication" 13 + @document "site.standard.document" 14 + 15 + @doc """ 16 + Creates or updates the user's publication record (one per repo, rkey `self`). 17 + """ 18 + @spec put_publication(integer(), Publication.t()) :: {:ok, map()} | {:error, term()} 19 + def put_publication(user_id, %Publication{} = publication) do 20 + with {:ok, user} <- fetch_user(user_id), 21 + {:ok, icon} <- upload_image(user_id, publication.icon) do 22 + put_record(user, @publication, "self", publication_record(publication, icon)) 23 + end 24 + end 25 + 26 + @doc """ 27 + Creates or updates a document record. 28 + """ 29 + @spec put_document(integer(), Document.t()) :: {:ok, map()} | {:error, term()} 30 + def put_document(user_id, %Document{} = document) do 31 + with {:ok, user} <- fetch_user(user_id), 32 + {:ok, cover} <- upload_image(user_id, document.cover_image) do 33 + put_record(user, @document, document.rkey, document_record(document, cover)) 34 + end 35 + end 36 + 37 + @doc """ 38 + The AT-URI of the user's publication record, used as a document's `site`. 39 + """ 40 + @spec publication_uri(String.t()) :: String.t() 41 + def publication_uri(did), do: "at://#{did}/#{@publication}/self" 42 + 43 + defp fetch_user(user_id) do 44 + case Accounts.get_user(user_id) do 45 + nil -> {:error, :no_session} 46 + user -> {:ok, user} 47 + end 48 + end 49 + 50 + defp upload_image(_user_id, nil), do: {:ok, nil} 51 + 52 + defp upload_image(user_id, {bytes, content_type}) do 53 + with {:ok, %{"blob" => blob}} <- Client.upload_blob(user_id, bytes, content_type) do 54 + {:ok, blob} 55 + end 56 + end 57 + 58 + defp put_record(user, collection, rkey, record) do 59 + body = %{repo: user.did, collection: collection, rkey: rkey, record: record} 60 + Client.procedure(user.id, "com.atproto.repo.putRecord", body) 61 + end 62 + 63 + defp publication_record(p, icon) do 64 + %{"$type" => @publication, "name" => p.name, "url" => p.url} 65 + |> put_optional("description", p.description) 66 + |> put_optional("icon", icon) 67 + end 68 + 69 + defp document_record(d, cover_image) do 70 + %{ 71 + "$type" => @document, 72 + "site" => d.site, 73 + "title" => d.title, 74 + "publishedAt" => DateTime.to_iso8601(d.published_at) 75 + } 76 + |> put_optional("path", d.path) 77 + |> put_optional("updatedAt", iso8601(d.updated_at)) 78 + |> put_optional("description", d.description) 79 + |> put_optional("textContent", d.text_content) 80 + |> put_optional("tags", d.tags) 81 + |> put_optional("coverImage", cover_image) 82 + end 83 + 84 + defp iso8601(nil), do: nil 85 + defp iso8601(%DateTime{} = dt), do: DateTime.to_iso8601(dt) 86 + 87 + defp put_optional(map, _key, nil), do: map 88 + defp put_optional(map, key, value), do: Map.put(map, key, value) 89 + end
+30
lib/annot_at/atproto/standard_site/document.ex
··· 1 + defmodule AnnotAt.Atproto.StandardSite.Document do 2 + @moduledoc "A `site.standard.document` record (a published post)." 3 + 4 + @enforce_keys [:rkey, :site, :title, :published_at] 5 + defstruct [ 6 + :rkey, 7 + :site, 8 + :title, 9 + :path, 10 + :published_at, 11 + :updated_at, 12 + :description, 13 + :text_content, 14 + :tags, 15 + :cover_image 16 + ] 17 + 18 + @type t :: %__MODULE__{ 19 + rkey: String.t(), 20 + site: String.t(), 21 + title: String.t(), 22 + path: String.t() | nil, 23 + published_at: DateTime.t(), 24 + updated_at: DateTime.t() | nil, 25 + description: String.t() | nil, 26 + text_content: String.t() | nil, 27 + tags: [String.t()] | nil, 28 + cover_image: {binary(), String.t()} | nil 29 + } 30 + end
+13
lib/annot_at/atproto/standard_site/publication.ex
··· 1 + defmodule AnnotAt.Atproto.StandardSite.Publication do 2 + @moduledoc "A `site.standard.publication` record (the site itself)." 3 + 4 + @enforce_keys [:name, :url] 5 + defstruct [:name, :url, :description, :icon] 6 + 7 + @type t :: %__MODULE__{ 8 + name: String.t(), 9 + url: String.t(), 10 + description: String.t() | nil, 11 + icon: {binary(), String.t()} | nil 12 + } 13 + end
+22
lib/annot_at/atproto/xrpc.ex
··· 31 31 ) 32 32 end 33 33 34 + @doc """ 35 + Performs and authenticated XRPC procedure against the session's PDS. 36 + """ 37 + @spec procedure(Session.t(), String.t(), map()) :: {:ok, map()} | {:error, error()} 38 + def procedure(%Session{} = session, method, body) do 39 + request(session, "POST", session.pds_endpoint <> "/xrpc/" <> method, {:json, body}) 40 + end 41 + 42 + @doc """ 43 + Uploads raw bytes of content_type as a blob, returning the response with 44 + the blog reference. 45 + """ 46 + @spec upload_blob(Session.t(), binary(), String.t()) :: {:ok, map()} | {:error, error()} 47 + def upload_blob(%Session{} = session, bytes, content_type) do 48 + request( 49 + session, 50 + "POST", 51 + session.pds_endpoint <> "/xrpc/com.atproto.repo.uploadBlob", 52 + {:raw, bytes, content_type} 53 + ) 54 + end 55 + 34 56 defp request(%Session{} = session, http_method, url, body, nonce \\ nil) do 35 57 proof = 36 58 DPoP.proof(session.dpop_key, http_method, url,
+14
test/annot_at/atproto/oauth/client_test.exs
··· 75 75 assert {:error, :no_session} = Client.query(user.id, "app.bsky.actor.getProfile") 76 76 end 77 77 78 + test "procedure/3 calls through with a fresh session" do 79 + user = create_user(future()) 80 + body = %{"text" => "hi"} 81 + 82 + expect(XRPC, :procedure, fn %Session{access_token: "access-old"}, 83 + "com.atproto.repo.createRecord", 84 + ^body -> 85 + {:ok, %{"uri" => "at://x"}} 86 + end) 87 + 88 + assert {:ok, %{"uri" => "at://x"}} = 89 + Client.procedure(user.id, "com.atproto.repo.createRecord", body) 90 + end 91 + 78 92 defp create_user(expires_at) do 79 93 {:ok, user} = 80 94 Accounts.upsert_login(
+1 -1
test/annot_at/atproto/oauth/config_test.exs
··· 9 9 end 10 10 11 11 test "scope/0 returns the configured scope" do 12 - assert "atproto" == Config.scope() 12 + assert Config.scope() =~ "atproto" 13 13 end 14 14 15 15 test "signing_key/0 parses the configured JWK" do
+135
test/annot_at/atproto/standard_site_test.exs
··· 1 + defmodule AnnotAt.Atproto.StandardSiteTest do 2 + use AnnotAt.DataCase, async: true 3 + use Mimic 4 + 5 + alias AnnotAt.Accounts 6 + alias AnnotAt.Atproto.OAuth.Client 7 + alias AnnotAt.Atproto.StandardSite 8 + alias AnnotAt.Atproto.StandardSite.Document 9 + alias AnnotAt.Atproto.StandardSite.Publication 10 + 11 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 12 + 13 + defp create_user do 14 + {:ok, user} = 15 + Accounts.upsert_user(%{did: @did, handle: "jola.dev", pds_host: "https://pds.example.com"}) 16 + 17 + user 18 + end 19 + 20 + test "put_publication/2 writes a publication record at rkey self" do 21 + user = create_user() 22 + 23 + expect(Client, :procedure, fn user_id, "com.atproto.repo.putRecord", body -> 24 + assert user.id == user_id 25 + assert @did == body.repo 26 + assert "site.standard.publication" == body.collection 27 + assert "self" == body.rkey 28 + assert "site.standard.publication" == body.record["$type"] 29 + assert "jola.dev" == body.record["name"] 30 + {:ok, %{"uri" => "at://x"}} 31 + end) 32 + 33 + pub = %Publication{name: "jola.dev", url: "https://jola.dev", description: "blog"} 34 + assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) 35 + end 36 + 37 + test "put_document/2 writes a document record with an rkey and rfc3339 timestamps" do 38 + user = create_user() 39 + 40 + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 41 + assert "site.standard.document" == body.collection 42 + assert "post-1" == body.rkey 43 + assert "Hello" == body.record["title"] 44 + assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] 45 + assert ["a", "b"] == body.record["tags"] 46 + {:ok, %{"uri" => "at://y"}} 47 + end) 48 + 49 + doc = %Document{ 50 + rkey: "post-1", 51 + site: StandardSite.publication_uri(@did), 52 + title: "Hello", 53 + path: "/posts/1", 54 + published_at: ~U[2026-01-01 00:00:00Z], 55 + updated_at: ~U[2026-01-01 00:00:00Z], 56 + description: "desc", 57 + text_content: "body", 58 + tags: ["a", "b"] 59 + } 60 + 61 + assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) 62 + end 63 + 64 + test "returns :no_session when the user does not exist" do 65 + reject(&Client.procedure/3) 66 + 67 + assert {:error, :no_session} = 68 + StandardSite.put_publication(-1, %Publication{name: "x", url: "y"}) 69 + end 70 + 71 + test "put_publication/2 uploads the icon and embeds the returned blob" do 72 + user = create_user() 73 + 74 + blob = %{ 75 + "$type" => "blob", 76 + "ref" => %{"$link" => "bafyicon"}, 77 + "mimeType" => "image/png", 78 + "size" => 3 79 + } 80 + 81 + expect(Client, :upload_blob, fn user_id, <<1, 2, 3>>, "image/png" -> 82 + assert user.id == user_id 83 + {:ok, %{"blob" => blob}} 84 + end) 85 + 86 + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 87 + assert blob == body.record["icon"] 88 + {:ok, %{"uri" => "at://x"}} 89 + end) 90 + 91 + pub = %Publication{ 92 + name: "jola.dev", 93 + url: "https://jola.dev", 94 + icon: {<<1, 2, 3>>, "image/png"} 95 + } 96 + 97 + assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub) 98 + end 99 + 100 + test "put_publication/2 omits optional fields that are nil" do 101 + user = create_user() 102 + 103 + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 104 + refute Map.has_key?(body.record, "description") 105 + refute Map.has_key?(body.record, "icon") 106 + {:ok, %{}} 107 + end) 108 + 109 + assert {:ok, %{}} = 110 + StandardSite.put_publication(user.id, %Publication{ 111 + name: "n", 112 + url: "https://n.example" 113 + }) 114 + end 115 + 116 + test "put_document/2 omits updatedAt and path when not set" do 117 + user = create_user() 118 + 119 + expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body -> 120 + refute Map.has_key?(body.record, "updatedAt") 121 + refute Map.has_key?(body.record, "path") 122 + assert "2026-01-01T00:00:00Z" == body.record["publishedAt"] 123 + {:ok, %{"uri" => "at://y"}} 124 + end) 125 + 126 + doc = %Document{ 127 + rkey: "post-2", 128 + site: StandardSite.publication_uri(@did), 129 + title: "Hello", 130 + published_at: ~U[2026-01-01 00:00:00Z] 131 + } 132 + 133 + assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc) 134 + end 135 + end
+41
test/annot_at/atproto/xrpc_test.exs
··· 6 6 alias AnnotAt.Atproto.OAuth.Session 7 7 alias AnnotAt.Atproto.XRPC 8 8 9 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 9 10 @pds "https://shaggymane.us-west.host.bsky.network" 10 11 11 12 setup do ··· 90 91 end) 91 92 92 93 assert {:error, :missing_dpop_nonce} = XRPC.query(session, "app.bsky.actor.getProfile") 94 + end 95 + 96 + test "procedure/3 sends an authenticated POST with a JSON body", %{session: session} do 97 + body = %{"repo" => @did, "collection" => "app.bsky.feed.post", "record" => %{"text" => "hi"}} 98 + 99 + expect(HTTP, :request, fn "POST", url, headers, {:json, ^body} -> 100 + assert "#{@pds}/xrpc/com.atproto.repo.createRecord" == url 101 + assert {"authorization", "DPoP access-1"} in headers 102 + 103 + {"dpop", proof} = List.keyfind(headers, "dpop", 0) 104 + %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(proof) 105 + assert "POST" == claims["htm"] 106 + assert claims["ath"] 107 + 108 + {:ok, %{status: 200, body: ~s({"uri":"at://x"}), headers: %{}}} 109 + end) 110 + 111 + assert {:ok, %{"uri" => "at://x"}} = 112 + XRPC.procedure(session, "com.atproto.repo.createRecord", body) 113 + end 114 + 115 + test "upload_blob/3 POSTs raw bytes with a content type and returns the blob", %{ 116 + session: session 117 + } do 118 + bytes = <<137, 80, 78, 71>> 119 + 120 + expect(HTTP, :request, fn "POST", url, headers, {:raw, ^bytes, "image/png"} -> 121 + assert "#{@pds}/xrpc/com.atproto.repo.uploadBlob" == url 122 + assert {"authorization", "DPoP access-1"} in headers 123 + 124 + {"dpop", proof} = List.keyfind(headers, "dpop", 0) 125 + %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(proof) 126 + assert "POST" == claims["htm"] 127 + assert claims["ath"] 128 + 129 + {:ok, %{status: 200, body: ~s({"blob":{"$type":"blob"}}), headers: %{}}} 130 + end) 131 + 132 + assert {:ok, %{"blob" => %{"$type" => "blob"}}} = 133 + XRPC.upload_blob(session, bytes, "image/png") 93 134 end 94 135 end
+1 -1
test/annot_at_web/controllers/auth_controller_test.exs
··· 30 30 31 31 assert "http://localhost:4002/oauth-client-metadata.json" == metadata["client_id"] 32 32 assert ["http://localhost:4002/auth/callback"] == metadata["redirect_uris"] 33 - assert "atproto" == metadata["scope"] 33 + assert metadata["scope"] =~ "atproto" 34 34 assert true == metadata["dpop_bound_access_tokens"] 35 35 assert "private_key_jwt" == metadata["token_endpoint_auth_method"] 36 36
+1
test/test_helper.exs
··· 3 3 Mimic.copy(AnnotAt.Atproto.Identity) 4 4 Mimic.copy(AnnotAt.Atproto.Profile) 5 5 Mimic.copy(AnnotAt.Atproto.XRPC) 6 + Mimic.copy(AnnotAt.Atproto.OAuth.Client) 6 7 Mimic.copy(AnnotAt.Atproto.OAuth.Discovery) 7 8 Mimic.copy(AnnotAt.Atproto.OAuth.Flow) 8 9 Mimic.copy(AnnotAt.Atproto.OAuth.Login)