An Elixir toolkit for the AT Protocol. hexdocs.pm/atex
elixir bluesky atproto decentralization
25
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: plc client

+388 -48
+26
CHANGELOG.md
··· 8 8 9 9 ## [Unreleased] 10 10 11 + ### Breaking Changes 12 + 13 + - The `Atex.IdentityResolver` config key has been replaced with a flat config option. 14 + Update your config from: 15 + 16 + ```elixir 17 + config :atex, Atex.IdentityResolver, 18 + directory_url: "https://plc.directory" 19 + ``` 20 + 21 + to: 22 + 23 + ```elixir 24 + config :atex, 25 + plc_directory_url: "https://plc.directory" 26 + ``` 27 + 28 + - `Atex.Config.IdentityResolver` has been renamed to `Atex.Config`. 29 + - `Atex.IdentityResolver.DIDDocument` has been renamed to `Atex.PLC.DIDDocument`. 30 + 31 + ### Added 32 + 33 + - `Atex.PLC` module for interacting with [a did:plc directory API](https://web.plc.directory/). 34 + 35 + ### Fixed 36 + 11 37 - Fix a problem where generated `%<LexiconId>.Params` structs could not be 12 38 passed to an XRPC call due to not having the Enumerable protocol implemented. 13 39
+2 -2
config/runtime.exs
··· 9 9 "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyIpxhuDm0i3mPkrk6UdX4Sd9Jsv6YtAmSTza+A2nArShRANCAAQLF1GLueOBZOVnKWfrcnoDOO9NSRqH2utmfGMz+Rce18MDB7Z6CwFWjEq2UFYNBI4MI5cMI0+m+UYAmj4OZm+m", 10 10 key_id: "awooga" 11 11 12 - config :atex, Atex.IdentityResolver, 13 - directory_url: "https://plc.directory" 12 + config :atex, 13 + plc_directory_url: "https://plc.directory"
+25
lib/atex/config.ex
··· 1 + defmodule Atex.Config do 2 + @moduledoc """ 3 + Library-wide configuration for `Atex`. 4 + 5 + ## Configuration 6 + 7 + The following keys are supported under `config :atex`: 8 + 9 + config :atex, 10 + plc_directory_url: "https://plc.directory" 11 + 12 + - `:plc_directory_url` — Base URL for the did:plc directory server. 13 + Defaults to `"https://plc.directory"`. 14 + """ 15 + 16 + @doc """ 17 + Returns the configured base URL for the did:plc directory server. 18 + 19 + Reads `:plc_directory_url` from the `:atex` application environment. 20 + Defaults to `"https://plc.directory"`. 21 + """ 22 + @spec directory_url :: String.t() 23 + def directory_url, 24 + do: Application.get_env(:atex, :plc_directory_url, "https://plc.directory") 25 + end
-26
lib/atex/config/identity_resolver.ex
··· 1 - defmodule Atex.Config.IdentityResolver do 2 - @moduledoc """ 3 - Configuration management for `Atex.IdentityResolver`. 4 - 5 - Contains all configuration logic for fetching identity documents. 6 - 7 - ## Configuration 8 - 9 - The following structure is expected in your application config: 10 - 11 - config :atex, Atex.IdentityResolver, 12 - directory_url: "https://plc.directory" # An address to a did:plc document host 13 - """ 14 - 15 - @doc """ 16 - Returns the configured URL for PLC queries. 17 - """ 18 - @spec directory_url :: String.t() 19 - def directory_url(), 20 - do: 21 - Keyword.get( 22 - Application.get_env(:atex, Atex.IdentityResolver, []), 23 - :directory_url, 24 - "https://plc.directory" 25 - ) 26 - end
+2 -1
lib/atex/identity_resolver.ex
··· 1 1 defmodule Atex.IdentityResolver do 2 - alias Atex.IdentityResolver.{Cache, DID, DIDDocument, Handle, Identity} 2 + alias Atex.IdentityResolver.{Cache, DID, Handle, Identity} 3 + alias Atex.PLC.DIDDocument 3 4 4 5 @handle_strategy Application.compile_env(:atex, :handle_resolver_strategy, :dns_first) 5 6 @type options() :: {:skip_cache, boolean()}
+6 -14
lib/atex/identity_resolver/did.ex
··· 1 1 defmodule Atex.IdentityResolver.DID do 2 - alias Atex.IdentityResolver.DIDDocument 3 - alias Atex.Config.IdentityResolver, as: Config 2 + alias Atex.PLC 4 3 5 4 @type resolution_result() :: 6 - {:ok, DIDDocument.t()} 5 + {:ok, PLC.DIDDocument.t()} 7 6 | {:error, :invalid_did_type | :invalid_did | :not_found | map() | atom() | any()} 8 7 9 8 @spec resolve(String.t()) :: resolution_result() ··· 14 13 15 14 @spec resolve_plc(String.t()) :: resolution_result() 16 15 defp resolve_plc("did:plc:" <> _id = did) do 17 - with {:ok, resp} when resp.status in 200..299 <- 18 - Req.get("#{Config.directory_url()}/#{did}"), 19 - {:ok, body} <- decode_body(resp.body), 20 - {:ok, document} <- DIDDocument.from_json(body), 21 - :ok <- DIDDocument.validate_for_atproto(document, did) do 16 + with {:ok, document} <- PLC.resolve_did(did), 17 + :ok <- PLC.DIDDocument.validate_for_atproto(document, did) do 22 18 {:ok, document} 23 - else 24 - {:ok, %{status: status}} when status in [404, 410] -> {:error, :not_found} 25 - {:ok, %{} = resp} -> {:error, resp} 26 - e -> e 27 19 end 28 20 end 29 21 ··· 32 24 with {:ok, resp} when resp.status in 200..299 <- 33 25 Req.get("https://#{domain}/.well-known/did.json"), 34 26 {:ok, body} <- decode_body(resp.body), 35 - {:ok, document} <- DIDDocument.from_json(body), 36 - :ok <- DIDDocument.validate_for_atproto(document, did) do 27 + {:ok, document} <- PLC.DIDDocument.from_json(body), 28 + :ok <- PLC.DIDDocument.validate_for_atproto(document, did) do 37 29 {:ok, document} 38 30 else 39 31 {:ok, %{status: 404}} -> {:error, :not_found}
+1 -3
lib/atex/identity_resolver/did_document.ex lib/atex/plc/did_document.ex
··· 1 - defmodule Atex.IdentityResolver.DIDDocument do 1 + defmodule Atex.PLC.DIDDocument do 2 2 @moduledoc """ 3 3 Struct and schema for describing and validating a [DID document](https://github.com/w3c/did-wg/blob/main/did-explainer.md#did-documents). 4 4 """ ··· 71 71 |> Recase.Enumerable.convert_keys(&Recase.to_snake/1) 72 72 |> schema() 73 73 |> case do 74 - # {:ok, params} -> {:ok, struct(__MODULE__, params)} 75 74 {:ok, params} -> {:ok, new(params)} 76 75 e -> e 77 76 end ··· 79 78 80 79 @spec validate_for_atproto(t(), String.t()) :: any() 81 80 def validate_for_atproto(%__MODULE__{} = doc, did) do 82 - # TODO: make sure this is ok 83 81 id_matches = doc.id == did 84 82 85 83 valid_signing_key =
+1 -2
lib/atex/oauth/plug.ex
··· 93 93 require Logger 94 94 use Plug.Router 95 95 require Plug.Router 96 - alias Atex.OAuth 97 - alias Atex.{IdentityResolver, IdentityResolver.DIDDocument} 96 + alias Atex.{IdentityResolver, OAuth, PLC.DIDDocument} 98 97 99 98 @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600] 100 99 @session_name :atex_session
+325
lib/atex/plc.ex
··· 1 + defmodule Atex.PLC do 2 + @moduledoc """ 3 + Client for the `did:plc` directory server HTTP API. 4 + 5 + `did:plc` is a self-authenticating DID method that is strongly-consistent, 6 + recoverable, and supports key rotation. The directory server receives and 7 + persists self-signed operation logs for each DID, starting with a genesis 8 + operation that defines the DID identifier itself. 9 + 10 + The API is permissionless, but only correctly-signed operations are accepted. 11 + The default server is `https://plc.directory`, but a custom host can be 12 + supplied via the `:host` option available on all functions. 13 + 14 + ## Options 15 + 16 + All functions accept an optional `opts` keyword list. Supported keys: 17 + 18 + - `:host` - Base URL of the PLC directory server. Defaults to 19 + `Atex.Config.directory_url/0`. 20 + 21 + ## Error returns 22 + 23 + Functions return `{:error, reason}` on failure. Common reasons: 24 + 25 + - `:not_found` - The DID is not registered (HTTP 404). 26 + - `:tombstoned` - The DID has been permanently deactivated (HTTP 410). 27 + - `:invalid_document` - The server returned a body that could not be parsed 28 + into a `DIDDocument`. 29 + - `{:invalid_operation, message}` - The submitted operation was rejected by 30 + the server, with an explanatory message (HTTP 400). 31 + - `:invalid_operation` - The submitted operation was rejected without a 32 + message (HTTP 400). 33 + - `%{status: status, body: body}` - An unexpected HTTP response. 34 + - Any transport-level error from `Req`. 35 + """ 36 + 37 + @type error_map() :: %{status: pos_integer(), body: any()} 38 + @type error() :: {:error, :not_found | :tombstoned | :invalid_document | error_map() | any()} 39 + @type create_op_error() :: 40 + {:error, {:invalid_operation, message :: String.t()} | :invalid_operation} | error() 41 + 42 + alias Atex.PLC.DIDDocument 43 + 44 + @doc """ 45 + Resolves the DID Document for the given `did:plc` identifier. 46 + 47 + Fetches the current DID Document from the directory server and parses it into 48 + an `Atex.PLC.DIDDocument` struct. 49 + 50 + ## Parameters 51 + 52 + - `did` - A `did:plc` identifier string. 53 + - `opts` - Optional keyword list. See module docs for supported keys. 54 + 55 + ## Examples 56 + 57 + iex> Atex.PLC.resolve_did("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 58 + {:ok, %Atex.PLC.DIDDocument{...}} 59 + 60 + iex> Atex.PLC.resolve_did("did:plc:doesnotexist") 61 + {:error, :not_found} 62 + """ 63 + @spec resolve_did(String.t(), keyword()) :: {:ok, DIDDocument.t()} | error() 64 + def resolve_did(did, opts \\ []) do 65 + opts 66 + |> host() 67 + |> URI.append_path("/#{did}") 68 + |> Req.get() 69 + |> handle_response() 70 + |> case do 71 + {:ok, body} -> 72 + case DIDDocument.from_json(body) do 73 + {:ok, document} -> {:ok, document} 74 + {:error, _reason} -> {:error, :invalid_document} 75 + end 76 + 77 + e -> 78 + e 79 + end 80 + end 81 + 82 + @doc """ 83 + Returns the current operation chain for the given DID. 84 + 85 + This is the authoritative, ordered sequence of operations that make up the 86 + DID's history. Unlike the audit log, nullified (overridden) operations are 87 + not included. 88 + 89 + ## Parameters 90 + 91 + - `did` - A `did:plc` identifier string. 92 + - `opts` - Optional keyword list. See module docs for supported keys. 93 + 94 + ## Examples 95 + 96 + iex> Atex.PLC.get_op_log("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 97 + {:ok, [%{"type" => "plc_operation", ...}]} 98 + """ 99 + @spec get_op_log(String.t(), keyword()) :: {:ok, any()} | error() 100 + def get_op_log(did, opts \\ []) do 101 + opts 102 + |> host() 103 + |> URI.append_path("/#{did}/log") 104 + |> Req.get() 105 + |> handle_response() 106 + end 107 + 108 + @doc """ 109 + Returns the full audit log for the given DID. 110 + 111 + Includes every operation ever submitted for the DID, including those that 112 + have been nullified (overridden by a recovery or conflicting operation). Each 113 + entry is a log entry map containing the operation, its CID hash, a 114 + `nullified` flag, and the timestamp at which the directory received it. 115 + 116 + ## Parameters 117 + 118 + - `did` - A `did:plc` identifier string. 119 + - `opts` - Optional keyword list. See module docs for supported keys. 120 + 121 + ## Examples 122 + 123 + iex> Atex.PLC.get_audit_log("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 124 + {:ok, [%{"did" => "did:plc:ewvi7nxzyoun6zhxrhs64oiz", "nullified" => false, ...}]} 125 + """ 126 + @spec get_audit_log(String.t(), keyword()) :: {:ok, any()} | error() 127 + def get_audit_log(did, opts \\ []) do 128 + opts 129 + |> host() 130 + |> URI.append_path("/#{did}/log/audit") 131 + |> Req.get() 132 + |> handle_response() 133 + end 134 + 135 + @doc """ 136 + Returns the most recent operation in the operation chain for the given DID. 137 + 138 + Useful for obtaining the `prev` CID reference required when constructing a 139 + new signed operation. 140 + 141 + ## Parameters 142 + 143 + - `did` - A `did:plc` identifier string. 144 + - `opts` - Optional keyword list. See module docs for supported keys. 145 + 146 + ## Examples 147 + 148 + iex> Atex.PLC.get_last_op("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 149 + {:ok, %{"type" => "plc_operation", "prev" => nil, ...}} 150 + """ 151 + @spec get_last_op(String.t(), keyword()) :: {:ok, any()} | error() 152 + def get_last_op(did, opts \\ []) do 153 + opts 154 + |> host() 155 + |> URI.append_path("/#{did}/log/last") 156 + |> Req.get() 157 + |> handle_response() 158 + end 159 + 160 + @doc """ 161 + Returns the current PLC data for the given DID. 162 + 163 + The response is similar to an operation map but may omit some fields. It 164 + reflects the effective state derived from the current operation chain. 165 + 166 + ## Parameters 167 + 168 + - `did` - A `did:plc` identifier string. 169 + - `opts` - Optional keyword list. See module docs for supported keys. 170 + 171 + ## Examples 172 + 173 + iex> Atex.PLC.get_data("did:plc:ewvi7nxzyoun6zhxrhs64oiz") 174 + {:ok, %{"rotationKeys" => [...], "verificationMethods" => %{}, ...}} 175 + """ 176 + @spec get_data(String.t(), keyword()) :: {:ok, any()} | error() 177 + def get_data(did, opts \\ []) do 178 + opts 179 + |> host() 180 + |> URI.append_path("/#{did}/data") 181 + |> Req.get() 182 + |> handle_response() 183 + end 184 + 185 + @doc """ 186 + Bulk-fetches PLC operations across all DIDs. 187 + 188 + Results are paginated and returned as a list of log entry maps. Each entry 189 + contains the DID, the operation, its CID hash, a `nullified` flag, and the 190 + server-assigned `createdAt` timestamp. 191 + 192 + ## Parameters 193 + 194 + - `opts` - Optional keyword list. Supported keys: 195 + - `:host` - Base URL of the PLC directory server. 196 + - `:count` - Number of records to return (default: `10`, max: `1000`). 197 + - `:after` - ISO 8601 datetime string; return only operations indexed after 198 + this timestamp. Useful for cursor-based pagination. 199 + 200 + ## Examples 201 + 202 + iex> Atex.PLC.export(count: 2) 203 + {:ok, [%{"did" => "did:plc:...", "nullified" => false, ...}, ...]} 204 + 205 + iex> Atex.PLC.export(count: 100, after: "2024-01-01T00:00:00Z") 206 + {:ok, [...]} 207 + """ 208 + @spec export(keyword()) :: {:ok, list(any())} | error() 209 + def export(opts \\ []) do 210 + {_, query} = Keyword.pop(opts, :host) 211 + query = URI.encode_query(query) 212 + 213 + opts 214 + |> host() 215 + |> URI.append_path("/export") 216 + |> URI.append_query(query) 217 + |> Req.get() 218 + |> handle_response(:jsonlines) 219 + end 220 + 221 + @doc """ 222 + Submits a new signed PLC operation for the given DID. 223 + 224 + The `operation` map must be a fully-formed, self-signed PLC operation. The 225 + server validates the signature and the operation's position in the chain 226 + before accepting it. 227 + 228 + Supported operation types: 229 + 230 + - `"plc_operation"` - A standard update or genesis operation. Required fields: 231 + `type`, `rotationKeys`, `verificationMethods`, `alsoKnownAs`, `services`, 232 + `prev`, `sig`. 233 + - `"plc_tombstone"` - Permanently deactivates the DID. Required fields: 234 + `type`, `prev`, `sig`. 235 + - `"create"` - Legacy genesis operation format (still supported for 236 + historical resolution). 237 + 238 + ## Parameters 239 + 240 + - `did` - A `did:plc` identifier string. 241 + - `operation` - A map representing the signed PLC operation. 242 + - `opts` - Optional keyword list. See module docs for supported keys. 243 + 244 + ## Examples 245 + 246 + iex> op = %{ 247 + ...> "type" => "plc_operation", 248 + ...> "rotationKeys" => ["did:key:..."], 249 + ...> "verificationMethods" => %{"atproto" => "did:key:..."}, 250 + ...> "alsoKnownAs" => ["at://handle.bsky.social"], 251 + ...> "services" => %{"atproto_pds" => %{"type" => "AtprotoPersonalDataServer", "endpoint" => "https://bsky.social"}}, 252 + ...> "prev" => "bafyreid6awsb6lzc54zxaq2roijyvpbjp5d6mii2xyztn55yli7htyjgqy", 253 + ...> "sig" => "..." 254 + ...> } 255 + iex> Atex.PLC.create_op("did:plc:ewvi7nxzyoun6zhxrhs64oiz", op) 256 + {:ok, nil} 257 + 258 + iex> Atex.PLC.create_op("did:plc:ewvi7nxzyoun6zhxrhs64oiz", %{"sig" => "bad"}) 259 + {:error, {:invalid_operation, "Invalid Signature"}} 260 + """ 261 + @spec create_op(String.t(), map(), keyword()) :: {:ok, any()} | create_op_error() 262 + def create_op(did, operation, opts \\ []) do 263 + # TODO: add a signing key option to automatically sign operation? 264 + # TODO: require Operation struct 265 + 266 + opts 267 + |> host() 268 + |> URI.append_path("/#{did}") 269 + |> Req.post(json: operation) 270 + |> case do 271 + {:ok, %{status: 200, body: body}} when body in ["", nil] -> 272 + {:ok, nil} 273 + 274 + {:ok, %{status: 200, body: body}} -> 275 + JSON.decode(body) 276 + 277 + {:ok, %{status: 400, body: %{"message" => message}}} -> 278 + {:error, {:invalid_operation, message}} 279 + 280 + {:ok, %{status: 400}} -> 281 + {:error, :invalid_operation} 282 + 283 + result -> 284 + handle_response(result) 285 + end 286 + end 287 + 288 + @spec handle_response({:ok, Req.Response.t()} | {:error, any()}, :json | :jsonlines) :: 289 + {:ok, any()} | error() 290 + defp handle_response(response_tuple, expected_body_type \\ :json) 291 + 292 + # DID document response from PLC uses a non-standard Content-Type header which 293 + # Req doesn't recognise, so have to handle it manually. 294 + defp handle_response({:ok, %{status: 200, body: body}}, :json) when is_binary(body), 295 + do: JSON.decode(body) 296 + 297 + defp handle_response({:ok, %{status: 200, body: body}}, :json), do: {:ok, body} 298 + 299 + defp handle_response({:ok, %{status: 200, body: body}}, :jsonlines), 300 + do: {:ok, decode_jsonlines(body)} 301 + 302 + defp handle_response({:ok, %{status: 404}}, _type), do: {:error, :not_found} 303 + defp handle_response({:ok, %{status: 410}}, _type), do: {:error, :tombstoned} 304 + defp handle_response({:ok, resp}, _type), do: {:error, %{status: resp.status, body: resp.body}} 305 + defp handle_response({:error, reason}, _type), do: {:error, reason} 306 + 307 + @spec decode_jsonlines(binary()) :: [any()] 308 + defp decode_jsonlines(body) when is_binary(body) do 309 + body 310 + |> String.split("\n") 311 + |> Enum.reject(&(&1 == "")) 312 + |> Enum.flat_map(fn line -> 313 + case JSON.decode(line) do 314 + {:ok, entry} -> [entry] 315 + {:error, _} -> [] 316 + end 317 + end) 318 + end 319 + 320 + @spec host(keyword()) :: URI.t() 321 + defp host(opts) do 322 + host = Keyword.get(opts, :host, Atex.Config.directory_url()) 323 + URI.new!(host) 324 + end 325 + end