defmodule Aether.ATProto.DID do @moduledoc """ Decentralized Identifier (DID) handling for ATProto. Supports DID methods commonly used in ATProto networks including: - plc: Placeholder DID method - web: Web-based DID method - key: Key-based DID method """ defstruct [:method, :identifier, :fragment, :query, :params] @type t :: %__MODULE__{ method: String.t(), identifier: String.t(), fragment: String.t() | nil, query: String.t() | nil, params: map() | nil } defmodule ParseError do defexception message: "Invalid DID format" end # Supported DID methods in ATProto @supported_methods ["plc", "web", "key"] # Regex patterns for validation @plc_pattern ~r/^[a-z2-7]{24}$/ @web_domain_pattern ~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])?)*$/ @web_pattern ~r/^[a-zA-Z0-9.-]+(:[a-zA-Z0-9.-]+)*$/ @key_pattern ~r/^z[1-9A-HJ-NP-Za-km-z]+$/ @doc """ Parse a DID string into structured data. ## Examples iex> Aether.ATProto.DID.parse_did("did:plc:z72i7hdynmk24r6zlsdc6nxd") {:ok, %Aether.ATProto.DID{method: "plc", identifier: "z72i7hdynmk24r6zlsdc6nxd"}} iex> Aether.ATProto.DID.parse_did("did:web:example.com") {:ok, %Aether.ATProto.DID{method: "web", identifier: "example.com"}} iex> Aether.ATProto.DID.parse_did("did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme") {:ok, %Aether.ATProto.DID{method: "key", identifier: "zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme"}} iex> Aether.ATProto.DID.parse_did("did:web:example.com:user#fragment") {:ok, %Aether.ATProto.DID{method: "web", identifier: "example.com:user", fragment: "fragment"}} """ def parse_did("did:" <> rest) when is_binary(rest) do with [method_raw, rest_with_identifier] <- String.split(rest, ":", parts: 2), method = String.downcase(method_raw), {identifier, fragment, query, params} <- parse_identifier_parts(rest_with_identifier) do validate_did(method, identifier, fragment, query, params) else _ -> {:error, :invalid_format} end end def parse_did(_invalid), do: {:error, :invalid_format} defp parse_identifier_parts(rest) do {identifier_with_params, fragment} = split_fragment(rest) {identifier, query} = split_query(identifier_with_params) params = parse_query_params(query) {identifier, fragment, query, params} end defp split_fragment(string) do case String.split(string, "#", parts: 2) do [id, frag] -> {id, frag} [id] -> {id, nil} end end defp split_query(string) do case String.split(string, "?", parts: 2) do [id, q] -> {id, q} [id] -> {id, nil} end end defp parse_query_params(nil), do: nil defp parse_query_params(query_string) do query_string |> String.split("&") |> Map.new(fn pair -> case String.split(pair, "=", parts: 2) do [key, value] -> {key, value} [key] -> {key, true} end end) end defp validate_did(method, identifier, fragment, query, params) do with :ok <- validate_method(method), :ok <- validate_identifier(method, identifier) do {:ok, %__MODULE__{ method: method, identifier: identifier, fragment: fragment, query: query, params: params }} end end defp validate_method(method) when method in @supported_methods, do: :ok defp validate_method(_method), do: {:error, :unsupported_method} # Allow uppercase PLC DIDs but normalize to lowercase defp validate_identifier("plc", identifier) do identifier |> String.downcase() |> validate_pattern(@plc_pattern) end # For web DIDs, the identifier can be a domain or domain with path segments separated by colons defp validate_identifier("web", identifier) do with [domain | _] <- String.split(identifier, ":"), true <- String.match?(identifier, @web_pattern), true <- String.match?(domain, @web_domain_pattern), true <- String.length(domain) <= 253 do :ok else _ -> {:error, :invalid_identifier} end end defp validate_identifier("key", identifier), do: validate_pattern(identifier, @key_pattern) defp validate_identifier(_, _), do: {:error, :invalid_method} defp validate_pattern(string, pattern) do if String.match?(string, pattern), do: :ok, else: {:error, :invalid_identifier} end @doc """ Parse a DID string, raising an exception on error. ## Examples iex> Aether.ATProto.DID.parse_did!("did:plc:z72i7hdynmk24r6zlsdc6nxd") %Aether.ATProto.DID{method: "plc", identifier: "z72i7hdynmk24r6zlsdc6nxd"} iex> Aether.ATProto.DID.parse_did!("invalid") ** (Aether.ATProto.DID.ParseError) Invalid DID: invalid_format """ def parse_did!(did_string) when is_binary(did_string) do case parse_did(did_string) do {:ok, did} -> did {:error, reason} -> raise ParseError, "Invalid DID: #{reason}" end end def parse_did!(%__MODULE__{} = did), do: did def parse_did!(_other), do: raise(ParseError, "Invalid DID: invalid_format") @doc """ Check if a value is a valid DID. ## Examples iex> Aether.ATProto.DID.valid_did?("did:plc:z72i7hdynmk24r6zlsdc6nxd") true iex> Aether.ATProto.DID.valid_did?("invalid") false iex> Aether.ATProto.DID.valid_did?(%Aether.ATProto.DID{method: "plc", identifier: "test"}) true """ def valid_did?(did_string) when is_binary(did_string) do match?({:ok, _}, parse_did(did_string)) end def valid_did?(%__MODULE__{}), do: true def valid_did?(_), do: false @doc """ Convert a DID struct back to its string representation. ## Examples iex> did = %Aether.ATProto.DID{method: "plc", identifier: "z72i7hdynmk24r6zlsdc6nxd"} iex> Aether.ATProto.DID.did_to_string(did) "did:plc:z72i7hdynmk24r6zlsdc6nxd" iex> did = %Aether.ATProto.DID{method: "web", identifier: "example.com", fragment: "key1"} iex> Aether.ATProto.DID.did_to_string(did) "did:web:example.com#key1" iex> did = %Aether.ATProto.DID{method: "web", identifier: "example.com", query: "version=1", fragment: "key1"} iex> Aether.ATProto.DID.did_to_string(did) "did:web:example.com?version=1#key1" """ def did_to_string(%__MODULE__{ method: method, identifier: identifier, query: query, fragment: fragment }) do ["did", method, identifier] |> Enum.join(":") |> append_query(query) |> append_fragment(fragment) end def did_to_string(did_string) when is_binary(did_string), do: did_string defp append_query(string, nil), do: string defp append_query(string, query), do: string <> "?" <> query defp append_fragment(string, nil), do: string defp append_fragment(string, fragment), do: string <> "#" <> fragment @doc """ Extract the method from a DID. ## Examples iex> Aether.ATProto.DID.did_method("did:plc:z72i7hdynmk24r6zlsdc6nxd") "plc" iex> Aether.ATProto.DID.did_method("did:web:example.com") "web" iex> Aether.ATProto.DID.did_method("invalid") {:error, :invalid_did} """ def did_method(%__MODULE__{method: method}), do: method def did_method(did_string) when is_binary(did_string) do case parse_did(did_string) do {:ok, %__MODULE__{method: method}} -> method {:error, _} -> {:error, :invalid_did} end end @doc """ Extract the identifier from a DID. ## Examples iex> Aether.ATProto.DID.did_identifier("did:plc:z72i7hdynmk24r6zlsdc6nxd") "z72i7hdynmk24r6zlsdc6nxd" iex> Aether.ATProto.DID.did_identifier("invalid") {:error, :invalid_did} """ def did_identifier(%__MODULE__{identifier: identifier}), do: identifier def did_identifier(did_string) when is_binary(did_string) do case parse_did(did_string) do {:ok, %__MODULE__{identifier: identifier}} -> identifier {:error, _} -> {:error, :invalid_did} end end @doc """ Check if a DID uses a specific method. ## Examples iex> Aether.ATProto.DID.is_method?("did:plc:z72i7hdynmk24r6zlsdc6nxd", "plc") true iex> Aether.ATProto.DID.is_method?("did:web:example.com", "plc") false iex> Aether.ATProto.DID.is_method?("invalid", "plc") false """ def is_method?(%__MODULE__{method: method}, expected_method), do: method == expected_method def is_method?(did_string, expected_method) when is_binary(did_string) and is_binary(expected_method) do did_method(did_string) == expected_method end def is_method?(_, _), do: false @doc """ Get the fragment from a DID. ## Examples iex> Aether.ATProto.DID.did_fragment("did:web:example.com#key1") "key1" iex> Aether.ATProto.DID.did_fragment("did:web:example.com") nil """ def did_fragment(%__MODULE__{fragment: fragment}), do: fragment def did_fragment(did_string) when is_binary(did_string) do case parse_did(did_string) do {:ok, %__MODULE__{fragment: fragment}} -> fragment {:error, _} -> {:error, :invalid_did} end end @doc """ Get query parameters from a DID. ## Examples iex> Aether.ATProto.DID.did_params("did:web:example.com?version=1&test=true") %{"version" => "1", "test" => "true"} iex> Aether.ATProto.DID.did_params("did:web:example.com") nil """ def did_params(%__MODULE__{params: params}), do: params def did_params(did_string) when is_binary(did_string) do case parse_did(did_string) do {:ok, %__MODULE__{params: params}} -> params {:error, _} -> {:error, :invalid_did} end end @doc """ Check if a DID method is supported. ## Examples iex> Aether.ATProto.DID.supported_method?("plc") true iex> Aether.ATProto.DID.supported_method?("unsupported") false """ def supported_method?(method) when is_binary(method) do method in @supported_methods end @doc """ Get all supported DID methods. ## Examples iex> Aether.ATProto.DID.supported_methods() ["plc", "web", "key"] """ def supported_methods, do: @supported_methods @doc """ Normalize a DID string (convert to lowercase for certain methods). ## Examples iex> Aether.ATProto.DID.normalize("DID:PLC:Z72I7HDYNMK24R6ZLSDC6NXD") "did:plc:z72i7hdynmk24r6zlsdc6nxd" iex> Aether.ATProto.DID.normalize("DID:WEB:EXAMPLE.COM?VERSION=1#KEY1") "did:web:example.com?VERSION=1#KEY1" """ def normalize(did_string) when is_binary(did_string) do case parse_did(did_string) do {:ok, did} -> normalize_valid_did(did_string, did) {:error, _} -> normalize_invalid_did(did_string) end end defp normalize_valid_did(original, %__MODULE__{method: method, identifier: identifier}) do ["did", String.downcase(method), String.downcase(identifier)] |> Enum.join(":") |> append_query(extract_query(original)) |> append_fragment(extract_fragment(original)) end defp normalize_invalid_did(did_string) do if String.starts_with?(String.downcase(did_string), "did:") do # Extract the DID prefix and rest <<"did:", rest::binary>> = String.downcase(String.slice(did_string, 0..3)) <> String.slice(did_string, 4..-1//1) case String.split(rest, ["?", "#"], parts: 2) do [method_and_id, _rest_parts] -> separator = if String.contains?(did_string, "?"), do: "?", else: "#" # Extract original query/fragment original_rest_parts = extract_after_separator(did_string, separator) build_normalized_did(String.downcase(method_and_id), separator, original_rest_parts) [method_and_id] -> build_normalized_did(String.downcase(method_and_id), nil, nil) end else did_string end end defp extract_after_separator(string, "?") do case String.split(string, "?", parts: 2) do [_, rest] -> rest _ -> "" end end defp extract_after_separator(string, "#") do case String.split(string, "#", parts: 2) do [_, rest] -> rest _ -> "" end end defp build_normalized_did(method_and_id, separator, rest_parts) do case String.split(method_and_id, ":", parts: 2) do [method, identifier] when not is_nil(separator) -> "did:#{method}:#{identifier}#{separator}#{rest_parts}" [method, identifier] -> "did:#{method}:#{identifier}" _ -> if separator, do: "did:#{method_and_id}#{separator}#{rest_parts}", else: "did:#{method_and_id}" end end defp extract_query(did_string) do with [_, rest] <- String.split(did_string, "?", parts: 2), [query | _] <- String.split(rest, "#", parts: 2) do query else _ -> nil end end defp extract_fragment(did_string) do case String.split(did_string, "#", parts: 2) do [_, fragment] -> fragment _ -> nil end end @doc """ Extract the web domain from a web DID. ## Examples iex> Aether.ATProto.DID.web_domain("did:web:example.com:path") "example.com" iex> Aether.ATProto.DID.web_domain("did:plc:z72i7hdynmk24r6zlsdc6nxd") {:error, :not_web_did} """ def web_domain(%__MODULE__{method: "web", identifier: identifier}), do: extract_domain(identifier) def web_domain(%__MODULE__{}), do: {:error, :not_web_did} def web_domain(did_string) when is_binary(did_string) do case parse_did(did_string) do {:ok, %__MODULE__{method: "web", identifier: identifier}} -> extract_domain(identifier) {:ok, %__MODULE__{}} -> {:error, :not_web_did} {:error, _} -> {:error, :invalid_did} end end defp extract_domain(identifier) do identifier |> String.split(":") |> List.first() end end