+6
-2
.formatter.exs
+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
+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
+3
-1
lib/atex/application.ex
+1
lib/atex/oauth/error.ex
+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
+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
+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
+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
+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
+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
+2
-1
mix.exs
+12
-11
mix.lock
+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"},