···12121313- `Atex.XRPC.UnauthedClient` module for running unauthenticated XRPC fetches on
1414 public APIs or PDSes.
1515+- `Atex.NSID.authority_domain/1` for deriving the `_lexicon.<authority>` DNS
1616+ name from an NSID.
1717+- `Atex.Lexicon.Resolver` module for resolving published lexicons by NSID,
1818+ following the
1919+ [publication and resolution spec](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution).
2020+- `mix atex.lexicons.resolve` task for resolving one or more lexicons by NSID
2121+ and writing to a JSON file.
15221623## [0.8.0] - 2026-03-29
1724
+137
lib/atex/lexicon/resolver.ex
···11+defmodule Atex.Lexicon.Resolver do
22+ @moduledoc """
33+ Resolves published AT Protocol Lexicon schemas by NSID.
44+55+ Implements the [Lexicon publication and resolution](https://atproto.com/specs/lexicon#lexicon-publication-and-resolution)
66+ specification, which uses DNS TXT records to locate the atproto repository
77+ that hosts a given NSID's schema, then fetches the record directly from
88+ the repository's PDS.
99+1010+ Per the specification, DNS resolution is not hierarchical: if the TXT
1111+ record for the exact authority domain is not found, resolution fails
1212+ immediately without traversing the DNS hierarchy.
1313+1414+ ## Error returns
1515+1616+ `resolve/1` returns `{:error, reason}` on failure. Possible reasons:
1717+1818+ - `:invalid_nsid` - the given string is not a valid NSID.
1919+ - `:dns_resolution_failed` - no valid `did=<did>` TXT record was found for
2020+ the authority domain.
2121+ - `:did_resolution_failed` - the DID from DNS could not be resolved to a
2222+ DID document.
2323+ - `:no_pds_endpoint` - the DID document does not contain a valid PDS service
2424+ endpoint.
2525+ - `:record_not_found` - the PDS has no lexicon record for the given NSID.
2626+ - `:invalid_record` - the PDS returned a response that could not be
2727+ interpreted as a lexicon record.
2828+ - Any transport-level error from `Req`.
2929+ """
3030+3131+ alias Atex.{DID, NSID, Util, XRPC}
3232+ alias Atex.IdentityResolver.DID, as: DIDResolver
3333+3434+ @collection "com.atproto.lexicon.schema"
3535+3636+ @doc """
3737+ Resolves the lexicon schema for the given NSID.
3838+3939+ Performs DNS-based authority lookup followed by an atproto record fetch to
4040+ retrieve the raw lexicon JSON map.
4141+4242+ ## Parameters
4343+4444+ - `nsid` - A valid AT Protocol NSID string.
4545+4646+ ## Examples
4747+4848+ iex> Atex.Lexicon.Resolver.resolve("app.bsky.feed.post")
4949+ {:ok, %{"lexicon" => 1, "id" => "app.bsky.feed.post", "defs" => %{...}}}
5050+5151+ iex> Atex.Lexicon.Resolver.resolve("not.valid!")
5252+ {:error, :invalid_nsid}
5353+ """
5454+ @spec resolve(String.t()) ::
5555+ {:ok, map()}
5656+ | {:error,
5757+ :invalid_nsid
5858+ | :dns_resolution_failed
5959+ | :did_resolution_failed
6060+ | :no_pds_endpoint
6161+ | :record_not_found
6262+ | :invalid_record
6363+ | any()}
6464+ def resolve(nsid) do
6565+ with {:ok, authority_domain} <- NSID.authority_domain(nsid),
6666+ {:ok, did} <- resolve_did_from_dns(authority_domain),
6767+ {:ok, document} <- resolve_did_document(did),
6868+ {:ok, pds_endpoint} <- get_pds_endpoint(document) do
6969+ fetch_record(pds_endpoint, did, nsid)
7070+ end
7171+ end
7272+7373+ @spec resolve_did_from_dns(String.t()) :: {:ok, String.t()} | {:error, :dns_resolution_failed}
7474+ defp resolve_did_from_dns(authority_domain) do
7575+ authority_domain
7676+ |> Util.query_dns(:txt)
7777+ |> Enum.find_value(fn
7878+ "did=" <> did -> if DID.match?(did), do: did
7979+ _ -> nil
8080+ end)
8181+ |> case do
8282+ nil -> {:error, :dns_resolution_failed}
8383+ did -> {:ok, did}
8484+ end
8585+ end
8686+8787+ @spec resolve_did_document(String.t()) ::
8888+ {:ok, DID.Document.t()} | {:error, :did_resolution_failed}
8989+ defp resolve_did_document(did) do
9090+ case DIDResolver.resolve(did) do
9191+ {:ok, document} -> {:ok, document}
9292+ _ -> {:error, :did_resolution_failed}
9393+ end
9494+ end
9595+9696+ @spec get_pds_endpoint(DID.Document.t()) ::
9797+ {:ok, String.t()} | {:error, :no_pds_endpoint}
9898+ defp get_pds_endpoint(document) do
9999+ case DID.Document.get_pds_endpoint(document) do
100100+ nil -> {:error, :no_pds_endpoint}
101101+ endpoint -> {:ok, endpoint}
102102+ end
103103+ end
104104+105105+ @spec fetch_record(String.t(), String.t(), String.t()) ::
106106+ {:ok, map()} | {:error, :record_not_found | :invalid_record | any()}
107107+ defp fetch_record(pds_endpoint, did, nsid) do
108108+ client = XRPC.UnauthedClient.new(pds_endpoint)
109109+110110+ case XRPC.get(client, "com.atproto.repo.getRecord",
111111+ params: [repo: did, collection: @collection, rkey: nsid]
112112+ ) do
113113+ {:ok, %{status: 200, body: body}, _} -> parse_record_body(body)
114114+ {:ok, %{status: 404}} -> {:error, :record_not_found}
115115+ {:ok, %{status: status, body: body}} -> {:error, %{status: status, body: body}}
116116+ {:error, reason} -> {:error, reason}
117117+ end
118118+ end
119119+120120+ @spec parse_record_body(map() | binary()) ::
121121+ {:ok, map()} | {:error, :invalid_record}
122122+ defp parse_record_body(body) when is_map(body) do
123123+ case Map.fetch(body, "value") do
124124+ {:ok, value} when is_map(value) -> {:ok, value}
125125+ _ -> {:error, :invalid_record}
126126+ end
127127+ end
128128+129129+ defp parse_record_body(body) when is_binary(body) do
130130+ case JSON.decode(body) do
131131+ {:ok, map} -> parse_record_body(map)
132132+ _ -> {:error, :invalid_record}
133133+ end
134134+ end
135135+136136+ defp parse_record_body(_), do: {:error, :invalid_record}
137137+end
+36-3
lib/atex/nsid.ex
···88 @spec match?(String.t()) :: boolean()
99 def match?(value), do: Regex.match?(@re, value)
10101111- # TODO: methods for fetching the authority and name from a nsid.
1212- # maybe stuff for fetching the repo that belongs to an authority
1313-1411 @spec to_atom(String.t()) :: atom()
1512 def to_atom(nsid, fully_qualify \\ true) do
1613 nsid
···5249 nsid
5350 else
5451 "#{nsid}##{fragment}"
5252+ end
5353+ end
5454+5555+ @doc """
5656+ Returns the DNS authority domain for a given NSID, as used for lexicon
5757+ resolution via DNS TXT records.
5858+5959+ The authority domain is derived by stripping the final name segment from the
6060+ NSID, reversing the remaining authority parts, and prepending `_lexicon.`.
6161+6262+ Returns `{:error, :invalid_nsid}` if the input is not a valid NSID.
6363+6464+ ## Examples
6565+6666+ iex> Atex.NSID.authority_domain("app.bsky.feed.post")
6767+ {:ok, "_lexicon.feed.bsky.app"}
6868+6969+ iex> Atex.NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost")
7070+ {:ok, "_lexicon.blogging.lab.dept.university.edu"}
7171+7272+ iex> Atex.NSID.authority_domain("invalid")
7373+ {:error, :invalid_nsid}
7474+ """
7575+ @spec authority_domain(String.t()) :: {:ok, String.t()} | {:error, :invalid_nsid}
7676+ def authority_domain(nsid) do
7777+ if match?(nsid) do
7878+ authority =
7979+ nsid
8080+ |> String.split(".")
8181+ |> Enum.drop(-1)
8282+ |> Enum.reverse()
8383+ |> Enum.join(".")
8484+8585+ {:ok, "_lexicon.#{authority}"}
8686+ else
8787+ {:error, :invalid_nsid}
5588 end
5689 end
5790end
+87
lib/mix/tasks/atex.lexicons.resolve.ex
···11+defmodule Mix.Tasks.Atex.Lexicons.Resolve do
22+ @moduledoc """
33+ Resolve published AT Protocol lexicons by NSID and write them to JSON files.
44+55+ Lexicon schemas are published as records in atproto repositories. This task
66+ resolves one or more NSIDs using DNS-based authority lookup (via
77+ `_lexicon.<authority>` TXT records) and fetches the schema directly from the
88+ authoritative PDS, then writes each schema to disk as a JSON file.
99+1010+ See `Atex.Lexicon.Resolver` for programmatic usage.
1111+1212+ ## Usage
1313+1414+ mix atex.lexicons.resolve [OPTIONS] <NSID> [<NSID> ...]
1515+1616+ ## Arguments
1717+1818+ - `NSID` - One or more AT Protocol NSIDs to resolve.
1919+2020+ ## Options
2121+2222+ - `-o`/`--output` - Output directory for resolved lexicon JSON files
2323+ (default: `lexicons`).
2424+2525+ ## Examples
2626+2727+ Resolve a single lexicon:
2828+2929+ mix atex.lexicons.resolve app.bsky.feed.post
3030+3131+ Resolve multiple lexicons into a custom directory:
3232+3333+ mix atex.lexicons.resolve --output priv/lexicons app.bsky.feed.post com.atproto.repo.createRecord
3434+ """
3535+ @shortdoc "Resolve published AT Protocol lexicons by NSID."
3636+3737+ use Mix.Task
3838+3939+ alias Atex.Lexicon.Resolver
4040+4141+ @switches [output: :string]
4242+ @aliases [o: :output]
4343+4444+ @impl true
4545+ def run(args) do
4646+ Mix.Task.run("app.start")
4747+4848+ {options, nsids} = OptionParser.parse!(args, switches: @switches, aliases: @aliases)
4949+5050+ output = Keyword.get(options, :output, "lexicons")
5151+5252+ if nsids == [] do
5353+ Mix.shell().error("No NSIDs provided. Usage: mix atex.lexicons.resolve <NSID> [<NSID> ...]")
5454+ else
5555+ Mix.shell().info("Resolving #{length(nsids)} lexicon(s) into #{output}/")
5656+5757+ Enum.each(nsids, fn nsid ->
5858+ Mix.shell().info("- Resolving #{nsid}...")
5959+6060+ case Resolver.resolve(nsid) do
6161+ {:ok, lexicon} ->
6262+ path = nsid_to_path(nsid, output)
6363+6464+ path
6565+ |> Path.dirname()
6666+ |> File.mkdir_p!()
6767+6868+ File.write!(path, Jason.encode!(lexicon, pretty: true))
6969+ Mix.shell().info(" Written to #{path}")
7070+7171+ {:error, reason} ->
7272+ Mix.shell().error(" Failed to resolve #{nsid}: #{inspect(reason)}")
7373+ end
7474+ end)
7575+ end
7676+ end
7777+7878+ @doc false
7979+ @spec nsid_to_path(String.t(), String.t()) :: String.t()
8080+ def nsid_to_path(nsid, output) do
8181+ nsid
8282+ |> String.split(".")
8383+ |> Enum.join("/")
8484+ |> then(&(&1 <> ".json"))
8585+ |> then(&Path.join(output, &1))
8686+ end
8787+end
+220
test/atex/lexicon/resolver_test.exs
···11+defmodule Atex.Lexicon.ResolverTest do
22+ use ExUnit.Case, async: true
33+44+ import Mox
55+66+ alias Atex.Lexicon.Resolver
77+ alias Atex.Lexicon.Resolver.{MockDIDClient, MockDNSClient}
88+ alias Atex.NSID
99+1010+ setup :verify_on_exit!
1111+1212+ # ---------------------------------------------------------------------------
1313+ # NSID.authority_domain/1
1414+ # ---------------------------------------------------------------------------
1515+1616+ describe "NSID.authority_domain/1" do
1717+ test "converts a standard 4-part NSID" do
1818+ assert {:ok, "_lexicon.feed.bsky.app"} = NSID.authority_domain("app.bsky.feed.post")
1919+ end
2020+2121+ test "matches the spec example" do
2222+ assert {:ok, "_lexicon.blogging.lab.dept.university.edu"} =
2323+ NSID.authority_domain("edu.university.dept.lab.blogging.getBlogPost")
2424+ end
2525+2626+ test "handles a minimal 3-segment NSID" do
2727+ assert {:ok, "_lexicon.example.com"} = NSID.authority_domain("com.example.record")
2828+ end
2929+3030+ test "handles NSIDs with numbers in segments" do
3131+ assert {:ok, "_lexicon.v0.comet.sh"} = NSID.authority_domain("sh.comet.v0.feed")
3232+ end
3333+3434+ test "returns error for a plain string without dots" do
3535+ assert {:error, :invalid_nsid} = NSID.authority_domain("invalid")
3636+ end
3737+3838+ test "returns error for an empty string" do
3939+ assert {:error, :invalid_nsid} = NSID.authority_domain("")
4040+ end
4141+4242+ test "returns error for a string with invalid characters" do
4343+ assert {:error, :invalid_nsid} = NSID.authority_domain("not.valid!")
4444+ end
4545+ end
4646+4747+ # ---------------------------------------------------------------------------
4848+ # Resolver.resolve/1 — invalid NSID (no network calls)
4949+ # ---------------------------------------------------------------------------
5050+5151+ describe "resolve/1 with invalid NSID" do
5252+ test "returns :invalid_nsid for a plain string" do
5353+ assert {:error, :invalid_nsid} = Resolver.resolve("notannsid")
5454+ end
5555+5656+ test "returns :invalid_nsid for an empty string" do
5757+ assert {:error, :invalid_nsid} = Resolver.resolve("")
5858+ end
5959+6060+ test "returns :invalid_nsid for a string with illegal characters" do
6161+ assert {:error, :invalid_nsid} = Resolver.resolve("not.valid!")
6262+ end
6363+ end
6464+6565+ # ---------------------------------------------------------------------------
6666+ # Resolver.resolve/1 — DNS failure
6767+ # ---------------------------------------------------------------------------
6868+6969+ describe "resolve/1 with DNS failure" do
7070+ test "returns :dns_resolution_failed when DNS returns no TXT records" do
7171+ expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" -> [] end)
7272+7373+ assert {:error, :dns_resolution_failed} = Resolver.resolve("app.bsky.feed.post")
7474+ end
7575+7676+ test "returns :dns_resolution_failed when TXT record has wrong prefix" do
7777+ expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" ->
7878+ [[~c"notadid=something"]]
7979+ end)
8080+8181+ assert {:error, :dns_resolution_failed} = Resolver.resolve("app.bsky.feed.post")
8282+ end
8383+8484+ test "returns :dns_resolution_failed when TXT value is not a valid DID" do
8585+ expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" ->
8686+ [[~c"did=notadidatall"]]
8787+ end)
8888+8989+ assert {:error, :dns_resolution_failed} = Resolver.resolve("app.bsky.feed.post")
9090+ end
9191+ end
9292+9393+ # ---------------------------------------------------------------------------
9494+ # Resolver.resolve/1 — DID resolution failure
9595+ # ---------------------------------------------------------------------------
9696+9797+ describe "resolve/1 with DID resolution failure" do
9898+ test "returns :did_resolution_failed when DID resolver errors" do
9999+ expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" ->
100100+ [[~c"did=did:plc:ewvi7nxzyoun6zhxrhs64oiz"]]
101101+ end)
102102+103103+ expect(MockDIDClient, :resolve, fn "did:plc:ewvi7nxzyoun6zhxrhs64oiz" ->
104104+ {:error, :not_found}
105105+ end)
106106+107107+ assert {:error, :did_resolution_failed} = Resolver.resolve("app.bsky.feed.post")
108108+ end
109109+ end
110110+111111+ # ---------------------------------------------------------------------------
112112+ # Resolver.resolve/1 — no PDS endpoint
113113+ # ---------------------------------------------------------------------------
114114+115115+ @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz"
116116+ @pds "https://pds.example.com"
117117+ @nsid "app.bsky.feed.post"
118118+119119+ @did_doc_no_pds %Atex.DID.Document{
120120+ "@context": ["https://www.w3.org/ns/did/v1"],
121121+ id: @did
122122+ }
123123+124124+ @did_doc_with_pds %Atex.DID.Document{
125125+ "@context": ["https://www.w3.org/ns/did/v1"],
126126+ id: @did,
127127+ service: [
128128+ %Atex.DID.Document.Service{
129129+ id: "#{@did}#atproto_pds",
130130+ type: "AtprotoPersonalDataServer",
131131+ service_endpoint: @pds
132132+ }
133133+ ]
134134+ }
135135+136136+ defp stub_dns_and_did(did_doc) do
137137+ expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" ->
138138+ [[~c"did=#{@did}"]]
139139+ end)
140140+141141+ expect(MockDIDClient, :resolve, fn @did -> {:ok, did_doc} end)
142142+ end
143143+144144+ describe "resolve/1 with no PDS endpoint" do
145145+ test "returns :no_pds_endpoint when DID document has no PDS service" do
146146+ stub_dns_and_did(@did_doc_no_pds)
147147+148148+ assert {:error, :no_pds_endpoint} = Resolver.resolve(@nsid)
149149+ end
150150+ end
151151+152152+ # ---------------------------------------------------------------------------
153153+ # Resolver.resolve/1 — record fetch (Req.Test plug)
154154+ # ---------------------------------------------------------------------------
155155+156156+ @lexicon_value %{
157157+ "lexicon" => 1,
158158+ "id" => @nsid,
159159+ "defs" => %{
160160+ "main" => %{
161161+ "type" => "record",
162162+ "key" => "tid",
163163+ "record" => %{"type" => "object", "properties" => %{}}
164164+ }
165165+ }
166166+ }
167167+168168+ defp stub_through_to_pds do
169169+ expect(MockDNSClient, :lookup_txt, fn "_lexicon.feed.bsky.app" ->
170170+ [[~c"did=#{@did}"]]
171171+ end)
172172+173173+ expect(MockDIDClient, :resolve, fn @did -> {:ok, @did_doc_with_pds} end)
174174+ end
175175+176176+ defp record_plug(record_resp) do
177177+ fn conn ->
178178+ case record_resp do
179179+ :not_found ->
180180+ Plug.Conn.send_resp(conn, 404, ~s({"error":"RecordNotFound"}))
181181+182182+ :missing_value ->
183183+ Req.Test.json(conn, %{"uri" => "at://#{@did}/com.atproto.lexicon.schema/#{@nsid}"})
184184+185185+ value ->
186186+ Req.Test.json(conn, %{
187187+ "uri" => "at://#{@did}/com.atproto.lexicon.schema/#{@nsid}",
188188+ "value" => value
189189+ })
190190+ end
191191+ end
192192+ end
193193+194194+ describe "resolve/1 — record fetch (Req.Test plug)" do
195195+ test "returns the lexicon value map on success" do
196196+ stub_through_to_pds()
197197+198198+ result = Resolver.resolve(@nsid, plug: record_plug(@lexicon_value))
199199+200200+ assert {:ok, lexicon} = result
201201+ assert lexicon["id"] == @nsid
202202+ assert lexicon["lexicon"] == 1
203203+ assert is_map(lexicon["defs"])
204204+ end
205205+206206+ test "returns :record_not_found when PDS returns 404" do
207207+ stub_through_to_pds()
208208+209209+ assert {:error, :record_not_found} =
210210+ Resolver.resolve(@nsid, plug: record_plug(:not_found))
211211+ end
212212+213213+ test "returns :invalid_record when PDS response has no value key" do
214214+ stub_through_to_pds()
215215+216216+ assert {:error, :invalid_record} =
217217+ Resolver.resolve(@nsid, plug: record_plug(:missing_value))
218218+ end
219219+ end
220220+end