···8899## [Unreleased]
10101111+### Breaking Changes
1212+1313+- The `Atex.IdentityResolver` config key has been replaced with a flat config option.
1414+ Update your config from:
1515+1616+ ```elixir
1717+ config :atex, Atex.IdentityResolver,
1818+ directory_url: "https://plc.directory"
1919+ ```
2020+2121+ to:
2222+2323+ ```elixir
2424+ config :atex,
2525+ plc_directory_url: "https://plc.directory"
2626+ ```
2727+2828+- `Atex.Config.IdentityResolver` has been renamed to `Atex.Config`.
2929+- `Atex.IdentityResolver.DIDDocument` has been renamed to `Atex.PLC.DIDDocument`.
3030+3131+### Added
3232+3333+- `Atex.PLC` module for interacting with [a did:plc directory API](https://web.plc.directory/).
3434+3535+### Fixed
3636+1137- Fix a problem where generated `%<LexiconId>.Params` structs could not be
1238 passed to an XRPC call due to not having the Enumerable protocol implemented.
1339
···11-defmodule Atex.IdentityResolver.DIDDocument do
11+defmodule Atex.PLC.DIDDocument do
22 @moduledoc """
33 Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents).
44 """
···7171 |> Recase.Enumerable.convert_keys(&Recase.to_snake/1)
7272 |> schema()
7373 |> case do
7474- # {:ok, params} -> {:ok, struct(__MODULE__, params)}
7574 {:ok, params} -> {:ok, new(params)}
7675 e -> e
7776 end
···79788079 @spec validate_for_atproto(t(), String.t()) :: any()
8180 def validate_for_atproto(%__MODULE__{} = doc, did) do
8282- # TODO: make sure this is ok
8381 id_matches = doc.id == did
84828583 valid_signing_key =
+1-2
lib/atex/oauth/plug.ex
···9393 require Logger
9494 use Plug.Router
9595 require Plug.Router
9696- alias Atex.OAuth
9797- alias Atex.{IdentityResolver, IdentityResolver.DIDDocument}
9696+ alias Atex.{IdentityResolver, OAuth, PLC.DIDDocument}
98979998 @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600]
10099 @session_name :atex_session
+325
lib/atex/plc.ex
···11+defmodule Atex.PLC do
22+ @moduledoc """
33+ Client for the `did:plc` directory server HTTP API.
44+55+ `did:plc` is a self-authenticating DID method that is strongly-consistent,
66+ recoverable, and supports key rotation. The directory server receives and
77+ persists self-signed operation logs for each DID, starting with a genesis
88+ operation that defines the DID identifier itself.
99+1010+ The API is permissionless, but only correctly-signed operations are accepted.
1111+ The default server is `https://plc.directory`, but a custom host can be
1212+ supplied via the `:host` option available on all functions.
1313+1414+ ## Options
1515+1616+ All functions accept an optional `opts` keyword list. Supported keys:
1717+1818+ - `:host` - Base URL of the PLC directory server. Defaults to
1919+ `Atex.Config.directory_url/0`.
2020+2121+ ## Error returns
2222+2323+ Functions return `{:error, reason}` on failure. Common reasons:
2424+2525+ - `:not_found` - The DID is not registered (HTTP 404).
2626+ - `:tombstoned` - The DID has been permanently deactivated (HTTP 410).
2727+ - `:invalid_document` - The server returned a body that could not be parsed
2828+ into a `DIDDocument`.
2929+ - `{:invalid_operation, message}` - The submitted operation was rejected by
3030+ the server, with an explanatory message (HTTP 400).
3131+ - `:invalid_operation` - The submitted operation was rejected without a
3232+ message (HTTP 400).
3333+ - `%{status: status, body: body}` - An unexpected HTTP response.
3434+ - Any transport-level error from `Req`.
3535+ """
3636+3737+ @type error_map() :: %{status: pos_integer(), body: any()}
3838+ @type error() :: {:error, :not_found | :tombstoned | :invalid_document | error_map() | any()}
3939+ @type create_op_error() ::
4040+ {:error, {:invalid_operation, message :: String.t()} | :invalid_operation} | error()
4141+4242+ alias Atex.PLC.DIDDocument
4343+4444+ @doc """
4545+ Resolves the DID Document for the given `did:plc` identifier.
4646+4747+ Fetches the current DID Document from the directory server and parses it into
4848+ an `Atex.PLC.DIDDocument` struct.
4949+5050+ ## Parameters
5151+5252+ - `did` - A `did:plc` identifier string.
5353+ - `opts` - Optional keyword list. See module docs for supported keys.
5454+5555+ ## Examples
5656+5757+ iex> Atex.PLC.resolve_did("did:plc:ewvi7nxzyoun6zhxrhs64oiz")
5858+ {:ok, %Atex.PLC.DIDDocument{...}}
5959+6060+ iex> Atex.PLC.resolve_did("did:plc:doesnotexist")
6161+ {:error, :not_found}
6262+ """
6363+ @spec resolve_did(String.t(), keyword()) :: {:ok, DIDDocument.t()} | error()
6464+ def resolve_did(did, opts \\ []) do
6565+ opts
6666+ |> host()
6767+ |> URI.append_path("/#{did}")
6868+ |> Req.get()
6969+ |> handle_response()
7070+ |> case do
7171+ {:ok, body} ->
7272+ case DIDDocument.from_json(body) do
7373+ {:ok, document} -> {:ok, document}
7474+ {:error, _reason} -> {:error, :invalid_document}
7575+ end
7676+7777+ e ->
7878+ e
7979+ end
8080+ end
8181+8282+ @doc """
8383+ Returns the current operation chain for the given DID.
8484+8585+ This is the authoritative, ordered sequence of operations that make up the
8686+ DID's history. Unlike the audit log, nullified (overridden) operations are
8787+ not included.
8888+8989+ ## Parameters
9090+9191+ - `did` - A `did:plc` identifier string.
9292+ - `opts` - Optional keyword list. See module docs for supported keys.
9393+9494+ ## Examples
9595+9696+ iex> Atex.PLC.get_op_log("did:plc:ewvi7nxzyoun6zhxrhs64oiz")
9797+ {:ok, [%{"type" => "plc_operation", ...}]}
9898+ """
9999+ @spec get_op_log(String.t(), keyword()) :: {:ok, any()} | error()
100100+ def get_op_log(did, opts \\ []) do
101101+ opts
102102+ |> host()
103103+ |> URI.append_path("/#{did}/log")
104104+ |> Req.get()
105105+ |> handle_response()
106106+ end
107107+108108+ @doc """
109109+ Returns the full audit log for the given DID.
110110+111111+ Includes every operation ever submitted for the DID, including those that
112112+ have been nullified (overridden by a recovery or conflicting operation). Each
113113+ entry is a log entry map containing the operation, its CID hash, a
114114+ `nullified` flag, and the timestamp at which the directory received it.
115115+116116+ ## Parameters
117117+118118+ - `did` - A `did:plc` identifier string.
119119+ - `opts` - Optional keyword list. See module docs for supported keys.
120120+121121+ ## Examples
122122+123123+ iex> Atex.PLC.get_audit_log("did:plc:ewvi7nxzyoun6zhxrhs64oiz")
124124+ {:ok, [%{"did" => "did:plc:ewvi7nxzyoun6zhxrhs64oiz", "nullified" => false, ...}]}
125125+ """
126126+ @spec get_audit_log(String.t(), keyword()) :: {:ok, any()} | error()
127127+ def get_audit_log(did, opts \\ []) do
128128+ opts
129129+ |> host()
130130+ |> URI.append_path("/#{did}/log/audit")
131131+ |> Req.get()
132132+ |> handle_response()
133133+ end
134134+135135+ @doc """
136136+ Returns the most recent operation in the operation chain for the given DID.
137137+138138+ Useful for obtaining the `prev` CID reference required when constructing a
139139+ new signed operation.
140140+141141+ ## Parameters
142142+143143+ - `did` - A `did:plc` identifier string.
144144+ - `opts` - Optional keyword list. See module docs for supported keys.
145145+146146+ ## Examples
147147+148148+ iex> Atex.PLC.get_last_op("did:plc:ewvi7nxzyoun6zhxrhs64oiz")
149149+ {:ok, %{"type" => "plc_operation", "prev" => nil, ...}}
150150+ """
151151+ @spec get_last_op(String.t(), keyword()) :: {:ok, any()} | error()
152152+ def get_last_op(did, opts \\ []) do
153153+ opts
154154+ |> host()
155155+ |> URI.append_path("/#{did}/log/last")
156156+ |> Req.get()
157157+ |> handle_response()
158158+ end
159159+160160+ @doc """
161161+ Returns the current PLC data for the given DID.
162162+163163+ The response is similar to an operation map but may omit some fields. It
164164+ reflects the effective state derived from the current operation chain.
165165+166166+ ## Parameters
167167+168168+ - `did` - A `did:plc` identifier string.
169169+ - `opts` - Optional keyword list. See module docs for supported keys.
170170+171171+ ## Examples
172172+173173+ iex> Atex.PLC.get_data("did:plc:ewvi7nxzyoun6zhxrhs64oiz")
174174+ {:ok, %{"rotationKeys" => [...], "verificationMethods" => %{}, ...}}
175175+ """
176176+ @spec get_data(String.t(), keyword()) :: {:ok, any()} | error()
177177+ def get_data(did, opts \\ []) do
178178+ opts
179179+ |> host()
180180+ |> URI.append_path("/#{did}/data")
181181+ |> Req.get()
182182+ |> handle_response()
183183+ end
184184+185185+ @doc """
186186+ Bulk-fetches PLC operations across all DIDs.
187187+188188+ Results are paginated and returned as a list of log entry maps. Each entry
189189+ contains the DID, the operation, its CID hash, a `nullified` flag, and the
190190+ server-assigned `createdAt` timestamp.
191191+192192+ ## Parameters
193193+194194+ - `opts` - Optional keyword list. Supported keys:
195195+ - `:host` - Base URL of the PLC directory server.
196196+ - `:count` - Number of records to return (default: `10`, max: `1000`).
197197+ - `:after` - ISO 8601 datetime string; return only operations indexed after
198198+ this timestamp. Useful for cursor-based pagination.
199199+200200+ ## Examples
201201+202202+ iex> Atex.PLC.export(count: 2)
203203+ {:ok, [%{"did" => "did:plc:...", "nullified" => false, ...}, ...]}
204204+205205+ iex> Atex.PLC.export(count: 100, after: "2024-01-01T00:00:00Z")
206206+ {:ok, [...]}
207207+ """
208208+ @spec export(keyword()) :: {:ok, list(any())} | error()
209209+ def export(opts \\ []) do
210210+ {_, query} = Keyword.pop(opts, :host)
211211+ query = URI.encode_query(query)
212212+213213+ opts
214214+ |> host()
215215+ |> URI.append_path("/export")
216216+ |> URI.append_query(query)
217217+ |> Req.get()
218218+ |> handle_response(:jsonlines)
219219+ end
220220+221221+ @doc """
222222+ Submits a new signed PLC operation for the given DID.
223223+224224+ The `operation` map must be a fully-formed, self-signed PLC operation. The
225225+ server validates the signature and the operation's position in the chain
226226+ before accepting it.
227227+228228+ Supported operation types:
229229+230230+ - `"plc_operation"` - A standard update or genesis operation. Required fields:
231231+ `type`, `rotationKeys`, `verificationMethods`, `alsoKnownAs`, `services`,
232232+ `prev`, `sig`.
233233+ - `"plc_tombstone"` - Permanently deactivates the DID. Required fields:
234234+ `type`, `prev`, `sig`.
235235+ - `"create"` - Legacy genesis operation format (still supported for
236236+ historical resolution).
237237+238238+ ## Parameters
239239+240240+ - `did` - A `did:plc` identifier string.
241241+ - `operation` - A map representing the signed PLC operation.
242242+ - `opts` - Optional keyword list. See module docs for supported keys.
243243+244244+ ## Examples
245245+246246+ iex> op = %{
247247+ ...> "type" => "plc_operation",
248248+ ...> "rotationKeys" => ["did:key:..."],
249249+ ...> "verificationMethods" => %{"atproto" => "did:key:..."},
250250+ ...> "alsoKnownAs" => ["at://handle.bsky.social"],
251251+ ...> "services" => %{"atproto_pds" => %{"type" => "AtprotoPersonalDataServer", "endpoint" => "https://bsky.social"}},
252252+ ...> "prev" => "bafyreid6awsb6lzc54zxaq2roijyvpbjp5d6mii2xyztn55yli7htyjgqy",
253253+ ...> "sig" => "..."
254254+ ...> }
255255+ iex> Atex.PLC.create_op("did:plc:ewvi7nxzyoun6zhxrhs64oiz", op)
256256+ {:ok, nil}
257257+258258+ iex> Atex.PLC.create_op("did:plc:ewvi7nxzyoun6zhxrhs64oiz", %{"sig" => "bad"})
259259+ {:error, {:invalid_operation, "Invalid Signature"}}
260260+ """
261261+ @spec create_op(String.t(), map(), keyword()) :: {:ok, any()} | create_op_error()
262262+ def create_op(did, operation, opts \\ []) do
263263+ # TODO: add a signing key option to automatically sign operation?
264264+ # TODO: require Operation struct
265265+266266+ opts
267267+ |> host()
268268+ |> URI.append_path("/#{did}")
269269+ |> Req.post(json: operation)
270270+ |> case do
271271+ {:ok, %{status: 200, body: body}} when body in ["", nil] ->
272272+ {:ok, nil}
273273+274274+ {:ok, %{status: 200, body: body}} ->
275275+ JSON.decode(body)
276276+277277+ {:ok, %{status: 400, body: %{"message" => message}}} ->
278278+ {:error, {:invalid_operation, message}}
279279+280280+ {:ok, %{status: 400}} ->
281281+ {:error, :invalid_operation}
282282+283283+ result ->
284284+ handle_response(result)
285285+ end
286286+ end
287287+288288+ @spec handle_response({:ok, Req.Response.t()} | {:error, any()}, :json | :jsonlines) ::
289289+ {:ok, any()} | error()
290290+ defp handle_response(response_tuple, expected_body_type \\ :json)
291291+292292+ # DID document response from PLC uses a non-standard Content-Type header which
293293+ # Req doesn't recognise, so have to handle it manually.
294294+ defp handle_response({:ok, %{status: 200, body: body}}, :json) when is_binary(body),
295295+ do: JSON.decode(body)
296296+297297+ defp handle_response({:ok, %{status: 200, body: body}}, :json), do: {:ok, body}
298298+299299+ defp handle_response({:ok, %{status: 200, body: body}}, :jsonlines),
300300+ do: {:ok, decode_jsonlines(body)}
301301+302302+ defp handle_response({:ok, %{status: 404}}, _type), do: {:error, :not_found}
303303+ defp handle_response({:ok, %{status: 410}}, _type), do: {:error, :tombstoned}
304304+ defp handle_response({:ok, resp}, _type), do: {:error, %{status: resp.status, body: resp.body}}
305305+ defp handle_response({:error, reason}, _type), do: {:error, reason}
306306+307307+ @spec decode_jsonlines(binary()) :: [any()]
308308+ defp decode_jsonlines(body) when is_binary(body) do
309309+ body
310310+ |> String.split("\n")
311311+ |> Enum.reject(&(&1 == ""))
312312+ |> Enum.flat_map(fn line ->
313313+ case JSON.decode(line) do
314314+ {:ok, entry} -> [entry]
315315+ {:error, _} -> []
316316+ end
317317+ end)
318318+ end
319319+320320+ @spec host(keyword()) :: URI.t()
321321+ defp host(opts) do
322322+ host = Keyword.get(opts, :host, Atex.Config.directory_url())
323323+ URI.new!(host)
324324+ end
325325+end