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.

Make login feel a bit faster

This shaves off a few hundred milliseconds from logging in by avoiding fetching the validated token endpoint and issuer again unnecessarily at the end of the oauth flow.

Also adds a little spinner so the login page doesn't just sit there while we're working.

Johanna Larsson (Jun 27, 2026, 4:43 PM +0100) 79b5eebb 8f77d1e8

+111 -83
+60 -39
assets/js/app.js
··· 18 18 // To load it, simply add a second `<link>` to your `root.html.heex` file. 19 19 20 20 // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 21 - import "phoenix_html" 21 + import "phoenix_html"; 22 22 // Establish Phoenix Socket and LiveView configuration. 23 - import {Socket} from "phoenix" 24 - import {LiveSocket} from "phoenix_live_view" 25 - import {hooks as colocatedHooks} from "phoenix-colocated/annot_at" 26 - import topbar from "../vendor/topbar" 23 + import { Socket } from "phoenix"; 24 + import { LiveSocket } from "phoenix_live_view"; 25 + import { hooks as colocatedHooks } from "phoenix-colocated/annot_at"; 26 + import topbar from "../vendor/topbar"; 27 27 28 - const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 28 + const csrfToken = document 29 + .querySelector("meta[name='csrf-token']") 30 + .getAttribute("content"); 29 31 const liveSocket = new LiveSocket("/live", Socket, { 30 32 longPollFallbackMs: 2500, 31 - params: {_csrf_token: csrfToken}, 32 - hooks: {...colocatedHooks}, 33 - }) 33 + params: { _csrf_token: csrfToken }, 34 + hooks: { ...colocatedHooks }, 35 + }); 34 36 35 37 // Show progress bar on live navigation and form submits 36 - topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 37 - window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) 38 - window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) 38 + topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); 39 + window.addEventListener("phx:page-loading-start", (_info) => topbar.show(2000)); 40 + window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); 39 41 40 42 // connect if there are any LiveViews on the page 41 - liveSocket.connect() 43 + liveSocket.connect(); 42 44 43 45 // expose liveSocket on window for web console debug logs and latency simulation: 44 46 // >> liveSocket.enableDebug() 45 47 // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 46 48 // >> liveSocket.disableLatencySim() 47 - window.liveSocket = liveSocket 49 + window.liveSocket = liveSocket; 50 + 51 + // Login is a DeadView form, this adds a nice little spinner for it 52 + document.addEventListener("submit", (e) => { 53 + if (e.target?.id !== "login-form") return; 54 + 55 + const btn = e.target.querySelector("button[type='submit']"); 56 + 57 + if (!btn) return; 58 + 59 + btn.disabled = true; 60 + btn.innerHTML = 61 + '<span class="inline-block size-5 animate-spin rounded-full border-2 border-current border-t-transparent"></span>Connecting…'; 62 + }); 48 63 49 64 // The lines below enable quality of life phoenix_live_reload 50 65 // development features: ··· 53 68 // 2. click on elements to jump to their definitions in your code editor 54 69 // 55 70 if (process.env.NODE_ENV === "development") { 56 - window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { 57 - // Enable server log streaming to client. 58 - // Disable with reloader.disableServerLogs() 59 - reloader.enableServerLogs() 71 + window.addEventListener( 72 + "phx:live_reload:attached", 73 + ({ detail: reloader }) => { 74 + // Enable server log streaming to client. 75 + // Disable with reloader.disableServerLogs() 76 + reloader.enableServerLogs(); 60 77 61 - // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component 62 - // 63 - // * click with "c" key pressed to open at caller location 64 - // * click with "d" key pressed to open at function component definition location 65 - let keyDown 66 - window.addEventListener("keydown", e => keyDown = e.key) 67 - window.addEventListener("keyup", _e => keyDown = null) 68 - window.addEventListener("click", e => { 69 - if(keyDown === "c"){ 70 - e.preventDefault() 71 - e.stopImmediatePropagation() 72 - reloader.openEditorAtCaller(e.target) 73 - } else if(keyDown === "d"){ 74 - e.preventDefault() 75 - e.stopImmediatePropagation() 76 - reloader.openEditorAtDef(e.target) 77 - } 78 - }, true) 78 + // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component 79 + // 80 + // * click with "c" key pressed to open at caller location 81 + // * click with "d" key pressed to open at function component definition location 82 + let keyDown; 83 + window.addEventListener("keydown", (e) => (keyDown = e.key)); 84 + window.addEventListener("keyup", (_e) => (keyDown = null)); 85 + window.addEventListener( 86 + "click", 87 + (e) => { 88 + if (keyDown === "c") { 89 + e.preventDefault(); 90 + e.stopImmediatePropagation(); 91 + reloader.openEditorAtCaller(e.target); 92 + } else if (keyDown === "d") { 93 + e.preventDefault(); 94 + e.stopImmediatePropagation(); 95 + reloader.openEditorAtDef(e.target); 96 + } 97 + }, 98 + true, 99 + ); 79 100 80 - window.liveReloader = reloader 81 - }) 101 + window.liveReloader = reloader; 102 + }, 103 + ); 82 104 } 83 -
+5 -2
lib/annot_at/accounts/oauth_login_request.ex
··· 14 14 field :pkce_verifier, AnnotAt.Encrypted.Binary 15 15 # Per-session DPoP key (serialized), tokens get bound to it at exchange 16 16 field :dpop_private_jwk, AnnotAt.Encrypted.Binary 17 + field :token_endpoint, :string 17 18 18 19 timestamps(type: :utc_datetime) 19 20 end ··· 27 28 :pds_host, 28 29 :auth_server_issuer, 29 30 :pkce_verifier, 30 - :dpop_private_jwk 31 + :dpop_private_jwk, 32 + :token_endpoint 31 33 ]) 32 34 |> validate_required([ 33 35 :state, ··· 36 38 :pds_host, 37 39 :auth_server_issuer, 38 40 :pkce_verifier, 39 - :dpop_private_jwk 41 + :dpop_private_jwk, 42 + :token_endpoint 40 43 ]) 41 44 |> unique_constraint(:state) 42 45 end
+10 -8
lib/annot_at/atproto/oauth/flow.ex
··· 106 106 ## Optional 107 107 - `:now` - base time for `expires_at` (defaults to the current time) 108 108 """ 109 - @spec exchange_code(ServerMetadata.t(), keyword()) :: 109 + @spec exchange_code(keyword()) :: 110 110 {:ok, Session.t()} 111 111 | {:error, 112 112 request_error() | {:missing, String.t()} | {:invalid, String.t()} | :did_mismatch} 113 - def exchange_code(%ServerMetadata{} = server, opts) do 113 + def exchange_code(opts) do 114 114 client_id = Keyword.fetch!(opts, :client_id) 115 115 client_jwk = Keyword.fetch!(opts, :client_jwk) 116 116 redirect_uri = Keyword.fetch!(opts, :redirect_uri) ··· 120 120 expected_did = Keyword.fetch!(opts, :expected_did) 121 121 pds_endpoint = Keyword.fetch!(opts, :pds_endpoint) 122 122 now = Keyword.get_lazy(opts, :now, &DateTime.utc_now/0) 123 + token_endpoint = Keyword.fetch!(opts, :token_endpoint) 124 + issuer = Keyword.fetch!(opts, :issuer) 123 125 124 126 build_form = fn -> 125 127 [ ··· 129 131 code_verifier: code_verifier, 130 132 client_id: client_id, 131 133 client_assertion_type: ClientAssertion.assertion_type(), 132 - client_assertion: ClientAssertion.sign(client_jwk, client_id, server.issuer) 134 + client_assertion: ClientAssertion.sign(client_jwk, client_id, issuer) 133 135 ] 134 136 end 135 137 136 - with {:ok, body} <- dpop_request(server.token_endpoint, build_form, dpop_key), 138 + with {:ok, body} <- dpop_request(token_endpoint, build_form, dpop_key), 137 139 {:ok, tokens} <- TokenResponse.parse(body), 138 140 :ok <- verify_sub(tokens.sub, expected_did) do 139 - {:ok, build_session(tokens, server, pds_endpoint, dpop_key, now)} 141 + {:ok, build_session(tokens, issuer, pds_endpoint, dpop_key, now)} 140 142 end 141 143 end 142 144 ··· 176 178 with {:ok, body} <- dpop_request(server.token_endpoint, build_form, session.dpop_key), 177 179 {:ok, tokens} <- TokenResponse.parse(body), 178 180 :ok <- verify_sub(tokens.sub, session.did) do 179 - {:ok, build_session(tokens, server, session.pds_endpoint, session.dpop_key, now)} 181 + {:ok, build_session(tokens, server.issuer, session.pds_endpoint, session.dpop_key, now)} 180 182 end 181 183 end 182 184 ··· 233 235 defp verify_sub(sub, sub), do: :ok 234 236 defp verify_sub(_sub, _expected), do: {:error, :did_mismatch} 235 237 236 - defp build_session(tokens, server, pds_endpoint, dpop_key, now) do 238 + defp build_session(tokens, issuer, pds_endpoint, dpop_key, now) do 237 239 %Session{ 238 240 did: tokens.sub, 239 241 access_token: tokens.access_token, 240 242 refresh_token: tokens.refresh_token, 241 243 dpop_key: dpop_key, 242 244 scope: tokens.scope, 243 - issuer: server.issuer, 245 + issuer: issuer, 244 246 pds_endpoint: pds_endpoint, 245 247 expires_at: DateTime.add(now, tokens.expires_in, :second) 246 248 }
+8 -16
lib/annot_at/atproto/oauth/login.ex
··· 66 66 result = 67 67 with {:ok, request} <- take_request(state), 68 68 :ok <- verify_issuer(request, iss), 69 - {:ok, server} <- rediscover(request), 70 - {:ok, session} <- exchange(server, request, code), 69 + {:ok, session} <- exchange(request, code), 71 70 {:ok, user} <- persist(request, session) do 72 71 Logger.info("atproto login completed for #{user.handle} (#{user.did})") 73 72 ··· 114 113 pds_host: identity.pds_endpoint, 115 114 auth_server_issuer: server.issuer, 116 115 pkce_verifier: verifier, 117 - dpop_private_jwk: DPoP.dump(dpop_key) 116 + dpop_private_jwk: DPoP.dump(dpop_key), 117 + token_endpoint: server.token_endpoint 118 118 }) 119 119 end 120 120 ··· 128 128 defp verify_issuer(%{auth_server_issuer: iss}, iss), do: :ok 129 129 defp verify_issuer(_request, _iss), do: {:error, :issuer_mismatch} 130 130 131 - defp rediscover(request) do 132 - with {:ok, server} <- Discovery.discover(request.pds_host) do 133 - if server.issuer == request.auth_server_issuer do 134 - {:ok, server} 135 - else 136 - {:error, :issuer_mismatch} 137 - end 138 - end 139 - end 140 - 141 - defp exchange(server, request, code) do 142 - Flow.exchange_code(server, 131 + defp exchange(request, code) do 132 + Flow.exchange_code( 143 133 client_id: Config.client_id(), 144 134 client_jwk: Config.signing_key(), 145 135 redirect_uri: Config.redirect_uri(), ··· 147 137 code_verifier: request.pkce_verifier, 148 138 dpop_key: DPoP.load(request.dpop_private_jwk), 149 139 expected_did: request.did, 150 - pds_endpoint: request.pds_host 140 + pds_endpoint: request.pds_host, 141 + issuer: request.auth_server_issuer, 142 + token_endpoint: request.token_endpoint 151 143 ) 152 144 end 153 145
+3 -1
lib/annot_at_web/components/site_components.ex
··· 21 21 <div class="truncate font-display text-lg font-bold 22 22 tracking-tight">{@site.url}</div> 23 23 <div class="mt-0.5 truncate text-sm 24 - text-ink/50">{@site.feed_url}</div> 24 + text-ink/50"> 25 + {@site.feed_url || "No feed selected"} 26 + </div> 25 27 </div> 26 28 <.status_badge status={Site.status(@site)} /> 27 29 </div>
+1 -1
lib/annot_at_web/controllers/auth_html/new.html.heex
··· 29 29 </div> 30 30 <button 31 31 type="submit" 32 - class="w-full rounded-xl bg-ink px-5 py-3 font-bold text-paper transition-all hover:scale-[1.01] active:scale-[0.99]" 32 + class="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-ink px-5 py-3 font-bold text-paper transition-all hover:scale-[1.01] active:scale-[0.99] disabled:opacity-70" 33 33 > 34 34 Continue 35 35 </button>
+9
priv/repo/migrations/20260626174635_add_token_endpoint_to_oauth_login_requests.exs
··· 1 + defmodule AnnotAt.Repo.Migrations.AddTokenEndpointToOauthLoginRequests do 2 + use Ecto.Migration 3 + 4 + def change do 5 + alter table(:oauth_login_requests) do 6 + add :token_endpoint, :text 7 + end 8 + end 9 + end
+2 -1
test/annot_at/accounts_test.exs
··· 137 137 pds_host: "https://pds.example.com", 138 138 auth_server_issuer: "https://bsky.social", 139 139 pkce_verifier: "verifier-123", 140 - dpop_private_jwk: "{}" 140 + dpop_private_jwk: "{}", 141 + token_endpoint: "somethnig" 141 142 }, 142 143 overrides 143 144 )
+8 -9
test/annot_at/atproto/oauth/flow_test.exs
··· 125 125 end 126 126 end 127 127 128 - describe "PAR.exchange_code/2" do 128 + describe "PAR.exchange_code/1" do 129 129 test "exchanges the code for a session", %{jwk: jwk} do 130 130 expect(HTTP, :post_form, fn _url, _form, _headers -> 131 131 {:ok, %{status: 200, body: @token_response, headers: %{}}} 132 132 end) 133 133 134 - assert {:ok, session} = Flow.exchange_code(@server, exchange_opts(jwk)) 134 + assert {:ok, session} = Flow.exchange_code(exchange_opts(jwk)) 135 135 assert @did == session.did 136 136 assert "atproto" == session.scope 137 137 assert "https://bsky.social" == session.issuer ··· 155 155 {:ok, %{status: 200, body: @token_response, headers: %{}}} 156 156 end) 157 157 158 - assert {:ok, _session} = Flow.exchange_code(@server, exchange_opts(jwk)) 158 + assert {:ok, _session} = Flow.exchange_code(exchange_opts(jwk)) 159 159 end 160 160 161 161 test "rejects a token whose sub does not match the expected DID", %{jwk: jwk} do ··· 164 164 end) 165 165 166 166 assert {:error, :did_mismatch} == 167 - Flow.exchange_code( 168 - @server, 169 - exchange_opts(jwk, expected_did: "did:plc:someoneelse") 170 - ) 167 + Flow.exchange_code(exchange_opts(jwk, expected_did: "did:plc:someoneelse")) 171 168 end 172 169 173 170 test "propagates a token response parse error", %{jwk: jwk} do ··· 184 181 {:ok, %{status: 200, body: body, headers: %{}}} 185 182 end) 186 183 187 - assert {:error, {:missing, "sub"}} == Flow.exchange_code(@server, exchange_opts(jwk)) 184 + assert {:error, {:missing, "sub"}} == Flow.exchange_code(exchange_opts(jwk)) 188 185 end 189 186 end 190 187 ··· 242 239 dpop_key: jwk, 243 240 expected_did: @did, 244 241 pds_endpoint: @pds, 245 - now: ~U[2026-01-01 00:00:00Z] 242 + now: ~U[2026-01-01 00:00:00Z], 243 + token_endpoint: "https://bsky.social/oauth/token", 244 + issuer: "https://bsky.social" 246 245 ], 247 246 overrides 248 247 )
+5 -6
test/annot_at/atproto/oauth/login_test.exs
··· 67 67 describe "Login.complete_login/1" do 68 68 test "exchanges the code and persists the user and session", %{jwk: jwk} do 69 69 create_request() 70 - server = server() 71 70 72 71 session = %Session{ 73 72 did: @did, ··· 80 79 expires_at: ~U[2026-01-01 01:00:00Z] 81 80 } 82 81 83 - expect(Discovery, :discover, fn @pds -> {:ok, server} end) 84 - expect(Flow, :exchange_code, fn ^server, _opts -> {:ok, session} end) 82 + expect(Flow, :exchange_code, fn _opts -> {:ok, session} end) 85 83 86 84 expect(Profile, :fetch, fn "jola.dev" -> 87 85 {:ok, %{display_name: "Johanna", avatar_url: "https://cdn/av.jpg"}} ··· 98 96 end 99 97 100 98 test "returns :invalid_state for an unknown state" do 101 - reject(&Flow.exchange_code/2) 99 + reject(&Flow.exchange_code/1) 102 100 103 101 params = %{"code" => "x", "state" => "nope", "iss" => @issuer} 104 102 assert {:error, :invalid_state} == Login.complete_login(params) ··· 106 104 107 105 test "rejects a callback whose iss does not match the stored issuer" do 108 106 create_request() 109 - reject(&Flow.exchange_code/2) 107 + reject(&Flow.exchange_code/1) 110 108 111 109 params = %{"code" => "c", "state" => "state-1", "iss" => "https://evil.example"} 112 110 assert {:error, :login_failed} == Login.complete_login(params) ··· 164 162 pds_host: @pds, 165 163 auth_server_issuer: @issuer, 166 164 pkce_verifier: "verifier-1", 167 - dpop_private_jwk: @dpop_jwk_json 165 + dpop_private_jwk: @dpop_jwk_json, 166 + token_endpoint: "#{@issuer}/oauth/token" 168 167 }, 169 168 overrides 170 169 )