A set of utilities for working with the AT Protocol in Elixir.

feat: dedicated store for oauth sessions

ovyerus.com cbe6b2ee 2ae52bab

verified
+6 -2
.formatter.exs
··· 1 1 # Used by "mix format" 2 2 [ 3 - inputs: ["{mix,.formatter,.credo}.exs", "{config,examples,lib,test}/**/*.{ex,exs}"], 3 + inputs: 4 + Enum.flat_map( 5 + ["{mix,.formatter,.credo}.exs", "{config,examples,lib,test}/**/*.{ex,exs}"], 6 + &Path.wildcard(&1, match_dot: true) 7 + ) -- Path.wildcard("lib/atproto/**/*.ex"), 4 8 import_deps: [:typedstruct, :peri, :plug], 5 - excludes: ["lib/atproto/**/*"], 9 + # excludes: ["lib/atproto/**/*.ex"], 6 10 export: [ 7 11 locals_without_parens: [deflexicon: 1] 8 12 ]
+5
CHANGELOG.md
··· 18 18 19 19 ### Added 20 20 21 + - `Atex.OAuth.SessionStore` behaviour and `Atex.OAuth.Session` struct for 22 + managing OAuth sessions with pluggable storage backends. 23 + - `Atex.OAuth.SessionStore.ETS` - in-memory session store implementation. 24 + - `Atex.OAuth.SessionStore.DETS` - persistent disk-based session store 25 + implementation. 21 26 - `Atex.OAuth.Plug` now requires a `:callback` option that is a MFA tuple 22 27 (Module, Function, Args), denoting a callback function to be invoked by after 23 28 a successful OAuth login. See [the OAuth example](./examples/oauth.ex) for a
+3 -1
lib/atex/application.ex
··· 6 6 def start(_type, _args) do 7 7 children = [ 8 8 Atex.IdentityResolver.Cache, 9 - Atex.OAuth.Cache 9 + Atex.OAuth.Cache, 10 + Atex.OAuth.SessionStore, 11 + {Mutex, name: Atex.SessionMutex} 10 12 ] 11 13 12 14 Supervisor.start_link(children, strategy: :one_for_one)
+1
lib/atex/oauth/error.ex
··· 18 18 - `:token_validation_failed` - Failed to validate the authorization code or 19 19 token 20 20 - `:issuer_mismatch` - OAuth issuer does not match PDS authorization server 21 + - `:session_store_failed` - OAuth succeeded but failed to store the session 21 22 """ 22 23 23 24 defexception [:message, :reason]
+30 -16
lib/atex/oauth/plug.ex
··· 97 97 alias Atex.{IdentityResolver, IdentityResolver.DIDDocument} 98 98 99 99 @oauth_cookie_opts [path: "/", http_only: true, secure: true, same_site: "lax", max_age: 600] 100 + @session_name :atex_session 100 101 101 102 def init(opts) do 102 103 callback = Keyword.get(opts, :callback, nil) ··· 198 199 pds <- DIDDocument.get_pds_endpoint(identity.document), 199 200 {:ok, authz_server} <- OAuth.get_authorization_server(pds), 200 201 true <- authz_server == stored_issuer do 201 - conn = 202 - conn 203 - |> delete_resp_cookie("state", @oauth_cookie_opts) 204 - |> delete_resp_cookie("code_verifier", @oauth_cookie_opts) 205 - |> delete_resp_cookie("issuer", @oauth_cookie_opts) 206 - |> put_session(:atex_oauth, %{ 207 - access_token: tokens.access_token, 208 - refresh_token: tokens.refresh_token, 209 - did: tokens.did, 210 - pds: pds, 211 - expires_at: tokens.expires_at, 212 - dpop_nonce: nonce, 213 - dpop_key: dpop_key 214 - }) 202 + session = %OAuth.Session{ 203 + iss: authz_server, 204 + aud: pds, 205 + sub: tokens.did, 206 + access_token: tokens.access_token, 207 + refresh_token: tokens.refresh_token, 208 + expires_at: tokens.expires_at, 209 + dpop_key: dpop_key, 210 + dpop_nonce: nonce 211 + } 212 + 213 + case OAuth.SessionStore.insert(session) do 214 + :ok -> 215 + conn = 216 + conn 217 + |> delete_resp_cookie("state", @oauth_cookie_opts) 218 + |> delete_resp_cookie("code_verifier", @oauth_cookie_opts) 219 + |> delete_resp_cookie("issuer", @oauth_cookie_opts) 220 + |> put_session(@session_name, tokens.did) 221 + 222 + {mod, func, args} = callback 223 + apply(mod, func, [conn | args]) 215 224 216 - {mod, func, args} = callback 217 - apply(mod, func, [conn | args]) 225 + {:error, reason} -> 226 + raise Atex.OAuth.Error, 227 + message: "Failed to store OAuth session, reason: #{reason}", 228 + reason: :session_store_failed 229 + end 218 230 else 219 231 false -> 220 232 raise Atex.OAuth.Error, ··· 227 239 reason: :token_validation_failed 228 240 end 229 241 end 242 + 243 + # TODO: logout route 230 244 end
+50
lib/atex/oauth/session.ex
··· 1 + defmodule Atex.OAuth.Session do 2 + @moduledoc """ 3 + Struct representing an active OAuth session for an AT Protocol user. 4 + 5 + Contains all the necessary credentials and metadata to make authenticated 6 + requests to a user's PDS using OAuth with DPoP. 7 + 8 + ## Fields 9 + 10 + - `:iss` - Authorization server issuer URL 11 + - `:aud` - PDS endpoint URL (audience) 12 + - `:sub` - User's DID (subject), used as the session key 13 + - `:access_token` - OAuth access token for authenticating requests 14 + - `:refresh_token` - OAuth refresh token for obtaining new access tokens 15 + - `:expires_at` - When the current access token expires (NaiveDateTime in UTC) 16 + - `:dpop_key` - DPoP signing key (Demonstrating Proof-of-Possession) 17 + - `:dpop_nonce` - Server-provided nonce for DPoP proofs (optional, updated per-request) 18 + 19 + ## Usage 20 + 21 + Sessions are typically created during the OAuth flow and stored in a `SessionStore`. 22 + They should not be created manually in most cases. 23 + 24 + session = %Atex.OAuth.Session{ 25 + iss: "https://bsky.social", 26 + aud: "https://puffball.us-east.host.bsky.network", 27 + sub: "did:plc:abc123", 28 + access_token: "...", 29 + refresh_token: "...", 30 + expires_at: ~N[2026-01-04 12:00:00], 31 + dpop_key: dpop_key, 32 + dpop_nonce: "server-nonce" 33 + } 34 + """ 35 + use TypedStruct 36 + 37 + typedstruct enforce: true do 38 + # Authz server issuer 39 + field :iss, String.t() 40 + # PDS endpoint 41 + field :aud, String.t() 42 + # User's DID 43 + field :sub, String.t() 44 + field :access_token, String.t() 45 + field :refresh_token, String.t() 46 + field :expires_at, NaiveDateTime.t() 47 + field :dpop_key, JOSE.JWK.t() 48 + field :dpop_nonce, String.t() | nil, enforce: false 49 + end 50 + end
+119
lib/atex/oauth/session_store.ex
··· 1 + defmodule Atex.OAuth.SessionStore do 2 + @moduledoc """ 3 + Storage interface for OAuth sessions. 4 + 5 + Provides a behaviour for implementing session storage backends, and functions 6 + to operate the backend using `Atex.OAuth.Session` 7 + 8 + ## Configuration 9 + 10 + The default implementation for the store is `Atex.OAuth.SessionStore.DETS`; 11 + this can be changed to a custom implementation in your config.exs: 12 + 13 + config :atex, :session_store, Atex.OAuth.SessionStore.ETS 14 + 15 + DETS is the default implementation as it provides simple, on-disk storage for 16 + sessions so they don't get discarded on an application restart, but a regular 17 + ETS implementation is also provided out-of-the-box for testing or other 18 + circumstances. 19 + 20 + For multi-node deployments, you can write your own implementation using a 21 + custom backend, such as Redis, by implementing the behaviour callbacks. 22 + 23 + ## Usage 24 + 25 + Sessions are keyed by the user's DID (`sub` field). 26 + 27 + session = %Atex.OAuth.Session{ 28 + iss: "https://bsky.social", 29 + aud: "https://puffball.us-east.host.bsky.network", 30 + sub: "did:plc:abc123", 31 + access_token: "...", 32 + refresh_token: "...", 33 + expires_at: ~N[2026-01-04 12:00:00], 34 + dpop_key: dpop_key, 35 + dpop_nonce: "server-nonce" 36 + } 37 + 38 + # Insert a new session 39 + :ok = Atex.OAuth.SessionStore.insert(session) 40 + 41 + # Retrieve a session 42 + {:ok, session} = Atex.OAuth.SessionStore.get("did:plc:abc123") 43 + 44 + # Update an existing session (e.g., after token refresh) 45 + updated_session = %{session | access_token: new_token} 46 + :ok = Atex.OAuth.SessionStore.update(updated_session) 47 + 48 + # Delete a session 49 + Atex.OAuth.SessionStore.delete(session) 50 + """ 51 + 52 + @store Application.compile_env(:atex, :session_store, Atex.OAuth.SessionStore.DETS) 53 + 54 + @doc """ 55 + Retrieve a session by DID. 56 + 57 + Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise. 58 + """ 59 + @callback get(key :: String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()} 60 + 61 + @doc """ 62 + Insert a new session. 63 + 64 + The key is the user's DID (`session.sub`). Returns `:ok` on success. 65 + """ 66 + @callback insert(key :: String.t(), session :: Atex.OAuth.Session.t()) :: 67 + :ok | {:error, atom()} 68 + 69 + @doc """ 70 + Update an existing session. 71 + 72 + Replaces the existing session data for the given key. Returns `:ok` on success. 73 + """ 74 + @callback update(key :: String.t(), session :: Atex.OAuth.Session.t()) :: 75 + :ok | {:error, atom()} 76 + 77 + @doc """ 78 + Delete a session. 79 + 80 + Returns `:ok` if deleted, `:noop` if the session didn't exist, :error if it failed. 81 + """ 82 + @callback delete(key :: String.t()) :: :ok | :error | :noop 83 + 84 + @callback child_spec(any()) :: Supervisor.child_spec() 85 + 86 + defdelegate child_spec(opts), to: @store 87 + 88 + @doc """ 89 + Retrieve a session by DID. 90 + """ 91 + @spec get(String.t()) :: {:ok, Atex.OAuth.Session.t()} | {:error, atom()} 92 + def get(key) do 93 + @store.get(key) 94 + end 95 + 96 + @doc """ 97 + Insert a new session. 98 + """ 99 + @spec insert(Atex.OAuth.Session.t()) :: :ok | {:error, atom()} 100 + def insert(session) do 101 + @store.insert(session.sub, session) 102 + end 103 + 104 + @doc """ 105 + Update an existing session. 106 + """ 107 + @spec update(Atex.OAuth.Session.t()) :: :ok | {:error, atom()} 108 + def update(session) do 109 + @store.update(session.sub, session) 110 + end 111 + 112 + @doc """ 113 + Delete a session. 114 + """ 115 + @callback delete(Atex.OAuth.Session.t()) :: :ok | :error | :noop 116 + def delete(session) do 117 + @store.delete(session.sub) 118 + end 119 + end
+121
lib/atex/oauth/session_store/dets.ex
··· 1 + defmodule Atex.OAuth.SessionStore.DETS do 2 + @moduledoc """ 3 + DETS implementation for `Atex.OAuth.SessionStore`. 4 + 5 + This is recommended for single-node production deployments, as sessions will 6 + persist on disk between application restarts. For more complex, multi-node 7 + deployments, consider making a custom implementation using Redis or some other 8 + distributed store. 9 + 10 + ## Configuration 11 + 12 + By default the DETS file is stored at `priv/dets/atex_oauth_sessions.dets` 13 + relative to where your application is running. You can configure the file path 14 + in your `config.exs`: 15 + 16 + config :atex, Atex.OAuth.SessionStore.DETS, 17 + file_path: "/var/lib/myapp/sessions.dets" 18 + 19 + Parent directories will be created as necessary if possible. 20 + """ 21 + 22 + alias Atex.OAuth.Session 23 + require Logger 24 + use Supervisor 25 + 26 + @behaviour Atex.OAuth.SessionStore 27 + @table :atex_oauth_sessions 28 + @default_file "priv/dets/atex_oauth_sessions.dets" 29 + 30 + def start_link(opts) do 31 + Supervisor.start_link(__MODULE__, opts) 32 + end 33 + 34 + @impl Supervisor 35 + def init(_opts) do 36 + dets_file = 37 + case Application.get_env(:atex, __MODULE__, [])[:file_path] do 38 + nil -> 39 + @default_file 40 + 41 + path -> 42 + path 43 + end 44 + 45 + # Ensure parent directory exists 46 + dets_file 47 + |> Path.dirname() 48 + |> File.mkdir_p!() 49 + 50 + case :dets.open_file(@table, file: String.to_charlist(dets_file), type: :set) do 51 + {:ok, @table} -> 52 + Logger.info("DETS session store opened: #{dets_file}") 53 + Supervisor.init([], strategy: :one_for_one) 54 + 55 + {:error, reason} -> 56 + Logger.error("Failed to open DETS file: #{inspect(reason)}") 57 + raise "Failed to initialize DETS session store: #{inspect(reason)}" 58 + end 59 + end 60 + 61 + @doc """ 62 + Insert a session into the DETS table. 63 + 64 + Returns `:ok` on success, `{:error, reason}` if an unexpected error occurs. 65 + """ 66 + @impl Atex.OAuth.SessionStore 67 + @spec insert(String.t(), Session.t()) :: :ok | {:error, atom()} 68 + def insert(key, session) do 69 + case :dets.insert(@table, {key, session}) do 70 + :ok -> 71 + :ok 72 + 73 + {:error, reason} -> 74 + Logger.error("DETS insert failed: #{inspect(reason)}") 75 + {:error, reason} 76 + end 77 + end 78 + 79 + @doc """ 80 + Update a session in the DETS table. 81 + 82 + In DETS, this is the same as insert - it replaces the existing entry. 83 + """ 84 + @impl Atex.OAuth.SessionStore 85 + @spec update(String.t(), Session.t()) :: :ok | {:error, atom()} 86 + def update(key, session) do 87 + insert(key, session) 88 + end 89 + 90 + @doc """ 91 + Retrieve a session from the DETS table. 92 + 93 + Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise. 94 + """ 95 + @impl Atex.OAuth.SessionStore 96 + @spec get(String.t()) :: {:ok, Session.t()} | {:error, atom()} 97 + def get(key) do 98 + case :dets.lookup(@table, key) do 99 + [{_key, session}] -> {:ok, session} 100 + [] -> {:error, :not_found} 101 + end 102 + end 103 + 104 + @doc """ 105 + Delete a session from the DETS table. 106 + 107 + Returns `:ok` if deleted, `:noop` if the session didn't exist. 108 + """ 109 + @impl Atex.OAuth.SessionStore 110 + @spec delete(String.t()) :: :ok | :error | :noop 111 + def delete(key) do 112 + case get(key) do 113 + {:ok, _session} -> 114 + :dets.delete(@table, key) 115 + :ok 116 + 117 + {:error, :not_found} -> 118 + :noop 119 + end 120 + end 121 + end
+88
lib/atex/oauth/session_store/ets.ex
··· 1 + defmodule Atex.OAuth.SessionStore.ETS do 2 + @moduledoc """ 3 + In-memory, ETS implementation for `Atex.OAuth.SessionStore`. 4 + 5 + This is moreso intended for testing or some occasion where you want the 6 + session store to be volatile for some reason. It's recommended you use 7 + `Atex.OAuth.SessionStore.DETS` for single-node production deployments. 8 + """ 9 + 10 + alias Atex.OAuth.Session 11 + require Logger 12 + use Supervisor 13 + 14 + @behaviour Atex.OAuth.SessionStore 15 + @table :atex_oauth_sessions 16 + 17 + def start_link(opts) do 18 + Supervisor.start_link(__MODULE__, opts) 19 + end 20 + 21 + @impl Supervisor 22 + def init(_opts) do 23 + :ets.new(@table, [:set, :public, :named_table]) 24 + Supervisor.init([], strategy: :one_for_one) 25 + end 26 + 27 + @doc """ 28 + Insert a session into the ETS table. 29 + 30 + Returns `:ok` on success, `{:error, :ets}` if an unexpected error occurs. 31 + """ 32 + @impl Atex.OAuth.SessionStore 33 + @spec insert(String.t(), Session.t()) :: :ok | {:error, atom()} 34 + def insert(key, session) do 35 + try do 36 + :ets.insert(@table, {key, session}) 37 + :ok 38 + rescue 39 + # Freak accidents can occur 40 + e -> 41 + Logger.error(Exception.format(:error, e, __STACKTRACE__)) 42 + {:error, :ets} 43 + end 44 + end 45 + 46 + @doc """ 47 + Update a session in the ETS table. 48 + 49 + In ETS, this is the same as insert - it replaces the existing entry. 50 + """ 51 + @impl Atex.OAuth.SessionStore 52 + @spec update(String.t(), Session.t()) :: :ok | {:error, atom()} 53 + def update(key, session) do 54 + insert(key, session) 55 + end 56 + 57 + @doc """ 58 + Retrieve a session from the ETS table. 59 + 60 + Returns `{:ok, session}` if found, `{:error, :not_found}` otherwise. 61 + """ 62 + @impl Atex.OAuth.SessionStore 63 + @spec get(String.t()) :: {:ok, Session.t()} | {:error, atom()} 64 + def get(key) do 65 + case :ets.lookup(@table, key) do 66 + [{_key, session}] -> {:ok, session} 67 + [] -> {:error, :not_found} 68 + end 69 + end 70 + 71 + @doc """ 72 + Delete a session from the ETS table. 73 + 74 + Returns `:ok` if deleted, `:noop` if the session didn't exist. 75 + """ 76 + @impl Atex.OAuth.SessionStore 77 + @spec delete(String.t()) :: :ok | :error | :noop 78 + def delete(key) do 79 + case get(key) do 80 + {:ok, _session} -> 81 + :ets.delete(@table, key) 82 + :ok 83 + 84 + {:error, :not_found} -> 85 + :noop 86 + end 87 + end 88 + end
+2 -1
mix.exs
··· 40 40 {:jason, "~> 1.4"}, 41 41 {:jose, "~> 1.11"}, 42 42 {:bandit, "~> 1.0", only: [:dev, :test]}, 43 - {:con_cache, "~> 1.1"} 43 + {:con_cache, "~> 1.1"}, 44 + {:mutex, "~> 3.0"} 44 45 ] 45 46 end 46 47
+12 -11
mix.lock
··· 1 1 %{ 2 - "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, 2 + "bandit": {:hex, :bandit, "1.10.0", "f8293b4a4e6c06b31655ae10bd3462f59d8c5dbd1df59028a4984f10c5961147", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "43ebceb7060a4d8273e47d83e703d01b112198624ba0826980caa3f5091243c4"}, 3 3 "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 - "cldr_utils": {:hex, :cldr_utils, "2.28.3", "d0ac5ed25913349dfaca8b7fe14722d588d8ccfa3e335b0510c7cc3f3c54d4e6", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "40083cd9a5d187f12d675cfeeb39285f0d43e7b7f2143765161b72205d57ffb5"}, 4 + "cldr_utils": {:hex, :cldr_utils, "2.29.1", "11ff0a50a36a7e5f3bd9fc2fb8486a4c1bcca3081d9c080bf9e48fe0e6742e2d", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "3844a0a0ed7f42e6590ddd8bd37eb4b1556b112898f67dea3ba068c29aabd6c2"}, 5 5 "con_cache": {:hex, :con_cache, "1.1.1", "9f47a68dfef5ac3bbff8ce2c499869dbc5ba889dadde6ac4aff8eb78ddaf6d82", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1def4d1bec296564c75b5bbc60a19f2b5649d81bfa345a2febcc6ae380e8ae15"}, 6 - "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 6 + "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, 7 7 "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 8 8 "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 9 - "ex_cldr": {:hex, :ex_cldr, "2.43.0", "8700031e30a03501cf65f7ba7c8287bb67339d03559f3108f3c54fe86d926b19", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "1524eb01275b89473ee5f53fcc6169bae16e4a5267ef109229f37694799e0b20"}, 9 + "ex_cldr": {:hex, :ex_cldr, "2.44.1", "0d220b175874e1ce77a0f7213bdfe700b9be11aefbf35933a0e98837803ebdc5", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "3880cd6137ea21c74250cd870d3330c4a9fdec07fabd5e37d1b239547929e29b"}, 10 10 "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, 11 - "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 11 + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 12 12 "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 13 13 "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 14 14 "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 15 - "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, 15 + "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, 16 16 "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 17 17 "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 18 18 "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 19 19 "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 20 20 "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [: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", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 21 21 "multiformats_ex": {:hex, :multiformats_ex, "0.2.0", "5b0a3faa1a770dc671aa8a89b6323cc20b0ecf67dc93dcd21312151fbea6b4ee", [:mix], [{:varint, "~> 1.4", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "aa406d9addb06dc197e0e92212992486af6599158d357680f29f2d11e08d0423"}, 22 + "mutex": {:hex, :mutex, "3.0.2", "528877fd0dbc09fc93ad667e10ea0d35a2126fa85205822f9dca85e87d732245", [:mix], [], "hexpm", "0a8f2ed3618160dca6a1e3520b293dc3c2ae53116265e71b4a732d35d29aa3c6"}, 22 23 "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 23 24 "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 24 25 "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 25 - "peri": {:hex, :peri, "0.6.1", "6a90ca728a27aef8fef37ce307444255d20364b0c8f8d39e52499d8d825cb514", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e20ffc659967baf9c4f28799fe7302b656d6662a8b3db7646fdafd017e192743"}, 26 - "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, 26 + "peri": {:hex, :peri, "0.6.2", "3c043bfb6aa18eb1ea41d80981d19294c5e943937b1311e8e958da3581139061", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "5e0d8e0bd9de93d0f8e3ad6b9a5bd143f7349c025196ef4a3591af93ce6ecad9"}, 27 + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, 27 28 "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 28 - "recase": {:hex, :recase, "0.9.0", "437982693fdfbec125f11c8868eb3b4d32e9aa6995d3a68ac8686f3e2bf5d8d1", [:mix], [], "hexpm", "efa7549ebd128988d1723037a6f6a61948055aec107db6288f1c52830cb6501c"}, 29 - "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, 29 + "recase": {:hex, :recase, "0.9.1", "82d2e2e2d4f9e92da1ce5db338ede2e4f15a50ac1141fc082b80050b9f49d96e", [:mix], [], "hexpm", "19ba03ceb811750e6bec4a015a9f9e45d16a8b9e09187f6d72c3798f454710f3"}, 30 + "req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"}, 30 31 "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 31 - "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, 32 + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, 32 33 "typedstruct": {:hex, :typedstruct, "0.5.4", "d1d33d58460a74f413e9c26d55e66fd633abd8ac0fb12639add9a11a60a0462a", [:make, :mix], [], "hexpm", "ffaef36d5dbaebdbf4ed07f7fb2ebd1037b2c1f757db6fb8e7bcbbfabbe608d8"}, 33 34 "varint": {:hex, :varint, "1.5.1", "17160c70d0428c3f8a7585e182468cac10bbf165c2360cf2328aaa39d3fb1795", [:mix], [], "hexpm", "24f3deb61e91cb988056de79d06f01161dd01be5e0acae61d8d936a552f1be73"}, 34 35 "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},