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.

Add accounts persistence

Adds some basic stuff to the Accounts context, including the session upsert

Johanna Larsson (Jun 14, 2026, 6:36 PM +0100) bce0c7c8 dba6c6c3

+163
+67
lib/annot_at/accounts.ex
··· 2 2 @moduledoc """ 3 3 Context module for accounts related database queries, such as looking up users. 4 4 """ 5 + 6 + import Ecto.Query, only: [from: 2] 7 + 8 + alias AnnotAt.Accounts.AtprotoSession 9 + alias AnnotAt.Accounts.User 10 + alias AnnotAt.Repo 11 + 12 + @spec get_user!(integer()) :: User.t() 13 + def get_user!(id) do 14 + Repo.get!(User, id) 15 + end 16 + 17 + @spec get_user_by_did(String.t()) :: User.t() | nil 18 + def get_user_by_did(did) do 19 + Repo.get_by(User, did: did) 20 + end 21 + 22 + @spec get_atproto_session(integer()) :: AtprotoSession.t() | nil 23 + def get_atproto_session(user_id) do 24 + Repo.get_by(AtprotoSession, user_id: user_id) 25 + end 26 + 27 + @doc """ 28 + Creates or updates a user and their atproto session for a login. 29 + 30 + The user is matches by DID and its cached fields refreshed. The session is 31 + one-per-user and fully replaced. Wrapped in a transaction so a login never 32 + leaves a user without its session. 33 + """ 34 + @spec upsert_login(map(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} 35 + def upsert_login(user_attrs, session_attrs) do 36 + Repo.transact(fn -> 37 + with {:ok, user} <- upsert_user(user_attrs), 38 + {:ok, session} <- upsert_session(user.id, session_attrs) do 39 + {:ok, %{user | atproto_session: session}} 40 + end 41 + end) 42 + end 43 + 44 + @doc """ 45 + Deletes a user's atproto session (logout), keeping the user record. 46 + """ 47 + @spec delete_atproto_session(User.t()) :: :ok 48 + def delete_atproto_session(%User{id: user_id}) do 49 + Repo.delete_all(from(s in AtprotoSession, where: s.user_id == ^user_id)) 50 + :ok 51 + end 52 + 53 + @spec upsert_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} 54 + def upsert_user(user_attrs) do 55 + Repo.insert(User.changeset(%User{}, user_attrs), 56 + on_conflict: {:replace_all_except, [:id, :did, :inserted_at]}, 57 + conflict_target: :did, 58 + returning: true 59 + ) 60 + end 61 + 62 + @spec upsert_session(integer(), map()) :: 63 + {:ok, AtprotoSession.t()} | {:error, Ecto.Changeset.t()} 64 + def upsert_session(user_id, session_attrs) do 65 + Repo.insert( 66 + AtprotoSession.changeset(%AtprotoSession{user_id: user_id}, session_attrs), 67 + on_conflict: {:replace_all_except, [:id, :user_id, :inserted_at]}, 68 + conflict_target: :user_id, 69 + returning: true 70 + ) 71 + end 5 72 end
+96
test/annot_at/accounts_test.exs
··· 1 + defmodule AnnotAt.AccountsTest do 2 + use AnnotAt.DataCase, async: true 3 + 4 + alias AnnotAt.Accounts 5 + alias AnnotAt.Accounts.AtprotoSession 6 + alias AnnotAt.Accounts.User 7 + 8 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 9 + 10 + defp user_attrs(overrides \\ %{}) do 11 + Map.merge( 12 + %{ 13 + did: @did, 14 + handle: "alice.test", 15 + pds_host: "https://pds.example.com", 16 + handle_verified_at: ~U[2026-01-01 00:00:00Z] 17 + }, 18 + overrides 19 + ) 20 + end 21 + 22 + defp session_attrs(overrides \\ %{}) do 23 + Map.merge( 24 + %{ 25 + auth_server_issuer: "https://bsky.social", 26 + granted_scopes: "atproto", 27 + access_token: "access-1", 28 + refresh_token: "refresh-1", 29 + dpop_private_jwk: "{}", 30 + expires_at: ~U[2026-01-01 01:00:00Z] 31 + }, 32 + overrides 33 + ) 34 + end 35 + 36 + test "upsert_login/2 creates a user and session" do 37 + assert {:ok, user} = Accounts.upsert_login(user_attrs(), session_attrs()) 38 + 39 + assert @did == user.did 40 + assert "alice.test" == user.handle 41 + assert %AtprotoSession{} = user.atproto_session 42 + assert "access-1" == user.atproto_session.access_token 43 + end 44 + 45 + test "upsert_login/2 updates the user and replaces the session on re-login" do 46 + {:ok, first} = Accounts.upsert_login(user_attrs(), session_attrs()) 47 + 48 + {:ok, second} = 49 + Accounts.upsert_login( 50 + user_attrs(%{handle: "alice.new"}), 51 + session_attrs(%{access_token: "access-2", refresh_token: "refresh-2"}) 52 + ) 53 + 54 + assert first.id == second.id 55 + assert "alice.new" == second.handle 56 + assert "access-2" == second.atproto_session.access_token 57 + assert 1 == Repo.aggregate(User, :count) 58 + assert 1 == Repo.aggregate(AtprotoSession, :count) 59 + end 60 + 61 + test "upsert_login/2 rolls back and returns an error for invalid attrs" do 62 + assert {:error, changeset} = Accounts.upsert_login(%{handle: "x"}, session_attrs()) 63 + assert %{did: ["can't be blank"]} = errors_on(changeset) 64 + assert 0 == Repo.aggregate(User, :count) 65 + end 66 + 67 + test "upsert_session/2 replaces the existing session for a user" do 68 + {:ok, user} = Accounts.upsert_user(user_attrs()) 69 + {:ok, _} = Accounts.upsert_session(user.id, session_attrs()) 70 + {:ok, replaced} = Accounts.upsert_session(user.id, session_attrs(%{access_token: "access-2"})) 71 + 72 + assert "access-2" == replaced.access_token 73 + assert 1 == Repo.aggregate(AtprotoSession, :count) 74 + end 75 + 76 + test "get_user_by_did/1 and get_atproto_session/1 fetch persisted records" do 77 + {:ok, user} = Accounts.upsert_login(user_attrs(), session_attrs()) 78 + 79 + assert user.id == Accounts.get_user_by_did(@did).id 80 + assert "access-1" == Accounts.get_atproto_session(user.id).access_token 81 + end 82 + 83 + test "get_user_by_did/1 returns nil for an unknown DID" do 84 + refute Accounts.get_user_by_did("did:plc:nope") 85 + end 86 + 87 + test "delete_atproto_session/1 removes the session, keeping the user" do 88 + {:ok, user} = Accounts.upsert_login(user_attrs(), session_attrs()) 89 + assert Accounts.get_atproto_session(user.id) 90 + 91 + assert :ok == Accounts.delete_atproto_session(user) 92 + 93 + refute Accounts.get_atproto_session(user.id) 94 + assert Accounts.get_user!(user.id) 95 + end 96 + end