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

feat: cache for authorization server information

ovyerus.com 47417790 ef9a2548

verified
+1
.formatter.exs
··· 2 [ 3 inputs: ["{mix,.formatter,.credo}.exs", "{config,examples,lib,test}/**/*.{ex,exs}"], 4 import_deps: [:typedstruct, :peri, :plug], 5 export: [ 6 locals_without_parens: [deflexicon: 1] 7 ]
··· 2 [ 3 inputs: ["{mix,.formatter,.credo}.exs", "{config,examples,lib,test}/**/*.{ex,exs}"], 4 import_deps: [:typedstruct, :peri, :plug], 5 + excludes: ["lib/atproto/**/*"], 6 export: [ 7 locals_without_parens: [deflexicon: 1] 8 ]
-1
.gitignore
··· 15 secrets 16 .DS_Store 17 CLAUDE.md 18 - AGENTS.md 19 tmp 20 temp
··· 15 secrets 16 .DS_Store 17 CLAUDE.md 18 tmp 19 temp
+39
AGENTS.md
···
··· 1 + # Agent Guidelines for atex 2 + 3 + ## Commands 4 + 5 + - **Test**: `mix test` (all), `mix test test/path/to/file_test.exs` (single 6 + file), `mix test test/path/to/file_test.exs:42` (single test at line) 7 + - **Format**: `mix format` (auto-formats all code) 8 + - **Lint**: `mix credo` (static analysis, TODO checks disabled) 9 + - **Compile**: `mix compile` 10 + - **Docs**: `mix docs` 11 + 12 + ## Code Style 13 + 14 + - **Imports**: Use `alias` for modules (e.g., 15 + `alias Atex.Config.OAuth, as: Config`), import macros sparingly 16 + - **Formatting**: Elixir 1.18+, auto-formatted via `.formatter.exs` with 17 + `import_deps: [:typedstruct, :peri, :plug]` 18 + - **Naming**: snake_case for functions/variables, PascalCase for modules, 19 + descriptive names (e.g., `authorization_metadata`, not `auth_meta`) 20 + - **Types**: Use `@type` and `@spec` for all public functions; leverage 21 + TypedStruct for structs 22 + - **Moduledocs**: All public modules need `@moduledoc`, public functions need 23 + `@doc` with examples 24 + - **Error Handling**: Return `{:ok, result}` or `{:error, reason}` tuples; use 25 + pattern matching in case statements 26 + - **Pattern Matching**: Prefer pattern matching over conditionals; use guards 27 + when appropriate 28 + - **Macros**: Use `deflexicon` macro for lexicon definitions; use `defschema` 29 + (from Peri) for validation schemas 30 + - **Tests**: Async by default (`use ExUnit.Case, async: true`), use doctests 31 + where applicable 32 + - **Dependencies**: Core deps include Peri (validation), Req (HTTP), JOSE 33 + (JWT/OAuth), TypedStruct (structs) 34 + 35 + ## Important Notes 36 + 37 + - **DO NOT modify** `lib/atproto/**/` - autogenerated from official AT Protocol 38 + lexicons 39 + - **Update CHANGELOG.md** when adding features, changes, or fixes
+5
CHANGELOG.md
··· 28 - `Atex.OAuth.Error` exception module for OAuth flow errors. Contains both a 29 human-readable `message` string and a machine-readable `reason` atom for error 30 handling. 31 32 ### Changed 33
··· 28 - `Atex.OAuth.Error` exception module for OAuth flow errors. Contains both a 29 human-readable `message` string and a machine-readable `reason` atom for error 30 handling. 31 + - `Atex.OAuth.Cache` module provides TTL caching for OAuth authorization server 32 + metadata with a 1-hour default TTL to reduce load on third-party PDSs. 33 + - `Atex.OAuth.get_authorization_server/2` and 34 + `Atex.OAuth.get_authorization_server_metadata/2` now support an optional 35 + `fresh` parameter to bypass the cache when needed. 36 37 ### Changed 38
+5 -1
lib/atex/application.ex
··· 4 use Application 5 6 def start(_type, _args) do 7 - children = [Atex.IdentityResolver.Cache] 8 Supervisor.start_link(children, strategy: :one_for_one) 9 end 10 end
··· 4 use Application 5 6 def start(_type, _args) do 7 + children = [ 8 + Atex.IdentityResolver.Cache, 9 + Atex.OAuth.Cache 10 + ] 11 + 12 Supervisor.start_link(children, strategy: :one_for_one) 13 end 14 end
+91 -40
lib/atex/oauth.ex
··· 289 290 Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint 291 to discover the associated authorization server that should be used for the 292 - OAuth flow. 293 294 ## Parameters 295 296 - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social") 297 298 ## Returns 299 ··· 302 - `{:error, :invalid_metadata}` - Server returned invalid metadata 303 - `{:error, reason}` - Error discovering authorization server 304 """ 305 - @spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()} 306 - def get_authorization_server(pds_host) do 307 - "#{pds_host}/.well-known/oauth-protected-resource" 308 - |> Req.get() 309 - |> case do 310 - # TODO: what to do when multiple authorization servers? 311 - {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server} 312 - {:ok, _} -> {:error, :invalid_metadata} 313 - err -> err 314 end 315 end 316 ··· 319 320 Retrieves the metadata from the authorization server's 321 `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs 322 - required for the OAuth flow. 323 324 ## Parameters 325 326 - `issuer` - Authorization server issuer URL 327 328 ## Returns 329 ··· 332 - `{:error, :invalid_issuer}` - Issuer mismatch in metadata 333 - `{:error, any()}` - Other error fetching metadata 334 """ 335 - @spec get_authorization_server_metadata(String.t()) :: 336 {:ok, authorization_metadata()} | {:error, any()} 337 - def get_authorization_server_metadata(issuer) do 338 - "#{issuer}/.well-known/oauth-authorization-server" 339 - |> Req.get() 340 - |> case do 341 - {:ok, 342 - %{ 343 - body: %{ 344 - "issuer" => metadata_issuer, 345 - "pushed_authorization_request_endpoint" => par_endpoint, 346 - "token_endpoint" => token_endpoint, 347 - "authorization_endpoint" => authorization_endpoint 348 - } 349 - }} -> 350 - if issuer != metadata_issuer do 351 - {:error, :invaild_issuer} 352 - else 353 - {:ok, 354 - %{ 355 - issuer: metadata_issuer, 356 - par_endpoint: par_endpoint, 357 - token_endpoint: token_endpoint, 358 - authorization_endpoint: authorization_endpoint 359 - }} 360 - end 361 362 - {:ok, _} -> 363 - {:error, :invalid_metadata} 364 365 - err -> 366 - err 367 end 368 end 369
··· 289 290 Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint 291 to discover the associated authorization server that should be used for the 292 + OAuth flow. Results are cached for 1 hour to reduce load on third-party PDSs. 293 294 ## Parameters 295 296 - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social") 297 + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) 298 299 ## Returns 300 ··· 303 - `{:error, :invalid_metadata}` - Server returned invalid metadata 304 - `{:error, reason}` - Error discovering authorization server 305 """ 306 + @spec get_authorization_server(String.t(), boolean()) :: {:ok, String.t()} | {:error, any()} 307 + def get_authorization_server(pds_host, fresh \\ false) do 308 + if fresh do 309 + fetch_authorization_server(pds_host) 310 + else 311 + case Atex.OAuth.Cache.get_authorization_server(pds_host) do 312 + {:ok, authz_server} -> 313 + {:ok, authz_server} 314 + 315 + {:error, :not_found} -> 316 + fetch_authorization_server(pds_host) 317 + end 318 + end 319 + end 320 + 321 + defp fetch_authorization_server(pds_host) do 322 + result = 323 + "#{pds_host}/.well-known/oauth-protected-resource" 324 + |> Req.get() 325 + |> case do 326 + # TODO: what to do when multiple authorization servers? 327 + {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server} 328 + {:ok, _} -> {:error, :invalid_metadata} 329 + err -> err 330 + end 331 + 332 + case result do 333 + {:ok, authz_server} -> 334 + Atex.OAuth.Cache.set_authorization_server(pds_host, authz_server) 335 + {:ok, authz_server} 336 + 337 + error -> 338 + error 339 end 340 end 341 ··· 344 345 Retrieves the metadata from the authorization server's 346 `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs 347 + required for the OAuth flow. Results are cached for 1 hour to reduce load on 348 + third-party PDSs. 349 350 ## Parameters 351 352 - `issuer` - Authorization server issuer URL 353 + - `fresh` - If `true`, bypasses the cache and fetches fresh data (default: `false`) 354 355 ## Returns 356 ··· 359 - `{:error, :invalid_issuer}` - Issuer mismatch in metadata 360 - `{:error, any()}` - Other error fetching metadata 361 """ 362 + @spec get_authorization_server_metadata(String.t(), boolean()) :: 363 {:ok, authorization_metadata()} | {:error, any()} 364 + def get_authorization_server_metadata(issuer, fresh \\ false) do 365 + if fresh do 366 + fetch_authorization_server_metadata(issuer) 367 + else 368 + case Atex.OAuth.Cache.get_authorization_server_metadata(issuer) do 369 + {:ok, metadata} -> 370 + {:ok, metadata} 371 + 372 + {:error, :not_found} -> 373 + fetch_authorization_server_metadata(issuer) 374 + end 375 + end 376 + end 377 + 378 + defp fetch_authorization_server_metadata(issuer) do 379 + result = 380 + "#{issuer}/.well-known/oauth-authorization-server" 381 + |> Req.get() 382 + |> case do 383 + {:ok, 384 + %{ 385 + body: %{ 386 + "issuer" => metadata_issuer, 387 + "pushed_authorization_request_endpoint" => par_endpoint, 388 + "token_endpoint" => token_endpoint, 389 + "authorization_endpoint" => authorization_endpoint 390 + } 391 + }} -> 392 + if issuer != metadata_issuer do 393 + {:error, :invaild_issuer} 394 + else 395 + {:ok, 396 + %{ 397 + issuer: metadata_issuer, 398 + par_endpoint: par_endpoint, 399 + token_endpoint: token_endpoint, 400 + authorization_endpoint: authorization_endpoint 401 + }} 402 + end 403 + 404 + {:ok, _} -> 405 + {:error, :invalid_metadata} 406 + 407 + err -> 408 + err 409 + end 410 411 + case result do 412 + {:ok, metadata} -> 413 + Atex.OAuth.Cache.set_authorization_server_metadata(issuer, metadata) 414 + {:ok, metadata} 415 416 + error -> 417 + error 418 end 419 end 420
+127
lib/atex/oauth/cache.ex
···
··· 1 + defmodule Atex.OAuth.Cache do 2 + @moduledoc """ 3 + TTL cache for OAuth authorization server information. 4 + 5 + This module manages two separate ConCache instances: 6 + - Authorization server cache (stores PDS -> authz server mappings) 7 + - Authorization metadata cache (stores authz server -> metadata mappings) 8 + 9 + Both caches use a 1-hour TTL to reduce load on third-party PDSs. 10 + """ 11 + 12 + use Supervisor 13 + 14 + @authz_server_cache :oauth_authz_server_cache 15 + @authz_metadata_cache :oauth_authz_metadata_cache 16 + @ttl_ms :timer.hours(1) 17 + 18 + @doc """ 19 + Starts the OAuth cache supervisor. 20 + """ 21 + def start_link(opts) do 22 + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) 23 + end 24 + 25 + @impl Supervisor 26 + def init(_opts) do 27 + children = [ 28 + Supervisor.child_spec( 29 + {ConCache, 30 + [ 31 + name: @authz_server_cache, 32 + ttl_check_interval: :timer.minutes(5), 33 + global_ttl: @ttl_ms 34 + ]}, 35 + id: :authz_server_cache 36 + ), 37 + Supervisor.child_spec( 38 + {ConCache, 39 + [ 40 + name: @authz_metadata_cache, 41 + ttl_check_interval: :timer.seconds(30), 42 + global_ttl: @ttl_ms 43 + ]}, 44 + id: :authz_metadata_cache 45 + ) 46 + ] 47 + 48 + Supervisor.init(children, strategy: :one_for_one) 49 + end 50 + 51 + @doc """ 52 + Get authorization server from cache. 53 + 54 + ## Parameters 55 + 56 + - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social") 57 + 58 + ## Returns 59 + 60 + - `{:ok, authorization_server}` - Successfully retrieved from cache 61 + - `{:error, :not_found}` - Not present in cache 62 + """ 63 + @spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, :not_found} 64 + def get_authorization_server(pds_host) do 65 + case ConCache.get(@authz_server_cache, pds_host) do 66 + nil -> {:error, :not_found} 67 + value -> {:ok, value} 68 + end 69 + end 70 + 71 + @doc """ 72 + Store authorization server in cache. 73 + 74 + ## Parameters 75 + 76 + - `pds_host` - Base URL of the PDS 77 + - `authorization_server` - Authorization server URL to cache 78 + 79 + ## Returns 80 + 81 + - `:ok` 82 + """ 83 + @spec set_authorization_server(String.t(), String.t()) :: :ok 84 + def set_authorization_server(pds_host, authorization_server) do 85 + ConCache.put(@authz_server_cache, pds_host, authorization_server) 86 + :ok 87 + end 88 + 89 + @doc """ 90 + Get authorization server metadata from cache. 91 + 92 + ## Parameters 93 + 94 + - `issuer` - Authorization server issuer URL 95 + 96 + ## Returns 97 + 98 + - `{:ok, metadata}` - Successfully retrieved from cache 99 + - `{:error, :not_found}` - Not present in cache 100 + """ 101 + @spec get_authorization_server_metadata(String.t()) :: 102 + {:ok, Atex.OAuth.authorization_metadata()} | {:error, :not_found} 103 + def get_authorization_server_metadata(issuer) do 104 + case ConCache.get(@authz_metadata_cache, issuer) do 105 + nil -> {:error, :not_found} 106 + value -> {:ok, value} 107 + end 108 + end 109 + 110 + @doc """ 111 + Store authorization server metadata in cache. 112 + 113 + ## Parameters 114 + 115 + - `issuer` - Authorization server issuer URL 116 + - `metadata` - Authorization server metadata to cache 117 + 118 + ## Returns 119 + 120 + - `:ok` 121 + """ 122 + @spec set_authorization_server_metadata(String.t(), Atex.OAuth.authorization_metadata()) :: :ok 123 + def set_authorization_server_metadata(issuer, metadata) do 124 + ConCache.put(@authz_metadata_cache, issuer, metadata) 125 + :ok 126 + end 127 + end
+3 -2
mix.exs
··· 35 {:typedstruct, "~> 0.5"}, 36 {:ex_cldr, "~> 2.42"}, 37 {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 38 - {:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}, 39 {:plug, "~> 1.18"}, 40 {:jason, "~> 1.4"}, 41 {:jose, "~> 1.11"}, 42 - {:bandit, "~> 1.0", only: [:dev, :test]} 43 ] 44 end 45
··· 35 {:typedstruct, "~> 0.5"}, 36 {:ex_cldr, "~> 2.42"}, 37 {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 38 + {:ex_doc, "~> 0.39", only: :dev, runtime: false, warn_if_outdated: true}, 39 {:plug, "~> 1.18"}, 40 {:jason, "~> 1.4"}, 41 {:jose, "~> 1.11"}, 42 + {:bandit, "~> 1.0", only: [:dev, :test]}, 43 + {:con_cache, "~> 1.1"} 44 ] 45 end 46
+2 -1
mix.lock
··· 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"}, 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"}, 5 "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 "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 7 "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 8 "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_doc": {:hex, :ex_doc, "0.38.3", "ddafe36b8e9fe101c093620879f6604f6254861a95133022101c08e75e6c759a", [: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", "ecaa785456a67f63b4e7d7f200e8832fa108279e7eb73fd9928e7e66215a01f9"}, 10 "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 11 "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"}, 12 "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
··· 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"}, 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"}, 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"}, 7 "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 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"}, 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"}, 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 "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},