···11+defmodule AnnotAt.Atproto.StandardSite.Publication do
22+ @moduledoc "A `site.standard.publication` record (the site itself)."
33+44+ @enforce_keys [:name, :url]
55+ defstruct [:name, :url, :description, :icon]
66+77+ @type t :: %__MODULE__{
88+ name: String.t(),
99+ url: String.t(),
1010+ description: String.t() | nil,
1111+ icon: {binary(), String.t()} | nil
1212+ }
1313+end
+22
lib/annot_at/atproto/xrpc.ex
···3131 )
3232 end
33333434+ @doc """
3535+ Performs and authenticated XRPC procedure against the session's PDS.
3636+ """
3737+ @spec procedure(Session.t(), String.t(), map()) :: {:ok, map()} | {:error, error()}
3838+ def procedure(%Session{} = session, method, body) do
3939+ request(session, "POST", session.pds_endpoint <> "/xrpc/" <> method, {:json, body})
4040+ end
4141+4242+ @doc """
4343+ Uploads raw bytes of content_type as a blob, returning the response with
4444+ the blog reference.
4545+ """
4646+ @spec upload_blob(Session.t(), binary(), String.t()) :: {:ok, map()} | {:error, error()}
4747+ def upload_blob(%Session{} = session, bytes, content_type) do
4848+ request(
4949+ session,
5050+ "POST",
5151+ session.pds_endpoint <> "/xrpc/com.atproto.repo.uploadBlob",
5252+ {:raw, bytes, content_type}
5353+ )
5454+ end
5555+3456 defp request(%Session{} = session, http_method, url, body, nonce \\ nil) do
3557 proof =
3658 DPoP.proof(session.dpop_key, http_method, url,
+14
test/annot_at/atproto/oauth/client_test.exs
···7575 assert {:error, :no_session} = Client.query(user.id, "app.bsky.actor.getProfile")
7676 end
77777878+ test "procedure/3 calls through with a fresh session" do
7979+ user = create_user(future())
8080+ body = %{"text" => "hi"}
8181+8282+ expect(XRPC, :procedure, fn %Session{access_token: "access-old"},
8383+ "com.atproto.repo.createRecord",
8484+ ^body ->
8585+ {:ok, %{"uri" => "at://x"}}
8686+ end)
8787+8888+ assert {:ok, %{"uri" => "at://x"}} =
8989+ Client.procedure(user.id, "com.atproto.repo.createRecord", body)
9090+ end
9191+7892 defp create_user(expires_at) do
7993 {:ok, user} =
8094 Accounts.upsert_login(
+1-1
test/annot_at/atproto/oauth/config_test.exs
···99 end
10101111 test "scope/0 returns the configured scope" do
1212- assert "atproto" == Config.scope()
1212+ assert Config.scope() =~ "atproto"
1313 end
14141515 test "signing_key/0 parses the configured JWK" do
+135
test/annot_at/atproto/standard_site_test.exs
···11+defmodule AnnotAt.Atproto.StandardSiteTest do
22+ use AnnotAt.DataCase, async: true
33+ use Mimic
44+55+ alias AnnotAt.Accounts
66+ alias AnnotAt.Atproto.OAuth.Client
77+ alias AnnotAt.Atproto.StandardSite
88+ alias AnnotAt.Atproto.StandardSite.Document
99+ alias AnnotAt.Atproto.StandardSite.Publication
1010+1111+ @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
1212+1313+ defp create_user do
1414+ {:ok, user} =
1515+ Accounts.upsert_user(%{did: @did, handle: "jola.dev", pds_host: "https://pds.example.com"})
1616+1717+ user
1818+ end
1919+2020+ test "put_publication/2 writes a publication record at rkey self" do
2121+ user = create_user()
2222+2323+ expect(Client, :procedure, fn user_id, "com.atproto.repo.putRecord", body ->
2424+ assert user.id == user_id
2525+ assert @did == body.repo
2626+ assert "site.standard.publication" == body.collection
2727+ assert "self" == body.rkey
2828+ assert "site.standard.publication" == body.record["$type"]
2929+ assert "jola.dev" == body.record["name"]
3030+ {:ok, %{"uri" => "at://x"}}
3131+ end)
3232+3333+ pub = %Publication{name: "jola.dev", url: "https://jola.dev", description: "blog"}
3434+ assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub)
3535+ end
3636+3737+ test "put_document/2 writes a document record with an rkey and rfc3339 timestamps" do
3838+ user = create_user()
3939+4040+ expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body ->
4141+ assert "site.standard.document" == body.collection
4242+ assert "post-1" == body.rkey
4343+ assert "Hello" == body.record["title"]
4444+ assert "2026-01-01T00:00:00Z" == body.record["publishedAt"]
4545+ assert ["a", "b"] == body.record["tags"]
4646+ {:ok, %{"uri" => "at://y"}}
4747+ end)
4848+4949+ doc = %Document{
5050+ rkey: "post-1",
5151+ site: StandardSite.publication_uri(@did),
5252+ title: "Hello",
5353+ path: "/posts/1",
5454+ published_at: ~U[2026-01-01 00:00:00Z],
5555+ updated_at: ~U[2026-01-01 00:00:00Z],
5656+ description: "desc",
5757+ text_content: "body",
5858+ tags: ["a", "b"]
5959+ }
6060+6161+ assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc)
6262+ end
6363+6464+ test "returns :no_session when the user does not exist" do
6565+ reject(&Client.procedure/3)
6666+6767+ assert {:error, :no_session} =
6868+ StandardSite.put_publication(-1, %Publication{name: "x", url: "y"})
6969+ end
7070+7171+ test "put_publication/2 uploads the icon and embeds the returned blob" do
7272+ user = create_user()
7373+7474+ blob = %{
7575+ "$type" => "blob",
7676+ "ref" => %{"$link" => "bafyicon"},
7777+ "mimeType" => "image/png",
7878+ "size" => 3
7979+ }
8080+8181+ expect(Client, :upload_blob, fn user_id, <<1, 2, 3>>, "image/png" ->
8282+ assert user.id == user_id
8383+ {:ok, %{"blob" => blob}}
8484+ end)
8585+8686+ expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body ->
8787+ assert blob == body.record["icon"]
8888+ {:ok, %{"uri" => "at://x"}}
8989+ end)
9090+9191+ pub = %Publication{
9292+ name: "jola.dev",
9393+ url: "https://jola.dev",
9494+ icon: {<<1, 2, 3>>, "image/png"}
9595+ }
9696+9797+ assert {:ok, %{"uri" => "at://x"}} = StandardSite.put_publication(user.id, pub)
9898+ end
9999+100100+ test "put_publication/2 omits optional fields that are nil" do
101101+ user = create_user()
102102+103103+ expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body ->
104104+ refute Map.has_key?(body.record, "description")
105105+ refute Map.has_key?(body.record, "icon")
106106+ {:ok, %{}}
107107+ end)
108108+109109+ assert {:ok, %{}} =
110110+ StandardSite.put_publication(user.id, %Publication{
111111+ name: "n",
112112+ url: "https://n.example"
113113+ })
114114+ end
115115+116116+ test "put_document/2 omits updatedAt and path when not set" do
117117+ user = create_user()
118118+119119+ expect(Client, :procedure, fn _user_id, "com.atproto.repo.putRecord", body ->
120120+ refute Map.has_key?(body.record, "updatedAt")
121121+ refute Map.has_key?(body.record, "path")
122122+ assert "2026-01-01T00:00:00Z" == body.record["publishedAt"]
123123+ {:ok, %{"uri" => "at://y"}}
124124+ end)
125125+126126+ doc = %Document{
127127+ rkey: "post-2",
128128+ site: StandardSite.publication_uri(@did),
129129+ title: "Hello",
130130+ published_at: ~U[2026-01-01 00:00:00Z]
131131+ }
132132+133133+ assert {:ok, %{"uri" => "at://y"}} = StandardSite.put_document(user.id, doc)
134134+ end
135135+end
+41
test/annot_at/atproto/xrpc_test.exs
···66 alias AnnotAt.Atproto.OAuth.Session
77 alias AnnotAt.Atproto.XRPC
8899+ @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
910 @pds "https://shaggymane.us-west.host.bsky.network"
10111112 setup do
···9091 end)
91929293 assert {:error, :missing_dpop_nonce} = XRPC.query(session, "app.bsky.actor.getProfile")
9494+ end
9595+9696+ test "procedure/3 sends an authenticated POST with a JSON body", %{session: session} do
9797+ body = %{"repo" => @did, "collection" => "app.bsky.feed.post", "record" => %{"text" => "hi"}}
9898+9999+ expect(HTTP, :request, fn "POST", url, headers, {:json, ^body} ->
100100+ assert "#{@pds}/xrpc/com.atproto.repo.createRecord" == url
101101+ assert {"authorization", "DPoP access-1"} in headers
102102+103103+ {"dpop", proof} = List.keyfind(headers, "dpop", 0)
104104+ %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(proof)
105105+ assert "POST" == claims["htm"]
106106+ assert claims["ath"]
107107+108108+ {:ok, %{status: 200, body: ~s({"uri":"at://x"}), headers: %{}}}
109109+ end)
110110+111111+ assert {:ok, %{"uri" => "at://x"}} =
112112+ XRPC.procedure(session, "com.atproto.repo.createRecord", body)
113113+ end
114114+115115+ test "upload_blob/3 POSTs raw bytes with a content type and returns the blob", %{
116116+ session: session
117117+ } do
118118+ bytes = <<137, 80, 78, 71>>
119119+120120+ expect(HTTP, :request, fn "POST", url, headers, {:raw, ^bytes, "image/png"} ->
121121+ assert "#{@pds}/xrpc/com.atproto.repo.uploadBlob" == url
122122+ assert {"authorization", "DPoP access-1"} in headers
123123+124124+ {"dpop", proof} = List.keyfind(headers, "dpop", 0)
125125+ %JOSE.JWT{fields: claims} = JOSE.JWT.peek_payload(proof)
126126+ assert "POST" == claims["htm"]
127127+ assert claims["ath"]
128128+129129+ {:ok, %{status: 200, body: ~s({"blob":{"$type":"blob"}}), headers: %{}}}
130130+ end)
131131+132132+ assert {:ok, %{"blob" => %{"$type" => "blob"}}} =
133133+ XRPC.upload_blob(session, bytes, "image/png")
93134 end
94135end