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 atproto identity resolution

Might've diverged a bit from my original plan. Verified in IEx

```
iex(2)> AnnotAt.Atproto.Identity.resolve_handle("atproto.com")
{:ok,
%AnnotAt.Atproto.Identity{
did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
handle: "atproto.com",
pds_endpoint: "https://enoki.us-east.host.bsky.network"
}}
iex(3)> AnnotAt.Atproto.Identity.resolve_handle("jola.dev")
{:ok,
%AnnotAt.Atproto.Identity{
did: "did:plc:bvraa6gajy4tfr3eh2sisdkr",
handle: "jola.dev",
pds_endpoint: "https://shaggymane.us-west.host.bsky.network"
}}
```

Johanna Larsson (Jun 14, 2026, 8:35 AM +0100) bcb605e9 92ff1f56

+629 -2
+1 -1
config/runtime.exs
··· 21 21 end 22 22 23 23 config :annot_at, AnnotAtWeb.Endpoint, 24 - http: [port: String.to_integer(System.get_env("PORT", "4000"))] 24 + http: [port: String.to_integer(System.get_env("PORT", "4002"))] 25 25 26 26 if config_env() == :prod do 27 27 database_url =
+30
lib/annot_at/atproto/did.ex
··· 1 + defmodule AnnotAt.Atproto.DID do 2 + @moduledoc """ 3 + DID syntax validation per the atproto DID spec. 4 + 5 + Validates syntax only. `did:web` is additionally required to be 6 + hostname-only (no path or port), per atproto. 7 + """ 8 + 9 + @max_length 2048 10 + 11 + # General atproto DID grammar. The identifier may not end in ":" or "%". 12 + @syntax ~r/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/ 13 + 14 + # A bare hostname: dot-separated labels of 1-63 alphanumeric/hyphen chars 15 + # with no edge hyphens. Rejects path segments (extra colons), ports, and 16 + # leading/trailing dots, keeping did:web hostname-only. 17 + @web_host ~r/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/ 18 + 19 + @doc """ 20 + Validates atproto DID syntax. For `did:web`, also requires the 21 + method-specific identifier to be a bare hostname. 22 + """ 23 + @spec valid?(String.t()) :: boolean() 24 + def valid?(did) when is_binary(did) do 25 + byte_size(did) <= @max_length and Regex.match?(@syntax, did) and method_valid?(did) 26 + end 27 + 28 + defp method_valid?("did:web:" <> host), do: Regex.match?(@web_host, host) 29 + defp method_valid?(_did), do: true 30 + end
+96
lib/annot_at/atproto/did_document.ex
··· 1 + defmodule AnnotAt.Atproto.DIDDocument do 2 + @moduledoc """ 3 + Parses atproto DID documents to extract the claimed handle and PDS endpoint. 4 + 5 + Verifies the document's internal correctness only. The caller must confirm 6 + the handle resolves back to this DID (bidirectional verification) before 7 + trusting it. 8 + """ 9 + 10 + alias AnnotAt.Atproto.Handle 11 + 12 + @pds_type "AtprotoPersonalDataServer" 13 + 14 + @enforce_keys [:did, :handle, :pds_endpoint] 15 + defstruct @enforce_keys 16 + 17 + @type t :: %__MODULE__{ 18 + did: String.t(), 19 + handle: String.t(), 20 + pds_endpoint: String.t() 21 + } 22 + 23 + @doc """ 24 + Parses a decoded DID document for a given DID. 25 + 26 + The `did` argument is the DID that was resolved to obtain this document. 27 + It must match the document's `id`. 28 + """ 29 + @spec parse(map(), String.t()) :: 30 + {:ok, t()} | {:error, :did_mismatch | :invalid_handle | :no_pds | :invalid_pds_endpoint} 31 + def parse(document, did) when is_map(document) and is_binary(did) do 32 + with :ok <- check_id(document, did), 33 + {:ok, handle} <- claimed_handle(document), 34 + {:ok, endpoint} <- pds_endpoint(document) do 35 + {:ok, %__MODULE__{did: did, handle: handle, pds_endpoint: endpoint}} 36 + end 37 + end 38 + 39 + defp check_id(document, did) do 40 + if Map.get(document, "id") == did do 41 + :ok 42 + else 43 + {:error, :did_mismatch} 44 + end 45 + end 46 + 47 + defp claimed_handle(document) do 48 + result = 49 + document 50 + |> Map.get("alsoKnownAs", []) 51 + |> Enum.find_value(fn 52 + "at://" <> handle = uri when is_binary(uri) -> 53 + if Handle.valid?(handle) do 54 + handle 55 + else 56 + nil 57 + end 58 + 59 + _ -> 60 + nil 61 + end) 62 + 63 + case result do 64 + nil -> {:error, :invalid_handle} 65 + handle -> {:ok, handle} 66 + end 67 + end 68 + 69 + defp pds_endpoint(document) do 70 + result = 71 + document 72 + |> Map.get("service", []) 73 + |> Enum.find(fn service -> 74 + String.ends_with?(to_string(service["id"]), "#atproto_pds") and 75 + Map.get(service, "type") == @pds_type 76 + end) 77 + 78 + case result do 79 + nil -> {:error, :no_pds} 80 + service -> validate_endpoint(service["serviceEndpoint"]) 81 + end 82 + end 83 + 84 + defp validate_endpoint(url) when is_binary(url) do 85 + case URI.parse(url) do 86 + %URI{scheme: scheme, host: host, path: nil, query: nil, userinfo: nil} 87 + when scheme in ["http", "https"] and is_binary(host) and host != "" -> 88 + {:ok, url} 89 + 90 + _ -> 91 + {:error, :invalid_pds_endpoint} 92 + end 93 + end 94 + 95 + defp validate_endpoint(_), do: {:error, :invalid_pds_endpoint} 96 + end
+23
lib/annot_at/atproto/dns.ex
··· 1 + defmodule AnnotAt.Atproto.DNS do 2 + @moduledoc """ 3 + DNS transport boundary for atproto handle resolution. 4 + 5 + Wraps Erlang's `:inet_res` so this looks more Elixiry. Stubbed 6 + via Mimic in tests, this module has no unit tests of its own. 7 + """ 8 + 9 + @doc """ 10 + Looks up TXT records for `name`, returning each record as a single string. 11 + 12 + A TXT record can arrive as multiple character-strings, they are concatenated 13 + per record. Returns an empty list when there are no records or the lookups 14 + fails, which callers treat as "no DNS answer". 15 + """ 16 + @spec lookup_txt(String.t()) :: [String.t()] 17 + def lookup_txt(name) do 18 + name 19 + |> String.to_charlist() 20 + |> :inet_res.lookup(:in, :txt) 21 + |> Enum.map(&List.to_string/1) 22 + end 23 + end
+30
lib/annot_at/atproto/handle.ex
··· 1 + defmodule AnnotAt.Atproto.Handle do 2 + @moduledoc """ 3 + Handle syntax validation and normalization per the atproto handle spec. 4 + """ 5 + 6 + @max_length 253 7 + 8 + # Segments of 1-63 alphanumeric/hyphen chars (no edge hyphens), at least 9 + # two segments, TLD must not start with a digit. 10 + @syntax ~r/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/ 11 + 12 + @doc """ 13 + Normalizes user input: trims whitespace, strips leading `@`, downcase. 14 + """ 15 + @spec normalize(String.t()) :: String.t() 16 + def normalize(input) when is_binary(input) do 17 + input 18 + |> String.trim() 19 + |> String.trim_leading("@") 20 + |> String.downcase() 21 + end 22 + 23 + @doc """ 24 + Checks handle syntax. Does not check resolvability or reserved names. 25 + """ 26 + @spec valid?(String.t()) :: boolean() 27 + def valid?(handle) when is_binary(handle) do 28 + byte_size(handle) <= @max_length and Regex.match?(@syntax, handle) 29 + end 30 + end
+44
lib/annot_at/atproto/http.ex
··· 1 + defmodule AnnotAt.Atproto.HTTP do 2 + @moduledoc """ 3 + HTTP transport boundary for atproto identify resolution. 4 + 5 + Fetches raw bodies and decodes JSON explicity, so behavior does not depend 6 + on server-provided content types (DID documents are seomtimes served as 7 + `application/did+ld+json, which generic encoders miss). Stubbed via Mimic in 8 + tests. This module has no unit tests of its own. 9 + """ 10 + 11 + @receive_timeout 10_000 12 + 13 + @doc """ 14 + GETs `url`, required a 2xx response, and decodes the body as a JSON object. 15 + """ 16 + @spec get_json(String.t()) :: 17 + {:ok, map()} 18 + | {:error, {:http_status, pos_integer()} | {:transport, term()} | :invalid_json} 19 + def get_json(url) when is_binary(url) do 20 + with {:ok, body} <- get_body(url) do 21 + case Jason.decode(body) do 22 + {:ok, %{} = json} -> {:ok, json} 23 + _ -> {:error, :invalid_json} 24 + end 25 + end 26 + end 27 + 28 + @doc """ 29 + GETs `url`, requires a 2xx response, and returns the raw response body. 30 + """ 31 + @spec get_text(String.t()) :: 32 + {:ok, String.t()} | {:error, {:http_status, pos_integer()} | {:transport, term()}} 33 + def get_text(url) when is_binary(url) do 34 + get_body(url) 35 + end 36 + 37 + defp get_body(url) when is_binary(url) do 38 + case Req.get(url, decode_body: false, receive_timeout: @receive_timeout) do 39 + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body} 40 + {:ok, %Req.Response{status: status}} -> {:error, {:http_status, status}} 41 + {:error, reason} -> {:error, {:transport, reason}} 42 + end 43 + end 44 + end
+131
lib/annot_at/atproto/identity.ex
··· 1 + defmodule AnnotAt.Atproto.Identity do 2 + @moduledoc """ 3 + Resolves an atproto handle to a verified identity. 4 + 5 + Resolution is bidirectional per the atproto identity spec, the handle is 6 + resolved to a DID and the DID document is fetched independently, and the 7 + docment's claimed handle must match the handle we started from. Neither 8 + direction alone is trusted, otherwise anyone could point DNS at a victim's 9 + DID. 10 + """ 11 + 12 + alias AnnotAt.Atproto.DID 13 + alias AnnotAt.Atproto.DIDDocument 14 + alias AnnotAt.Atproto.DNS 15 + alias AnnotAt.Atproto.Handle 16 + alias AnnotAt.Atproto.HTTP 17 + 18 + @plc_directory "https://plc.directory" 19 + 20 + @enforce_keys [:did, :handle, :pds_endpoint] 21 + defstruct @enforce_keys 22 + 23 + @type t :: %__MODULE__{ 24 + did: String.t(), 25 + handle: String.t(), 26 + pds_endpoint: String.t() 27 + } 28 + 29 + @doc """ 30 + Resolves and verifies a handle, returning its DID and PDS endpoint. 31 + 32 + Domain failures are atoms (`:invalid_handle`, `:ambiguous_dns`, 33 + `:handle_mismatch`, `:unsupported_did_method`, ...). Fetch failures 34 + propagate the transport tuples from `HTTP`. 35 + """ 36 + @spec resolve_handle(String.t()) :: 37 + {:ok, t()} 38 + | {:error, 39 + :handle_not_found 40 + | :invalid_handle 41 + | :ambiguous_dns 42 + | :invalid_did 43 + | :handle_mismatch 44 + | :unsupported_did_method 45 + | {:http_status, pos_integer()} 46 + | {:transport, term()} 47 + | :invalid_json 48 + | :did_mismatch 49 + | :no_pds 50 + | :invalid_pds_endpoint} 51 + def resolve_handle(handle) when is_binary(handle) do 52 + handle = Handle.normalize(handle) 53 + 54 + with :ok <- validate_handle(handle), 55 + {:ok, did} <- handle_to_did(handle), 56 + :ok <- validate_did(did), 57 + {:ok, document} <- did_to_document(did), 58 + {:ok, parsed} <- DIDDocument.parse(document, did), 59 + :ok <- confirm_bidirectional(parsed, handle) do 60 + {:ok, %__MODULE__{did: did, handle: handle, pds_endpoint: parsed.pds_endpoint}} 61 + end 62 + end 63 + 64 + defp validate_handle(handle) do 65 + if Handle.valid?(handle) do 66 + :ok 67 + else 68 + {:error, :invalid_handle} 69 + end 70 + end 71 + 72 + # DNS TXT is preferred, the HTTPS well-known method is only consulated when 73 + # DNS returns no record. Conflicting DNS records hard-fail per spec. 74 + defp handle_to_did(handle) do 75 + case dns_did(handle) do 76 + {:ok, _did} = ok -> ok 77 + {:error, :ambiguous_dns} = error -> error 78 + :none -> https_did(handle) 79 + end 80 + end 81 + 82 + defp validate_did(did) do 83 + if DID.valid?(did) do 84 + :ok 85 + else 86 + {:error, :invalid_did} 87 + end 88 + end 89 + 90 + defp dns_did(handle) do 91 + with_prefix = "_atproto." <> handle 92 + 93 + dids = 94 + with_prefix 95 + |> DNS.lookup_txt() 96 + |> Enum.flat_map(fn 97 + "did=" <> did -> [did] 98 + _ -> [] 99 + end) 100 + |> Enum.uniq() 101 + 102 + case dids do 103 + [did] -> {:ok, did} 104 + [] -> :none 105 + _ -> {:error, :ambiguous_dns} 106 + end 107 + end 108 + 109 + defp https_did(handle) do 110 + case HTTP.get_text("https://" <> handle <> "/.well-known/atproto-did") do 111 + {:ok, body} -> 112 + {:ok, String.trim(body)} 113 + 114 + {:error, _reason} -> 115 + {:error, :handle_not_found} 116 + end 117 + end 118 + 119 + defp did_to_document("did:plc:" <> _ = did) do 120 + HTTP.get_json(@plc_directory <> "/" <> did) 121 + end 122 + 123 + defp did_to_document("did:web:" <> host) do 124 + HTTP.get_json("https://" <> URI.decode(host) <> "/.well-known/did.json") 125 + end 126 + 127 + defp did_to_document(_), do: {:error, :unsupported_did_method} 128 + 129 + defp confirm_bidirectional(%DIDDocument{handle: handle}, handle), do: :ok 130 + defp confirm_bidirectional(_parsed, _handle_), do: {:error, :handle_mismatch} 131 + end
+2 -1
mix.exs
··· 67 67 {:dns_cluster, "~> 0.2.0"}, 68 68 {:bandit, "~> 1.5"}, 69 69 {:jose, "~> 1.11"}, 70 - {:credo, "~> 1.7", only: [:dev, :test]} 70 + {:credo, "~> 1.7", only: [:dev, :test]}, 71 + {:mimic, "~> 2.3", only: :test} 71 72 ] 72 73 end 73 74
+2
mix.lock
··· 15 15 "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, 16 16 "fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"}, 17 17 "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, 18 + "ham": {:hex, :ham, "0.3.2", "02ae195f49970ef667faf9d01bc454fb80909a83d6c775bcac724ca567aeb7b3", [:mix], [], "hexpm", "b71cc684c0e5a3d32b5f94b186770551509e93a9ae44ca1c1a313700f2f6a69a"}, 18 19 "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, 19 20 "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 20 21 "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, ··· 22 23 "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, 23 24 "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, 24 25 "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 26 + "mimic": {:hex, :mimic, "2.3.0", "88b1d13c285e57df6ea57204317bb56e49e7329668006cdcb80a9aafc73a9616", [:mix], [{:ham, "~> 0.3", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "52771f23689398c5d41c7d05e91c2c28e10df273b784f40ca8b02e35e46850d3"}, 25 27 "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, 26 28 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 27 29 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+62
test/annot_at/atproto/did_document_test.exs
··· 1 + defmodule AnnotAt.Atproto.DIDDocumentTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Atproto.DIDDocument 5 + 6 + @fixture Path.expand("../../support/fixtures/atproto/did_document.json", __DIR__) 7 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 8 + 9 + setup do 10 + document = 11 + @fixture 12 + |> File.read!() 13 + |> Jason.decode!() 14 + 15 + %{document: document} 16 + end 17 + 18 + test "parse/2 extracts handle and PDS from the atproto.com document", %{document: document} do 19 + assert {:ok, parsed} = DIDDocument.parse(document, @did) 20 + 21 + assert parsed.did == @did 22 + assert parsed.handle == "atproto.com" 23 + assert parsed.pds_endpoint == "https://enoki.us-east.host.bsky.network" 24 + end 25 + 26 + test "parse/2 rejects a document whose id does not match the resolved DID", %{ 27 + document: document 28 + } do 29 + assert DIDDocument.parse(document, "did:plc:someoneelse") == {:error, :did_mismatch} 30 + end 31 + 32 + test "parse/2 returns :invalid_handle when no alsoKnownAs entry is a valid at:// handle", %{ 33 + document: document 34 + } do 35 + document = Map.put(document, "alsoKnownAs", ["https://atproto.com", "at://-bad-.handle"]) 36 + assert DIDDocument.parse(document, @did) == {:error, :invalid_handle} 37 + end 38 + 39 + test "parse/2 picks the first syntactically valid handle", %{document: document} do 40 + document = Map.put(document, "alsoKnownAs", ["at://nothttp", "at://second.example.com"]) 41 + assert {:ok, parsed} = DIDDocument.parse(document, @did) 42 + assert parsed.handle == "second.example.com" 43 + end 44 + 45 + test "parse/2 returns :no_pds when no service entry is an atproto PDS", %{document: document} do 46 + document = Map.put(document, "service", [%{"id" => "#other", "type" => "Something"}]) 47 + assert DIDDocument.parse(document, @did) == {:error, :no_pds} 48 + end 49 + 50 + test "parse/2 rejects a PDS endpoint that has a path", %{document: document} do 51 + service = [ 52 + %{ 53 + "id" => "#atproto_pds", 54 + "type" => "AtprotoPersonalDataServer", 55 + "serviceEndpoint" => "https://pds.example.com/path" 56 + } 57 + ] 58 + 59 + document = Map.put(document, "service", service) 60 + assert DIDDocument.parse(document, @did) == {:error, :invalid_pds_endpoint} 61 + end 62 + end
+49
test/annot_at/atproto/did_test.exs
··· 1 + defmodule AnnotAt.Atproto.DIDTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Atproto.DID 5 + 6 + describe "valid?/1" do 7 + test "accepts well-formed plc and web DIDs" do 8 + for did <- [ 9 + "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 10 + "did:web:example.com", 11 + "did:web:pds.example.com", 12 + "did:web:localhost" 13 + ] do 14 + assert DID.valid?(did), "expected #{did} to be valid" 15 + end 16 + end 17 + 18 + test "rejects malformed DID syntax" do 19 + for did <- [ 20 + "notadid", 21 + "did:", 22 + "did:plc:", 23 + "did:plc:abc:", 24 + "DID:plc:x", 25 + "did:PLC:x" 26 + ] do 27 + refute DID.valid?(did), "expected #{did} to be invalid" 28 + end 29 + end 30 + 31 + test "rejects did:web identifiers that are not bare hostnames" do 32 + for did <- [ 33 + "did:web:.pds.example.com", 34 + "did:web:example.com.", 35 + "did:web:example.com:8080", 36 + "did:web:localhost%3A3000", 37 + "did:web:ex_ample.com" 38 + ] do 39 + refute DID.valid?(did), "expected #{did} to be invalid" 40 + end 41 + end 42 + 43 + test "rejects DIDs longer than 2048 characters" do 44 + long = "did:web:" <> String.duplicate("a", 2048) <> ".com" 45 + assert byte_size(long) > 2048 46 + refute DID.valid?(long) 47 + end 48 + end 49 + end
+53
test/annot_at/atproto/handle_test.exs
··· 1 + defmodule AnnotAt.Atproto.HandleTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias AnnotAt.Atproto.Handle 5 + 6 + describe "valid?/1" do 7 + test "accepts well-formed handles" do 8 + for handle <- [ 9 + "bnewold.bsky.social", 10 + "8.cn", 11 + "a.co", 12 + "xn--notarealidn.com", 13 + "john.test", 14 + "atproto.com", 15 + "low-end.example.com" 16 + ] do 17 + assert Handle.valid?(handle), "expected #{handle} to be valid" 18 + end 19 + end 20 + 21 + test "rejects malformed handles" do 22 + for handle <- [ 23 + "jo@hn.test", 24 + "john..test", 25 + "xn--bcher-.tld", 26 + "john.0", 27 + "cn.8", 28 + "org", 29 + "💩.test", 30 + "-john.test", 31 + "john-.test" 32 + ] do 33 + refute Handle.valid?(handle), "expected #{handle} to be invalid" 34 + end 35 + end 36 + 37 + test "rejects handles longer than 253 characters" do 38 + long = String.duplicate("a.", 130) <> "com" 39 + assert byte_size(long) > 253 40 + refute Handle.valid?(long) 41 + end 42 + end 43 + 44 + describe "normalize/1" do 45 + test "trims whitespace, strips a leading @, and downcases" do 46 + assert Handle.normalize(" @bnewold.BSKY.Social ") == "bnewold.bsky.social" 47 + end 48 + 49 + test "leaves an already-clean handle unchanged" do 50 + assert Handle.normalize("atproto.com") == "atproto.com" 51 + end 52 + end 53 + end
+103
test/annot_at/atproto/identity_test.exs
··· 1 + defmodule AnnotAt.Atproto.IdentityTest do 2 + use ExUnit.Case, async: true 3 + use Mimic 4 + 5 + alias AnnotAt.Atproto.DNS 6 + alias AnnotAt.Atproto.HTTP 7 + alias AnnotAt.Atproto.Identity 8 + 9 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 10 + @handle "atproto.com" 11 + @pds "https://enoki.us-east.host.bsky.network" 12 + 13 + @document "../../support/fixtures/atproto/did_document.json" 14 + |> Path.expand(__DIR__) 15 + |> File.read!() 16 + |> Jason.decode!() 17 + 18 + test "resolves via DNS and verifies bidirectionally" do 19 + expect(DNS, :lookup_txt, fn "_atproto.atproto.com" -> ["did=" <> @did] end) 20 + expect(HTTP, :get_json, fn "https://plc.directory/" <> _ -> {:ok, @document} end) 21 + reject(&HTTP.get_text/1) 22 + 23 + assert {:ok, identity} = Identity.resolve_handle(@handle) 24 + assert identity.did == @did 25 + assert identity.handle == @handle 26 + assert identity.pds_endpoint == @pds 27 + end 28 + 29 + test "normalizes input before resolving" do 30 + expect(DNS, :lookup_txt, fn "_atproto.atproto.com" -> ["did=" <> @did] end) 31 + expect(HTTP, :get_json, fn _ -> {:ok, @document} end) 32 + 33 + assert {:ok, identity} = Identity.resolve_handle(" @ATPROTO.com ") 34 + assert identity.handle == @handle 35 + end 36 + 37 + test "falls back to the HTTPS well-known method when DNS has no record" do 38 + expect(DNS, :lookup_txt, fn _ -> [] end) 39 + 40 + expect(HTTP, :get_text, fn "https://atproto.com/.well-known/atproto-did" -> 41 + {:ok, @did <> "\n"} 42 + end) 43 + 44 + expect(HTTP, :get_json, fn _ -> {:ok, @document} end) 45 + 46 + assert {:ok, identity} = Identity.resolve_handle(@handle) 47 + assert identity.did == @did 48 + end 49 + 50 + test "hard-fails on conflicting DNS records without trying HTTPS" do 51 + expect(DNS, :lookup_txt, fn _ -> 52 + ["did=did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", "did=did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"] 53 + end) 54 + 55 + reject(&HTTP.get_text/1) 56 + assert {:error, :ambiguous_dns} == Identity.resolve_handle(@handle) 57 + end 58 + 59 + test "rejects a DID document that claims a different handle" do 60 + expect(DNS, :lookup_txt, fn _ -> ["did=" <> @did] end) 61 + expect(HTTP, :get_json, fn _ -> {:ok, @document} end) 62 + 63 + assert {:error, :handle_mismatch} == Identity.resolve_handle("evil.example.com") 64 + end 65 + 66 + test "rejects a syntatically invalid handle before any lookup" do 67 + reject(&DNS.lookup_txt/1) 68 + assert {:error, :invalid_handle} == Identity.resolve_handle("not a handle") 69 + end 70 + 71 + test "resolves a did:web identity" do 72 + web_did = "did:web:pds.example.com" 73 + 74 + doc = %{ 75 + "id" => web_did, 76 + "alsoKnownAs" => ["at://example.com"], 77 + "service" => [ 78 + %{ 79 + "id" => "#atproto_pds", 80 + "type" => "AtprotoPersonalDataServer", 81 + "serviceEndpoint" => "https://pds.example.com" 82 + } 83 + ] 84 + } 85 + 86 + expect(DNS, :lookup_txt, fn _ -> ["did=" <> web_did] end) 87 + 88 + expect(HTTP, :get_json, fn "https://pds.example.com/.well-known/did.json" -> 89 + {:ok, doc} 90 + end) 91 + 92 + assert {:ok, identity} = Identity.resolve_handle("example.com") 93 + assert identity.did == web_did 94 + assert identity.pds_endpoint == "https://pds.example.com" 95 + end 96 + 97 + test "rejects an invalid DID from handle resolution before fetching" do 98 + expect(DNS, :lookup_txt, fn _ -> ["did=did:web:.evil.example.com"] end) 99 + reject(&HTTP.get_json/1) 100 + 101 + assert Identity.resolve_handle("evil.example.com") == {:error, :invalid_did} 102 + end 103 + end
+3
test/test_helper.exs
··· 1 + Mimic.copy(AnnotAt.Atproto.HTTP) 2 + Mimic.copy(AnnotAt.Atproto.DNS) 3 + 1 4 ExUnit.start() 2 5 Ecto.Adapters.SQL.Sandbox.mode(AnnotAt.Repo, :manual)